diff options
351 files changed, 40380 insertions, 21568 deletions
diff --git a/.ameba.yml b/.ameba.yml new file mode 100644 index 00000000..36d7c48f --- /dev/null +++ b/.ameba.yml @@ -0,0 +1,72 @@ +# +# Lint +# + +# Exclude assigns for ECR files +Lint/UselessAssign: + Excluded: + - src/invidious.cr + - src/invidious/helpers/errors.cr + - src/invidious/routes/**/*.cr + +# Ignore false negative (if !db.query_one?...) +Lint/UnreachableCode: + Excluded: + - src/invidious/database/base.cr + +# Ignore shadowed variable `key` (it works for now, and that's +# a sensitive part of the code) +Lint/ShadowingOuterLocalVar: + Excluded: + - src/invidious/helpers/tokens.cr + +Lint/NotNil: + Enabled: false + +Lint/SpecFilename: + Excluded: + - spec/parsers_helper.cr + + +# +# Style +# + +Style/RedundantBegin: + Enabled: false + +Style/RedundantReturn: + Enabled: false + +Style/RedundantNext: + Enabled: false + +Style/ParenthesesAroundCondition: + Enabled: false + +# This requires a rewrite of most data structs (and their usage) in Invidious. +Naming/QueryBoolMethods: + Enabled: false + +Naming/AccessorMethodName: + Enabled: false + +Naming/BlockParameterName: + Enabled: false + +# Hides TODO comment warnings. +# +# Call `bin/ameba --only Documentation/DocumentationAdmonition` to +# list them +Documentation/DocumentationAdmonition: + Enabled: false + + +# +# Metrics +# + +# Ignore function complexity (number of if/else & case/when branches) +# For some functions that can hardly be simplified for now +Metrics/CyclomaticComplexity: + Enabled: false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4f2e5a98 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# https://github.community/t/how-to-change-the-category/2261/3 +videojs-*.js linguist-detectable=false +video.min.js linguist-detectable=false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..9ca09368 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,18 @@ +# Default and lowest precedence. If none of the below matches, @iv-org/developers would be requested for review. +* @iv-org/developers + +docker-compose.yml @unixfox +docker/ @unixfox +kubernetes/ @unixfox + +README.md @thefrenchghosty +config/config.example.yml @SamantazFox @unixfox + +scripts/ @syeopite +shards.lock @syeopite +shards.yml @syeopite + +locales/ @SamantazFox +src/invidious/helpers/i18n.cr @SamantazFox + +src/invidious/helpers/youtube_api.cr @SamantazFox diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..3f28c2b7 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://invidious.io/donate/ diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index c0485266..4c1a6330 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,9 +7,16 @@ assignees: '' --- -<!-- Please use the search function to check if the bug you found has already been reported by someone else --> -<!-- If you want to suggest a new feature please use "Feature request" instead --> -<!-- If you want to suggest an enhancement to an existing feature please use "Enhancement" instead --> +<!-- + BEFORE TRYING TO REPORT A BUG: + + * Read the FAQ! + * Use the search function to check if there is already an issue open for your problem! + + If you want to suggest a new feature please use "Feature request" instead + If you want to suggest an enhancement to an existing feature please use "Enhancement" instead +--> + **Describe the bug** <!-- A clear and concise description of what the bug is. --> diff --git a/.github/workflows/auto-close-duplicate.yaml b/.github/workflows/auto-close-duplicate.yaml new file mode 100644 index 00000000..2eea099e --- /dev/null +++ b/.github/workflows/auto-close-duplicate.yaml @@ -0,0 +1,37 @@ +name: Close duplicates +on: + issues: + types: [opened] +jobs: + run: + runs-on: ubuntu-latest + permissions: write-all + steps: + - uses: iv-org/close-potential-duplicates@v1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Issue title filter work with anymatch https://www.npmjs.com/package/anymatch. + # Any matched issue will stop detection immediately. + # You can specify multi filters in each line. + filter: '' + # Exclude keywords in title before detecting. + exclude: '' + # Label to set, when potential duplicates are detected. + label: duplicate + # Get issues with state to compare. Supported state: 'all', 'closed', 'open'. + state: open + # If similarity is higher than this threshold([0,1]), issue will be marked as duplicate. + threshold: 0.9 + # Reactions to be add to comment when potential duplicates are detected. + # Available reactions: "-1", "+1", "confused", "laugh", "heart", "hooray", "rocket", "eyes" + reactions: '' + close: true + # Comment to post when potential duplicates are detected. + comment: | + Hello, your issue is a duplicate of this/these issue(s): {{#issues}} + - #{{ number }} [accuracy: {{ accuracy }}%] + {{/issues}} + + If this is a mistake please explain why and ping @\unixfox, @\SamantazFox and @\TheFrenchGhosty. + + Please refrain from opening new issues, it won't help in solving your problem. diff --git a/.github/workflows/build-nightly-container.yml b/.github/workflows/build-nightly-container.yml new file mode 100644 index 00000000..bee27600 --- /dev/null +++ b/.github/workflows/build-nightly-container.yml @@ -0,0 +1,100 @@ +name: Build and release container directly from master + +on: + push: + branches: + - "master" + paths-ignore: + - "*.md" + - LICENCE + - TRANSLATION + - invidious.service + - .git* + - .editorconfig + - screenshots/* + - .github/ISSUE_TEMPLATE/* + - kubernetes/** + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Crystal + uses: crystal-lang/install-crystal@v1.8.2 + with: + crystal: 1.12.2 + + - name: Run lint + run: | + if ! crystal tool format --check; then + crystal tool format + git diff + exit 1 + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to registry + uses: docker/login-action@v3 + with: + registry: quay.io + username: ${{ secrets.QUAY_USERNAME }} + password: ${{ secrets.QUAY_PASSWORD }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: quay.io/invidious/invidious + tags: | + type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} + type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} + labels: | + quay.expires-after=12w + + - name: Build and push Docker AMD64 image for Push Event + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile + platforms: linux/amd64 + labels: ${{ steps.meta.outputs.labels }} + push: true + tags: ${{ steps.meta.outputs.tags }} + build-args: | + "release=1" + + - name: Docker meta + id: meta-arm64 + uses: docker/metadata-action@v5 + with: + images: quay.io/invidious/invidious + flavor: | + suffix=-arm64 + tags: | + type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} + type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} + labels: | + quay.expires-after=12w + + - name: Build and push Docker ARM64 image for Push Event + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.arm64 + platforms: linux/arm64/v8 + labels: ${{ steps.meta-arm64.outputs.labels }} + push: true + tags: ${{ steps.meta-arm64.outputs.tags }} + build-args: | + "release=1" diff --git a/.github/workflows/build-stable-container.yml b/.github/workflows/build-stable-container.yml new file mode 100644 index 00000000..d2d106b6 --- /dev/null +++ b/.github/workflows/build-stable-container.yml @@ -0,0 +1,94 @@ +name: Build and release container + +on: + workflow_dispatch: + push: + tags: + - "v*" + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Crystal + uses: crystal-lang/install-crystal@v1.8.2 + with: + crystal: 1.12.2 + + - name: Run lint + run: | + if ! crystal tool format --check; then + crystal tool format + git diff + exit 1 + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to registry + uses: docker/login-action@v3 + with: + registry: quay.io + username: ${{ secrets.QUAY_USERNAME }} + password: ${{ secrets.QUAY_PASSWORD }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: quay.io/invidious/invidious + flavor: | + latest=false + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + labels: | + quay.expires-after=12w + + - name: Build and push Docker AMD64 image for Push Event + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile + platforms: linux/amd64 + labels: ${{ steps.meta.outputs.labels }} + push: true + tags: ${{ steps.meta.outputs.tags }} + build-args: | + "release=1" + + - name: Docker meta + id: meta-arm64 + uses: docker/metadata-action@v5 + with: + images: quay.io/invidious/invidious + flavor: | + latest=false + suffix=-arm64 + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + labels: | + quay.expires-after=12w + + - name: Build and push Docker ARM64 image for Push Event + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.arm64 + platforms: linux/arm64/v8 + labels: ${{ steps.meta-arm64.outputs.labels }} + push: true + tags: ${{ steps.meta-arm64.outputs.tags }} + build-args: | + "release=1" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66aacff9..dd472d1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,62 +1,166 @@ name: Invidious CI on: + schedule: + - cron: "0 0 * * *" # Every day at 00:00 push: branches: - "master" - "api-only" pull_request: branches: "*" + paths-ignore: + - "*.md" + - LICENCE + - TRANSLATION + - invidious.service + - .git* + - .editorconfig + + - screenshots/* + - assets/** + - locales/* + - config/** + - .github/ISSUE_TEMPLATE/* + - kubernetes/** jobs: build: - + runs-on: ubuntu-latest - + + name: "build - crystal: ${{ matrix.crystal }}, stable: ${{ matrix.stable }}" + + continue-on-error: ${{ !matrix.stable }} + + strategy: + fail-fast: false + matrix: + stable: [true] + crystal: + - 1.10.1 + - 1.11.2 + - 1.12.1 + - 1.13.2 + - 1.14.0 + include: + - crystal: nightly + stable: false + steps: - - uses: actions/checkout@v2 - + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Install required APT packages + run: | + sudo apt install -y libsqlite3-dev + shell: bash + - name: Install Crystal - uses: oprypin/install-crystal@v1.2.4 + uses: crystal-lang/install-crystal@v1.8.0 with: - crystal: 0.36.1 - + crystal: ${{ matrix.crystal }} + - name: Cache Shards - uses: actions/cache@v2 + uses: actions/cache@v3 with: - path: ./lib + path: | + ./lib + ./bin key: shards-${{ hashFiles('shard.lock') }} - + - name: Install Shards run: | if ! shards check; then shards install fi - + - name: Run tests run: crystal spec - - - name: Run lint - run: | - if ! crystal tool format --check; then - crystal tool format - git diff - exit 1 - fi - + - name: Build run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr - + build-docker: - + runs-on: ubuntu-latest - + steps: - - uses: actions/checkout@v2 - + - uses: actions/checkout@v4 + - name: Build Docker - run: docker-compose up -d - + run: docker compose build --build-arg release=0 + + - name: Run Docker + run: docker compose up -d + + - name: Test Docker + run: while curl -Isf http://localhost:3000; do sleep 1; done + + build-docker-arm64: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker ARM64 image + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.arm64 + platforms: linux/arm64/v8 + build-args: release=0 + - name: Test Docker run: while curl -Isf http://localhost:3000; do sleep 1; done + lint: + + runs-on: ubuntu-latest + + continue-on-error: true + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Install Crystal + uses: crystal-lang/install-crystal@v1.8.0 + with: + crystal: latest + + - name: Cache Shards + uses: actions/cache@v3 + with: + path: | + ./lib + ./bin + key: shards-${{ hashFiles('shard.lock') }} + + - name: Install Shards + run: | + if ! shards check; then + shards install + fi + + - name: Check Crystal formatter compliance + run: | + if ! crystal tool format --check; then + crystal tool format + git diff + exit 1 + fi + + - name: Run Ameba linter + run: bin/ameba diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml deleted file mode 100644 index 1f811b7c..00000000 --- a/.github/workflows/container-release.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Build and release container - -on: - push: - branches: - - "master" - schedule: - - cron: 0 0 * * * - -jobs: - release: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to registry - uses: docker/login-action@v1 - with: - registry: quay.io - username: ${{ secrets.QUAY_USERNAME }} - password: ${{ secrets.QUAY_PASSWORD }} - - - name: Build and push for Push Event - if: github.ref == 'refs/heads/master' - uses: docker/build-push-action@v2 - with: - context: . - file: docker/Dockerfile - labels: quay.expires-after=12w - push: true - tags: quay.io/invidious/invidious:${{ github.sha }},quay.io/invidious/invidious:latest diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e452274b..498a2c1b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -10,13 +10,14 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v3 + - uses: actions/stale@v8 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-stale: 365 - days-before-close: 30 + days-before-stale: 730 + days-before-pr-stale: -1 + days-before-close: 60 stale-issue-message: 'This issue has been automatically marked as stale and will be closed in 30 days because it has not had recent activity and is much likely outdated. If you think this issue is still relevant and applicable, you just have to post a comment and it will be unmarked.' - stale-pr-message: 'This pull request has been automatically marked as stale and will be closed in 30 days because it has not had recent activity and is much likely outdated. If you think this pull request is still relevant and applicable, you just have to post a comment and it will be unmarked.' stale-issue-label: "stale" - stale-pr-label: "stale" ascending: true + # Exempt the following types of issues from being staled + exempt-issue-labels: "feature-request,enhancement,discussion,exempt-stale" @@ -1,4 +1,4 @@ -/doc/ +/docs/ /dev/ /lib/ /bin/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..3d19d888 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "mocks"] + path = mocks + url = ../mocks diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa416ec..5af38003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,844 +1,347 @@ -# Note: This is no longer updated and links to omarroths repo, which doesn't exist anymore.
-
-# 0.20.0 (2019-011-06)
-
-# Version 0.20.0: Custom Playlists
-
-It's been quite a while since the last release! There've been [198 commits](https://github.com/omarroth/invidious/compare/0.19.0..0.20.0) from 27 contributors.
-
-A couple smaller features have since been added. Channel pages and playlists in particular have received a bit of a face-lift, with both now displaying their descriptions as expected, and playlists providing video count and published information. Channels will also now provide video descriptions in their RSS feed.
-
-Turkish (tr), Chinese (zh-TW, in addition to zh-CN), and Japanese (jp) are all now supported languages. Thank you as always to the hard work done by translators that makes this possible.
-
-The feed menu and default home page are both now configurable for registered and unregistered users, and is quite a bit of an improvement for users looking to reduce distractions for their daily use.
-
-## For Administrators
-
-`feed_menu` and `default_home` are now configurable by the user, and have therefore been moved into `default_user_preferences`:
-
-```yaml
-feed_menu: ["Popular", "Top"]
-default_home: Top
-
-# becomes:
-
-default_user_preferences:
- feed_menu: ["Popular", "Top"]
- default_home: Top
-```
-
-Several new options have also been added, including the ability to set a support email for the instance using `admin_email: EMAIL`, and forcing the use of a specific connection in the case of rate-limiting using `force_resolve` (see below).
-
-## For Developers
-
-Authenticated endpoints are now [properly documented](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints), as well how to generate and use API tokens. My hope is that this makes some of the more [interesting](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authnotifications) endpoints more accessible for developers to use in their own applications.
-
-API endpoints for interacting with custom playlists have also been added with documentation available [here](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authplaylists).
-
-## Custom playlists
-
-This is probably the feature that has been the longest in the pipe and that I'm quite pleased is now implemented. It is now possible to create custom playlists, which can be played and edited through Invidious. API endpoints have also been added (documentation [here](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authplaylists)).
-
-Overall I'm quite pleased with how smoothly it has been rolled out and with the experience so far, and I'm exctited for how it can be extended and improved in future.
-
-## [instances.invidio.us](https://instances.invidio.us)
-
-It is now possible to view a list of public instances (as provided in the [wiki](https://github.com/omarroth/invidious/wiki/Invidious-Instances)) through an API or a pretty new interface [here](https://instances.invidio.us). It combines uptime information, statistics from each instance and basic information already provided in the wiki. I expect it should be much more user-friendly than compiling the information yourself, and is already used by [Invidition](https://codeberg.org/Booteille/Invidition) to provide a list of instances for users to choose from.
-
-The site itself is licensed under the AGPLv3 and the source is available [here](https://github.com/omarroth/instances.invidio.us).
-
-## Video unavailable [#811](https://github.com/omarroth/invidious/issues/811)
-
-Many users have likely noticed this error message if using Invidious directly or through another service, such as FreeTube. This issue is caused by rate-limiting by Google, and is not a new issuee for projects like Invidious (notably [youtube-dl](https://github.com/ytdl-org/youtube-dl#http-error-429-too-many-requests-or-402-payment-required)) and appears to be affecting smaller, private instances as well.
-
-There is not a permanent fix for administrators currently, however there is some information available [here](https://github.com/omarroth/invidious/issues/811#issuecomment-540017772) that may provide a temporary solution. Unfortanately, in most cases the best option is to wait for the instance to be unbanned or to move the instance to a different IP. A more informative error message is also now provided, which should help an administrator more quickly diagnose the problem.
-
-For those interested, I would recommend following [#811](https://github.com/omarroth/invidious/issues/811) for any future progress on the issue.
-
-## BAT verified publisher
-
-I'm quite late to this announcement, however I'm pleased to mention that Invidious is now a BAT verified publisher! I would recommend looking [here](https://basicattentiontoken.org/about/) or [here](https://www.reddit.com/r/BATProject/comments/7cr7yc/new_to_bat_read_this_introduction_to_basic/) for learning more about what it is and how it works. Overall I think it makes an interesting substitute for services like Liberapay, and a (hopefully) much less-intrusive alternative to direct advertising.
-
-BAT is combined under other cryptocurrencies below. Currently there's a fairly significant delay in payout, which is the reason for the large fluctuation in crypto donations between September and October (and also the reason for the late announcement).
-
-## Release schedule
-
-Currently I'm quite pleased with the current state of the project. There's plenty of things I'd still like to add, however at this point I expect the rate of most new additions will slow down a bit, with more focus on stabililty and any long-standing bugs.
-
-Because of this, I'm planning on releasing a new version quarterly, with any necessary hotfixes being pushed as a new patch release as necessary. As always it will be possible to run Invidious directly from [master](https://github.com/omarroth/invidious/wiki/Updating) if you'd still like to have the lastest version.
-
-I'll plan on providing finances each release, with a similar monthly breakdown as below.
-
-## Finances for September 2019
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$64.37
-- [Liberapay](https://liberapay.com/omarroth) : \$76.04
-- Crypto : ~\$99.89 (converted from BAT, BCH, BTC)
-- Total : \$240.30
-
-### Expenses
-
-- invidious-lb1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node11 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node12 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node13 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node14 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node15 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node16 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$135.00
-
-## Finances for October 2019
-
-- [Liberapay](https://liberapay.com/omarroth) : \$134.40
-- Crypto : ~\$8.29 (converted from BAT, BCH, BTC)
-- Total : \$142.69
-
-### Expenses
-
-- invidious-lb1 (nyc1) : \$5.00 (load balancer)
-- invidious-lb2 (nyc1) : \$5.00 (load balancer)
-- invidious-lb3 (nyc1) : \$5.00 (load balancer)
-- invidious-lb4 (nyc1) : \$5.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node11 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node12 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node13 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node14 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node15 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node16 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node17 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node18 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$155.00
-
-# 0.19.0 (2019-07-13)
-
-# Version 0.19.0: Communities
-
-Hello again everyone! Focus this month has mainly been on improving playback performance, along with a couple new features I'd like to announce. There have been [109 commits](https://github.com/omarroth/invidious/compare/0.18.0...0.19.0) this past month from 10 contributors.
-
-This past month has seen the addition of Chinese (`zh-CN`) and Icelandic (`is`) translations. I would like to give a huge thanks to their respective translators, and again an enormous thanks to everyone who helps translate the site.
-
-I'm delighted to mention that [FreeTube 0.6.0](https://github.com/FreeTubeApp/FreeTube) now supports 1080p thanks to the Invidious API. I would very much recommend reading the [relevant post](https://freetube.writeas.com/freetube-release-0-6-0-beta-1080p-and-a-lot-of-qol) for some more information on how it works, along with several other major improvements. Folks that are interested in adding similar functionality for their own projects should feel free to get in touch.
-
-This past month there has been quite a bit of work on improving memory usage and improving download and playback speeds. As mentioned in the previous release, some extra hardware has been allocated which should also help with this. I'm still looking for ways to improve performance and feedback is always appreciated.
-
-Along with performance, a couple quality of life improvements have been added, including author thumbnails and banners, clickable titles for embedded videos, and better styling for captions, among some other enhancements.
-
-## Communities
-
-Support for YouTube's [communities tab](https://creatoracademy.youtube.com/page/lesson/community-tab) has been added. It's a very interesting but surprisingly unknown feature. Essentially, providing comments for a channel, rather than a video, where an author can post updates for their subscribers.
-
-It's commonly used to promote interesting links and foster discussion. I hope this feature helps people find more interesting content that otherwise would have been overlooked.
-
-## For Developers
-
-For accessing channel communities, an `/api/v1/channels/comments/:ucid` endpoint has been added, with similar behavior and schema to `/api/v1/comments/:id`, with an extra `attachment` field for top-level comments. More info on usage and available data can be found in the [wiki](https://github.com/omarroth/invidious/wiki/API#get-apiv1channelscommentsucid-apiv1channelsucidcomments).
-
-An `/api/v1/auth/feeds` endpoint has been added for programmatically accessing a user's subscription feed, with options for displaying notifications and filtering an existing feed.
-
-An `/api/v1/search/suggestions` endpoint has been added for retrieving suggestions for a given query.
-
-## For Administrators
-
-It is now possible to disable more resource intensive features, such as downloads and DASH functionality by adding `disable_proxy` to your config. See [#453](https://github.com/omarroth/invidious/issues/453) and the [Wiki](https://github.com/omarroth/invidious/wiki/Configuration) for more information and example usage. I expect this to be a big help for folks with limited bandwidth when hosting their own instances.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$38.39
-- [Liberapay](https://liberapay.com/omarroth) : \$84.85
-- Crypto : ~\$0.00 (converted from BCH, BTC)
-- Total : \$123.24
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$105.00
-
-The goal on Patreon has been updated to reflect the above expenses. As mentioned above, the main reason for more hardware is to improve playback and download speeds, although I'm still looking into improving performance without allocating more hardware.
-
-As always I'm grateful for everyone's support and feedback. I'll see you all next month.
-
-# 0.18.0 (2019-06-06)
-
-# Version 0.18.0: Native Notifications and Optimizations
-
-Hope everyone has been doing well. This past month there have been [97 commits](https://github.com/omarroth/invidious/compare/0.17.0...0.18.0) from 10 contributors. For the most part changes this month have been on optimizing various parts of the site, mainly subscription feeds and support for serving images and other assets.
-
-I'm quite happy to mention that support for Greek (`el`) has been added, which I hope will continue to make the site accessible for more users.
-
-Subscription feeds will now only update when necessary, rather than periodically. This greatly lightens the load on DB as well as making the feeds generally more responsive when changing subscriptions, importing data, and when receiving new uploads.
-
-Caching for images and other assets should be greatly improved with [#456](https://github.com/omarroth/invidious/issues/456). JavaScript has been pulled out into separate files where possible to take advantage of this, which should result in lighter pages and faster load times.
-
-This past month several people have encountered issues with downloads and watching high quality video through the site, see [#532](https://github.com/omarroth/invidious/issues/532) and [#562](https://github.com/omarroth/invidious/issues/562). For this coming month I've allocated some more hardware which should help with this, and I'm also looking into optimizing how videos are currently served.
-
-## For Developers
-
-`viewCount` is now available for `/api/v1/popular` and all videos returned from `/api/v1/auth/notifications`. Both also now provide `"type"` for indicating available information for each object.
-
-An `/authorize_token` page is now available for more easily creating new tokens for use in applications, see [this comment](https://github.com/omarroth/invidious/issues/473#issuecomment-496230812) in [#473](https://github.com/omarroth/invidious/issues/473) for more details.
-
-A POST `/api/v1/auth/notifications` endpoint is also now available for correctly returning notifications for 150+ channels.
-
-## For Administrators
-
-There are two new schema changes for administrators: `views` for adding view count to the popular page, and `feed_needs_update` for tracking feed changes.
-
-As always the relevant migration scripts are provided which should run when following instructions for [updating](https://github.com/omarroth/invidious/wiki/Updating). Otherwise, adding `check_tables: true` to your config will automatically make the required changes.
-
-## Native Notifications
-
-[<img src="https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png" height="160" width="472">](https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png "Example of native notification, available in repository under screnshots/native_notification.png")
-
-It is now possible to receive [Web notifications](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) from subscribed channels.
-
-You can enable notifications by clicking "Enable web notifications" in your preferences. Generally they appear within 20-60 seconds of a new video being uploaded, and I've found them to be an enormous quality of life improvement.
-
-Although it has been fairly stable, please feel free to report any issues you find [here](https://github.com/omarroth/invidious/issues) or emailing me directly at omarroth@protonmail.com.
-
-Important to note for administrators is that instances require [`use_pubsub_feeds`](https://github.com/omarroth/invidious/wiki/Configuration) and must be served over HTTPS in order to correctly send web notifications.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$49.73
-- [Liberapay](https://liberapay.com/omarroth) : \$100.57
-- Crypto : ~\$11.12 (converted from BCH, BTC)
-- Total : \$161.42
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$85.00
-
-See you all next month!
-
-# 0.17.0 (2019-05-06)
-
-# Version 0.17.0: Player and Authentication API
-
-Hello everyone! This past month there have been [130 commits](https://github.com/omarroth/invidious/compare/0.16.0..0.17.0) from 11 contributors. Large focus has been on improving the player as well as adding API access for other projects to make use of Invidious.
-
-There have also been significant changes in preparation of native notifications (see [#195](https://github.com/omarroth/invidious/issues/195), [#469](https://github.com/omarroth/invidious/issues/469), [#473](https://github.com/omarroth/invidious/issues/473), and [#502](https://github.com/omarroth/invidious/issues/502)), and playlists. I expect to see both of these to be added in the next release.
-
-I'm quite happy to mention that new translations have been added for Esperanto (`eo`) and Ukranian (`uk`). Support for pluralization has also been added, so it should now be possible to make a more native experience for speakers in other languages. The system currently in place is a bit cumbersome, so for any help using this feature please get in touch!
-
-## For Administrators
-
-A `check_tables` option has been added to automatically migrate without the use of custom scripts. This method will likely prove to be much more robust, and is currently enabled for the official instance. To prevent any unintended changes to the DB, `check_tables` is disabled by default and will print commands before executing. Having this makes features that require schema changes much easier to implement, and also makes it easier to upgrade from older instances.
-
-As part of [#303](https://github.com/omarroth/invidious/issues/303), a `cache_annotations` option has been added to speed up access from `/api/v1/annotations/:id`. This vastly improves the experience for videos with annotations. Currently, only videos that contain legacy annotations will be cached, which should help keep down the size of the cache. `cache_annotations` is disabled by default.
-
-## For Developers
-
-An authorization API has been added which allows other applications to read and modify user subscriptions and preferences (see [#473](https://github.com/omarroth/invidious/issues/473)). Support for accessing user feeds and notifications is also planned. I believe this feature is a large step forward in supporting syncing subscriptions and preferences with other services, and I'm excited to see what other developers do with this functionality.
-
-Support for server-to-client push notifications is currently underway. This allows Invidious users, as well as applications using the Invidious API, to receive notifications about uploads in near real-time (see #469). An `/api/v1/auth/notifications` endpoint is currently available. I'm very excited for this to be integrated into the site, and to see how other developers use it in their own projects.
-
-An `/api/v1/storyboards/:id` endpoint has been added for accessing storyboard URLs, which allows developers to add video previews to their players (see below).
-
-## Player
-
-Support for annotations has been merged into master with [#303](https://github.com/omarroth/invidious/issues/303), thanks @glmdgrielson! Annotations can be enabled by default or only for subscribed channels, and can also be toggled per video. I'm extremely proud of the progress made here, and I'm so thankful to everyone that has made this possible. I expect this to be the last update with regards to supporting annotations, but I do plan on continuing to improve the experience as much as possible.
-
-The Invidious player now supports video previews and a corresponding API endpoint `/api/v1/storyboards/:id` has been added for developers looking to add similar functionality to their own players. Not much else to say here. Overall it's a very nice quality of life improvement and an attractive addition to the site.
-
-It is now possible to select specific sources for videos provided using DASH (see [#34](https://github.com/omarroth/invidious/issues/34)). I would consider support largely feature complete, although there are still several issues to be fixed before I would consider it ready for larger rollout. You can watch videos in 1080p by setting `Default quality` to `dash` in your preferences, or by adding `&quality=dash` to the end of video URLs.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$49.73
-- [Liberapay](https://liberapay.com/omarroth) : \$63.03
-- Crypto : ~\$0.00 (converted from BCH, BTC)
-- Total : \$112.76
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$80.00
-
-That's all for now. Thanks!
-
-# 0.16.0 (2019-04-06)
-
-# Version 0.16.0: API Improvements and Annotations
-
-Hello again! This past month has seen [116 commits](https://github.com/omarroth/invidious/compare/0.15.0..0.16.0) from 13 contributors and a couple important changes I'd like to announce.
-
-A privacy policy is now available [here](https://invidio.us/privacy). I've done my best to explain things as clearly as possible without oversimplifying, and would very much recommend reading it if you're concerned about your privacy and want to learn more about how Invidious uses your data. Please let me know if there is anything that needs clarification.
-
-I'm also very happy to announce that a Spanish translation has been added to the site. You can use it with `?hl=es` or by setting `es` as your default locale. As always I'm extremely grateful to translators for making the site accessible to more people.
-
-## For Administrators
-
-Invidious now supports server-to-server [push notifications](https://developers.google.com/youtube/v3/guides/push_notifications). This uses [PubSubHubbub](https://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html) to automatically handle new videos sent to an instance, which is less resource intensive and generally faster. Note that it will not pull all videos from a subscribed channel, so recommended usage is in addition to `channel_threads`. Using PubSub requires a valid `domain` that updates can be sent to, and a random string that can be used to sign updates sent to the instance. You can enable it by adding `use_pubsub_feeds: true` to your `config.yml`. See [Configuration](https://github.com/omarroth/invidious/wiki/Configuration) for more info.
-
-Unfortunately there are a couple necessary changes to the DB to support `liveNow` and `premiereTimestamp` in subscription feeds. Migration scripts have been provided that should be used automatically if following the instructions [here](https://github.com/omarroth/invidious/wiki/Updating).
-
-You can now configure default user preferences for your instance. This allows you to set default locale, player preferences, and more. See [#415](https://github.com/omarroth/invidious/issues/415) for more details and example usage.
-
-## For Developers
-
-The [fields](https://developers.google.com/youtube/v3/getting-started#fields) API has been added with [#429](https://github.com/omarroth/invidious/pull/429) and is now supported on all JSON endpoints, thanks [**@afrmtbl**](https://github.com/afrmtbl)! Synax is straight-forward and can be used to reduce data transfer and create a simpler response for debugging. You can see an example [here](https://invidio.us/api/v1/videos/CvFH_6DNRCY?pretty=1&fields=title,recommendedVideos/title). I've been quite happy using it and hope it is similarly useful for others.
-
-An `/api/v1/annotations/:id` endpoint has been added for pulling legacy annotation data from [this](https://archive.org/details/youtubeannotations) archive, see below for more details. You can also access annotation data available on YouTube using `?source=youtube`, although this will only return card data as legacy annotations were deleted on January 15th.
-
-A couple minor changes to existing endpoints:
-
-- A `premiereTimestamp` field has been added to `/api/v1/videos/:id`
-- A `sort_by` param has been added to `/api/v1/comments/:id`, supports `new`, `top`.
-
-More info is available in the [documentation](https://github.com/omarroth/invidious/wiki/API).
-
-## Annotations
-
-I'm pleased to announce that annotation data is finally available from the roughly 1.4 billion videos archived as part of [this](https://www.reddit.com/r/DataHoarder/comments/aa6czg/youtube_annotation_archive/) project. They are accessible from the Internet Archive [here](https://archive.org/details/youtubeannotations) or as a 355GB torrent, see [here](https://www.reddit.com/r/DataHoarder/comments/b7imx9/youtube_annotation_archive_annotation_data_from/) for more details. A corresponding `/api/v1/annotations/:id` endpoint has been added to Invidious which uses the collection from IA to provide legacy annotations.
-
-Support for them in the player is possible thanks to [this](https://github.com/afrmtbl/videojs-youtube-annotations) plugin developed by [**@afrmtbl**](https://github.com/afrmtbl). A PR for adding support to the site is available as [#303](https://github.com/omarroth/invidious/pull/303). There's also an [extension](https://github.com/afrmtbl/AnnotationsRestored) for overlaying them on top of the YouTube player (again thanks to [**@afrmtbl**](https://github.com/afrmtbl)), and an [extension](https://tech234a.bitbucket.io/AnnotationsReloaded?src=invidious) for hooking into code still present in the YouTube player itself, developed by [**@tech234a**](https://github.com/tech234a).
-
-I would recommend reading the [official announcement](https://www.reddit.com/r/DataHoarder/comments/b7imx9/youtube_annotation_archive_annotation_data_from/) for more details. I would like to again thank everyone that helped contribute to this project.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$42.42
-- [Liberapay](https://liberapay.com/omarroth) : \$70.11
-- Crypto : ~\$1.76 (converted from BCH, BTC, BSV)
-- Total : \$114.29
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$80.00
-
-This past month the site saw a couple abnormal peaks in traffic, so an additional webserver has been added to match the increased load. The goal on Patreon has been updated to match the above expenses.
-
-Thanks everyone!
-
-# 0.15.0 (2019-03-06)
-
-## Version 0.15.0: Preferences and Channel Playlists
-
-The project has seen quite a bit of activity this past month. Large focus has been on fixing bugs, but there's still quite a few new features I'm happy to announce. There have been [133 commits](https://github.com/omarroth/invidious/compare/0.14.0...0.15.0) from 15 contributors this past month.
-
-As a couple miscellaneous changes, a couple [nice screenshots](https://github.com/omarroth/invidious#screenshots) have been added to the README, so folks can see more of what the site has to offer without creating an account.
-
-The footer has also been cleaned up quite a bit, and now displays the current version, so it's easier to know what features are available from the current instance.
-
-## For Administrators
-
-This past month there has been a minor release - `0.14.1` - which fixes a breaking change made by YouTube for their polymer redesign.
-
-There have been several new features that unfortunately require a database migration. There are migration scripts provided in `config/migrate-scripts`, and the [wiki](https://github.com/omarroth/invidious/wiki/Updating) has instructions for automatically applying them. I'll do my best to keep those changes to a minimum, and expect to see a corresponding script to automatically apply any new changes.
-
-Administrator preferences have been added with [#312](https://github.com/omarroth/invidious/issues/312), which allows administrators to customize their instance. Administrators can change the order of feed menus, change the default homepage, disable open registration, and several other options. There's a short 'how-to' [here](https://github.com/omarroth/invidious/issues/312#issuecomment-468831842), and the new options are documented [here](https://github.com/omarroth/invidious/wiki/Configuration).
-
-An `/api/v1/stats` endpoint has been added with [#356](https://github.com/omarroth/invidious/issues/356), which reports the instance version and number of active users. Statistics are disabled by default, and can be enabled in administator preferences. Statistics for the official instance are available [here](https://invidio.us/api/v1/stats?pretty=1).
-
-## For Developers
-
-`/api/v1/channels/:ucid` now provides an `autoGenerated` tag, which returns true for topic channels, and larger genre channels generated by YouTube. These channels don't have any videos of their own, so `latestVideos` will be empty. It is recommended instead to display a list of playlists generated by YouTube.
-
-You can now pull a list of playlists from a channel with `/api/v1/channels/playlists/:ucid`. Supported options are documented in the [wiki](https://github.com/omarroth/invidious/wiki/API#get-apiv1channelsplaylistsucid-apiv1channelsucidplaylists). Pagination is handled with a `continuation` token, which is generated on each call. Of note is that auto-generated channels currently have one page of results, and subsequent calls will be empty.
-
-For quickly pulling the latest 30 videos from a channel, there is now `/api/v1/channels/latest/:ucid`. It is much faster than a call to `/api/v1/channels/:ucid`. It will not convert an author name to a valid ucid automatically, and will not return any extra data about a channel.
-
-## Preferences
-
-In addition to administrator preferences mentioned above, you can now change your preferences without an account (see [#42](https://github.com/omarroth/invidious/pull/42)). I think this is quite an improvement to the usability of the site, and is much friendlier to privacy-conscious folks that don't want to make an account. Preferences will be automatically imported to a newly created account.
-
-Several issues with sorting subscriptions have been fixed, and `/manage_subscriptions` has been sped up significantly. The subscription feed has also seen a bump in performance. Delayed notifications have unfortunately started becoming a problem now that there are more users on the site. Some new changes are currently being tested which should mostly resolve the issue, so expect to see more in the next release.
-
-## Channel Playlists
-
-You can now view available playlists from a channel, and [auto-generated channels](https://invidio.us/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ) are no longer empty. You can sort as you would on YouTube, and all the same functionality should be available. I'm quite pleased to finally have it implemented, since it's currently the only data available from the above mentioned auto-generated channels, and makes it much easier to consume music on the site.
-
-There's also more discussion on improving Invidious for streaming music in [#304](https://github.com/omarroth/invidious/issues/304), and adding support for music.youtube.com. I would appreciate any thoughts on how to improve that experience, since it's a very large and useful part of YouTube.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$42.42
-- [Liberapay](https://liberapay.com/omarroth) : \$30.97
-- Crypto : ~\$0.00 (converted from BCH, BTC)
-- Total : \$73.39
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$75.00
-
-It's been very humbling to see how fast the project has grown, and I look forward to making the site even better. Thank you everyone.
-
-# 0.14.0 (2019-02-06)
-
-## Version 0.14.0: Community
-
-This last month several contributors have made improvements specifically for the people using this project. New pages have been added to the wiki, and there is now a [Matrix Server](https://riot.im/app/#/room/#invidious:matrix.org) and IRC channel so it's easier and faster for people to ask questions or chat. There have been [101 commits](https://github.com/omarroth/invidious/compare/0.13.0...0.14.0) since the last major release from 8 contributors.
-
-It has come to my attention in the past month how many people are self-hosting, and I would like to make it easier for them to do so.
-
-With that in mind, expect future releases to have a section for For Administrators (if any relevant changes) and For Developers (if any relevant changes).
-
-## For Administrators
-
-This month the most notable change for administrators is releases. As always, there will be a major release each month. However, a new minor release will be made whenever there are any critical bugs that need to be fixed.
-
-This past month is the first time there has been a minor release - `0.13.1` - which fixes a breaking change made by YouTube. Administrators using versioning for their instances will be able to rely on the latest version, and should have a system in place to upgrade their instance as soon as a new release is available.
-
-Several new pages have been added to the [wiki](https://github.com/omarroth/invidious/wiki#for-administrators) (as mentioned below) that will help administrators better setup their own instances. Configuration, maintenance, and instructions for updating are of note, as well as several common issues that are encountered when first setting up.
-
-## For Developers
-
-There's now a `pretty=1` parameter for most endpoints so you can view data easily from the browser, which is convenient for debugging and casual use. You can see an example [here](https://invidio.us/api/v1/videos/CvFH_6DNRCY?pretty=1).
-
-Unfortunately the `/api/v1/insights/:id` endpoint is no longer functional, as YouTube removed all publicly available analytics around a month ago. The YouTube endpoint now returns a 404, so it's unlikely it will be functional again.
-
-## Wiki
-
-There have been a sizable number of changes to the Wiki, including a [list of public Invidious instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances), the [list of extensions](https://github.com/omarroth/invidious/wiki/Extensions), and documentation for administrators (as mentioned above) and developers.
-
-The wiki is editable by anyone so feel free to add anything you think is useful.
-
-## Matrix & IRC
-
-Thee is now a [Matrix Server](https://riot.im/app/#/room/#invidious:matrix.org) for Invidious, so please feel free to hop on if you have any questions or want to chat. There is also a registered IRC channel: #invidious on Freenode which is bridged to Matrix.
-
-## Features
-
-Several new features have been added, including a download button, creator hearts and comment colors, and a French translation.
-
-There have been fixes for Google logins, missing text in locales, invalid links to genre channels, and better error handling in the player, among others.
-
-Several fixes and features are omitted for space, so I'd recommend taking a look at the [compare tab](https://github.com/omarroth/invidious/compare/0.13.0...0.14.0) for more information.
-
-## Annotations Update
-
-Annotations were removed January 15th, 2019 around15:00 UTC. Before they were deleted we were able to archive annotations from around 1.4 billion videos. I'd very much recommend taking a look [here](https://www.reddit.com/r/DataHoarder/comments/al7exa/youtube_annotation_archive_update_and_preview/) for more information and a list of acknowledgements. I'm extremely thankful to everyone who was able to contribute and I'm glad we were able to save such a large part of internet history.
-
-There's been large strides in supporting them in the player as well, which you can follow in [#303](https://github.com/omarroth/invidious/pull/303). You can preview the functionality at https://dev.invidio.us . Before they are added to the main site expect to see an option to disable them, both site-wide and per video.
-
-Organizing this project has unfortunately taken up quite a bit of my time, and I've been very grateful for everyone's patience.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth) : \$49.42
-- [Liberapay](https://liberapay.com/omarroth) : \$27.89
-- Crypto : ~\$0.00 (converted from BCH, BTC)
-- Total : \$77.31
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$75.00
-
-As always I'm grateful for everyone's contributions and support. I'll see you all in March.
-
-# 0.13.1 (2019-01-19)
-
-##
-
-# 0.13.0 (2019-01-06)
-
-## Version 0.13.0: Translations, Annotations, and Tor
-
-I hope everyone had a happy New Year! There's been a couple new additions since last release, with [44 commits](https://github.com/omarroth/invidious/compare/0.12.0...0.13.0) from 9 contributors. It's been quite a year for the project, and I hope to continue improving the project into 2019! Starting off the new year:
-
-## Translations
-
-I'm happy to announce support for translations has been added with [`a160c64`](https://github.com/omarroth/invidious/a160c64). Currently, there is support for:
-
-- Arabic (`ar`)
-- Dutch (`nl`)
-- English (`en-US`)
-- German (`de`)
-- Norwegian Bokmål (`nb_NO`)
-- Polish (`pl`)
-- Russian (`ru`)
-
-Which you can change in your preferences under `Language`. You can also add `&hl=LANGUAGE` to the end of any request to translate it to your preferred language, for example https://invidio.us/?hl=ru. I'd like to say thank you again to everyone who has helped translate the site! I've mentioned this before, but I'm delighted that so many people find the project useful.
-
-## Annotations
-
-Recently, [YouTube announced that all annotations will be deleted on January 15th, 2019](https://support.google.com/youtube/answer/7342737). I believe that annotations have a very important place in YouTube's history, and [announced a project to archive them](https://www.reddit.com/r/DataHoarder/comments/aa6czg/youtube_annotation_archive/).
-
-I expect annotations to be supported in the Invidious player once archiving is complete (see [#110](https://github.com/omarroth/invidious/issues/110) for details), and would also like to host them for other developers to use in their projects.
-
-The code is available [here](https://github.com/omarroth/archive), and contains instructions for running a worker if you would like to contribute. There's much more information available in the announcement as well for anyone who is interested.
-
-## Tor
-
-I unfortunately missed the chance to mention this in the previous release, but I'm now happy to announce that you can now view Invidious through Tor at the following links:
-
-kgg2m7yk5aybusll.onion
-axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid.onion
-
-Invidious is well suited to use through Tor, as it does not require any JS and is fairly lightweight. I'd recommend looking [here](https://diasp.org/posts/10965196) and [here](https://www.reddit.com/r/TOR/comments/a3c1ak/you_can_now_watch_youtube_videos_anonymously_with/) for more details on how to use the onion links, and would like to say thank you to [/u/whonix-os](https://www.reddit.com/user/whonix-os) for suggesting it and providing support setting setting them up.
-
-## Popular and Trending
-
-You can now easily view videos trending on YouTube with [`a16f967`](https://github.com/omarroth/invidious/a16f967). It also provides support for viewing YouTube's various categories categories, such as `News`, `Gaming`, and `Music`. You can also change the `region` parameter to view trending in different countries, which should be made easier to use in the coming weeks.
-
-A link to `/feed/popular` has also been added, which provides a list of videos sorted using the algorithm described [here](https://github.com/omarroth/invidious/issues/217#issuecomment-436503761). I think it better reflects what users watch on the site, but I'd like to hear peoples' thoughts on this and on how it could be improved.
-
-## Finances
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth): \$64.63
-- [Liberapay](https://liberapay.com/omarroth) : \$30.05
-- Crypto : ~\$28.74 (converted from BCH, BTC)
-- Total : \$123.42
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$75.00
-
-### What will happen with what's left over?
-
-I believe this is the first month that all expenses have been fully paid for by donations. Thank you! I expect to allocate the current amount for hardware to improve performance and for hosting annotation data, as mentioned above.
-
-Anything that is left over is kept to continue hosting the project for as long as possible. Thank you again everyone!
-
-I think that's everything for 2018. There's lots still planned, and I'm very excited for the future of this project!
-
-# 0.12.0 (2018-12-06)
-
-## Version 0.12.0: Accessibility, Privacy, Transparency
-
-Hello again, it's been a while! A lot has happened since the last release. Invidious has seen [134 commits](https://github.com/omarroth/invidious/compare/0.11.0...0.12.0) from 3 contributors, and I'm quite happy with the progress that has been made. I enjoyed this past month, and I believe having a monthly release schedule allows me to focus on more long-term improvements, and I hope people enjoy these more substantial updates as well.
-
-## Accessability and Privacy
-
-There have been quite a few improvements for user privacy, and improvements that improve accessibility for both people and software.
-
-You can now view comments without JS with [`19516ea`](https://github.com/omarroth/invidious/19516ea). Currently, this functionality is limited to the first 20 comments, but expect this functionality to be improved to come as close to the JS version as possible. Folks can track progress in [#204](https://github.com/omarroth/invidious/issues/204).
-
-Invidious is now compatible with [LibreJS](https://www.gnu.org/software/librejs/), and provides license information [here](https://invidio.us/licenses) with [`7f868ec`](https://github.com/omarroth/invidious/7f868ec). As expected, all libraries are compatible under the AGPLv3, and I'm happy to mention that no other changes were required to make Invidious compatible with LibreJS.
-
-A DNT policy has also been added with [`9194f47`](https://github.com/omarroth/invidious/9194f47) for compatibility with [Privacy Badger](https://www.eff.org/privacybadger). I'm pleased to mention that here too no other changes had to be made in order for Invidious to be compatible with this extension. I expect a privacy policy to be added soon as well, so users can better understand how Invidious uses their data.
-
-For users that are visually impaired, there is now a text CAPTCHA available so it's easier to register and login. Because of the simple front-end of the project, I expect screen readers and other software to be able to easily understand the site's interface. In combination with the ability to listen-only, I believe Invidious is much more accessible than YouTube. Folks can read [#244](https://github.com/omarroth/invidious/issues/244) for more details, and I would very much appreciate any feedback on how this can be improved.
-
-## User Preferences
-
-There have been a lot of improvements to preferences. Options for enabling audio-only by default and continuous playback (autoplay) have been added with [`e39dec9`](https://github.com/omarroth/invidious/e39dec9), with [`4b76b93`](https://github.com/omarroth/invidious/4b76b93), respectively. Users can also now mark videos as watched from their subscription feed and view watch history by going to https://invidio.us/feed/history. I expect to add more information to history so that it's easier to use. Folks can track progress with [#182](https://github.com/omarroth/invidious/issues/182). As with all data Invidious keeps, watch history can be exported [here](https://invidio.us/data_control).
-
-Users can now delete their account with [`b9c29bf`](https://github.com/omarroth/invidious/b9c29bf). This will remove _all_ user data from Invidious, including session IDs, watch history, and subscriptions. As mentioned above, it's easy to export that data and import it to a local instance, or export subscriptions for use with other applications such as [FreeTube](https://github.com/FreeTubeApp/FreeTube) or [NewPipe](https://github.com/TeamNewPipe/NewPipe).
-
-## Translation and Internationalis(z)ation
-
-Invidious has been approved for hosting by Weblate, available [here](https://hosted.weblate.org/projects/invidious/translations/). At the time of writing, translations for Arabic, Dutch, German, Polish, and Russian are currently underway. I would like to say a very big thank you to everyone working on them, and I hope to fully support them within around 2 weeks. Folks can track progress with [#251](https://github.com/omarroth/invidious/issues/251).
-
-## Transperency and Finances
-
-For the sake of transparency, I plan on publishing each month's finances. This is currently already done on Liberapay and Patreon, but there is not a total amount currently provided anywhere, and I would also like to include expenses to provide a better explanation of how patrons' money is being spent.
-
-### Donations
-
-- [Patreon](https://www.patreon.com/omarroth): \$43.60 (Patreon takes roughly 9%)
-- [Liberapay](https://liberapay.com/omarroth) : \$22.10
-- Crypto : ~\$1.25 (converted from BCH, BTC)
-- Total : \$66.95
-
-### Expenses
-
-- invidious-load1 (nyc1) : \$10.00 (load balancer)
-- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
-- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
-- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
-- Total : \$75.00
-
-I'd be happy to provide any explanation where needed. I would also like to thank everyone who donates, it really helps and I can't say how happy I am to see that so many people find it valuable.
-
-That's all for this month. I wish everyone the best for the holidays, and I'll see you all again in January!
-
-# 0.11.0 (2018-10-23)
-
-## Week 11: FreeTube and Styling
-
-This past Friday I'm been very excited to see that FreeTube version [0.4.0](https://github.com/FreeTubeApp/FreeTube/tree/0.4.0) has been released! I'd recommend taking a look at the official patch notes, but to spoil a little bit here: FreeTube now uses the Invidious API for _all_ requests previously sent to YouTube, and has also seen support for playlists, keyboard shortcuts, and more default settings (speed, autoplay, and subtitles). I'm happy to see that FreeTube has reached 500 stars on Github, and I think it's very much deserved. I'd recommend keeping an eye on the newly-launched [FreeTube blog](https://freetube.writeas.com/) for updates on the project.
-
-Quite a few styling changes have been added this past week, including channel subscriber count to the subscribe and unsubscribe buttons. The changes sound small, but they've been a very big improvement and I'm quite satisfied with how they look. Also to note is that partial support for duration in thumbnails have been added with [#202](https://github.com/omarroth/invidious/issues/202). Overall, I think the site is becoming much more pleasing visually, and I hope to continue to improve it.
-
-I've been very pleased to see Invidious in its current state, and I believe it's many times more mature compared to even a month ago. Changes have also started slowing down a bit as it's become more mature, and therefore I'd like to transition to a monthly update schedule in order to provide more comprehensive updates for everyone. I want to thank you all for helping me reach this point. I can't say how happy I am for Invidious to be where it is now.
-
-Enjoy the rest of your week everyone, I'll see you in November!
-
-# 0.10.0 (2018-10-16)
-
-## Week 10: Subscriptions
-
-This week I'm happy to announce that subscriptions have been drastically sped up with
-35e63fa. As I mentioned last week, this essentially "caches" a user's feed, meaning that operations that previously took 20 seconds or timed out, now can load in under a second. I'd take a look at [#173](https://github.com/omarroth/invidious/issues/173) for a sample benchmark. Previously features that made Invidious's feed so useful, such as filtering by unseen and by author would take too long to load, and so instead would timeout. I'm very happy that this has been fixed, and folks can get back to using these features.
-
-Among some smaller features that have been added this week include [#118](https://github.com/omarroth/invidious/issues/118), which adds, in my opinion, some very attractive subscribe and unsubscribe buttons. I think it's also a bit of a functional improvement as well, since it doesn't require a user to reload the page in order to subscribe or unsubscribe to a channel, and also gives the opportunity to put the channel's sub count on display.
-
-An option to swap between Reddit and YouTube comments without a page reload has been added with
-5eefab6, bringing it somewhat closer in functionality to the popular [AlienTube](https://github.com/xlexi/alientube) extension, on which it is based (although the extension unfortunately appears now to be fragmented).
-
-As always, there are a couple smaller improvements this week, including some minor fixes for geo-bypass with
-e46e618 and [`245d0b5`](https://github.com/omarroth/invidious/245d0b5), playlist preferences with [`81b4477`](https://github.com/omarroth/invidious/81b4477), and YouTube comments with [`02335f3`](https://github.com/omarroth/invidious/02335f3).
-
-This coming week I'd also recommend keeping an eye on the excellent [FreeTube](https://github.com/FreeTubeApp/FreeTube), which is looking forward to a new release. I've been very lucky to work with [**@PrestonN**](https://github.com/PrestonN) for the past few weeks to improve the Invidious API, and I'm quite looking forward to the new release.
-
-That's all for this week folks, thank you all again for your continued interest and support.
-
-# 0.9.0 (2018-10-08)
-
-## Week 9: Playlists
-
-Not as much to announce this week, but I'm still quite happy to announce a couple things, namely:
-
-Playback support for playlists has finally been added with [`88430a6`](https://github.com/omarroth/invidious/88430a6). You can now view playlists with the `&list=` query param, as you would on YouTube. You can also view mixes with the mentioned `&list=`, although they require some extra handling that I would like to add in the coming week, as well as adding playlist looping and shuffle. I think playback support has been a roadblock for more exciting features such as [#114](https://github.com/omarroth/invidious/issues/114), and I look forward to improving the experience.
-
-Comments have had a bit of a cosmetic upgrade with [#132](https://github.com/omarroth/invidious/issues/132), which I think helps better distinguish between Reddit and YouTube comments, as it makes them appear similarly to their respective sites. You can also now switch between YouTube and Reddit comments with a push of a button, which I think is quite an improvement, especially for newer or less popular videos with fewer comments.
-
-I've had a small breakthrough in speeding up users' subscription feeds with PostgreSQL's [materialized views](https://www.postgresql.org/docs/current/static/rules-materializedviews.html). Without going into too much detail, materialized views essentially cache the result of a query, making it possible to run resource-intensive queries once, rather than every time a user visits their feed. In the coming week I hope to push this out to users, and hopefully close [#173](https://github.com/omarroth/invidious/issues/173).
-
-I haven't had as much time to work on the project this week, but I'm quite happy to have added some new features. Have a great week everyone.
-
-# 0.8.0 (2018-10-02)
-
-## Week 8: Mixes
-
-Hello again!
-
-Mixes have been added with [`20130db`](https://github.com/omarroth/invidious/20130db), which makes it easy to create a playlist of related content. See [#188](https://github.com/omarroth/invidious/issues/188) for more info on how they work. Currently, they return the first 50 videos rather than a continuous feed to avoid tracking by Google/YouTube, which I think is a good trade-off between usability and privacy, and I hope other folks agree. You can create mixes by adding `RD` to the beginning of a video ID, an example is provided [here](https://www.invidio.us/mix?list=RDYE7VzlLtp-4) based on Big Buck Bunny. I've been quite happy with the results returned for the mixes I've tried, and it is not limited to music, which I think is a big plus. To emulate a continuous feed provided many are used to, using the last video of each mix as a new 'seed' has worked well for me. In the coming week I'd like to to add playback support in the player to listen to these easily.
-
-A very big thanks to [**@flourgaz**](https://github.com/flourgaz) for Docker support with [#186](https://github.com/omarroth/invidious/pull/186). This is an enormous improvement in portability for the project, and opens the door for Heroku support (see [#162](https://github.com/omarroth/invidious/issues/162)), and seamless support on Windows. For most users, it should be as easy as running `docker-compose up`.
-
-I've spent quite a bit of time this past week improving support for geo-bypass (see [#92](https://github.com/omarroth/invidious/issues/92)), and am happy to note that Invidious has been able to proxy ~50% of the geo-restricted videos I've tried. In addition, you can now watch geo-restricted videos if you have `dash` enabled as your `preferred quality`, for more details see [#34](https://github.com/omarroth/invidious/issues/34) and [#185](https://github.com/omarroth/invidious/issues/185), or last week's update. For folks interested in replicating these results for themselves, I'd take a look [here](https://gist.github.com/omarroth/3ce0f276c43e0c4b13e7d9cd35524688) for the script used, and [here](https://gist.github.com/omarroth/beffc4a76a7b82a422e1b36a571878ef) for a list of videos restricted in the US.
-
-1080p has seen a fairly smooth roll-out, although there have been a couple issues reported, mainly [#193](https://github.com/omarroth/invidious/issues/193), which is likely an issue in the player. I've also encountered a couple other issues myself that I would like to investigate. Although none are major, I'd like to keep 1080p opt-in for registered users another week to better address these issues.
-
-Have an excellent week everyone.
-
-# 0.7.0 (2018-09-25)
-
-## Week 7: 1080p and Search Types
-
-Hello again everyone! I've got quite a couple announcements this week:
-
-Experimental 1080p support has been added with [`b3ca392`](https://github.com/omarroth/invidious/b3ca392), and can be enabled by going to preferences and changing `preferred video quality` to `dash`. You can find more details [here](https://github.com/omarroth/invidious/issues/34#issuecomment-424171888). Currently quality and speed controls have not yet been integrated into the player, but I'd still appreciate feedback, mainly on any issues with buffering or DASH playback. I hope to integrate 1080p support into the player and push support site-wide in the coming weeks.
-
-You can now filter content types in search with the `type:TYPE` filter. Supported content types are `playlist`, `channel`, and `video`. More info is available [here](https://github.com/omarroth/invidious/issues/126#issuecomment-423823148). I think this is quite an improvement in usability and I hope others find the same.
-
-A [CHANGELOG](https://github.com/omarroth/invidious/blob/master/CHANGELOG.md) has been added to the repository, so folks will now receive a copy of all these updates when cloning. I think this is an improvement in hosting the project, as it is no longer tied to the `/releases` tab on Github or the posts on Patreon.
-
-Recently, users have been reporting 504s when attempting to access their subscriptions, which is tracked in [#173](https://github.com/omarroth/invidious/issues/173). This is most likely caused by an uptick in usage, which I am absolutely grateful for, but unfortunately has resulted in an increase in costs for hosting the site, which is why I will be bumping my goal on Patreon from $60 to $80. I would appreciate any feedback on how subscriptions could be improved.
-
-Other minor improvements include:
-
-- Additional regions added to bypass geo-block with [`9a78523`](https://github.com/omarroth/invidious/9a78523)
-- Fix for playlists containing less than 100 videos (previously shown as empty) with [`35ac887`](https://github.com/omarroth/invidious/35ac887)
-- Fix for `published` date for Reddit comments (previously showing negative seconds) with [`6e09202`](https://github.com/omarroth/invidious/6e09202)
-
-Thank you everyone for your support!
-
-# 0.6.0 (2018-09-18)
-
-## Week 6: Filters and Thumbnails
-
-Hello again! This week I'm happy to mention a couple new features to search as well as some miscellaneous usability improvements.
-
-You can now constrain your search query to a specific channel with the `channel:CHANNEL` filter (see [#165](https://github.com/omarroth/invidious/issues/165) for more details). Unfortunately, other search filters combined with channel search are not yet supported. I hope to add support for them in the coming weeks.
-
-You can also now search only your subscriptions by adding `subscriptions:true` to your query (see [#30](https://github.com/omarroth/invidious/issues/30) for more details). It's not quite ready for widespread use but I would appreciate feedback as the site updates to fully support it. Other search filters are not yet supported with `subscriptions:true`, but I hope to add more functionality to this as well.
-
-With [#153](https://github.com/omarroth/invidious/issues/153) and [#168](https://github.com/omarroth/invidious/issues/168) all images on the site are now proxied through Invidious. In addition to offering the user more protection from Google's eyes, it also allows the site to automatically pick out the highest resolution thumbnail for videos. I think this is quite a large aesthetic improvement and I hope others will find the same.
-
-As a smaller improvement to the site, you can also now view RSS feeds for playlists with [#113](https://github.com/omarroth/invidious/issues/113).
-
-These updates are also now listed under Github's [releases](https://github.com/omarroth/invidious/releases). I'm also planning on adding them as a `CHANGELOG.md` in the repository itself so people can receive a copy with the project's source.
-
-That's all for this week. Thank you everyone for your support!
-
-# 0.5.0 (2018-09-11)
-
-## Week 5: Privacy and Security
-
-I hope everyone had a good weekend! This past week I've been fixing some issues that have been brought to my attention to help better protect users and help them keep their anonymity.
-
-An issue with open referers has been fixed with [`29a2186`](https://github.com/omarroth/invidious/29a2186), which prevents potential redirects to external sites on actions such as login or modifying preferences.
-
-Additionally, X-XSS-Protection, X-Content-Type-Options, and X-Frame-Options headers have been added with [`96234e5`](https://github.com/omarroth/invidious/96234e5), which should keep users safer while using the site.
-
-A potential XSS vector has also been fixed in YouTube comments with [`8c45694`](https://github.com/omarroth/invidious/8c45694).
-
-All the above vulnerabilities were brought to my attention by someone who wishes to remain anonymous, but I would like to say again here how thankful I am. If anyone else would like to get in touch please feel free to email me at omarroth@hotmail.com or omarroth@protonmail.com.
-
-This week a couple changes have been made to better protect user's privacy as well.
-All CSS and JS assets are now served locally with [`3ec684a`](https://github.com/omarroth/invidious/3ec684a), which means users no longer need to whitelist unpkg.com. Although I personally have encountered few issues, I understand that many folks would like to keep their browsing activity contained to as few parties as possible. In the coming week I also hope to proxy YouTube images, so that no user data is sent to Google.
-
-YouTube links in comments now should redirect properly to the Invidious alternate with [`1c8bd67`](https://github.com/omarroth/invidious/1c8bd67) and [`cf63c82`](https://github.com/omarroth/invidious/cf63c82), so users can more easily evade Google tracking.
-
-I'm also happy to mention a couple quality of life features this week:
-
-Invidious now shows a video's "license" if provided, see [#159](https://github.com/omarroth/invidious/issues/159) for more details. You can also search for videos licensed under the creative commons with "QUERY features:creative_commons".
-
-Videos with only one source will always display the cog for changing quality, so that users can see what quality is currently playing. See [#158](https://github.com/omarroth/invidious/issues/158) for more details.
-
-Folks have also probably noticed that the gutters on either side of the screen have been shrunk down quite significantly, so that more of the screen is filled with content. Hopefully this can be improved even more in the coming weeks.
-
-"Music", "Sports", and "Popular on YouTube" channels now properly display their videos. You can subscribe to these channels just as you would normally.
-
-This coming week I'm planning on spending time with my family, so I unfortunately may not be as responsive. I do still hope to add some smaller features for next week however, and I hope to continue development soon.
-Thank you everyone again for your support.
-
-# 0.4.0 (2018-09-06)
-
-## Week 4: Genre Channels
-
-Hello! I hope everyone enjoyed their weekend. Without further ado:
-Just today genre channels have been added with [#119](https://github.com/omarroth/invidious/issues/119). More information on genre channels is available [here](https://support.google.com/youtube/answer/2579942). You can subscribe to them as normally, and view them as RSS. I think they offer an interesting alternative way to find new content and I hope people find them useful.
-
-This past week folks have started reporting 504s on their subscription page (see [#144](https://github.com/omarroth/invidious/issues/144) for more details). Upgrading the database server appeared to fix the issue, as well as providing a smoother experience across the site. Unfortunately, that means I will be increasing the goal from $50 to $60 in order to meet the increased hosting costs.
-
-With [#134](https://github.com/omarroth/invidious/issues/134), comments are now formatted correctly, providing support for bold, italics, and links in comments. I think this improvement makes them much easier to read, and I hope others find the same. Also to note is that links in both comments and the video description now no longer contain any of Google's tracking with [#115](https://github.com/omarroth/invidious/issues/115).
-
-One of the major use cases for Invidious is as a stripped-down version of YouTube. In line with that, I'm happy to announce that you can now hide related videos if you're logged in, for users that prefer an even more lightweight experience.
-
-Finally, I'm pleased to announce that Invidious has hit 100 stars on GitHub. I am very happy that Invidious has proven to be useful to so many people, and I can't say how grateful I am to everyone for their continued support.
-
-Enjoy the rest of your week everyone!
-
-# 0.3.0 (2018-09-06)
-
-## Week 3: Quality of Life
-
-Hello everyone! This week I've been working on some smaller features that will hopefully make the site more functional.
-Search filters have been added with [#126](https://github.com/omarroth/invidious/issues/126). You can now specify 'sort', 'date', 'duration', and 'features' within your query using the 'operator:value' syntax. I'd recommend taking a look [here](https://github.com/omarroth/invidious/blob/master/src/invidious/search.cr#L33-L114) for a list of supported options and at [#126](https://github.com/omarroth/invidious/issues/126) for some examples. This also opens the door for features such as [#30](https://github.com/omarroth/invidious/issues/30) which can be implemented as filters. I think advanced search is a major point in which Invidious can improve on YouTube and hope to add more features soon!
-
-This week a more advanced system for viewing fallback comments has been added (see [#84](https://github.com/omarroth/invidious/issues/84) for more details). You can now specify a comment fallback in your preferences, which Invidious will use. If, for example, no Reddit comments are available for a given video, it can choose to fallback on YouTube comments. This also makes it possible to turn comments off completely for users that prefer a more streamlined experience.
-
-With [#98](https://github.com/omarroth/invidious/issues/98), it is now possible for users to specify preferences without creating an account. You can now change speed, volume, subtitles, autoplay, loop, and quality using query parameters. See the issue above for more details and several examples.
-
-I'd also like to announce that I've set up an account on [Liberapay](https://liberapay.com/omarroth), for patrons that prefer a privacy-friendly alternative to Patreon. Liberapay also does not take any percentage of donations, so I'd recommend donating some to the Liberapay for their hard work. Go check it out!
-
-[Two weeks ago](https://github.com/omarroth/invidious/releases/tag/0.1.0) I mentioned adding 1080p support into the player. Currently, the only thing blocking is [#207](https://github.com/videojs/http-streaming/pull/207) in the excellent [http-streaming](https://github.com/videojs/http-streaming) library. I hope to work with the videojs team to merge it soon and finally implement 1080p support!
-
-That's all for this week, thank you again everyone for your support!
-
-# 0.2.0 (2018-09-06)
-
-## Week 2: Toward Playlists
-
-Sorry for the late update! Not as much to announce this week, but still a couple things of note:
-I'm happy to announce that a playlists page and API endpoint has been added so you can now view playlists. Currently, you cannot watch playlists through the player, but I hope to add that in the coming week as well as adding functionality to add and modify playlists. There is a good conversation on [#114](https://github.com/omarroth/invidious/issues/114) about giving playlists even more functionality, which I think is interesting and would appreciate feedback on.
-
-As an update to the Invidious API announcement last week, I've been working with [**@PrestonN**](https://github.com/PrestonN), the developer of [FreeTube](https://github.com/FreeTubeApp/FreeTube), to help migrate his project to the Invidious API. Because of it's increasing popularity, he has had trouble keeping under the quota set by YouTube's API. I hope to improve the API to meet his and others needs and I'd recommend folks to keep an eye on his excellent project! There is a good discussion with his thoughts [here](https://github.com/FreeTubeApp/FreeTube/issues/100).
-
-A couple of miscellaneous features and bugfixes:
-
-- You can now login to Invidious simultaneously from multiple devices - [#109](https://github.com/omarroth/invidious/issues/109)
-
-- Added a note for scheduled livestreams - [#124](https://github.com/omarroth/invidious/issues/124)
-
-- Changed YouTube comment header to "View x comments" - [#120](https://github.com/omarroth/invidious/issues/120)
-
-Enjoy your week everyone!
-
-# 0.1.0 (2018-09-06)
-
-## Week 1: Invidious API and Geo-Bypass
-
-Hello everyone! This past week there have been quite a few things worthy of mention:
-
-I'm happy to announce the [Invidious Developer API](https://github.com/omarroth/invidious/wiki/API). The Invidious API does not use any of the official YouTube APIs, and instead crawls the site to provide a JSON interface for other developers to use. It's still under development but is already powering [CloudTube](https://github.com/cloudrac3r/cadencegq). The API currently does not have a quota (compared to YouTube) which I hope to continue thanks to continued support from my Patrons. Hopefully other developers find it useful, and I hope to continue to improve it so it can better serve the community.
-
-Just today partial support for bypassing geo-restrictions has been added with [fada57a](https://github.com/omarroth/invidious/commit/fada57a307d66d696d9286fc943c579a3fd22de6). If a video is unblocked in one of: United States, Canada, Germany, France, Japan, Russia, or United Kingdom, then Invidious will be able to serve video info. Currently you will not yet be able to access the video files themselves, but in the coming week I hope to proxy videos so that users can enjoy content across borders.
-
-Support for generating DASH manifests has been fixed, in the coming week I hope to integrate this functionality into the watch page, so users can view videos in 1080p and above.
-
-Thank you everyone for your continued interest and support!
+# CHANGELOG + +## vX.Y.0 (future) + + +## v2.20241110.0 + +### Wrap-up + +This release is most importantly here to fix to the annoying "Youtube API returned error 400" +error that prevented all channel pages from loading. + +If you're updating from the previous release, it provides no improvements on the ability to play +videos. If updating from a commit in-between release, it removes the "Please sign in" error caused +by a previous attempt at restoring video playback on large instances. + +In the preferences, a new option allows for control of video preload. When enabled, this option +tells the browser to load the video as soon as the page is loaded (this used to be the default). +When disabled, the video starts loading only when the "play" button is pressed. + +New interface languages available: Bulgarian, Welsh and Lombard + +New dependency required: `tzdata`. + +An HTTP proxy can be configured directly in Invidious, if needed. \ +**NOTE:** In that case, it is recommended to comment out `force_resolve`. + + +### New features & important changes + +#### For users + +* Channels: Fix "Youtube API returned error 400" error preventing channel pages from loading +* Channels: Shorts can now be sorted by "newest", "oldest" and "popular" +* Preferences: Addition of the new "preload" option +* New interface languages available: Bulgarian, Welsh and Lombard +* Added "Filipino (auto-generated)" to the list of caption languages available +* Lots of new translations from Weblate + +#### For instance owners + +* Allow the configuration of an HTTP proxy to talk to Youtube +* Invidious tries to reconnect to `inv_sig_helper` if the socket is closed +* The instance list is downloaded in the background to improve redirection speed +* New `colorize_logs` option makes each log level a different color + +#### For developpers + +* `/api/v1/channels/{id}/shorts` now supports the `sort-by` parameter with the following values: + `newest`, `oldest` and `popular` +* Older `/api/v1/channels/xyz/{id}` (tab name before UCID) were removed +* API/Search: New video metadata available: `isNew`, `is4k`, `is8k`, `isVr180`, `isVr360`, + `is3d` and `hasCaptions` + +### Bugs fixed + +#### User-side + +* Channels: The second page of shorts now loads as expected +* Channels: Fixed intermittent empty "playlists" tab +* Search: Fixed `youtu.be` URLs not being properly redirected to the watch page +* Fixed `DB::MappingException` error on the subscriptions feed (due to missing `tzdata` in docker) +* Switching to another instance is much faster +* Fixed an "invalid byte sequence" error when subscribing to a playlist +* Videos: Playback URLs were sometimes broken when cached and `inv_sig_helper` was used + +#### For instance owners + +* Fix `force_resolve` being ignored in some cases + +#### API + +* API/Videos: Fixed `live_now` and `premiere_timestamp` sometimes not having the right values + + +### Full list of pull requests merged since the last release (newest first) + +* API: Add "sort_by" parameter to channels/shorts endpoint ([#5071], thanks @iBicha) +* Docker: Install tzdata in Dockerfile ([#5070], by @SamantazFox) +* Videos: Stop using TVHTML5_SIMPLY_EMBEDDED_PLAYER ([#5063], thanks @unixfox) +* Routing: Deprecate old channel API routes ([#5045], by @SamantazFox) +* Videos: use WEB client instead of WEB CREATOR ([#4984], thanks @unixfox) +* Parsers: Fix parsing live_now and premiere_timestamp ([#4934], thanks @absidue) +* Stale bot updates ([#5060], thanks @syeopite) +* Channels: Fix "Youtube API returned error 400" ([#5059], by @SamantazFox) +* Channels: Fix for live videos ([#5027], thanks @iBicha) +* Locales: Add Bulgarian, Welsh and Lombard to the list ([#5046], by @SamantazFox) +* Shards: Update database dependencies ([#5034], by @SamantazFox) +* Logger: Add color support for different log levels ([#4931], thanks @Fijxu) +* Fix named arg syntax when passing force_resolve ([#4754], thanks @syeopite) +* Use make_client instead of calling HTTP::Client ([#4709], thanks @syeopite) +* Add "Filipino (auto-generated)" to the list of caption languages ([#4995], by @SamantazFox) +* Makefile: Add MT option to enable the 'preview_mt' flag ([#4993], by @SamantazFox) +* SigHelper: Reconnect to signature helper ([#4991], thanks @Fijxu) +* Fix player menus hiding onHover ready ([#4750], thanks @giacomocerquone) +* Use connection pools when requesting images from YouTube ([#4326], thanks @syeopite) +* Add support for using Invidious through a HTTP Proxy ([#4270], thanks @syeopite) +* Search: Fix 'youtu.be' URLs in sanitizer ([#4894], by @SamantazFox) +* Ameba: Disable Style/RedundantNext rule ([#4888], thanks @syeopite) +* Playlists: Fix 'invalid byte sequence' error when subscribing ([#4887], thanks @DmitrySandalov) +* Parse more metadata badges for SearchVideos ([#4863], thanks @ChunkyProgrammer) +* Translations update from Hosted Weblate ([#4862], thanks to our many translators) +* Videos: Convert URL before putting result into cache ([#4850], by @SamantazFox) +* HTML: Add error message to "search issues on GitHub" link ([#4652], thanks @tracedgod) +* Preferences: Add option to control preloading of video data ([#4122], thanks @Nerdmind) +* Performance: Improve speed of automatic instance redirection ([#4193], thanks @syeopite) +* Remove myself from CODEOWNERS on the config file ([#4942], by @TheFrenchGhosty) +* Update latest version WEB_CREATOR + fix comment web embed ([#4930], thanks @unixfox) +* use WEB_CREATOR when po_token with WEB_EMBED as a fallback ([#4928], thanks @unixfox) +* Revert "use web screen embed for fixing potoken functionality" +* use web screen embed for fixing potoken functionality ([#4923], thanks @unixfox) + +[#4122]: https://github.com/iv-org/invidious/pull/4122 +[#4193]: https://github.com/iv-org/invidious/pull/4193 +[#4270]: https://github.com/iv-org/invidious/pull/4270 +[#4326]: https://github.com/iv-org/invidious/pull/4326 +[#4652]: https://github.com/iv-org/invidious/pull/4652 +[#4709]: https://github.com/iv-org/invidious/pull/4709 +[#4750]: https://github.com/iv-org/invidious/pull/4750 +[#4754]: https://github.com/iv-org/invidious/pull/4754 +[#4850]: https://github.com/iv-org/invidious/pull/4850 +[#4862]: https://github.com/iv-org/invidious/pull/4862 +[#4863]: https://github.com/iv-org/invidious/pull/4863 +[#4887]: https://github.com/iv-org/invidious/pull/4887 +[#4888]: https://github.com/iv-org/invidious/pull/4888 +[#4894]: https://github.com/iv-org/invidious/pull/4894 +[#4923]: https://github.com/iv-org/invidious/pull/4923 +[#4928]: https://github.com/iv-org/invidious/pull/4928 +[#4930]: https://github.com/iv-org/invidious/pull/4930 +[#4931]: https://github.com/iv-org/invidious/pull/4931 +[#4934]: https://github.com/iv-org/invidious/pull/4934 +[#4942]: https://github.com/iv-org/invidious/pull/4942 +[#4984]: https://github.com/iv-org/invidious/pull/4984 +[#4991]: https://github.com/iv-org/invidious/pull/4991 +[#4993]: https://github.com/iv-org/invidious/pull/4993 +[#4995]: https://github.com/iv-org/invidious/pull/4995 +[#5027]: https://github.com/iv-org/invidious/pull/5027 +[#5034]: https://github.com/iv-org/invidious/pull/5034 +[#5045]: https://github.com/iv-org/invidious/pull/5045 +[#5046]: https://github.com/iv-org/invidious/pull/5046 +[#5059]: https://github.com/iv-org/invidious/pull/5059 +[#5060]: https://github.com/iv-org/invidious/pull/5060 +[#5063]: https://github.com/iv-org/invidious/pull/5063 +[#5070]: https://github.com/iv-org/invidious/pull/5070 +[#5071]: https://github.com/iv-org/invidious/pull/5071 + + +## v2.20240825.2 (2024-08-26) + +This releases fixes the container tags pushed on quay.io. +Previously, the ARM64 build was released under the `latest` tag, instead of `latest-arm64`. + +### Full list of pull requests merged since the last release (newest first) + +CI: Fix docker container tags ([#4883], by @SamantazFox) + +[#4877]: https://github.com/iv-org/invidious/pull/4877 + + +## v2.20240825.1 (2024-08-25) + +Add patch component to be [semver] compliant and make github actions happy. + +[semver]: https://semver.org/ + +### Full list of pull requests merged since the last release (newest first) + +Allow manual trigger of release-container build ([#4877], thanks @syeopite) + +[#4877]: https://github.com/iv-org/invidious/pull/4877 + + +## v2.20240825.0 (2024-08-25) + +### New features & important changes + +#### For users + +* The search bar now has a button that you can click! +* Youtube URLs can be pasted directly in the search bar. Prepend search query with a + backslash (`\`) to disable that feature (useful if you need to search for a video whose + title contains some youtube URL). +* On the channel page the "streams" tab can be sorted by either: "newest", "oldest" or "popular" +* Lots of translations have been updated (thanks to our contributors on Weblate!) +* Videos embedded in local HTML files (e.g: a webpage saved from a blog) can now be played + +#### For instance owners + +* Invidious now has the ability to provide a `po_token` and `visitordata` to Youtube in order to + circumvent current Youtube restrictions. +* Invidious can use an (optional) external signature server like [inv_sig_helper]. Please note that + some videos can't be played without that signature server. +* The Helm charts were moved to a separate repo: https://github.com/iv-org/invidious-helm-chart +* We have changed how containers are released: the `latest` tag now tracks tagged releases, whereas + the `master` tag tracks the most recent commits of the `master` branch ("nightly" builds). + +[inv_sig_helper]: https://github.com/iv-org/inv_sig_helper + +#### For developpers + +* The versions of Crystal that we test in CI/CD are now: `1.9.2`, `1.10.1`, `1.11.2`, `1.12.1`. + Please note that due to a bug in the `libxml` bindings (See [#4256]), versions prior to `1.10.0` + are not recommended to use. +* Thanks to @syeopite, the code is now [ameba] compliant. +* Ameba is part of our CI/CD pipeline, and its rules will be enforced in future PRs. +* The transcript code has been rewritten to permit transcripts as a feature rather than being + only a workaround for captions. Trancripts feature is coming soon! +* Various fixes regarding the logic interacting with Youtube +* The `sort_by` parameter can be used on the `/api/v1/channels/{id}/streams` endpoint. Accepted + values are: "newest", "oldest" and "popular" + +[ameba]: https://github.com/crystal-ameba/ameba +[#4256]: https://github.com/iv-org/invidious/issues/4256 + + +### Bugs fixed + +#### User-side + +* Channels: fixed broken "subscribers" and "views" counters +* Watch page: playback position is reset at the end of a video, so that the next time this video + is watched, it will start from the beginning rather than 15 seconds before the end +* Watch page: the items in the "add to playlist" drop down are now sorted alphabetically +* Videos: the "genre" URL is now always pointing to a valid webpage +* Playlists: Fixed `Could not parse N episodes` error on podcast playlists +* All external links should now have the [`rel`] attibute set to `noreferrer noopener` for + increased privacy. +* Preferences: Fixed the admin-only "modified source code" input being ignored +* Watch/channel pages: use the full image URL in `og:image` and `twitter:image` meta tags + +[`rel`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel + +#### API + +* fixed the `local` parameter not applying to `formatStreams` on `/api/v1/videos/{id}` +* fixed an `Index out of bounds` error hapenning when a playlist had no videos +* fixed duplicated query parameters in proxied video URLs +* Return actual video height/width/fps rather than hard coded values +* Fixed the `/api/v1/popular` endpoint not returning a proper error code/message when the + popular page/endpoint are disabled. + + +### Full list of pull requests merged since the last release (newest first) + +* HTML: Sort playlists alphabetically in watch page drop down ([#4853], by @SamantazFox) +* Videos: Fix XSS vulnerability in description/comments ([#4852], thanks _anonymous_) +* YtAPI: Bump client versions ([#4849], by @SamantazFox) +* SigHelper: Fix inverted time comparison in 'check_update' ([#4845], by @SamantazFox) +* Storyboards: Various fixes and code cleaning ([#4153], by SamantazFox) +* Fix lint errors introduced in #4146 and #4295 ([#4876], thanks @syeopite) +* Search: Add support for Youtube URLs ([#4146], by @SamantazFox) +* Channel: Render age restricted channels ([#4295], thanks @ChunkyProgrammer) +* Ameba: Miscellaneous fixes ([#4807], thanks @syeopite) +* API: Proxy formatStreams URLs too ([#4859], thanks @colinleroy) +* UI: Add search button to search bar ([#4706], thanks @thansk) +* Add ability to set po_token and visitordata ID ([#4789], thanks @unixfox) +* Add support for an external signature server ([#4772], by @SamantazFox) +* Ameba: Fix Naming/VariableNames ([#4790], thanks @syeopite) +* Translations update from Hosted Weblate ([#4659]) +* Ameba: Fix Lint/UselessAssign ([#4795], thanks @syeopite) +* HTML: Add rel="noreferrer noopener" to external links ([#4667], thanks @ulmemxpoc) +* Remove unused methods in Invidious::LogHandler ([#4812], thanks @syeopite) +* Ameba: Fix Lint/NotNilAfterNoBang ([#4796], thanks @syeopite) +* Ameba: Fix unused argument Lint warnings ([#4805], thanks @syeopite) +* Ameba: i18next.cr fixes ([#4806], thanks @syeopite) +* Ameba: Disable rules ([#4792], thanks @syeopite) +* Channel: parse subscriber count and channel banner ([#4785], thanks @ChunkyProgrammer) +* Player: Fix playback position of already watched videos ([#4731], thanks @Fijxu) +* Videos: Fix genre url being unusable ([#4717], thanks @meatball133) +* API: Fix out of bound error on empty playlists ([#4696], thanks @Fijxu) +* Handle playlists cataloged as Podcast ([#4695], thanks @Fijxu) +* API: Fix duplicated query parameters in proxied video URLs ([#4587], thanks @absidue) +* API: Return actual stream height, width and fps ([#4586], thanks @absidue) +* Preferences: Fix handling of modified source code URL ([#4437], thanks @nooptek) +* API: Fix URL for vtt subtitles ([#4221], thanks @karelrooted) +* Channels: Add sort options to streams ([#4224], thanks @src-tinkerer) +* API: Fix error code for disabled popular endpoint ([#4296], thanks @iBicha) +* Allow embedding videos in local HTML files ([#4450], thanks @tomasz1986) +* CI: Bump Crystal version matrix ([#4654], by @SamantazFox) +* YtAPI: Remove API keys like official clients ([#4655], by @SamantazFox) +* HTML: Use full URL in the og:image property ([#4675], thanks @Fijxu) +* Rewrite transcript logic to be more generic ([#4747], thanks @syeopite) +* CI: Run Ameba ([#4753], thanks @syeopite) +* CI: Add release based containers ([#4763], thanks @syeopite) +* move helm chart to a dedicated github repository ([#4711], thanks @unixfox) + +[#4146]: https://github.com/iv-org/invidious/pull/4146 +[#4153]: https://github.com/iv-org/invidious/pull/4153 +[#4221]: https://github.com/iv-org/invidious/pull/4221 +[#4224]: https://github.com/iv-org/invidious/pull/4224 +[#4295]: https://github.com/iv-org/invidious/pull/4295 +[#4296]: https://github.com/iv-org/invidious/pull/4296 +[#4437]: https://github.com/iv-org/invidious/pull/4437 +[#4450]: https://github.com/iv-org/invidious/pull/4450 +[#4586]: https://github.com/iv-org/invidious/pull/4586 +[#4587]: https://github.com/iv-org/invidious/pull/4587 +[#4654]: https://github.com/iv-org/invidious/pull/4654 +[#4655]: https://github.com/iv-org/invidious/pull/4655 +[#4659]: https://github.com/iv-org/invidious/pull/4659 +[#4667]: https://github.com/iv-org/invidious/pull/4667 +[#4675]: https://github.com/iv-org/invidious/pull/4675 +[#4695]: https://github.com/iv-org/invidious/pull/4695 +[#4696]: https://github.com/iv-org/invidious/pull/4696 +[#4706]: https://github.com/iv-org/invidious/pull/4706 +[#4711]: https://github.com/iv-org/invidious/pull/4711 +[#4717]: https://github.com/iv-org/invidious/pull/4717 +[#4731]: https://github.com/iv-org/invidious/pull/4731 +[#4747]: https://github.com/iv-org/invidious/pull/4747 +[#4753]: https://github.com/iv-org/invidious/pull/4753 +[#4763]: https://github.com/iv-org/invidious/pull/4763 +[#4772]: https://github.com/iv-org/invidious/pull/4772 +[#4785]: https://github.com/iv-org/invidious/pull/4785 +[#4789]: https://github.com/iv-org/invidious/pull/4789 +[#4790]: https://github.com/iv-org/invidious/pull/4790 +[#4792]: https://github.com/iv-org/invidious/pull/4792 +[#4795]: https://github.com/iv-org/invidious/pull/4795 +[#4796]: https://github.com/iv-org/invidious/pull/4796 +[#4805]: https://github.com/iv-org/invidious/pull/4805 +[#4806]: https://github.com/iv-org/invidious/pull/4806 +[#4807]: https://github.com/iv-org/invidious/pull/4807 +[#4812]: https://github.com/iv-org/invidious/pull/4812 +[#4845]: https://github.com/iv-org/invidious/pull/4845 +[#4849]: https://github.com/iv-org/invidious/pull/4849 +[#4852]: https://github.com/iv-org/invidious/pull/4852 +[#4853]: https://github.com/iv-org/invidious/pull/4853 +[#4859]: https://github.com/iv-org/invidious/pull/4859 +[#4876]: https://github.com/iv-org/invidious/pull/4876 + + +## v2.20240427 (2024-04-27) + +Major bug fixes: + * Videos: Use android test suite client (#4650, thanks @SamantazFox) + * Trending: Un-nest category if this is the only one (#4600, thanks @ChunkyProgrammer) + * Comments: Add support for new format (#4576, thanks @ChunkyProgrammer) + +Minor bug fixes: + * API: Add bitrate to formatStreams too (#4590, thanks @absidue) + * API: Add 'authorVerified' field on recommended videos (#4562, thanks @ChunkyProgrammer) + * Videos: Add support for new likes format (#4462, thanks @ChunkyProgrammer) + * Proxy: Handle non-200 HTTP codes on DASH manifests (#4429, thanks @absidue) + +Other improvements: + * Remove legacy proxy code (#4570, thanks @syeopite) + * API: convey info "is post live" from Youtube response (#4569, thanks @ChunkyProgrammer) + * API: Parse channel's tags (#4294, thanks @ChunkyProgrammer) + * Translations update from Hosted Weblate (#4164, thanks to our many translators) diff --git a/CHANGELOG_legacy.md b/CHANGELOG_legacy.md new file mode 100644 index 00000000..8aa416ec --- /dev/null +++ b/CHANGELOG_legacy.md @@ -0,0 +1,844 @@ +# Note: This is no longer updated and links to omarroths repo, which doesn't exist anymore.
+
+# 0.20.0 (2019-011-06)
+
+# Version 0.20.0: Custom Playlists
+
+It's been quite a while since the last release! There've been [198 commits](https://github.com/omarroth/invidious/compare/0.19.0..0.20.0) from 27 contributors.
+
+A couple smaller features have since been added. Channel pages and playlists in particular have received a bit of a face-lift, with both now displaying their descriptions as expected, and playlists providing video count and published information. Channels will also now provide video descriptions in their RSS feed.
+
+Turkish (tr), Chinese (zh-TW, in addition to zh-CN), and Japanese (jp) are all now supported languages. Thank you as always to the hard work done by translators that makes this possible.
+
+The feed menu and default home page are both now configurable for registered and unregistered users, and is quite a bit of an improvement for users looking to reduce distractions for their daily use.
+
+## For Administrators
+
+`feed_menu` and `default_home` are now configurable by the user, and have therefore been moved into `default_user_preferences`:
+
+```yaml
+feed_menu: ["Popular", "Top"]
+default_home: Top
+
+# becomes:
+
+default_user_preferences:
+ feed_menu: ["Popular", "Top"]
+ default_home: Top
+```
+
+Several new options have also been added, including the ability to set a support email for the instance using `admin_email: EMAIL`, and forcing the use of a specific connection in the case of rate-limiting using `force_resolve` (see below).
+
+## For Developers
+
+Authenticated endpoints are now [properly documented](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints), as well how to generate and use API tokens. My hope is that this makes some of the more [interesting](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authnotifications) endpoints more accessible for developers to use in their own applications.
+
+API endpoints for interacting with custom playlists have also been added with documentation available [here](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authplaylists).
+
+## Custom playlists
+
+This is probably the feature that has been the longest in the pipe and that I'm quite pleased is now implemented. It is now possible to create custom playlists, which can be played and edited through Invidious. API endpoints have also been added (documentation [here](https://github.com/omarroth/invidious/wiki/Authenticated-Endpoints#get-apiv1authplaylists)).
+
+Overall I'm quite pleased with how smoothly it has been rolled out and with the experience so far, and I'm exctited for how it can be extended and improved in future.
+
+## [instances.invidio.us](https://instances.invidio.us)
+
+It is now possible to view a list of public instances (as provided in the [wiki](https://github.com/omarroth/invidious/wiki/Invidious-Instances)) through an API or a pretty new interface [here](https://instances.invidio.us). It combines uptime information, statistics from each instance and basic information already provided in the wiki. I expect it should be much more user-friendly than compiling the information yourself, and is already used by [Invidition](https://codeberg.org/Booteille/Invidition) to provide a list of instances for users to choose from.
+
+The site itself is licensed under the AGPLv3 and the source is available [here](https://github.com/omarroth/instances.invidio.us).
+
+## Video unavailable [#811](https://github.com/omarroth/invidious/issues/811)
+
+Many users have likely noticed this error message if using Invidious directly or through another service, such as FreeTube. This issue is caused by rate-limiting by Google, and is not a new issuee for projects like Invidious (notably [youtube-dl](https://github.com/ytdl-org/youtube-dl#http-error-429-too-many-requests-or-402-payment-required)) and appears to be affecting smaller, private instances as well.
+
+There is not a permanent fix for administrators currently, however there is some information available [here](https://github.com/omarroth/invidious/issues/811#issuecomment-540017772) that may provide a temporary solution. Unfortanately, in most cases the best option is to wait for the instance to be unbanned or to move the instance to a different IP. A more informative error message is also now provided, which should help an administrator more quickly diagnose the problem.
+
+For those interested, I would recommend following [#811](https://github.com/omarroth/invidious/issues/811) for any future progress on the issue.
+
+## BAT verified publisher
+
+I'm quite late to this announcement, however I'm pleased to mention that Invidious is now a BAT verified publisher! I would recommend looking [here](https://basicattentiontoken.org/about/) or [here](https://www.reddit.com/r/BATProject/comments/7cr7yc/new_to_bat_read_this_introduction_to_basic/) for learning more about what it is and how it works. Overall I think it makes an interesting substitute for services like Liberapay, and a (hopefully) much less-intrusive alternative to direct advertising.
+
+BAT is combined under other cryptocurrencies below. Currently there's a fairly significant delay in payout, which is the reason for the large fluctuation in crypto donations between September and October (and also the reason for the late announcement).
+
+## Release schedule
+
+Currently I'm quite pleased with the current state of the project. There's plenty of things I'd still like to add, however at this point I expect the rate of most new additions will slow down a bit, with more focus on stabililty and any long-standing bugs.
+
+Because of this, I'm planning on releasing a new version quarterly, with any necessary hotfixes being pushed as a new patch release as necessary. As always it will be possible to run Invidious directly from [master](https://github.com/omarroth/invidious/wiki/Updating) if you'd still like to have the lastest version.
+
+I'll plan on providing finances each release, with a similar monthly breakdown as below.
+
+## Finances for September 2019
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$64.37
+- [Liberapay](https://liberapay.com/omarroth) : \$76.04
+- Crypto : ~\$99.89 (converted from BAT, BCH, BTC)
+- Total : \$240.30
+
+### Expenses
+
+- invidious-lb1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node11 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node12 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node13 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node14 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node15 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node16 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$135.00
+
+## Finances for October 2019
+
+- [Liberapay](https://liberapay.com/omarroth) : \$134.40
+- Crypto : ~\$8.29 (converted from BAT, BCH, BTC)
+- Total : \$142.69
+
+### Expenses
+
+- invidious-lb1 (nyc1) : \$5.00 (load balancer)
+- invidious-lb2 (nyc1) : \$5.00 (load balancer)
+- invidious-lb3 (nyc1) : \$5.00 (load balancer)
+- invidious-lb4 (nyc1) : \$5.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node11 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node12 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node13 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node14 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node15 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node16 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node17 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node18 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$155.00
+
+# 0.19.0 (2019-07-13)
+
+# Version 0.19.0: Communities
+
+Hello again everyone! Focus this month has mainly been on improving playback performance, along with a couple new features I'd like to announce. There have been [109 commits](https://github.com/omarroth/invidious/compare/0.18.0...0.19.0) this past month from 10 contributors.
+
+This past month has seen the addition of Chinese (`zh-CN`) and Icelandic (`is`) translations. I would like to give a huge thanks to their respective translators, and again an enormous thanks to everyone who helps translate the site.
+
+I'm delighted to mention that [FreeTube 0.6.0](https://github.com/FreeTubeApp/FreeTube) now supports 1080p thanks to the Invidious API. I would very much recommend reading the [relevant post](https://freetube.writeas.com/freetube-release-0-6-0-beta-1080p-and-a-lot-of-qol) for some more information on how it works, along with several other major improvements. Folks that are interested in adding similar functionality for their own projects should feel free to get in touch.
+
+This past month there has been quite a bit of work on improving memory usage and improving download and playback speeds. As mentioned in the previous release, some extra hardware has been allocated which should also help with this. I'm still looking for ways to improve performance and feedback is always appreciated.
+
+Along with performance, a couple quality of life improvements have been added, including author thumbnails and banners, clickable titles for embedded videos, and better styling for captions, among some other enhancements.
+
+## Communities
+
+Support for YouTube's [communities tab](https://creatoracademy.youtube.com/page/lesson/community-tab) has been added. It's a very interesting but surprisingly unknown feature. Essentially, providing comments for a channel, rather than a video, where an author can post updates for their subscribers.
+
+It's commonly used to promote interesting links and foster discussion. I hope this feature helps people find more interesting content that otherwise would have been overlooked.
+
+## For Developers
+
+For accessing channel communities, an `/api/v1/channels/comments/:ucid` endpoint has been added, with similar behavior and schema to `/api/v1/comments/:id`, with an extra `attachment` field for top-level comments. More info on usage and available data can be found in the [wiki](https://github.com/omarroth/invidious/wiki/API#get-apiv1channelscommentsucid-apiv1channelsucidcomments).
+
+An `/api/v1/auth/feeds` endpoint has been added for programmatically accessing a user's subscription feed, with options for displaying notifications and filtering an existing feed.
+
+An `/api/v1/search/suggestions` endpoint has been added for retrieving suggestions for a given query.
+
+## For Administrators
+
+It is now possible to disable more resource intensive features, such as downloads and DASH functionality by adding `disable_proxy` to your config. See [#453](https://github.com/omarroth/invidious/issues/453) and the [Wiki](https://github.com/omarroth/invidious/wiki/Configuration) for more information and example usage. I expect this to be a big help for folks with limited bandwidth when hosting their own instances.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$38.39
+- [Liberapay](https://liberapay.com/omarroth) : \$84.85
+- Crypto : ~\$0.00 (converted from BCH, BTC)
+- Total : \$123.24
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node7 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node8 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node9 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node10 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$105.00
+
+The goal on Patreon has been updated to reflect the above expenses. As mentioned above, the main reason for more hardware is to improve playback and download speeds, although I'm still looking into improving performance without allocating more hardware.
+
+As always I'm grateful for everyone's support and feedback. I'll see you all next month.
+
+# 0.18.0 (2019-06-06)
+
+# Version 0.18.0: Native Notifications and Optimizations
+
+Hope everyone has been doing well. This past month there have been [97 commits](https://github.com/omarroth/invidious/compare/0.17.0...0.18.0) from 10 contributors. For the most part changes this month have been on optimizing various parts of the site, mainly subscription feeds and support for serving images and other assets.
+
+I'm quite happy to mention that support for Greek (`el`) has been added, which I hope will continue to make the site accessible for more users.
+
+Subscription feeds will now only update when necessary, rather than periodically. This greatly lightens the load on DB as well as making the feeds generally more responsive when changing subscriptions, importing data, and when receiving new uploads.
+
+Caching for images and other assets should be greatly improved with [#456](https://github.com/omarroth/invidious/issues/456). JavaScript has been pulled out into separate files where possible to take advantage of this, which should result in lighter pages and faster load times.
+
+This past month several people have encountered issues with downloads and watching high quality video through the site, see [#532](https://github.com/omarroth/invidious/issues/532) and [#562](https://github.com/omarroth/invidious/issues/562). For this coming month I've allocated some more hardware which should help with this, and I'm also looking into optimizing how videos are currently served.
+
+## For Developers
+
+`viewCount` is now available for `/api/v1/popular` and all videos returned from `/api/v1/auth/notifications`. Both also now provide `"type"` for indicating available information for each object.
+
+An `/authorize_token` page is now available for more easily creating new tokens for use in applications, see [this comment](https://github.com/omarroth/invidious/issues/473#issuecomment-496230812) in [#473](https://github.com/omarroth/invidious/issues/473) for more details.
+
+A POST `/api/v1/auth/notifications` endpoint is also now available for correctly returning notifications for 150+ channels.
+
+## For Administrators
+
+There are two new schema changes for administrators: `views` for adding view count to the popular page, and `feed_needs_update` for tracking feed changes.
+
+As always the relevant migration scripts are provided which should run when following instructions for [updating](https://github.com/omarroth/invidious/wiki/Updating). Otherwise, adding `check_tables: true` to your config will automatically make the required changes.
+
+## Native Notifications
+
+[<img src="https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png" height="160" width="472">](https://omar.yt/81c3ae1839831bd9300d75e273b6552a86dc2352/native_notification.png "Example of native notification, available in repository under screnshots/native_notification.png")
+
+It is now possible to receive [Web notifications](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) from subscribed channels.
+
+You can enable notifications by clicking "Enable web notifications" in your preferences. Generally they appear within 20-60 seconds of a new video being uploaded, and I've found them to be an enormous quality of life improvement.
+
+Although it has been fairly stable, please feel free to report any issues you find [here](https://github.com/omarroth/invidious/issues) or emailing me directly at omarroth@protonmail.com.
+
+Important to note for administrators is that instances require [`use_pubsub_feeds`](https://github.com/omarroth/invidious/wiki/Configuration) and must be served over HTTPS in order to correctly send web notifications.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$49.73
+- [Liberapay](https://liberapay.com/omarroth) : \$100.57
+- Crypto : ~\$11.12 (converted from BCH, BTC)
+- Total : \$161.42
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node6 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$85.00
+
+See you all next month!
+
+# 0.17.0 (2019-05-06)
+
+# Version 0.17.0: Player and Authentication API
+
+Hello everyone! This past month there have been [130 commits](https://github.com/omarroth/invidious/compare/0.16.0..0.17.0) from 11 contributors. Large focus has been on improving the player as well as adding API access for other projects to make use of Invidious.
+
+There have also been significant changes in preparation of native notifications (see [#195](https://github.com/omarroth/invidious/issues/195), [#469](https://github.com/omarroth/invidious/issues/469), [#473](https://github.com/omarroth/invidious/issues/473), and [#502](https://github.com/omarroth/invidious/issues/502)), and playlists. I expect to see both of these to be added in the next release.
+
+I'm quite happy to mention that new translations have been added for Esperanto (`eo`) and Ukranian (`uk`). Support for pluralization has also been added, so it should now be possible to make a more native experience for speakers in other languages. The system currently in place is a bit cumbersome, so for any help using this feature please get in touch!
+
+## For Administrators
+
+A `check_tables` option has been added to automatically migrate without the use of custom scripts. This method will likely prove to be much more robust, and is currently enabled for the official instance. To prevent any unintended changes to the DB, `check_tables` is disabled by default and will print commands before executing. Having this makes features that require schema changes much easier to implement, and also makes it easier to upgrade from older instances.
+
+As part of [#303](https://github.com/omarroth/invidious/issues/303), a `cache_annotations` option has been added to speed up access from `/api/v1/annotations/:id`. This vastly improves the experience for videos with annotations. Currently, only videos that contain legacy annotations will be cached, which should help keep down the size of the cache. `cache_annotations` is disabled by default.
+
+## For Developers
+
+An authorization API has been added which allows other applications to read and modify user subscriptions and preferences (see [#473](https://github.com/omarroth/invidious/issues/473)). Support for accessing user feeds and notifications is also planned. I believe this feature is a large step forward in supporting syncing subscriptions and preferences with other services, and I'm excited to see what other developers do with this functionality.
+
+Support for server-to-client push notifications is currently underway. This allows Invidious users, as well as applications using the Invidious API, to receive notifications about uploads in near real-time (see #469). An `/api/v1/auth/notifications` endpoint is currently available. I'm very excited for this to be integrated into the site, and to see how other developers use it in their own projects.
+
+An `/api/v1/storyboards/:id` endpoint has been added for accessing storyboard URLs, which allows developers to add video previews to their players (see below).
+
+## Player
+
+Support for annotations has been merged into master with [#303](https://github.com/omarroth/invidious/issues/303), thanks @glmdgrielson! Annotations can be enabled by default or only for subscribed channels, and can also be toggled per video. I'm extremely proud of the progress made here, and I'm so thankful to everyone that has made this possible. I expect this to be the last update with regards to supporting annotations, but I do plan on continuing to improve the experience as much as possible.
+
+The Invidious player now supports video previews and a corresponding API endpoint `/api/v1/storyboards/:id` has been added for developers looking to add similar functionality to their own players. Not much else to say here. Overall it's a very nice quality of life improvement and an attractive addition to the site.
+
+It is now possible to select specific sources for videos provided using DASH (see [#34](https://github.com/omarroth/invidious/issues/34)). I would consider support largely feature complete, although there are still several issues to be fixed before I would consider it ready for larger rollout. You can watch videos in 1080p by setting `Default quality` to `dash` in your preferences, or by adding `&quality=dash` to the end of video URLs.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$49.73
+- [Liberapay](https://liberapay.com/omarroth) : \$63.03
+- Crypto : ~\$0.00 (converted from BCH, BTC)
+- Total : \$112.76
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$80.00
+
+That's all for now. Thanks!
+
+# 0.16.0 (2019-04-06)
+
+# Version 0.16.0: API Improvements and Annotations
+
+Hello again! This past month has seen [116 commits](https://github.com/omarroth/invidious/compare/0.15.0..0.16.0) from 13 contributors and a couple important changes I'd like to announce.
+
+A privacy policy is now available [here](https://invidio.us/privacy). I've done my best to explain things as clearly as possible without oversimplifying, and would very much recommend reading it if you're concerned about your privacy and want to learn more about how Invidious uses your data. Please let me know if there is anything that needs clarification.
+
+I'm also very happy to announce that a Spanish translation has been added to the site. You can use it with `?hl=es` or by setting `es` as your default locale. As always I'm extremely grateful to translators for making the site accessible to more people.
+
+## For Administrators
+
+Invidious now supports server-to-server [push notifications](https://developers.google.com/youtube/v3/guides/push_notifications). This uses [PubSubHubbub](https://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html) to automatically handle new videos sent to an instance, which is less resource intensive and generally faster. Note that it will not pull all videos from a subscribed channel, so recommended usage is in addition to `channel_threads`. Using PubSub requires a valid `domain` that updates can be sent to, and a random string that can be used to sign updates sent to the instance. You can enable it by adding `use_pubsub_feeds: true` to your `config.yml`. See [Configuration](https://github.com/omarroth/invidious/wiki/Configuration) for more info.
+
+Unfortunately there are a couple necessary changes to the DB to support `liveNow` and `premiereTimestamp` in subscription feeds. Migration scripts have been provided that should be used automatically if following the instructions [here](https://github.com/omarroth/invidious/wiki/Updating).
+
+You can now configure default user preferences for your instance. This allows you to set default locale, player preferences, and more. See [#415](https://github.com/omarroth/invidious/issues/415) for more details and example usage.
+
+## For Developers
+
+The [fields](https://developers.google.com/youtube/v3/getting-started#fields) API has been added with [#429](https://github.com/omarroth/invidious/pull/429) and is now supported on all JSON endpoints, thanks [**@afrmtbl**](https://github.com/afrmtbl)! Synax is straight-forward and can be used to reduce data transfer and create a simpler response for debugging. You can see an example [here](https://invidio.us/api/v1/videos/CvFH_6DNRCY?pretty=1&fields=title,recommendedVideos/title). I've been quite happy using it and hope it is similarly useful for others.
+
+An `/api/v1/annotations/:id` endpoint has been added for pulling legacy annotation data from [this](https://archive.org/details/youtubeannotations) archive, see below for more details. You can also access annotation data available on YouTube using `?source=youtube`, although this will only return card data as legacy annotations were deleted on January 15th.
+
+A couple minor changes to existing endpoints:
+
+- A `premiereTimestamp` field has been added to `/api/v1/videos/:id`
+- A `sort_by` param has been added to `/api/v1/comments/:id`, supports `new`, `top`.
+
+More info is available in the [documentation](https://github.com/omarroth/invidious/wiki/API).
+
+## Annotations
+
+I'm pleased to announce that annotation data is finally available from the roughly 1.4 billion videos archived as part of [this](https://www.reddit.com/r/DataHoarder/comments/aa6czg/youtube_annotation_archive/) project. They are accessible from the Internet Archive [here](https://archive.org/details/youtubeannotations) or as a 355GB torrent, see [here](https://www.reddit.com/r/DataHoarder/comments/b7imx9/youtube_annotation_archive_annotation_data_from/) for more details. A corresponding `/api/v1/annotations/:id` endpoint has been added to Invidious which uses the collection from IA to provide legacy annotations.
+
+Support for them in the player is possible thanks to [this](https://github.com/afrmtbl/videojs-youtube-annotations) plugin developed by [**@afrmtbl**](https://github.com/afrmtbl). A PR for adding support to the site is available as [#303](https://github.com/omarroth/invidious/pull/303). There's also an [extension](https://github.com/afrmtbl/AnnotationsRestored) for overlaying them on top of the YouTube player (again thanks to [**@afrmtbl**](https://github.com/afrmtbl)), and an [extension](https://tech234a.bitbucket.io/AnnotationsReloaded?src=invidious) for hooking into code still present in the YouTube player itself, developed by [**@tech234a**](https://github.com/tech234a).
+
+I would recommend reading the [official announcement](https://www.reddit.com/r/DataHoarder/comments/b7imx9/youtube_annotation_archive_annotation_data_from/) for more details. I would like to again thank everyone that helped contribute to this project.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$42.42
+- [Liberapay](https://liberapay.com/omarroth) : \$70.11
+- Crypto : ~\$1.76 (converted from BCH, BTC, BSV)
+- Total : \$114.29
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node5 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$80.00
+
+This past month the site saw a couple abnormal peaks in traffic, so an additional webserver has been added to match the increased load. The goal on Patreon has been updated to match the above expenses.
+
+Thanks everyone!
+
+# 0.15.0 (2019-03-06)
+
+## Version 0.15.0: Preferences and Channel Playlists
+
+The project has seen quite a bit of activity this past month. Large focus has been on fixing bugs, but there's still quite a few new features I'm happy to announce. There have been [133 commits](https://github.com/omarroth/invidious/compare/0.14.0...0.15.0) from 15 contributors this past month.
+
+As a couple miscellaneous changes, a couple [nice screenshots](https://github.com/omarroth/invidious#screenshots) have been added to the README, so folks can see more of what the site has to offer without creating an account.
+
+The footer has also been cleaned up quite a bit, and now displays the current version, so it's easier to know what features are available from the current instance.
+
+## For Administrators
+
+This past month there has been a minor release - `0.14.1` - which fixes a breaking change made by YouTube for their polymer redesign.
+
+There have been several new features that unfortunately require a database migration. There are migration scripts provided in `config/migrate-scripts`, and the [wiki](https://github.com/omarroth/invidious/wiki/Updating) has instructions for automatically applying them. I'll do my best to keep those changes to a minimum, and expect to see a corresponding script to automatically apply any new changes.
+
+Administrator preferences have been added with [#312](https://github.com/omarroth/invidious/issues/312), which allows administrators to customize their instance. Administrators can change the order of feed menus, change the default homepage, disable open registration, and several other options. There's a short 'how-to' [here](https://github.com/omarroth/invidious/issues/312#issuecomment-468831842), and the new options are documented [here](https://github.com/omarroth/invidious/wiki/Configuration).
+
+An `/api/v1/stats` endpoint has been added with [#356](https://github.com/omarroth/invidious/issues/356), which reports the instance version and number of active users. Statistics are disabled by default, and can be enabled in administator preferences. Statistics for the official instance are available [here](https://invidio.us/api/v1/stats?pretty=1).
+
+## For Developers
+
+`/api/v1/channels/:ucid` now provides an `autoGenerated` tag, which returns true for topic channels, and larger genre channels generated by YouTube. These channels don't have any videos of their own, so `latestVideos` will be empty. It is recommended instead to display a list of playlists generated by YouTube.
+
+You can now pull a list of playlists from a channel with `/api/v1/channels/playlists/:ucid`. Supported options are documented in the [wiki](https://github.com/omarroth/invidious/wiki/API#get-apiv1channelsplaylistsucid-apiv1channelsucidplaylists). Pagination is handled with a `continuation` token, which is generated on each call. Of note is that auto-generated channels currently have one page of results, and subsequent calls will be empty.
+
+For quickly pulling the latest 30 videos from a channel, there is now `/api/v1/channels/latest/:ucid`. It is much faster than a call to `/api/v1/channels/:ucid`. It will not convert an author name to a valid ucid automatically, and will not return any extra data about a channel.
+
+## Preferences
+
+In addition to administrator preferences mentioned above, you can now change your preferences without an account (see [#42](https://github.com/omarroth/invidious/pull/42)). I think this is quite an improvement to the usability of the site, and is much friendlier to privacy-conscious folks that don't want to make an account. Preferences will be automatically imported to a newly created account.
+
+Several issues with sorting subscriptions have been fixed, and `/manage_subscriptions` has been sped up significantly. The subscription feed has also seen a bump in performance. Delayed notifications have unfortunately started becoming a problem now that there are more users on the site. Some new changes are currently being tested which should mostly resolve the issue, so expect to see more in the next release.
+
+## Channel Playlists
+
+You can now view available playlists from a channel, and [auto-generated channels](https://invidio.us/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ) are no longer empty. You can sort as you would on YouTube, and all the same functionality should be available. I'm quite pleased to finally have it implemented, since it's currently the only data available from the above mentioned auto-generated channels, and makes it much easier to consume music on the site.
+
+There's also more discussion on improving Invidious for streaming music in [#304](https://github.com/omarroth/invidious/issues/304), and adding support for music.youtube.com. I would appreciate any thoughts on how to improve that experience, since it's a very large and useful part of YouTube.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$42.42
+- [Liberapay](https://liberapay.com/omarroth) : \$30.97
+- Crypto : ~\$0.00 (converted from BCH, BTC)
+- Total : \$73.39
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$75.00
+
+It's been very humbling to see how fast the project has grown, and I look forward to making the site even better. Thank you everyone.
+
+# 0.14.0 (2019-02-06)
+
+## Version 0.14.0: Community
+
+This last month several contributors have made improvements specifically for the people using this project. New pages have been added to the wiki, and there is now a [Matrix Server](https://riot.im/app/#/room/#invidious:matrix.org) and IRC channel so it's easier and faster for people to ask questions or chat. There have been [101 commits](https://github.com/omarroth/invidious/compare/0.13.0...0.14.0) since the last major release from 8 contributors.
+
+It has come to my attention in the past month how many people are self-hosting, and I would like to make it easier for them to do so.
+
+With that in mind, expect future releases to have a section for For Administrators (if any relevant changes) and For Developers (if any relevant changes).
+
+## For Administrators
+
+This month the most notable change for administrators is releases. As always, there will be a major release each month. However, a new minor release will be made whenever there are any critical bugs that need to be fixed.
+
+This past month is the first time there has been a minor release - `0.13.1` - which fixes a breaking change made by YouTube. Administrators using versioning for their instances will be able to rely on the latest version, and should have a system in place to upgrade their instance as soon as a new release is available.
+
+Several new pages have been added to the [wiki](https://github.com/omarroth/invidious/wiki#for-administrators) (as mentioned below) that will help administrators better setup their own instances. Configuration, maintenance, and instructions for updating are of note, as well as several common issues that are encountered when first setting up.
+
+## For Developers
+
+There's now a `pretty=1` parameter for most endpoints so you can view data easily from the browser, which is convenient for debugging and casual use. You can see an example [here](https://invidio.us/api/v1/videos/CvFH_6DNRCY?pretty=1).
+
+Unfortunately the `/api/v1/insights/:id` endpoint is no longer functional, as YouTube removed all publicly available analytics around a month ago. The YouTube endpoint now returns a 404, so it's unlikely it will be functional again.
+
+## Wiki
+
+There have been a sizable number of changes to the Wiki, including a [list of public Invidious instances](https://github.com/omarroth/invidious/wiki/Invidious-Instances), the [list of extensions](https://github.com/omarroth/invidious/wiki/Extensions), and documentation for administrators (as mentioned above) and developers.
+
+The wiki is editable by anyone so feel free to add anything you think is useful.
+
+## Matrix & IRC
+
+Thee is now a [Matrix Server](https://riot.im/app/#/room/#invidious:matrix.org) for Invidious, so please feel free to hop on if you have any questions or want to chat. There is also a registered IRC channel: #invidious on Freenode which is bridged to Matrix.
+
+## Features
+
+Several new features have been added, including a download button, creator hearts and comment colors, and a French translation.
+
+There have been fixes for Google logins, missing text in locales, invalid links to genre channels, and better error handling in the player, among others.
+
+Several fixes and features are omitted for space, so I'd recommend taking a look at the [compare tab](https://github.com/omarroth/invidious/compare/0.13.0...0.14.0) for more information.
+
+## Annotations Update
+
+Annotations were removed January 15th, 2019 around15:00 UTC. Before they were deleted we were able to archive annotations from around 1.4 billion videos. I'd very much recommend taking a look [here](https://www.reddit.com/r/DataHoarder/comments/al7exa/youtube_annotation_archive_update_and_preview/) for more information and a list of acknowledgements. I'm extremely thankful to everyone who was able to contribute and I'm glad we were able to save such a large part of internet history.
+
+There's been large strides in supporting them in the player as well, which you can follow in [#303](https://github.com/omarroth/invidious/pull/303). You can preview the functionality at https://dev.invidio.us . Before they are added to the main site expect to see an option to disable them, both site-wide and per video.
+
+Organizing this project has unfortunately taken up quite a bit of my time, and I've been very grateful for everyone's patience.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth) : \$49.42
+- [Liberapay](https://liberapay.com/omarroth) : \$27.89
+- Crypto : ~\$0.00 (converted from BCH, BTC)
+- Total : \$77.31
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$75.00
+
+As always I'm grateful for everyone's contributions and support. I'll see you all in March.
+
+# 0.13.1 (2019-01-19)
+
+##
+
+# 0.13.0 (2019-01-06)
+
+## Version 0.13.0: Translations, Annotations, and Tor
+
+I hope everyone had a happy New Year! There's been a couple new additions since last release, with [44 commits](https://github.com/omarroth/invidious/compare/0.12.0...0.13.0) from 9 contributors. It's been quite a year for the project, and I hope to continue improving the project into 2019! Starting off the new year:
+
+## Translations
+
+I'm happy to announce support for translations has been added with [`a160c64`](https://github.com/omarroth/invidious/a160c64). Currently, there is support for:
+
+- Arabic (`ar`)
+- Dutch (`nl`)
+- English (`en-US`)
+- German (`de`)
+- Norwegian Bokmål (`nb_NO`)
+- Polish (`pl`)
+- Russian (`ru`)
+
+Which you can change in your preferences under `Language`. You can also add `&hl=LANGUAGE` to the end of any request to translate it to your preferred language, for example https://invidio.us/?hl=ru. I'd like to say thank you again to everyone who has helped translate the site! I've mentioned this before, but I'm delighted that so many people find the project useful.
+
+## Annotations
+
+Recently, [YouTube announced that all annotations will be deleted on January 15th, 2019](https://support.google.com/youtube/answer/7342737). I believe that annotations have a very important place in YouTube's history, and [announced a project to archive them](https://www.reddit.com/r/DataHoarder/comments/aa6czg/youtube_annotation_archive/).
+
+I expect annotations to be supported in the Invidious player once archiving is complete (see [#110](https://github.com/omarroth/invidious/issues/110) for details), and would also like to host them for other developers to use in their projects.
+
+The code is available [here](https://github.com/omarroth/archive), and contains instructions for running a worker if you would like to contribute. There's much more information available in the announcement as well for anyone who is interested.
+
+## Tor
+
+I unfortunately missed the chance to mention this in the previous release, but I'm now happy to announce that you can now view Invidious through Tor at the following links:
+
+kgg2m7yk5aybusll.onion
+axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid.onion
+
+Invidious is well suited to use through Tor, as it does not require any JS and is fairly lightweight. I'd recommend looking [here](https://diasp.org/posts/10965196) and [here](https://www.reddit.com/r/TOR/comments/a3c1ak/you_can_now_watch_youtube_videos_anonymously_with/) for more details on how to use the onion links, and would like to say thank you to [/u/whonix-os](https://www.reddit.com/user/whonix-os) for suggesting it and providing support setting setting them up.
+
+## Popular and Trending
+
+You can now easily view videos trending on YouTube with [`a16f967`](https://github.com/omarroth/invidious/a16f967). It also provides support for viewing YouTube's various categories categories, such as `News`, `Gaming`, and `Music`. You can also change the `region` parameter to view trending in different countries, which should be made easier to use in the coming weeks.
+
+A link to `/feed/popular` has also been added, which provides a list of videos sorted using the algorithm described [here](https://github.com/omarroth/invidious/issues/217#issuecomment-436503761). I think it better reflects what users watch on the site, but I'd like to hear peoples' thoughts on this and on how it could be improved.
+
+## Finances
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth): \$64.63
+- [Liberapay](https://liberapay.com/omarroth) : \$30.05
+- Crypto : ~\$28.74 (converted from BCH, BTC)
+- Total : \$123.42
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$75.00
+
+### What will happen with what's left over?
+
+I believe this is the first month that all expenses have been fully paid for by donations. Thank you! I expect to allocate the current amount for hardware to improve performance and for hosting annotation data, as mentioned above.
+
+Anything that is left over is kept to continue hosting the project for as long as possible. Thank you again everyone!
+
+I think that's everything for 2018. There's lots still planned, and I'm very excited for the future of this project!
+
+# 0.12.0 (2018-12-06)
+
+## Version 0.12.0: Accessibility, Privacy, Transparency
+
+Hello again, it's been a while! A lot has happened since the last release. Invidious has seen [134 commits](https://github.com/omarroth/invidious/compare/0.11.0...0.12.0) from 3 contributors, and I'm quite happy with the progress that has been made. I enjoyed this past month, and I believe having a monthly release schedule allows me to focus on more long-term improvements, and I hope people enjoy these more substantial updates as well.
+
+## Accessability and Privacy
+
+There have been quite a few improvements for user privacy, and improvements that improve accessibility for both people and software.
+
+You can now view comments without JS with [`19516ea`](https://github.com/omarroth/invidious/19516ea). Currently, this functionality is limited to the first 20 comments, but expect this functionality to be improved to come as close to the JS version as possible. Folks can track progress in [#204](https://github.com/omarroth/invidious/issues/204).
+
+Invidious is now compatible with [LibreJS](https://www.gnu.org/software/librejs/), and provides license information [here](https://invidio.us/licenses) with [`7f868ec`](https://github.com/omarroth/invidious/7f868ec). As expected, all libraries are compatible under the AGPLv3, and I'm happy to mention that no other changes were required to make Invidious compatible with LibreJS.
+
+A DNT policy has also been added with [`9194f47`](https://github.com/omarroth/invidious/9194f47) for compatibility with [Privacy Badger](https://www.eff.org/privacybadger). I'm pleased to mention that here too no other changes had to be made in order for Invidious to be compatible with this extension. I expect a privacy policy to be added soon as well, so users can better understand how Invidious uses their data.
+
+For users that are visually impaired, there is now a text CAPTCHA available so it's easier to register and login. Because of the simple front-end of the project, I expect screen readers and other software to be able to easily understand the site's interface. In combination with the ability to listen-only, I believe Invidious is much more accessible than YouTube. Folks can read [#244](https://github.com/omarroth/invidious/issues/244) for more details, and I would very much appreciate any feedback on how this can be improved.
+
+## User Preferences
+
+There have been a lot of improvements to preferences. Options for enabling audio-only by default and continuous playback (autoplay) have been added with [`e39dec9`](https://github.com/omarroth/invidious/e39dec9), with [`4b76b93`](https://github.com/omarroth/invidious/4b76b93), respectively. Users can also now mark videos as watched from their subscription feed and view watch history by going to https://invidio.us/feed/history. I expect to add more information to history so that it's easier to use. Folks can track progress with [#182](https://github.com/omarroth/invidious/issues/182). As with all data Invidious keeps, watch history can be exported [here](https://invidio.us/data_control).
+
+Users can now delete their account with [`b9c29bf`](https://github.com/omarroth/invidious/b9c29bf). This will remove _all_ user data from Invidious, including session IDs, watch history, and subscriptions. As mentioned above, it's easy to export that data and import it to a local instance, or export subscriptions for use with other applications such as [FreeTube](https://github.com/FreeTubeApp/FreeTube) or [NewPipe](https://github.com/TeamNewPipe/NewPipe).
+
+## Translation and Internationalis(z)ation
+
+Invidious has been approved for hosting by Weblate, available [here](https://hosted.weblate.org/projects/invidious/translations/). At the time of writing, translations for Arabic, Dutch, German, Polish, and Russian are currently underway. I would like to say a very big thank you to everyone working on them, and I hope to fully support them within around 2 weeks. Folks can track progress with [#251](https://github.com/omarroth/invidious/issues/251).
+
+## Transperency and Finances
+
+For the sake of transparency, I plan on publishing each month's finances. This is currently already done on Liberapay and Patreon, but there is not a total amount currently provided anywhere, and I would also like to include expenses to provide a better explanation of how patrons' money is being spent.
+
+### Donations
+
+- [Patreon](https://www.patreon.com/omarroth): \$43.60 (Patreon takes roughly 9%)
+- [Liberapay](https://liberapay.com/omarroth) : \$22.10
+- Crypto : ~\$1.25 (converted from BCH, BTC)
+- Total : \$66.95
+
+### Expenses
+
+- invidious-load1 (nyc1) : \$10.00 (load balancer)
+- invidious-update1 (s-1vcpu-1gb) : \$5.00 (updates feeds)
+- invidious-node1 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node2 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node3 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-node4 (s-1vcpu-1gb) : \$5.00 (web server)
+- invidious-db1 (s-4vcpu-8gb) : \$40.00 (database)
+- Total : \$75.00
+
+I'd be happy to provide any explanation where needed. I would also like to thank everyone who donates, it really helps and I can't say how happy I am to see that so many people find it valuable.
+
+That's all for this month. I wish everyone the best for the holidays, and I'll see you all again in January!
+
+# 0.11.0 (2018-10-23)
+
+## Week 11: FreeTube and Styling
+
+This past Friday I'm been very excited to see that FreeTube version [0.4.0](https://github.com/FreeTubeApp/FreeTube/tree/0.4.0) has been released! I'd recommend taking a look at the official patch notes, but to spoil a little bit here: FreeTube now uses the Invidious API for _all_ requests previously sent to YouTube, and has also seen support for playlists, keyboard shortcuts, and more default settings (speed, autoplay, and subtitles). I'm happy to see that FreeTube has reached 500 stars on Github, and I think it's very much deserved. I'd recommend keeping an eye on the newly-launched [FreeTube blog](https://freetube.writeas.com/) for updates on the project.
+
+Quite a few styling changes have been added this past week, including channel subscriber count to the subscribe and unsubscribe buttons. The changes sound small, but they've been a very big improvement and I'm quite satisfied with how they look. Also to note is that partial support for duration in thumbnails have been added with [#202](https://github.com/omarroth/invidious/issues/202). Overall, I think the site is becoming much more pleasing visually, and I hope to continue to improve it.
+
+I've been very pleased to see Invidious in its current state, and I believe it's many times more mature compared to even a month ago. Changes have also started slowing down a bit as it's become more mature, and therefore I'd like to transition to a monthly update schedule in order to provide more comprehensive updates for everyone. I want to thank you all for helping me reach this point. I can't say how happy I am for Invidious to be where it is now.
+
+Enjoy the rest of your week everyone, I'll see you in November!
+
+# 0.10.0 (2018-10-16)
+
+## Week 10: Subscriptions
+
+This week I'm happy to announce that subscriptions have been drastically sped up with
+35e63fa. As I mentioned last week, this essentially "caches" a user's feed, meaning that operations that previously took 20 seconds or timed out, now can load in under a second. I'd take a look at [#173](https://github.com/omarroth/invidious/issues/173) for a sample benchmark. Previously features that made Invidious's feed so useful, such as filtering by unseen and by author would take too long to load, and so instead would timeout. I'm very happy that this has been fixed, and folks can get back to using these features.
+
+Among some smaller features that have been added this week include [#118](https://github.com/omarroth/invidious/issues/118), which adds, in my opinion, some very attractive subscribe and unsubscribe buttons. I think it's also a bit of a functional improvement as well, since it doesn't require a user to reload the page in order to subscribe or unsubscribe to a channel, and also gives the opportunity to put the channel's sub count on display.
+
+An option to swap between Reddit and YouTube comments without a page reload has been added with
+5eefab6, bringing it somewhat closer in functionality to the popular [AlienTube](https://github.com/xlexi/alientube) extension, on which it is based (although the extension unfortunately appears now to be fragmented).
+
+As always, there are a couple smaller improvements this week, including some minor fixes for geo-bypass with
+e46e618 and [`245d0b5`](https://github.com/omarroth/invidious/245d0b5), playlist preferences with [`81b4477`](https://github.com/omarroth/invidious/81b4477), and YouTube comments with [`02335f3`](https://github.com/omarroth/invidious/02335f3).
+
+This coming week I'd also recommend keeping an eye on the excellent [FreeTube](https://github.com/FreeTubeApp/FreeTube), which is looking forward to a new release. I've been very lucky to work with [**@PrestonN**](https://github.com/PrestonN) for the past few weeks to improve the Invidious API, and I'm quite looking forward to the new release.
+
+That's all for this week folks, thank you all again for your continued interest and support.
+
+# 0.9.0 (2018-10-08)
+
+## Week 9: Playlists
+
+Not as much to announce this week, but I'm still quite happy to announce a couple things, namely:
+
+Playback support for playlists has finally been added with [`88430a6`](https://github.com/omarroth/invidious/88430a6). You can now view playlists with the `&list=` query param, as you would on YouTube. You can also view mixes with the mentioned `&list=`, although they require some extra handling that I would like to add in the coming week, as well as adding playlist looping and shuffle. I think playback support has been a roadblock for more exciting features such as [#114](https://github.com/omarroth/invidious/issues/114), and I look forward to improving the experience.
+
+Comments have had a bit of a cosmetic upgrade with [#132](https://github.com/omarroth/invidious/issues/132), which I think helps better distinguish between Reddit and YouTube comments, as it makes them appear similarly to their respective sites. You can also now switch between YouTube and Reddit comments with a push of a button, which I think is quite an improvement, especially for newer or less popular videos with fewer comments.
+
+I've had a small breakthrough in speeding up users' subscription feeds with PostgreSQL's [materialized views](https://www.postgresql.org/docs/current/static/rules-materializedviews.html). Without going into too much detail, materialized views essentially cache the result of a query, making it possible to run resource-intensive queries once, rather than every time a user visits their feed. In the coming week I hope to push this out to users, and hopefully close [#173](https://github.com/omarroth/invidious/issues/173).
+
+I haven't had as much time to work on the project this week, but I'm quite happy to have added some new features. Have a great week everyone.
+
+# 0.8.0 (2018-10-02)
+
+## Week 8: Mixes
+
+Hello again!
+
+Mixes have been added with [`20130db`](https://github.com/omarroth/invidious/20130db), which makes it easy to create a playlist of related content. See [#188](https://github.com/omarroth/invidious/issues/188) for more info on how they work. Currently, they return the first 50 videos rather than a continuous feed to avoid tracking by Google/YouTube, which I think is a good trade-off between usability and privacy, and I hope other folks agree. You can create mixes by adding `RD` to the beginning of a video ID, an example is provided [here](https://www.invidio.us/mix?list=RDYE7VzlLtp-4) based on Big Buck Bunny. I've been quite happy with the results returned for the mixes I've tried, and it is not limited to music, which I think is a big plus. To emulate a continuous feed provided many are used to, using the last video of each mix as a new 'seed' has worked well for me. In the coming week I'd like to to add playback support in the player to listen to these easily.
+
+A very big thanks to [**@flourgaz**](https://github.com/flourgaz) for Docker support with [#186](https://github.com/omarroth/invidious/pull/186). This is an enormous improvement in portability for the project, and opens the door for Heroku support (see [#162](https://github.com/omarroth/invidious/issues/162)), and seamless support on Windows. For most users, it should be as easy as running `docker-compose up`.
+
+I've spent quite a bit of time this past week improving support for geo-bypass (see [#92](https://github.com/omarroth/invidious/issues/92)), and am happy to note that Invidious has been able to proxy ~50% of the geo-restricted videos I've tried. In addition, you can now watch geo-restricted videos if you have `dash` enabled as your `preferred quality`, for more details see [#34](https://github.com/omarroth/invidious/issues/34) and [#185](https://github.com/omarroth/invidious/issues/185), or last week's update. For folks interested in replicating these results for themselves, I'd take a look [here](https://gist.github.com/omarroth/3ce0f276c43e0c4b13e7d9cd35524688) for the script used, and [here](https://gist.github.com/omarroth/beffc4a76a7b82a422e1b36a571878ef) for a list of videos restricted in the US.
+
+1080p has seen a fairly smooth roll-out, although there have been a couple issues reported, mainly [#193](https://github.com/omarroth/invidious/issues/193), which is likely an issue in the player. I've also encountered a couple other issues myself that I would like to investigate. Although none are major, I'd like to keep 1080p opt-in for registered users another week to better address these issues.
+
+Have an excellent week everyone.
+
+# 0.7.0 (2018-09-25)
+
+## Week 7: 1080p and Search Types
+
+Hello again everyone! I've got quite a couple announcements this week:
+
+Experimental 1080p support has been added with [`b3ca392`](https://github.com/omarroth/invidious/b3ca392), and can be enabled by going to preferences and changing `preferred video quality` to `dash`. You can find more details [here](https://github.com/omarroth/invidious/issues/34#issuecomment-424171888). Currently quality and speed controls have not yet been integrated into the player, but I'd still appreciate feedback, mainly on any issues with buffering or DASH playback. I hope to integrate 1080p support into the player and push support site-wide in the coming weeks.
+
+You can now filter content types in search with the `type:TYPE` filter. Supported content types are `playlist`, `channel`, and `video`. More info is available [here](https://github.com/omarroth/invidious/issues/126#issuecomment-423823148). I think this is quite an improvement in usability and I hope others find the same.
+
+A [CHANGELOG](https://github.com/omarroth/invidious/blob/master/CHANGELOG.md) has been added to the repository, so folks will now receive a copy of all these updates when cloning. I think this is an improvement in hosting the project, as it is no longer tied to the `/releases` tab on Github or the posts on Patreon.
+
+Recently, users have been reporting 504s when attempting to access their subscriptions, which is tracked in [#173](https://github.com/omarroth/invidious/issues/173). This is most likely caused by an uptick in usage, which I am absolutely grateful for, but unfortunately has resulted in an increase in costs for hosting the site, which is why I will be bumping my goal on Patreon from $60 to $80. I would appreciate any feedback on how subscriptions could be improved.
+
+Other minor improvements include:
+
+- Additional regions added to bypass geo-block with [`9a78523`](https://github.com/omarroth/invidious/9a78523)
+- Fix for playlists containing less than 100 videos (previously shown as empty) with [`35ac887`](https://github.com/omarroth/invidious/35ac887)
+- Fix for `published` date for Reddit comments (previously showing negative seconds) with [`6e09202`](https://github.com/omarroth/invidious/6e09202)
+
+Thank you everyone for your support!
+
+# 0.6.0 (2018-09-18)
+
+## Week 6: Filters and Thumbnails
+
+Hello again! This week I'm happy to mention a couple new features to search as well as some miscellaneous usability improvements.
+
+You can now constrain your search query to a specific channel with the `channel:CHANNEL` filter (see [#165](https://github.com/omarroth/invidious/issues/165) for more details). Unfortunately, other search filters combined with channel search are not yet supported. I hope to add support for them in the coming weeks.
+
+You can also now search only your subscriptions by adding `subscriptions:true` to your query (see [#30](https://github.com/omarroth/invidious/issues/30) for more details). It's not quite ready for widespread use but I would appreciate feedback as the site updates to fully support it. Other search filters are not yet supported with `subscriptions:true`, but I hope to add more functionality to this as well.
+
+With [#153](https://github.com/omarroth/invidious/issues/153) and [#168](https://github.com/omarroth/invidious/issues/168) all images on the site are now proxied through Invidious. In addition to offering the user more protection from Google's eyes, it also allows the site to automatically pick out the highest resolution thumbnail for videos. I think this is quite a large aesthetic improvement and I hope others will find the same.
+
+As a smaller improvement to the site, you can also now view RSS feeds for playlists with [#113](https://github.com/omarroth/invidious/issues/113).
+
+These updates are also now listed under Github's [releases](https://github.com/omarroth/invidious/releases). I'm also planning on adding them as a `CHANGELOG.md` in the repository itself so people can receive a copy with the project's source.
+
+That's all for this week. Thank you everyone for your support!
+
+# 0.5.0 (2018-09-11)
+
+## Week 5: Privacy and Security
+
+I hope everyone had a good weekend! This past week I've been fixing some issues that have been brought to my attention to help better protect users and help them keep their anonymity.
+
+An issue with open referers has been fixed with [`29a2186`](https://github.com/omarroth/invidious/29a2186), which prevents potential redirects to external sites on actions such as login or modifying preferences.
+
+Additionally, X-XSS-Protection, X-Content-Type-Options, and X-Frame-Options headers have been added with [`96234e5`](https://github.com/omarroth/invidious/96234e5), which should keep users safer while using the site.
+
+A potential XSS vector has also been fixed in YouTube comments with [`8c45694`](https://github.com/omarroth/invidious/8c45694).
+
+All the above vulnerabilities were brought to my attention by someone who wishes to remain anonymous, but I would like to say again here how thankful I am. If anyone else would like to get in touch please feel free to email me at omarroth@hotmail.com or omarroth@protonmail.com.
+
+This week a couple changes have been made to better protect user's privacy as well.
+All CSS and JS assets are now served locally with [`3ec684a`](https://github.com/omarroth/invidious/3ec684a), which means users no longer need to whitelist unpkg.com. Although I personally have encountered few issues, I understand that many folks would like to keep their browsing activity contained to as few parties as possible. In the coming week I also hope to proxy YouTube images, so that no user data is sent to Google.
+
+YouTube links in comments now should redirect properly to the Invidious alternate with [`1c8bd67`](https://github.com/omarroth/invidious/1c8bd67) and [`cf63c82`](https://github.com/omarroth/invidious/cf63c82), so users can more easily evade Google tracking.
+
+I'm also happy to mention a couple quality of life features this week:
+
+Invidious now shows a video's "license" if provided, see [#159](https://github.com/omarroth/invidious/issues/159) for more details. You can also search for videos licensed under the creative commons with "QUERY features:creative_commons".
+
+Videos with only one source will always display the cog for changing quality, so that users can see what quality is currently playing. See [#158](https://github.com/omarroth/invidious/issues/158) for more details.
+
+Folks have also probably noticed that the gutters on either side of the screen have been shrunk down quite significantly, so that more of the screen is filled with content. Hopefully this can be improved even more in the coming weeks.
+
+"Music", "Sports", and "Popular on YouTube" channels now properly display their videos. You can subscribe to these channels just as you would normally.
+
+This coming week I'm planning on spending time with my family, so I unfortunately may not be as responsive. I do still hope to add some smaller features for next week however, and I hope to continue development soon.
+Thank you everyone again for your support.
+
+# 0.4.0 (2018-09-06)
+
+## Week 4: Genre Channels
+
+Hello! I hope everyone enjoyed their weekend. Without further ado:
+Just today genre channels have been added with [#119](https://github.com/omarroth/invidious/issues/119). More information on genre channels is available [here](https://support.google.com/youtube/answer/2579942). You can subscribe to them as normally, and view them as RSS. I think they offer an interesting alternative way to find new content and I hope people find them useful.
+
+This past week folks have started reporting 504s on their subscription page (see [#144](https://github.com/omarroth/invidious/issues/144) for more details). Upgrading the database server appeared to fix the issue, as well as providing a smoother experience across the site. Unfortunately, that means I will be increasing the goal from $50 to $60 in order to meet the increased hosting costs.
+
+With [#134](https://github.com/omarroth/invidious/issues/134), comments are now formatted correctly, providing support for bold, italics, and links in comments. I think this improvement makes them much easier to read, and I hope others find the same. Also to note is that links in both comments and the video description now no longer contain any of Google's tracking with [#115](https://github.com/omarroth/invidious/issues/115).
+
+One of the major use cases for Invidious is as a stripped-down version of YouTube. In line with that, I'm happy to announce that you can now hide related videos if you're logged in, for users that prefer an even more lightweight experience.
+
+Finally, I'm pleased to announce that Invidious has hit 100 stars on GitHub. I am very happy that Invidious has proven to be useful to so many people, and I can't say how grateful I am to everyone for their continued support.
+
+Enjoy the rest of your week everyone!
+
+# 0.3.0 (2018-09-06)
+
+## Week 3: Quality of Life
+
+Hello everyone! This week I've been working on some smaller features that will hopefully make the site more functional.
+Search filters have been added with [#126](https://github.com/omarroth/invidious/issues/126). You can now specify 'sort', 'date', 'duration', and 'features' within your query using the 'operator:value' syntax. I'd recommend taking a look [here](https://github.com/omarroth/invidious/blob/master/src/invidious/search.cr#L33-L114) for a list of supported options and at [#126](https://github.com/omarroth/invidious/issues/126) for some examples. This also opens the door for features such as [#30](https://github.com/omarroth/invidious/issues/30) which can be implemented as filters. I think advanced search is a major point in which Invidious can improve on YouTube and hope to add more features soon!
+
+This week a more advanced system for viewing fallback comments has been added (see [#84](https://github.com/omarroth/invidious/issues/84) for more details). You can now specify a comment fallback in your preferences, which Invidious will use. If, for example, no Reddit comments are available for a given video, it can choose to fallback on YouTube comments. This also makes it possible to turn comments off completely for users that prefer a more streamlined experience.
+
+With [#98](https://github.com/omarroth/invidious/issues/98), it is now possible for users to specify preferences without creating an account. You can now change speed, volume, subtitles, autoplay, loop, and quality using query parameters. See the issue above for more details and several examples.
+
+I'd also like to announce that I've set up an account on [Liberapay](https://liberapay.com/omarroth), for patrons that prefer a privacy-friendly alternative to Patreon. Liberapay also does not take any percentage of donations, so I'd recommend donating some to the Liberapay for their hard work. Go check it out!
+
+[Two weeks ago](https://github.com/omarroth/invidious/releases/tag/0.1.0) I mentioned adding 1080p support into the player. Currently, the only thing blocking is [#207](https://github.com/videojs/http-streaming/pull/207) in the excellent [http-streaming](https://github.com/videojs/http-streaming) library. I hope to work with the videojs team to merge it soon and finally implement 1080p support!
+
+That's all for this week, thank you again everyone for your support!
+
+# 0.2.0 (2018-09-06)
+
+## Week 2: Toward Playlists
+
+Sorry for the late update! Not as much to announce this week, but still a couple things of note:
+I'm happy to announce that a playlists page and API endpoint has been added so you can now view playlists. Currently, you cannot watch playlists through the player, but I hope to add that in the coming week as well as adding functionality to add and modify playlists. There is a good conversation on [#114](https://github.com/omarroth/invidious/issues/114) about giving playlists even more functionality, which I think is interesting and would appreciate feedback on.
+
+As an update to the Invidious API announcement last week, I've been working with [**@PrestonN**](https://github.com/PrestonN), the developer of [FreeTube](https://github.com/FreeTubeApp/FreeTube), to help migrate his project to the Invidious API. Because of it's increasing popularity, he has had trouble keeping under the quota set by YouTube's API. I hope to improve the API to meet his and others needs and I'd recommend folks to keep an eye on his excellent project! There is a good discussion with his thoughts [here](https://github.com/FreeTubeApp/FreeTube/issues/100).
+
+A couple of miscellaneous features and bugfixes:
+
+- You can now login to Invidious simultaneously from multiple devices - [#109](https://github.com/omarroth/invidious/issues/109)
+
+- Added a note for scheduled livestreams - [#124](https://github.com/omarroth/invidious/issues/124)
+
+- Changed YouTube comment header to "View x comments" - [#120](https://github.com/omarroth/invidious/issues/120)
+
+Enjoy your week everyone!
+
+# 0.1.0 (2018-09-06)
+
+## Week 1: Invidious API and Geo-Bypass
+
+Hello everyone! This past week there have been quite a few things worthy of mention:
+
+I'm happy to announce the [Invidious Developer API](https://github.com/omarroth/invidious/wiki/API). The Invidious API does not use any of the official YouTube APIs, and instead crawls the site to provide a JSON interface for other developers to use. It's still under development but is already powering [CloudTube](https://github.com/cloudrac3r/cadencegq). The API currently does not have a quota (compared to YouTube) which I hope to continue thanks to continued support from my Patrons. Hopefully other developers find it useful, and I hope to continue to improve it so it can better serve the community.
+
+Just today partial support for bypassing geo-restrictions has been added with [fada57a](https://github.com/omarroth/invidious/commit/fada57a307d66d696d9286fc943c579a3fd22de6). If a video is unblocked in one of: United States, Canada, Germany, France, Japan, Russia, or United Kingdom, then Invidious will be able to serve video info. Currently you will not yet be able to access the video files themselves, but in the coming week I hope to proxy videos so that users can enjoy content across borders.
+
+Support for generating DASH manifests has been fixed, in the coming week I hope to integrate this functionality into the watch page, so users can view videos in 1080p and above.
+
+Thank you everyone for your continued interest and support!
diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ec22a0de --- /dev/null +++ b/Makefile @@ -0,0 +1,128 @@ +# ----------------------- +# Compilation options +# ----------------------- + +RELEASE := 1 +STATIC := 0 + +NO_DBG_SYMBOLS := 0 + +# Enable multi-threading. +# Warning: Experimental feature!! +# invidious is not stable when MT is enabled. +MT := 0 + + +FLAGS ?= + + +ifeq ($(RELEASE), 1) + FLAGS += --release +endif + +ifeq ($(STATIC), 1) + FLAGS += --static +endif + +ifeq ($(MT), 1) + FLAGS += -Dpreview_mt +endif + + +ifeq ($(NO_DBG_SYMBOLS), 1) + FLAGS += --no-debug +else + FLAGS += --debug +endif + +ifeq ($(API_ONLY), 1) + FLAGS += -Dapi_only +endif + + +# ----------------------- +# Main +# ----------------------- + +all: invidious + +get-libs: + shards install --production + +# TODO: add support for ARM64 via cross-compilation +invidious: get-libs + crystal build src/invidious.cr $(FLAGS) --progress --stats --error-trace + + +run: invidious + ./invidious + + +# ----------------------- +# Development +# ----------------------- + + +format: + crystal tool format + +test: + crystal spec + +verify: + crystal build src/invidious.cr -Dskip_videojs_download \ + --no-codegen --progress --stats --error-trace + + +# ----------------------- +# (Un)Install +# ----------------------- + +# TODO + + +# ----------------------- +# Cleaning +# ----------------------- + +clean: + rm invidious + +distclean: clean + rm -rf libs + rm -rf ~/.cache/{crystal,shards} + + +# ----------------------- +# Help page +# ----------------------- + +help: + @echo "Targets available in this Makefile:" + @echo "" + @echo " get-libs Fetch Crystal libraries" + @echo " invidious Build Invidious" + @echo " run Launch Invidious" + @echo "" + @echo " format Run the Crystal formatter" + @echo " test Run tests" + @echo " verify Just make sure that the code compiles, but without" + @echo " generating any binaries. Useful to search for errors" + @echo "" + @echo " clean Remove build artifacts" + @echo " distclean Remove build artifacts and libraries" + @echo "" + @echo "" + @echo "Build options available for this Makefile:" + @echo "" + @echo " RELEASE Make a release build (Default: 1)" + @echo " STATIC Link libraries statically (Default: 0)" + @echo "" + @echo " API_ONLY Build invidious without a GUI (Default: 0)" + @echo " NO_DBG_SYMBOLS Strip debug symbols (Default: 0)" + + + +# No targets generates an output named after themselves +.PHONY: all get-libs build amd64 run +.PHONY: format test verify clean distclean help @@ -1,107 +1,159 @@ -<h1 align="center">Invidious</h1> - -<h2 align="center">Invidious is an alternative front-end to YouTube.</h2> - ---- - -## Invidious instances: - -Public Invidious instances are listed on the documentation website: https://instances.invidious.io/ - ---- - -## Invidious features: - -- [Copylefted libre software](https://github.com/iv-org/invidious) (AGPLv3+ licensed) -- Lightweight (the homepage is ~4 KB compressed) +<div align="center"> + <img src="assets/invidious-colored-vector.svg" width="192" height="192" alt="Invidious logo"> + <h1>Invidious</h1> + + <a href="https://www.gnu.org/licenses/agpl-3.0.en.html"> + <img alt="License: AGPLv3" src="https://shields.io/badge/License-AGPL%20v3-blue.svg"> + </a> + <a href="https://github.com/iv-org/invidious/actions"> + <img alt="Build Status" src="https://github.com/iv-org/invidious/workflows/Invidious%20CI/badge.svg"> + </a> + <a href="https://github.com/iv-org/invidious/commits/master"> + <img alt="GitHub commits" src="https://img.shields.io/github/commit-activity/y/iv-org/invidious?color=red&label=commits"> + </a> + <a href="https://github.com/iv-org/invidious/issues"> + <img alt="GitHub issues" src="https://img.shields.io/github/issues/iv-org/invidious?color=important"> + </a> + <a href="https://github.com/iv-org/invidious/pulls"> + <img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/iv-org/invidious?color=blueviolet"> + </a> + <a href="https://hosted.weblate.org/engage/invidious/"> + <img alt="Translation Status" src="https://hosted.weblate.org/widgets/invidious/-/translations/svg-badge.svg"> + </a> + + <a href="https://github.com/humanetech-community/awesome-humane-tech"> + <img alt="Awesome Humane Tech" src="https://raw.githubusercontent.com/humanetech-community/awesome-humane-tech/main/humane-tech-badge.svg?sanitize=true"> + </a> + + <h3>An open source alternative front-end to YouTube</h3> + + <a href="https://invidious.io/">Website</a> + • + <a href="https://instances.invidious.io/">Instances list</a> + • + <a href="https://docs.invidious.io/faq/">FAQ</a> + • + <a href="https://docs.invidious.io/">Documentation</a> + • + <a href="#contribute">Contribute</a> + • + <a href="https://invidious.io/donate/">Donate</a> + + <h5>Chat with us:</h5> + <a href="https://matrix.to/#/#invidious:matrix.org"> + <img alt="Matrix" src="https://img.shields.io/matrix/invidious:matrix.org?label=Matrix&color=darkgreen"> + </a> + <a href="https://web.libera.chat/?channel=#invidious"> + <img alt="Libera.chat (IRC)" src="https://img.shields.io/badge/IRC%20%28Libera.chat%29-%23invidious-darkgreen"> + </a> + <br> + <a rel="me" href="https://social.tchncs.de/@invidious"> + <img alt="Fediverse: @invidious@social.tchncs.de" src="https://img.shields.io/badge/Fediverse-%40invidious%40social.tchncs.de-darkgreen"> + </a> + <br> + <a href="https://invidious.io/contact/"> + <img alt="E-mail" src="https://img.shields.io/badge/E%2d%2dmail-darkgreen"> + </a> +</div> + + +## Screenshots + +| Player | Preferences | Subscriptions | +|-------------------------------------|-------------------------------------|---------------------------------------| +|  |  |  | +|  |  |  | + + +## Features + +**User features** +- Lightweight - No ads - No tracking -- Javascript is 100% optional -- Tools for managing subscriptions: - - Only show unseen videos - - Only show latest (or latest unseen) video from each channel - - Delivers notifications from all subscribed channels - - Automatically redirect homepage to feed - - Import subscriptions from YouTube -- Audio-only mode (and no need to keep window open on mobile) -- Dark mode -- Embed support -- Set default player options (speed, quality, autoplay, loop) -- Support for Reddit comments in place of YouTube comments -- Import/Export subscriptions, watch history, preferences -- [Developer API](https://docs.invidious.io/API.md) -- Does not use any of the official YouTube APIs -- No need to create a Google account to save subscriptions -- No Code of Conduct -- No Contributor license Agreement -- Available in many languages, thanks to [Weblate](https://hosted.weblate.org/projects/invidious/) +- No JavaScript required +- Light/Dark themes +- Customizable homepage +- Subscriptions independent from Google +- Notifications for all subscribed channels +- Audio-only mode (with background play on mobile) +- Support for Reddit comments +- [Available in many languages](locales/), thanks to [our translators](#contribute) ---- +**Data import/export** +- Import subscriptions from YouTube, NewPipe and Freetube +- Import watch history from YouTube and NewPipe +- Export subscriptions to NewPipe and Freetube +- Import/Export Invidious user data -## Screenshots: +**Technical features** +- Embedded video support +- [Developer API](https://docs.invidious.io/api/) +- Does not use official YouTube APIs +- No Contributor License Agreement (CLA) -| Player | Preferences | Subscriptions | -| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| [<img src="screenshots/01_player.png?raw=true" height="140" width="280">](screenshots/01_player.png?raw=true) | [<img src="screenshots/02_preferences.png?raw=true" height="140" width="280">](screenshots/02_preferences.png?raw=true) | [<img src="screenshots/03_subscriptions.png?raw=true" height="140" width="280">](screenshots/03_subscriptions.png?raw=true) | -| [<img src="screenshots/04_description.png?raw=true" height="140" width="280">](screenshots/04_description.png?raw=true) | [<img src="screenshots/05_preferences.png?raw=true" height="140" width="280">](screenshots/05_preferences.png?raw=true) | [<img src="screenshots/06_subscriptions.png?raw=true" height="140" width="280">](screenshots/06_subscriptions.png?raw=true) | ---- +## Quick start -## Donate: +**Using invidious:** -Bitcoin (BTC): [bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr](bitcoin:bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr) +- [Select a public instance from the list](https://instances.invidious.io) and start watching videos right now! -Monero (XMR): [41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR](monero:41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR) +**Hosting invidious:** ---- +- [Follow the installation instructions](https://docs.invidious.io/installation/) -## Documentation: -The complete documentation is available on https://docs.invidious.io/ (or alternatively on its own [Github repository](https://github.com/iv-org/documentation)). +## Documentation ---- +The full documentation can be accessed online at https://docs.invidious.io/ -## Extensions: +The documentation's source code is available in this repository: +https://github.com/iv-org/documentation -[Extensions](https://docs.invidious.io/Extensions.md) can be found in the wiki, as well as documentation for integrating it into other projects. +### Extensions ---- +We highly recommend the use of [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect#get), +a browser extension that automatically redirects Youtube URLs to any Invidious instance and replaces +embedded youtube videos on other websites with invidious. -## Made with Invidious: +The documentation contains a list of browser extensions that we recommended to use along with Invidious. -- [FreeTube](https://github.com/FreeTubeApp/FreeTube): A libre software YouTube app for privacy. -- [CloudTube](https://sr.ht/~cadence/tube/): A JavaScript-rich alternate YouTube player. -- [PeerTubeify](https://gitlab.com/Cha_deL/peertubeify): On YouTube, displays a link to the same video on PeerTube, if it exists. -- [MusicPiped](https://github.com/deep-gaurav/MusicPiped): A material design music player that streams music from YouTube. -- [HoloPlay](https://github.com/stephane-r/HoloPlay): Funny Android application connecting on Invidious API's with search, playlists and favoris. +You can read more here: https://docs.invidious.io/applications/ ---- -## Contributing: +## Contribute -[](https://github.com/iv-org/invidious/actions) [](https://hosted.weblate.org/engage/invidious/) +### Code 1. Fork it ( https://github.com/iv-org/invidious/fork ). -2. Create your feature branch (git checkout -b my-new-feature). -3. Commit your changes (git commit -am 'Add some feature'). -4. Push to the branch (git push origin my-new-feature). -5. Create a new pull request. +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. Push to the branch (`git push origin my-new-feature`). +1. Create a new pull request ( https://github.com/iv-org/invidious/compare ). + +### Translations -### Translation: +We use [Weblate](https://weblate.org) to manage Invidious translations. -- Log in with an account you have elsewhere, or register an account and start translating at [Hosted Weblate](https://hosted.weblate.org/engage/invidious/). +You can suggest new translations and/or correction here: https://hosted.weblate.org/engage/invidious/. ---- +Creating an account is not required, but recommended, especially if you want to contribute regularly. +Weblate also allows you to log-in with major SSO providers like Github, Gitlab, BitBucket, Google, ... -## Contact: -Feel free to join our [Matrix room](https://matrix.to/#/#invidious:matrix.org). +## Projects using Invidious ---- +A list of projects and extensions for or utilizing Invidious can be found in the documentation: https://docs.invidious.io/applications/ -## Liability: +## Liability -We take no responsibility for the use of our tool, or external instances provided by third parties. We strongly recommend you abide by the valid official regulations in your country. Furthermore, we refuse liability for any inappropriate use of Invidious, such as illegal downloading. This tool is provided to you in the spirit of free, open software. +We take no responsibility for the use of our tool, or external instances +provided by third parties. We strongly recommend you abide by the valid +official regulations in your country. Furthermore, we refuse liability +for any inappropriate use of Invidious, such as illegal downloading. +This tool is provided to you in the spirit of free, open software. You may view the LICENSE in which this software is provided to you [here](./LICENSE). diff --git a/assets/css/carousel.css b/assets/css/carousel.css new file mode 100644 index 00000000..4bae92e5 --- /dev/null +++ b/assets/css/carousel.css @@ -0,0 +1,119 @@ +/* +Copyright (c) 2024 by Jennifer (https://codepen.io/jwjertzoch/pen/JjyGeRy) + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall +be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +*/ + +.carousel { + margin: 0 auto; + overflow: hidden; + text-align: center; +} + +.slides { + width: 100%; + display: flex; + overflow-x: scroll; + scrollbar-width: none; + scroll-snap-type: x mandatory; + scroll-behavior: smooth; +} + +.slides::-webkit-scrollbar { + display: none; +} + +.slides-item { + align-items: center; + border-radius: 10px; + display: flex; + flex-shrink: 0; + font-size: 100px; + height: 600px; + justify-content: center; + margin: 0 1rem; + position: relative; + scroll-snap-align: start; + transform: scale(1); + transform-origin: center center; + transition: transform .5s; + width: 100%; +} + +.carousel__nav { + padding: 1.25rem .5rem; +} + +.slider-nav { + align-items: center; + background-color: #ddd; + border-radius: 50%; + color: #000; + display: inline-flex; + height: 1.5rem; + justify-content: center; + padding: .5rem; + position: relative; + text-decoration: none; + width: 1.5rem; +} + +.skip-link { + height: 1px; + overflow: hidden; + position: absolute; + top: auto; + width: 1px; +} + +.skip-link:focus { + align-items: center; + background-color: #000; + color: #fff; + display: flex; + font-size: 30px; + height: 30px; + justify-content: center; + opacity: .8; + text-decoration: none; + width: 50%; + z-index: 1; +} + +.light-theme .slider-nav { + background-color: #ddd; +} + +.dark-theme .slider-nav { + background-color: #0005; +} + +@media (prefers-color-scheme: light) { + .no-theme .slider-nav { + background-color: #ddd; + } +} + +@media (prefers-color-scheme: dark) { + .no-theme .slider-nav { + background-color: #0005; + } +} diff --git a/assets/css/default.css b/assets/css/default.css index 07a879bb..2cedcf0c 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -1,3 +1,7 @@ +/* + * Common attributes + */ + html, body { font-family: BlinkMacSystemFont, -apple-system, "Segoe UI", Roboto, Oxygen, @@ -9,16 +13,61 @@ body { display: flex; flex-direction: column; min-height: 100vh; + margin: auto; +} + +.h-box { + padding-left: 1em; + padding-right: 1em; +} + +.v-box { + padding-top: 1em; + padding-bottom: 1em; } .deleted { background-color: rgb(255, 0, 0, 0.5); } +.underlined { + border-bottom: 1px solid; + margin-bottom: 20px; +} + +.title { + margin: 0.5em 0 1em 0; +} + +/* A flex container */ +.flexible { + display: flex; + align-items: center; +} + +.flex-left { + display: flex; + flex: 1 1 auto; + flex-flow: row wrap; + justify-content: flex-start; +} +.flex-right { + display: flex; + flex: 2 0 auto; + flex-flow: row nowrap; + justify-content: flex-end; +} + + +/* + * Channel page + */ + .channel-profile > * { font-size: 1.17em; font-weight: bold; vertical-align: middle; + border-radius: 50%; } .channel-profile > img { @@ -40,6 +89,7 @@ body a.channel-owner { } .creator-heart { + display: inline-block; position: relative; width: 16px; height: 16px; @@ -60,6 +110,7 @@ body a.channel-owner { } .creator-heart-small-container { + display: block; position: relative; width: 13px; height: 13px; @@ -82,16 +133,6 @@ body a.channel-owner { } } -.h-box { - padding-left: 1em; - padding-right: 1em; -} - -.v-box { - padding-top: 1em; - padding-bottom: 1em; -} - div { overflow-wrap: break-word; word-wrap: break-word; @@ -107,62 +148,108 @@ div { padding-right: 10px; } + +/* + * Buttons + */ + body a.pure-button { color: rgba(0,0,0,.8); } button.pure-button-primary, body a.pure-button-primary, -.channel-owner:hover { +.channel-owner:hover, +.channel-owner:focus { background-color: #a0a0a0; color: rgba(35, 35, 35, 1); } -button.pure-button-primary:hover, -body a.pure-button-primary:hover { - background-color: rgba(0, 182, 240, 1); - color: #fff; +.pure-button-primary, +.pure-button-secondary { + border: 1px solid #a0a0a0; + border-radius: 3px; + margin: 0 .4em; } +.pure-button-secondary.low-profile { + padding: 5px 10px; + margin: 0; +} + +/* Has to be combined with flex-left/right */ +.button-container { + flex-flow: wrap; + gap: 0.5em 0.75em; +} + + +/* + * Video thumbnails + */ + div.thumbnail { - padding: 28.125%; position: relative; + width: 100%; box-sizing: border-box; } img.thumbnail { - position: absolute; + display: block; /* See: https://stackoverflow.com/a/11635197 */ width: 100%; - height: 100%; - left: 0; - top: 0; object-fit: cover; + aspect-ratio: 16 / 9; } -.length { - z-index: 100; +.thumbnail-placeholder { + min-height: 50px; + border: 2px dotted; +} + +div.watched-overlay { + z-index: 50; position: absolute; - background-color: rgba(35, 35, 35, 0.75); - color: #fff; - border-radius: 2px; - padding: 2px; - font-size: 16px; - right: 0.25em; - bottom: -0.75em; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255,255,255,.4); } -.watched { +div.watched-indicator { + position: absolute; + left: 0; + bottom: 0; + height: 4px; + width: 100%; + background-color: red; +} + +div.thumbnail > .top-left-overlay, +div.thumbnail > .bottom-right-overlay { z-index: 100; position: absolute; - background-color: rgba(35, 35, 35, 0.75); - color: #fff; - border-radius: 2px; - padding: 4px 8px 4px 8px; + padding: 0; + margin: 0; font-size: 16px; - left: 0.2em; - top: -0.7em; } +.top-left-overlay { top: 0.6em; left: 0.6em; } +.bottom-right-overlay { bottom: 0.6em; right: 0.6em; } + +.length { + padding: 1px; + margin: -2px 0; + color: #fff; + border-radius: 3px; +} + +.length, .top-left-overlay button { + color: #eee; + background-color: rgba(35, 35, 35, 0.85) !important; +} + + /* * Navbar */ @@ -191,20 +278,34 @@ img.thumbnail { display: inline; } -.searchbar .pure-form input[type="search"] { - margin-bottom: 1px; +.searchbar .pure-form { + display: flex; +} + +.searchbar .pure-form fieldset { + padding: 0; + flex: 1; +} - border-top: 0; - border-left: 0; - border-right: 0; - border-bottom: 1px solid #ccc; - border-radius: 0; +.searchbar input[type="search"] { + width: 100%; + margin: 1px; - padding: initial 0; + border: 1px solid; + border-color: rgba(0,0,0,0); + border-bottom-color: #CCC; + border-radius: 0; - box-shadow: none; + box-shadow: none; + appearance: none; + -webkit-appearance: none; +} - -webkit-appearance: none; +.searchbar input[type="search"]:focus { + margin: 0; + border: 2px solid; + border-color: rgba(0,0,0,0); + border-bottom-color: #FED; } /* https://stackoverflow.com/a/55170420 */ @@ -216,14 +317,14 @@ input[type="search"]::-webkit-search-cancel-button { background-size: 14px; } -.searchbar .pure-form fieldset { - padding: 0; +.searchbar #searchbutton { + border: none; + background: none; + margin-top: 0; } -/* attract focus to the searchbar by adding a subtle transition */ -.searchbar .pure-form input[type="search"]:focus { - margin-bottom: 0px; - border-bottom: 2px solid #aaa; +.searchbar #searchbutton:hover { + color: rgb(0, 182, 240); } .user-field { @@ -234,13 +335,18 @@ input[type="search"]::-webkit-search-cancel-button { } .user-field div { - width: initial; + width: auto; } .user-field div:not(:last-child) { margin-right: 1em; } + +/* + * Responsive rules + */ + @media only screen and (max-aspect-ratio: 16/9) { .player-dimensions.vjs-fluid { padding-top: 46.86% !important; @@ -259,20 +365,28 @@ input[type="search"]::-webkit-search-cancel-button { .navbar > div { display: flex; justify-content: center; - } - - .navbar > div:not(:last-child) { - margin-bottom: 1em; + margin-bottom: 25px; } .navbar > .searchbar > form { - width: 60%; + width: 75%; } h1 { font-size: 1.25em; margin: 0.42em 0; } + + /* Space out the subscribe & RSS buttons and align them to the left */ + .title.flexible { display: block; } + .title.flexible > .flex-right { margin: 0.75em 0; justify-content: flex-start; } + + /* Space out buttons to make them easier to tap */ + .user-field { font-size: 125%; } + .user-field > :not(:last-child) { margin-right: 1.75em; } + + .icon-buttons { font-size: 125%; } + .icon-buttons > :not(:last-child) { margin-right: 0.75em; } } @media screen and (max-width: 320px) { @@ -282,21 +396,95 @@ input[type="search"]::-webkit-search-cancel-button { } } + +/* + * Video "cards" (results/playlist/channel videos) + */ + +.video-card-row { margin: 15px 0; } + +p.channel-name { margin: 0; } +p.video-data { margin: 0; font-weight: bold; font-size: 80%; } + + +/* + * Comments & community posts + */ + +.comments { + max-width: 800px; + margin: auto; +} + +/* + * We don't want the top and bottom margin on the post page. + */ +.comments.post-comments { + margin-bottom: 0; + margin-top: 0; +} + +.video-iframe-wrapper { + position: relative; + height: 0; + padding-bottom: 56.25%; +} + +.video-iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; +} + + +/* + * Page navigation + */ + +.page-nav-container { margin: 15px 0 30px 0; } + +.page-prev-container { text-align: start; } +.page-next-container { text-align: end; } + +.page-prev-container, +.page-next-container { + display: inline-block; +} + + /* * Footer */ footer { - color: #919191; margin-top: auto; padding: 1.5em 0; text-align: center; max-height: 30vh; } -footer a { - color: #919191 !important; - text-decoration: underline; +.light-theme footer { + color: #7c7c7c; +} + +.dark-theme footer { + color: #adadad; +} + +.light-theme footer a { + color: #7c7c7c !important; +} + +.dark-theme footer a { + color: #adadad !important; +} + +footer span { + margin: 4px 0; + display: block; } /* keyframes */ @@ -319,18 +507,31 @@ span > select { word-wrap: normal; } + /* * Light theme */ .light-theme a:hover, .light-theme a:active, -.light-theme summary:hover { +.light-theme summary:hover, +.light-theme a:focus, +.light-theme summary:focus { color: #075A9E !important; } -.light-theme a.pure-button-primary:hover { +.light-theme .pure-button-primary:hover, +.light-theme .pure-button-primary:focus, +.light-theme .pure-button-secondary:hover, +.light-theme .pure-button-secondary:focus { color: #fff !important; + border-color: rgba(0, 182, 240, 0.75) !important; + background-color: rgba(0, 182, 240, 0.75) !important; +} + +.light-theme .pure-button-secondary:not(.low-profile) { + color: #335d7a; + background-color: #fff2; } .light-theme a { @@ -352,12 +553,24 @@ span > select { @media (prefers-color-scheme: light) { .no-theme a:hover, .no-theme a:active, - .no-theme summary:hover { + .no-theme summary:hover, + .no-theme a:focus, + .no-theme summary:focus { color: #075A9E !important; } - .no-theme a.pure-button-primary:hover { + .no-theme .pure-button-primary:hover, + .no-theme .pure-button-primary:focus, + .no-theme .pure-button-secondary:hover, + .no-theme .pure-button-secondary:focus { color: #fff !important; + border-color: rgba(0, 182, 240, 0.75) !important; + background-color: rgba(0, 182, 240, 0.75) !important; + } + + .no-theme .pure-button-secondary:not(.low-profile) { + color: #335d7a; + background-color: #fff2; } .no-theme a { @@ -372,23 +585,48 @@ span > select { color: #303030; } + .no-theme footer { + color: #7c7c7c; + } + + .no-theme footer a { + color: #7c7c7c !important; + } + .light-theme .pure-menu-heading { color: #565d64; } } + /* * Dark theme */ .dark-theme a:hover, .dark-theme a:active, -.dark-theme summary:hover { +.dark-theme summary:hover, +.dark-theme a:focus, +.dark-theme summary:focus { color: rgb(0, 182, 240); } +.dark-theme .pure-button-primary:hover, +.dark-theme .pure-button-primary:focus, +.dark-theme .pure-button-secondary:hover, +.dark-theme .pure-button-secondary:focus { + color: #fff !important; + border-color: rgb(0, 182, 240) !important; + background-color: rgba(0, 182, 240, 1) !important; +} + +.dark-theme .pure-button-secondary { + background-color: #0002; + color: #ddd; +} + .dark-theme a { - color: #a0a0a0; + color: #adadad; text-decoration: none; } @@ -422,12 +660,27 @@ body.dark-theme { @media (prefers-color-scheme: dark) { .no-theme a:hover, - .no-theme a:active { + .no-theme a:active, + .no-theme a:focus { color: rgb(0, 182, 240); } + .no-theme .pure-button-primary:hover, + .no-theme .pure-button-primary:focus, + .no-theme .pure-button-secondary:hover, + .no-theme .pure-button-secondary:focus { + color: #fff !important; + border-color: rgb(0, 182, 240) !important; + background-color: rgba(0, 182, 240, 1) !important; + } + + .no-theme .pure-button-secondary { + background-color: #0002; + color: #ddd; + } + .no-theme a { - color: #a0a0a0; + color: #adadad; text-decoration: none; } @@ -458,52 +711,41 @@ body.dark-theme { background-color: inherit; color: inherit; } -} -#filters { - display: inline; - margin-top: 15px; -} + .no-theme footer { + color: #adadad; + } -#filters > div { - display: inline-block; + .no-theme footer a { + color: #adadad !important; + } } -#filters > summary { - display: block; - margin-bottom: 15px; -} -#filters > summary::before { - content: "[ + ]"; - font-size: 1.5em; -} +/* + * Miscellanous + */ -#filters[open] > summary::before { - content: "[ - ]"; - font-size: 1.5em; -} /*With commit d9528f5 all contents of the page is now within a flexbox. However, the hr element is rendered improperly within one. See https://stackoverflow.com/a/34372979 for more info */ hr { - margin: auto 0 auto 0; + margin: 10px 0 10px 0; } /* Description Expansion Styling*/ -#description-box { - display: flex; - flex-direction: column; -} - -#descexpansionbutton { - display: none +#descexpansionbutton, +#music-desc-expansion { + display: none; } #descexpansionbutton ~ div { overflow: hidden; - height: 8.3em; +} + +#descexpansionbutton:not(:checked) ~ div { + max-height: 8.3em; } #descexpansionbutton:checked ~ div { @@ -511,7 +753,66 @@ hr { height: 100%; } -#descexpansionbutton + label { +#descexpansionbutton ~ label { order: 1; margin-top: 20px; } + +label[for="descexpansionbutton"]:hover, +label[for="music-desc-expansion"]:hover { + cursor: pointer; +} + +/* Bidi (bidirectional text) support */ +h1, h2, h3, h4, h5, p, +#descriptionWrapper, +#description-box, +#music-description-box { + unicode-bidi: plaintext; + text-align: start; +} + +#descriptionWrapper { + max-width: 600px; + white-space: pre-wrap; +} + +#music-description-box { + display: none; +} + +#music-desc-expansion:checked ~ #music-description-box { + display: block; +} + +#music-desc-expansion ~ label > h3 > .ion-ios-arrow-up, +#music-desc-expansion:checked ~ label > h3 > .ion-ios-arrow-down { + display: none; +} + +#music-desc-expansion:checked ~ label > h3 > .ion-ios-arrow-up, +#music-desc-expansion ~ label > h3 > .ion-ios-arrow-down { + display: inline; +} + +/* Select all the music items except the first one */ +.music-item + .music-item { + border-top: 1px solid #ffffff; +} + +/* Center the "invidious" logo on the search page */ +#logo > h1 { text-align: center; } + +/* IE11 fixes */ +:-ms-input-placeholder { color: #888; } + +/* Wider settings name to less word wrap */ +.pure-form-aligned .pure-control-group label { width: 19em; } + +.channel-emoji { + margin: 0 2px; +} + +#download_widget { + width: 100%; +} diff --git a/assets/css/embed.css b/assets/css/embed.css index 466a284a..cbafcfea 100644 --- a/assets/css/embed.css +++ b/assets/css/embed.css @@ -21,6 +21,7 @@ color: white; } -.watch-on-invidious > a:hover { +.watch-on-invidious > a:hover, +.watch-on-invidious > a:focus { color: rgba(0, 182, 240, 1);; } diff --git a/assets/css/player.css b/assets/css/player.css index 656fb48c..9cb400ad 100644 --- a/assets/css/player.css +++ b/assets/css/player.css @@ -34,7 +34,7 @@ .video-js.player-style-youtube .vjs-control-bar > .vjs-spacer { flex: 1; order: 2; -} +} .video-js.player-style-youtube .vjs-play-progress .vjs-time-tooltip { display: none; @@ -68,8 +68,12 @@ .video-js.player-style-youtube .vjs-menu-button-popup .vjs-menu { margin-bottom: 2em; + padding-top: 2em } +.video-js.player-style-youtube .vjs-progress-control .vjs-progress-holder, .video-js.player-style-youtube .vjs-progress-control {height: 5px; +margin-bottom: 10px;} + ul.vjs-menu-content::-webkit-scrollbar { display: none; } @@ -98,21 +102,25 @@ ul.vjs-menu-content::-webkit-scrollbar { order: 2; } +.vjs-audio-button { + order: 3; +} + .vjs-quality-selector, .video-js .vjs-http-source-selector { - order: 3; + order: 4; } .vjs-playback-rate { - order: 4; + order: 5; } .vjs-share-control { - order: 5; + order: 6; } .vjs-fullscreen-control { - order: 6; + order: 7; } .vjs-playback-rate > .vjs-menu { @@ -168,11 +176,14 @@ ul.vjs-menu-content::-webkit-scrollbar { .video-js.player-style-invidious .vjs-play-progress { background-color: rgba(0, 182, 240, 1); } -vjs-menu-content + /* Overlay */ .video-js .vjs-overlay { - background-color: rgba(35, 35, 35, 0.75); - color: rgba(255, 255, 255, 1); + background-color: rgba(35, 35, 35, 0.75) !important; +} +.video-js .vjs-overlay * { + color: rgba(255, 255, 255, 1) !important; + text-align: center; } /* ProgressBar marker */ @@ -218,6 +229,10 @@ video.video-js { #player-container { position: relative; + padding-left: 0; + padding-right: 0; + margin-left: 1em; + margin-right: 1em; padding-bottom: 82vh; height: 0; } diff --git a/assets/css/search.css b/assets/css/search.css new file mode 100644 index 00000000..7036fd28 --- /dev/null +++ b/assets/css/search.css @@ -0,0 +1,121 @@ +summary { + /* This should hide the marker */ + display: block; + + font-size: 1.17em; + font-weight: bold; + margin: 0 auto 10px auto; + cursor: pointer; +} + +summary::-webkit-details-marker, +summary::marker { display: none; } + +summary:before { + border-radius: 5px; + content: "[ + ]"; + margin: -2px 10px 0 10px; + padding: 1px 0 3px 0; + text-align: center; + width: 40px; +} + +details[open] > summary:before { content: "[ − ]"; } + + +#filters-box { + padding: 10px 20px 20px 10px; + margin: 10px 15px; +} +#filters-flex { + display: flex; + flex-wrap: wrap; + flex-direction: row; + align-items: flex-start; + align-content: flex-start; + justify-content: flex-start; +} + + +fieldset, legend { + display: contents !important; + border: none !important; + margin: 0 !important; + padding: 0 !important; +} + + +.filter-column { + display: inline-block; + display: inline-flex; + width: max-content; + min-width: max-content; + max-width: 16em; + margin: 15px; + flex-grow: 2; + flex-basis: auto; + flex-direction: column; +} +.filter-name, .filter-options { + display: block; + padding: 5px 10px; + margin: 0; + text-align: start; +} + +.filter-options div { margin: 6px 0; } +.filter-options div * { vertical-align: middle; } +.filter-options label { margin: 0 10px; } + + +#filters-apply { + text-align: right; /* IE11 only */ + text-align: end; /* Override for compatible browsers */ +} + +/* Error message */ + +.no-results-error { + text-align: center; + line-height: 180%; + font-size: 110%; + padding: 15px 15px 125px 15px; +} + +/* Responsive rules */ + +@media only screen and (max-width: 800px) { + summary { font-size: 1.30em; } + #filters-box { + margin: 10px 0 0 0; + padding: 0; + } + #filters-apply { + text-align: center; + padding: 15px; + } +} + +/* Light theme */ + +.light-theme #filters-box { + background: #dfdfdf; +} + +@media (prefers-color-scheme: light) { + .no-theme #filters-box { + background: #dfdfdf; + } +} + +/* Dark theme */ + +.dark-theme #filters-box { + background: #373737; +} + +@media (prefers-color-scheme: dark) { + .no-theme #filters-box { + background: #373737; + } +} diff --git a/assets/css/video-js.min.css b/assets/css/video-js.min.css deleted file mode 100644 index 17702b1b..00000000 --- a/assets/css/video-js.min.css +++ /dev/null @@ -1 +0,0 @@ -@charset "UTF-8";.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-modal-dialog,.vjs-button>.vjs-icon-placeholder:before,.vjs-modal-dialog .vjs-modal-dialog-content{position:absolute;top:0;left:0;width:100%;height:100%}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.vjs-button>.vjs-icon-placeholder:before{text-align:center}@font-face{font-family:VideoJS;src:url(data:application/font-woff;charset=utf-8;base64,d09GRgABAAAAABDkAAsAAAAAG6gAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABCAAAADsAAABUIIslek9TLzIAAAFEAAAAPgAAAFZRiV3hY21hcAAAAYQAAADaAAADPv749/pnbHlmAAACYAAAC3AAABHQZg6OcWhlYWQAAA3QAAAAKwAAADYZw251aGhlYQAADfwAAAAdAAAAJA+RCLFobXR4AAAOHAAAABMAAACM744AAGxvY2EAAA4wAAAASAAAAEhF6kqubWF4cAAADngAAAAfAAAAIAE0AIFuYW1lAAAOmAAAASUAAAIK1cf1oHBvc3QAAA/AAAABJAAAAdPExYuNeJxjYGRgYOBiMGCwY2BycfMJYeDLSSzJY5BiYGGAAJA8MpsxJzM9kYEDxgPKsYBpDiBmg4gCACY7BUgAeJxjYGS7wTiBgZWBgaWQ5RkDA8MvCM0cwxDOeI6BgYmBlZkBKwhIc01hcPjI+FGJHcRdyA4RZgQRADK3CxEAAHic7dFZbsMgAEXRS0ycyZnnOeG7y+qC8pU1dHusIOXxuoxaOlwZYWQB0Aea4quIEN4E9LzKbKjzDeM6H/mua6Lmc/p8yhg0lvdYx15ZG8uOLQOGjMp3EzqmzJizYMmKNRu27Nhz4MiJMxeu3Ljz4Ekqm7T8P52G8PP3lnTOVk++Z6iN6QZzNN1F7ptuN7eGOjDUoaGODHVsuvU8MdTO9Hd5aqgzQ50b6sJQl4a6MtS1oW4MdWuoO0PdG+rBUI+GejLUs6FeDPVqqDdDvRvqw1CfhpqM9At0iFLaAAB4nJ1YDXBTVRZ+5/22TUlJ8we0pHlJm7RJf5O8F2j6EymlSPkpxaL8U2xpa3DKj0CBhc2IW4eWKSokIoLsuMqssM64f+jA4HSdWXXXscBq67IOs3FXZ1ZYWVyRFdo899yXtIBQZ90k7717zz3v3HPPOfd854YCCj9cL9dL0RQFOqCbGJnrHb5EayiKIWN8iA/hWBblo6hUWm8TtCDwE80WMJus/irwyxOdxeB0MDb14VNJHnXYoLLSl6FfCUYO9nYPTA8Epg9090LprfbBbZ2hY0UlJUXHQp3/vtWkS6EBv8+rPMq5u9692f/dNxJNiqwC1xPE9TCUgCsSdQWgE3XQD25lkG4CN2xmTcOXWBOyser6RN6KnGbKSbmQ3+d0OI1m2W8QzLLkI2sykrWAgJJEtA8vGGW/2Q+CmT3n8zS9wZwu2DCvtuZKZN3xkrLh36yCZuUomQSqGpY8t/25VfHVhw8z4ebGBtfLb0ya9PCaDc+8dGTvk2dsh6z7WzvowlXKUSWo9MJ15a3KrEP2loOr2Ojhw6iW6hf2BDdEccQvZGpaAy7YovSwq8kr7HGllxpd71rkS6G0Sf11sl9OvMK1+jwPPODxjUwkOim9CU3ix1wNjXDfmJSEn618Bs6lpWwUpU+8PCqLMY650zjq8VhCIP17NEKTx3eaLL+s5Pi6yJWaWjTHLR1jYzPSV9VF/6Ojdb/1kO3Mk3uhHC0x6gc1BjlKQ+nQFxTYdaJkZ7ySVxLBbhR1dsboNXp1tCYKW2LRaEzpYcIx2BKNxaL0ZaUnSqfFoiNhHKR/GkX6PWUSAaJelQaqZL1EpoHNsajSEyPSoJ9IjhIxTdjHLmwZvhRDOiFTY/YeQnvrVZmiTQtGncECXtFTBZLOVwwMRgoXHAkXzMzPn1nAJJ8jYSbMDaqN2waGLzNhih/bZynUBMpIWSg7VYi7DRx2m8ALkIdRCJwI6ArJx2EI8kaDWeTQKeAFk9fjl/1AvwktjQ1P7NjyMGQyfd4vjipX6M/i52D7Cq80kqlcxEcGXRr/FEcgs0u5uGgB4VWuMFfpdn2Re6Hi3PqzmxWKsz6+ae2Pn9hXXw/fqM859UiGC0oKYYILJBqJrsn1Z1E5qOs9rQCiUQRREjm8yJcbHF5cUJufX1vAHlefw0XgUoboS3ETfQlTxBC4SOtuE8VPRJTBSCQSjZCpk7Gqzu+masaZ2y7Zjehho4F3g82BNDkAHpORG4+OCS+f6JTPmtRn/PH1kch6d04sp7AQb25aQ/pqUyXeQ8vrebG8OYQdXOQ+585u0sdW9rqalzRURiJ+9F4MweRFrKUjl1GUYhH1A27WOHw5cTFSFPMo9EeUIGnQTZHIaJ7AHLaOKsOODaNF9jkBjYG2QEsQ2xjMUAx2bBEbeTBWMHwskBjngq56S/yfgkBnWBa4K9sqKtq2t1UI8S9He5XuBRbawAdatrQEAi30Aks2+LM8WeCbalVZkWNylvJ+dqJnzVb+OHlSoKW8nPCP7Rd+CcZ2DdWAGqJ2CBFOphgywFFCFBNtfAbGtNPBCwxvygHeYMZMY9ZboBqwq/pVrsbgN5tkv152ODlbMfiqwGMBgxa4Exz3QhovRIUp6acqZmQzRq0ypDXS2TPLT02YIkQETnOE445oOGxOmXAqUJNNG7XgupMjPq2ua9asrj5yY/yuKteO1Kx0YNJTufrirLe1mZnat7OL6rnUdCWenpW6I8mAnbsY8KWs1PuSovCW9A/Z25PQ24a7cNOqgmTkLmBMgh4THgc4b9k2IVv1/g/F5nGljwPLfOgHAzJzh45V/4+WenTzmMtR5Z7us2Tys909UHqrPY7KbckoxRvRHhmVc3cJGE97uml0R1S0jdULVl7EvZtDFVBF35N9cEdjpgmAiOlFZ+Dtoh93+D3zzHr8RRNZQhnCNMNbcegOvpEwZoL+06cJQ07h+th3fZ/7PVbVC6ngTAV/KoLFuO6+2KFcU651gEb5ugPSIb1D+Xp8V4+k3sEIGnw5mYe4If4k1lFYr6SCzmM2EQ8iWtmwjnBI9kTwe1TlfAmXh7H02by9fW2gsjKwtv0aaURKil4OdV7rDL1MXIFNrhdxohcZXYTnq47WisrKitaObbf5+yvkLi5J6lCNZZ+B6GC38VNBZBDidSS/+mSvh6s+srgC8pyKMvDtt+de3c9fU76ZPfuM8ud4Kv0fyP/LqfepMT/3oZxSqpZaTa1DaQYLY8TFsHYbWYsPoRhRWfL5eSSQbhUGgGC3YLbVMk6PitTFNGpAsNrC6D1VNBKgBHMejaiuRWEWGgsSDBTJjqWIl8kJLlsaLJ2tXDr6xGfT85bM2Q06a46x2HTgvdnV8z5YDy/27J4zt6x2VtkzjoYpkq36kaBr4eQSg7tyiVweWubXZugtadl58ydapfbORfKsDTuZ0OBgx4cfdjCf5tbWNITnL120fdOi1RV1C3uKGzNdwYLcMvZ3BxoPyTOCD1XvXTp7U10gWCVmTV9b3r2z0SkGWovb2hp9I89O8a2smlyaO8muMU+dRmtzp60IzAoFpjLr1n388boLyf0dRvxhsHZ0qbWqDkwqvvpkj4l0fY6EIXRi5sQSrAvsVYwXRy4qJ2EVtD1AN7a0HWth9ymvL1xc3WTUKK/TAHA/bXDVtVWfOMfuGxGZv4Ln/jVr9jc3j1yMv0tndmyt9Vq88Y9gH1wtLX3KWjot5++jWHgAoZZkQ14wGQ20Fli71UmKJAy4xKMSTGbVdybW7FDDAut9XpD5AzWrYO7zQ8qffqF8+Ynd/clrHcdyxGy3a/3+mfNnzC/cBsveTjnTvXf1o6vzOlZw7WtqtdmPK/Errz/6NNtD72zmNOZfbmYdTGHfoofqI79Oc+R2n1lrnL6pOm0Up7kwxhTW12Amm7WYkXR2qYrF2AmgmbAsxZjwy1xpg/m1Je2vrp8v/nz2xpmlBg4E9hrMU341wVpTOh/OfmGvAnra8q6uctr60ZQHV3Q+WMQJykMj8ZsWn2QBOmmHMB+m5pDIpTFonYigiaKAhGEiAHF7EliVnQkjoLVIMPtJpBKHYd3A8GYH9jJzrWwmHx5Qjp7vDAX0suGRym1vtm/9W1/HyR8vczfMs6Sk8DSv855/5dlX9oQq52hT8syyp2rx5Id17IAyAM3wIjQPMOHzytEB64q6D5zT91yNbnx3V/nqnd017S9Y0605k3izoXLpsxde2n38yoOV9s1LcjwzNjbdX6asnBVaBj/6/DwKwPkpcqbDG7BnsXoSqWnUAmottYF6jMSdVyYZh3zVXCjwTiwwHH6sGuRiEHQGzuRX6whZkp123oy1BWE2mEfJ/tvIRtM4ZM5bDXiMsPMaAKOTyc5uL57rqyyc5y5JE5pm1i2S2iUX0CcaQ6lC6Zog7JqSqZmYlosl2K6pwNA84zRnQW6SaALYZQGW5lhCtU/W34N6o+bKfZ8cf3/Cl/+iTX3wBzpOY4mRkeNf3rptycGSshQWgGbYt5jFc2e0+DglIrwl6DVWQ7BuwaJ3Xk1J4VL5urnLl/Wf+gHU/hZoZdKNym6lG+I34FaNeZKcSpJIo2IeCVvpdsDGfKvzJnAwmeD37Ow65ZWwSowpgwX5T69s/rB55dP5BcpgDKFV8p7q2sn/1uc93bVzT/w6UrCqDTWvfCq/oCD/qZXNoUj8BL5Kp6GU017frfNXkAtiiyf/SOCEeLqnd8R/Ql9GlCRfctS6k5chvIBuQ1zCCjoCHL2DHNHIXxMJ3kQeO8lbsUXONeSfA5EjcG6/E+KdhN4bP04vBhdi883+BFBzQbxFbvZzQeY9LNBZc0FNfn5NwfDn6rCTnTw6R8o+gfpf5hCom33cRuiTlss3KHmZjD+BPN+5gXuA2ziS/Q73mLxUkpbKN/eqwz5uK0X9F3h2d1V4nGNgZGBgAOJd776+iue3+crAzc4AAje5Bfcg0xz9YHEOBiYQBQA8FQlFAHicY2BkYGBnAAGOPgaG//85+hkYGVCBMgBGGwNYAAAAeJxjYGBgYB8EmKOPgQEAQ04BfgAAAAAAAA4AaAB+AMwA4AECAUIBbAGYAcICGAJYArQC4AMwA7AD3gQwBJYE3AUkBWYFigYgBmYGtAbqB1gIEghYCG4IhAi2COh4nGNgZGBgUGYoZWBnAAEmIOYCQgaG/2A+AwAYCQG2AHicXZBNaoNAGIZfE5PQCKFQ2lUps2oXBfOzzAESyDKBQJdGR2NQR3QSSE/QE/QEPUUPUHqsvsrXjTMw83zPvPMNCuAWP3DQDAejdm1GjzwS7pMmwi75XngAD4/CQ/oX4TFe4Qt7uMMbOzjuDc0EmXCP/C7cJ38Iu+RP4QEe8CU8pP8WHmOPX2EPz87TPo202ey2OjlnQSXV/6arOjWFmvszMWtd6CqwOlKHq6ovycLaWMWVydXKFFZnmVFlZU46tP7R2nI5ncbi/dDkfDtFBA2DDXbYkhKc+V0Bqs5Zt9JM1HQGBRTm/EezTmZNKtpcAMs9Yu6AK9caF76zoLWIWcfMGOSkVduvSWechqZsz040Ib2PY3urxBJTzriT95lipz+TN1fmAAAAeJxtkMl2wjAMRfOAhABlKm2h80C3+ajgCKKDY6cegP59TYBzukAL+z1Zsq8ctaJTTKPrsUQLbXQQI0EXKXroY4AbDDHCGBNMcYsZ7nCPB8yxwCOe8IwXvOIN7/jAJ76wxHfUqWX+OzgumWAjJMV17i0Ndlr6irLKO+qftdT7i6y4uFSUvCknay+lFYZIZaQcmfH/xIFdYn98bqhra1aKTM/6lWMnyaYirx1rFUQZFBkb2zJUtoXeJCeg0WnLtHeSFc3OtrnozNwqi0TkSpBMDB1nSde5oJXW23hTS2/T0LilglXX7dmFVxLnq5U0vYATHFk3zX3BOisoQHNDFDeZnqKDy9hRNawN7Vh727hFzcJ5c8TILrKZfH7tIPxAFP0BpLeJPA==) format("woff");font-weight:400;font-style:normal}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-play-control .vjs-icon-placeholder,.vjs-icon-play{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-big-play-button .vjs-icon-placeholder:before,.video-js .vjs-play-control .vjs-icon-placeholder:before,.vjs-icon-play:before{content:"\f101"}.vjs-icon-play-circle{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-play-circle:before{content:"\f102"}.video-js .vjs-play-control.vjs-playing .vjs-icon-placeholder,.vjs-icon-pause{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-control.vjs-playing .vjs-icon-placeholder:before,.vjs-icon-pause:before{content:"\f103"}.video-js .vjs-mute-control.vjs-vol-0 .vjs-icon-placeholder,.vjs-icon-volume-mute{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-0 .vjs-icon-placeholder:before,.vjs-icon-volume-mute:before{content:"\f104"}.video-js .vjs-mute-control.vjs-vol-1 .vjs-icon-placeholder,.vjs-icon-volume-low{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-1 .vjs-icon-placeholder:before,.vjs-icon-volume-low:before{content:"\f105"}.video-js .vjs-mute-control.vjs-vol-2 .vjs-icon-placeholder,.vjs-icon-volume-mid{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control.vjs-vol-2 .vjs-icon-placeholder:before,.vjs-icon-volume-mid:before{content:"\f106"}.video-js .vjs-mute-control .vjs-icon-placeholder,.vjs-icon-volume-high{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-mute-control .vjs-icon-placeholder:before,.vjs-icon-volume-high:before{content:"\f107"}.video-js .vjs-fullscreen-control .vjs-icon-placeholder,.vjs-icon-fullscreen-enter{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-fullscreen-control .vjs-icon-placeholder:before,.vjs-icon-fullscreen-enter:before{content:"\f108"}.video-js.vjs-fullscreen .vjs-fullscreen-control .vjs-icon-placeholder,.vjs-icon-fullscreen-exit{font-family:VideoJS;font-weight:400;font-style:normal}.video-js.vjs-fullscreen .vjs-fullscreen-control .vjs-icon-placeholder:before,.vjs-icon-fullscreen-exit:before{content:"\f109"}.vjs-icon-square{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-square:before{content:"\f10a"}.vjs-icon-spinner{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-spinner:before{content:"\f10b"}.video-js .vjs-subs-caps-button .vjs-icon-placeholder,.video-js .vjs-subtitles-button .vjs-icon-placeholder,.video-js.video-js:lang(en-AU) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-GB) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-IE) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js.video-js:lang(en-NZ) .vjs-subs-caps-button .vjs-icon-placeholder,.vjs-icon-subtitles{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js .vjs-subtitles-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-AU) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-GB) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-IE) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js.video-js:lang(en-NZ) .vjs-subs-caps-button .vjs-icon-placeholder:before,.vjs-icon-subtitles:before{content:"\f10c"}.video-js .vjs-captions-button .vjs-icon-placeholder,.video-js:lang(en) .vjs-subs-caps-button .vjs-icon-placeholder,.video-js:lang(fr-CA) .vjs-subs-caps-button .vjs-icon-placeholder,.vjs-icon-captions{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-captions-button .vjs-icon-placeholder:before,.video-js:lang(en) .vjs-subs-caps-button .vjs-icon-placeholder:before,.video-js:lang(fr-CA) .vjs-subs-caps-button .vjs-icon-placeholder:before,.vjs-icon-captions:before{content:"\f10d"}.video-js .vjs-chapters-button .vjs-icon-placeholder,.vjs-icon-chapters{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-chapters-button .vjs-icon-placeholder:before,.vjs-icon-chapters:before{content:"\f10e"}.vjs-icon-share{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-share:before{content:"\f10f"}.vjs-icon-cog{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-cog:before{content:"\f110"}.video-js .vjs-play-progress,.video-js .vjs-volume-level,.vjs-icon-circle,.vjs-seek-to-live-control .vjs-icon-placeholder{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-progress:before,.video-js .vjs-volume-level:before,.vjs-icon-circle:before,.vjs-seek-to-live-control .vjs-icon-placeholder:before{content:"\f111"}.vjs-icon-circle-outline{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-circle-outline:before{content:"\f112"}.vjs-icon-circle-inner-circle{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-circle-inner-circle:before{content:"\f113"}.vjs-icon-hd{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-hd:before{content:"\f114"}.video-js .vjs-control.vjs-close-button .vjs-icon-placeholder,.vjs-icon-cancel{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-control.vjs-close-button .vjs-icon-placeholder:before,.vjs-icon-cancel:before{content:"\f115"}.video-js .vjs-play-control.vjs-ended .vjs-icon-placeholder,.vjs-icon-replay{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-play-control.vjs-ended .vjs-icon-placeholder:before,.vjs-icon-replay:before{content:"\f116"}.vjs-icon-facebook{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-facebook:before{content:"\f117"}.vjs-icon-gplus{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-gplus:before{content:"\f118"}.vjs-icon-linkedin{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-linkedin:before{content:"\f119"}.vjs-icon-twitter{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-twitter:before{content:"\f11a"}.vjs-icon-tumblr{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-tumblr:before{content:"\f11b"}.vjs-icon-pinterest{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-pinterest:before{content:"\f11c"}.video-js .vjs-descriptions-button .vjs-icon-placeholder,.vjs-icon-audio-description{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-descriptions-button .vjs-icon-placeholder:before,.vjs-icon-audio-description:before{content:"\f11d"}.video-js .vjs-audio-button .vjs-icon-placeholder,.vjs-icon-audio{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-audio-button .vjs-icon-placeholder:before,.vjs-icon-audio:before{content:"\f11e"}.vjs-icon-next-item{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-next-item:before{content:"\f11f"}.vjs-icon-previous-item{font-family:VideoJS;font-weight:400;font-style:normal}.vjs-icon-previous-item:before{content:"\f120"}.video-js .vjs-picture-in-picture-control .vjs-icon-placeholder,.vjs-icon-picture-in-picture-enter{font-family:VideoJS;font-weight:400;font-style:normal}.video-js .vjs-picture-in-picture-control .vjs-icon-placeholder:before,.vjs-icon-picture-in-picture-enter:before{content:"\f121"}.video-js.vjs-picture-in-picture .vjs-picture-in-picture-control .vjs-icon-placeholder,.vjs-icon-picture-in-picture-exit{font-family:VideoJS;font-weight:400;font-style:normal}.video-js.vjs-picture-in-picture .vjs-picture-in-picture-control .vjs-icon-placeholder:before,.vjs-icon-picture-in-picture-exit:before{content:"\f122"}.video-js{display:block;vertical-align:top;box-sizing:border-box;color:#fff;background-color:#000;position:relative;padding:0;font-size:10px;line-height:1;font-weight:400;font-style:normal;font-family:Arial,Helvetica,sans-serif;word-break:initial}.video-js:-moz-full-screen{position:absolute}.video-js:-webkit-full-screen{width:100%!important;height:100%!important}.video-js[tabindex="-1"]{outline:0}.video-js *,.video-js :after,.video-js :before{box-sizing:inherit}.video-js ul{font-family:inherit;font-size:inherit;line-height:inherit;list-style-position:outside;margin-left:0;margin-right:0;margin-top:0;margin-bottom:0}.video-js.vjs-16-9,.video-js.vjs-4-3,.video-js.vjs-fluid{width:100%;max-width:100%;height:0}.video-js.vjs-16-9{padding-top:56.25%}.video-js.vjs-4-3{padding-top:75%}.video-js.vjs-fill{width:100%;height:100%}.video-js .vjs-tech{position:absolute;top:0;left:0;width:100%;height:100%}body.vjs-full-window{padding:0;margin:0;height:100%}.vjs-full-window .video-js.vjs-fullscreen{position:fixed;overflow:hidden;z-index:1000;left:0;top:0;bottom:0;right:0}.video-js.vjs-fullscreen:not(.vjs-ios-native-fs){width:100%!important;height:100%!important;padding-top:0!important}.video-js.vjs-fullscreen.vjs-user-inactive{cursor:none}.vjs-hidden{display:none!important}.vjs-disabled{opacity:.5;cursor:default}.video-js .vjs-offscreen{height:1px;left:-9999px;position:absolute;top:0;width:1px}.vjs-lock-showing{display:block!important;opacity:1;visibility:visible}.vjs-no-js{padding:20px;color:#fff;background-color:#000;font-size:18px;font-family:Arial,Helvetica,sans-serif;text-align:center;width:300px;height:150px;margin:0 auto}.vjs-no-js a,.vjs-no-js a:visited{color:#66a8cc}.video-js .vjs-big-play-button{font-size:3em;line-height:1.5em;height:1.63332em;width:3em;display:block;position:absolute;top:10px;left:10px;padding:0;cursor:pointer;opacity:1;border:.06666em solid #fff;background-color:#2b333f;background-color:rgba(43,51,63,.7);border-radius:.3em;transition:all .4s}.vjs-big-play-centered .vjs-big-play-button{top:50%;left:50%;margin-top:-.81666em;margin-left:-1.5em}.video-js .vjs-big-play-button:focus,.video-js:hover .vjs-big-play-button{border-color:#fff;background-color:#73859f;background-color:rgba(115,133,159,.5);transition:all 0s}.vjs-controls-disabled .vjs-big-play-button,.vjs-error .vjs-big-play-button,.vjs-has-started .vjs-big-play-button,.vjs-using-native-controls .vjs-big-play-button{display:none}.vjs-has-started.vjs-paused.vjs-show-big-play-button-on-pause .vjs-big-play-button{display:block}.video-js button{background:0 0;border:none;color:inherit;display:inline-block;font-size:inherit;line-height:inherit;text-transform:none;text-decoration:none;transition:none;-webkit-appearance:none;-moz-appearance:none;appearance:none}.vjs-control .vjs-button{width:100%;height:100%}.video-js .vjs-control.vjs-close-button{cursor:pointer;height:3em;position:absolute;right:0;top:.5em;z-index:2}.video-js .vjs-modal-dialog{background:rgba(0,0,0,.8);background:linear-gradient(180deg,rgba(0,0,0,.8),rgba(255,255,255,0));overflow:auto}.video-js .vjs-modal-dialog>*{box-sizing:border-box}.vjs-modal-dialog .vjs-modal-dialog-content{font-size:1.2em;line-height:1.5;padding:20px 24px;z-index:1}.vjs-menu-button{cursor:pointer}.vjs-menu-button.vjs-disabled{cursor:default}.vjs-workinghover .vjs-menu-button.vjs-disabled:hover .vjs-menu{display:none}.vjs-menu .vjs-menu-content{display:block;padding:0;margin:0;font-family:Arial,Helvetica,sans-serif;overflow:auto}.vjs-menu .vjs-menu-content>*{box-sizing:border-box}.vjs-scrubbing .vjs-control.vjs-menu-button:hover .vjs-menu{display:none}.vjs-menu li{list-style:none;margin:0;padding:.2em 0;line-height:1.4em;font-size:1.2em;text-align:center;text-transform:lowercase}.js-focus-visible .vjs-menu li.vjs-menu-item:hover,.vjs-menu li.vjs-menu-item:focus,.vjs-menu li.vjs-menu-item:hover{background-color:#73859f;background-color:rgba(115,133,159,.5)}.js-focus-visible .vjs-menu li.vjs-selected:hover,.vjs-menu li.vjs-selected,.vjs-menu li.vjs-selected:focus,.vjs-menu li.vjs-selected:hover{background-color:#fff;color:#2b333f}.vjs-menu li.vjs-menu-title{text-align:center;text-transform:uppercase;font-size:1em;line-height:2em;padding:0;margin:0 0 .3em 0;font-weight:700;cursor:default}.vjs-menu-button-popup .vjs-menu{display:none;position:absolute;bottom:0;width:10em;left:-3em;height:0;margin-bottom:1.5em;border-top-color:rgba(43,51,63,.7)}.vjs-menu-button-popup .vjs-menu .vjs-menu-content{background-color:#2b333f;background-color:rgba(43,51,63,.7);position:absolute;width:100%;bottom:1.5em;max-height:15em}.vjs-layout-tiny .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-x-small .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:5em}.vjs-layout-small .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:10em}.vjs-layout-medium .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:14em}.vjs-layout-huge .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-large .vjs-menu-button-popup .vjs-menu .vjs-menu-content,.vjs-layout-x-large .vjs-menu-button-popup .vjs-menu .vjs-menu-content{max-height:25em}.vjs-menu-button-popup .vjs-menu.vjs-lock-showing,.vjs-workinghover .vjs-menu-button-popup.vjs-hover .vjs-menu{display:block}.video-js .vjs-menu-button-inline{transition:all .4s;overflow:hidden}.video-js .vjs-menu-button-inline:before{width:2.222222222em}.video-js .vjs-menu-button-inline.vjs-slider-active,.video-js .vjs-menu-button-inline:focus,.video-js .vjs-menu-button-inline:hover,.video-js.vjs-no-flex .vjs-menu-button-inline{width:12em}.vjs-menu-button-inline .vjs-menu{opacity:0;height:100%;width:auto;position:absolute;left:4em;top:0;padding:0;margin:0;transition:all .4s}.vjs-menu-button-inline.vjs-slider-active .vjs-menu,.vjs-menu-button-inline:focus .vjs-menu,.vjs-menu-button-inline:hover .vjs-menu{display:block;opacity:1}.vjs-no-flex .vjs-menu-button-inline .vjs-menu{display:block;opacity:1;position:relative;width:auto}.vjs-no-flex .vjs-menu-button-inline.vjs-slider-active .vjs-menu,.vjs-no-flex .vjs-menu-button-inline:focus .vjs-menu,.vjs-no-flex .vjs-menu-button-inline:hover .vjs-menu{width:auto}.vjs-menu-button-inline .vjs-menu-content{width:auto;height:100%;margin:0;overflow:hidden}.video-js .vjs-control-bar{display:none;width:100%;position:absolute;bottom:0;left:0;right:0;height:3em;background-color:#2b333f;background-color:rgba(43,51,63,.7)}.vjs-has-started .vjs-control-bar{display:flex;visibility:visible;opacity:1;transition:visibility .1s,opacity .1s}.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-control-bar{visibility:visible;opacity:0;transition:visibility 1s,opacity 1s}.vjs-controls-disabled .vjs-control-bar,.vjs-error .vjs-control-bar,.vjs-using-native-controls .vjs-control-bar{display:none!important}.vjs-audio.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-control-bar{opacity:1;visibility:visible}.vjs-has-started.vjs-no-flex .vjs-control-bar{display:table}.video-js .vjs-control{position:relative;text-align:center;margin:0;padding:0;height:100%;width:4em;flex:none}.vjs-button>.vjs-icon-placeholder:before{font-size:1.8em;line-height:1.67}.video-js .vjs-control:focus,.video-js .vjs-control:focus:before,.video-js .vjs-control:hover:before{text-shadow:0 0 1em #fff}.video-js .vjs-control-text{border:0;clip:rect(0 0 0 0);height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.vjs-no-flex .vjs-control{display:table-cell;vertical-align:middle}.video-js .vjs-custom-control-spacer{display:none}.video-js .vjs-progress-control{cursor:pointer;flex:auto;display:flex;align-items:center;min-width:4em;touch-action:none}.video-js .vjs-progress-control.disabled{cursor:default}.vjs-live .vjs-progress-control{display:none}.vjs-liveui .vjs-progress-control{display:flex;align-items:center}.vjs-no-flex .vjs-progress-control{width:auto}.video-js .vjs-progress-holder{flex:auto;transition:all .2s;height:.3em}.video-js .vjs-progress-control .vjs-progress-holder{margin:0 10px}.video-js .vjs-progress-control:hover .vjs-progress-holder{font-size:1.6666666667em}.video-js .vjs-progress-control:hover .vjs-progress-holder.disabled{font-size:1em}.video-js .vjs-progress-holder .vjs-load-progress,.video-js .vjs-progress-holder .vjs-load-progress div,.video-js .vjs-progress-holder .vjs-play-progress{position:absolute;display:block;height:100%;margin:0;padding:0;width:0}.video-js .vjs-play-progress{background-color:#fff}.video-js .vjs-play-progress:before{font-size:.9em;position:absolute;right:-.5em;top:-.3333333333em;z-index:1}.video-js .vjs-load-progress{background:rgba(115,133,159,.5)}.video-js .vjs-load-progress div{background:rgba(115,133,159,.75)}.video-js .vjs-time-tooltip{background-color:#fff;background-color:rgba(255,255,255,.8);border-radius:.3em;color:#000;float:right;font-family:Arial,Helvetica,sans-serif;font-size:1em;padding:6px 8px 8px 8px;pointer-events:none;position:absolute;top:-3.4em;visibility:hidden;z-index:1}.video-js .vjs-progress-holder:focus .vjs-time-tooltip{display:none}.video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-time-tooltip,.video-js .vjs-progress-control:hover .vjs-time-tooltip{display:block;font-size:.6em;visibility:visible}.video-js .vjs-progress-control.disabled:hover .vjs-time-tooltip{font-size:1em}.video-js .vjs-progress-control .vjs-mouse-display{display:none;position:absolute;width:1px;height:100%;background-color:#000;z-index:1}.vjs-no-flex .vjs-progress-control .vjs-mouse-display{z-index:0}.video-js .vjs-progress-control:hover .vjs-mouse-display{display:block}.video-js.vjs-user-inactive .vjs-progress-control .vjs-mouse-display{visibility:hidden;opacity:0;transition:visibility 1s,opacity 1s}.video-js.vjs-user-inactive.vjs-no-flex .vjs-progress-control .vjs-mouse-display{display:none}.vjs-mouse-display .vjs-time-tooltip{color:#fff;background-color:#000;background-color:rgba(0,0,0,.8)}.video-js .vjs-slider{position:relative;cursor:pointer;padding:0;margin:0 .45em 0 .45em;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:#73859f;background-color:rgba(115,133,159,.5)}.video-js .vjs-slider.disabled{cursor:default}.video-js .vjs-slider:focus{text-shadow:0 0 1em #fff;box-shadow:0 0 1em #fff}.video-js .vjs-mute-control{cursor:pointer;flex:none}.video-js .vjs-volume-control{cursor:pointer;margin-right:1em;display:flex}.video-js .vjs-volume-control.vjs-volume-horizontal{width:5em}.video-js .vjs-volume-panel .vjs-volume-control{visibility:visible;opacity:0;width:1px;height:1px;margin-left:-1px}.video-js .vjs-volume-panel{transition:width 1s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active,.video-js .vjs-volume-panel .vjs-volume-control:active,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control,.video-js .vjs-volume-panel:active .vjs-volume-control,.video-js .vjs-volume-panel:focus .vjs-volume-control{visibility:visible;opacity:1;position:relative;transition:visibility .1s,opacity .1s,height .1s,width .1s,left 0s,top 0s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active.vjs-volume-horizontal,.video-js .vjs-volume-panel .vjs-volume-control:active.vjs-volume-horizontal,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel:active .vjs-volume-control.vjs-volume-horizontal,.video-js .vjs-volume-panel:focus .vjs-volume-control.vjs-volume-horizontal{width:5em;height:3em;margin-right:0}.video-js .vjs-volume-panel .vjs-volume-control.vjs-slider-active.vjs-volume-vertical,.video-js .vjs-volume-panel .vjs-volume-control:active.vjs-volume-vertical,.video-js .vjs-volume-panel.vjs-hover .vjs-mute-control~.vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel.vjs-hover .vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel:active .vjs-volume-control.vjs-volume-vertical,.video-js .vjs-volume-panel:focus .vjs-volume-control.vjs-volume-vertical{left:-3.5em;transition:left 0s}.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-hover,.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js .vjs-volume-panel.vjs-volume-panel-horizontal:active{width:10em;transition:width .1s}.video-js .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-mute-toggle-only{width:4em}.video-js .vjs-volume-panel .vjs-volume-control.vjs-volume-vertical{height:8em;width:3em;left:-3000em;transition:visibility 1s,opacity 1s,height 1s 1s,width 1s 1s,left 1s 1s,top 1s 1s}.video-js .vjs-volume-panel .vjs-volume-control.vjs-volume-horizontal{transition:visibility 1s,opacity 1s,height 1s 1s,width 1s,left 1s 1s,top 1s 1s}.video-js.vjs-no-flex .vjs-volume-panel .vjs-volume-control.vjs-volume-horizontal{width:5em;height:3em;visibility:visible;opacity:1;position:relative;transition:none}.video-js.vjs-no-flex .vjs-volume-control.vjs-volume-vertical,.video-js.vjs-no-flex .vjs-volume-panel .vjs-volume-control.vjs-volume-vertical{position:absolute;bottom:3em;left:.5em}.video-js .vjs-volume-panel{display:flex}.video-js .vjs-volume-bar{margin:1.35em .45em}.vjs-volume-bar.vjs-slider-horizontal{width:5em;height:.3em}.vjs-volume-bar.vjs-slider-vertical{width:.3em;height:5em;margin:1.35em auto}.video-js .vjs-volume-level{position:absolute;bottom:0;left:0;background-color:#fff}.video-js .vjs-volume-level:before{position:absolute;font-size:.9em}.vjs-slider-vertical .vjs-volume-level{width:.3em}.vjs-slider-vertical .vjs-volume-level:before{top:-.5em;left:-.3em}.vjs-slider-horizontal .vjs-volume-level{height:.3em}.vjs-slider-horizontal .vjs-volume-level:before{top:-.3em;right:-.5em}.video-js .vjs-volume-panel.vjs-volume-panel-vertical{width:4em}.vjs-volume-bar.vjs-slider-vertical .vjs-volume-level{height:100%}.vjs-volume-bar.vjs-slider-horizontal .vjs-volume-level{width:100%}.video-js .vjs-volume-vertical{width:3em;height:8em;bottom:8em;background-color:#2b333f;background-color:rgba(43,51,63,.7)}.video-js .vjs-volume-horizontal .vjs-menu{left:-2em}.vjs-poster{display:inline-block;vertical-align:middle;background-repeat:no-repeat;background-position:50% 50%;background-size:contain;background-color:#000;cursor:pointer;margin:0;padding:0;position:absolute;top:0;right:0;bottom:0;left:0;height:100%}.vjs-has-started .vjs-poster{display:none}.vjs-audio.vjs-has-started .vjs-poster{display:block}.vjs-using-native-controls .vjs-poster{display:none}.video-js .vjs-live-control{display:flex;align-items:flex-start;flex:auto;font-size:1em;line-height:3em}.vjs-no-flex .vjs-live-control{display:table-cell;width:auto;text-align:left}.video-js.vjs-liveui .vjs-live-control,.video-js:not(.vjs-live) .vjs-live-control{display:none}.video-js .vjs-seek-to-live-control{cursor:pointer;flex:none;display:inline-flex;height:100%;padding-left:.5em;padding-right:.5em;font-size:1em;line-height:3em;width:auto;min-width:4em}.vjs-no-flex .vjs-seek-to-live-control{display:table-cell;width:auto;text-align:left}.video-js.vjs-live:not(.vjs-liveui) .vjs-seek-to-live-control,.video-js:not(.vjs-live) .vjs-seek-to-live-control{display:none}.vjs-seek-to-live-control.vjs-control.vjs-at-live-edge{cursor:auto}.vjs-seek-to-live-control .vjs-icon-placeholder{margin-right:.5em;color:#888}.vjs-seek-to-live-control.vjs-control.vjs-at-live-edge .vjs-icon-placeholder{color:red}.video-js .vjs-time-control{flex:none;font-size:1em;line-height:3em;min-width:2em;width:auto;padding-left:1em;padding-right:1em}.vjs-live .vjs-time-control{display:none}.video-js .vjs-current-time,.vjs-no-flex .vjs-current-time{display:none}.video-js .vjs-duration,.vjs-no-flex .vjs-duration{display:none}.vjs-time-divider{display:none;line-height:3em}.vjs-live .vjs-time-divider{display:none}.video-js .vjs-play-control{cursor:pointer}.video-js .vjs-play-control .vjs-icon-placeholder{flex:none}.vjs-text-track-display{position:absolute;bottom:3em;left:0;right:0;top:0;pointer-events:none}.video-js.vjs-user-inactive.vjs-playing .vjs-text-track-display{bottom:1em}.video-js .vjs-text-track{font-size:1.4em;text-align:center;margin-bottom:.1em}.vjs-subtitles{color:#fff}.vjs-captions{color:#fc6}.vjs-tt-cue{display:block}video::-webkit-media-text-track-display{transform:translateY(-3em)}.video-js.vjs-user-inactive.vjs-playing video::-webkit-media-text-track-display{transform:translateY(-1.5em)}.video-js .vjs-picture-in-picture-control{cursor:pointer;flex:none}.video-js .vjs-fullscreen-control{cursor:pointer;flex:none}.vjs-playback-rate .vjs-playback-rate-value,.vjs-playback-rate>.vjs-menu-button{position:absolute;top:0;left:0;width:100%;height:100%}.vjs-playback-rate .vjs-playback-rate-value{pointer-events:none;font-size:1.5em;line-height:2;text-align:center}.vjs-playback-rate .vjs-menu{width:4em;left:0}.vjs-error .vjs-error-display .vjs-modal-dialog-content{font-size:1.4em;text-align:center}.vjs-error .vjs-error-display:before{color:#fff;content:"X";font-family:Arial,Helvetica,sans-serif;font-size:4em;left:0;line-height:1;margin-top:-.5em;position:absolute;text-shadow:.05em .05em .1em #000;text-align:center;top:50%;vertical-align:middle;width:100%}.vjs-loading-spinner{display:none;position:absolute;top:50%;left:50%;margin:-25px 0 0 -25px;opacity:.85;text-align:left;border:6px solid rgba(43,51,63,.7);box-sizing:border-box;background-clip:padding-box;width:50px;height:50px;border-radius:25px;visibility:hidden}.vjs-seeking .vjs-loading-spinner,.vjs-waiting .vjs-loading-spinner{display:block;-webkit-animation:vjs-spinner-show 0s linear .3s forwards;animation:vjs-spinner-show 0s linear .3s forwards}.vjs-loading-spinner:after,.vjs-loading-spinner:before{content:"";position:absolute;margin:-6px;box-sizing:inherit;width:inherit;height:inherit;border-radius:inherit;opacity:1;border:inherit;border-color:transparent;border-top-color:#fff}.vjs-seeking .vjs-loading-spinner:after,.vjs-seeking .vjs-loading-spinner:before,.vjs-waiting .vjs-loading-spinner:after,.vjs-waiting .vjs-loading-spinner:before{-webkit-animation:vjs-spinner-spin 1.1s cubic-bezier(.6,.2,0,.8) infinite,vjs-spinner-fade 1.1s linear infinite;animation:vjs-spinner-spin 1.1s cubic-bezier(.6,.2,0,.8) infinite,vjs-spinner-fade 1.1s linear infinite}.vjs-seeking .vjs-loading-spinner:before,.vjs-waiting .vjs-loading-spinner:before{border-top-color:#fff}.vjs-seeking .vjs-loading-spinner:after,.vjs-waiting .vjs-loading-spinner:after{border-top-color:#fff;-webkit-animation-delay:.44s;animation-delay:.44s}@keyframes vjs-spinner-show{to{visibility:visible}}@-webkit-keyframes vjs-spinner-show{to{visibility:visible}}@keyframes vjs-spinner-spin{100%{transform:rotate(360deg)}}@-webkit-keyframes vjs-spinner-spin{100%{-webkit-transform:rotate(360deg)}}@keyframes vjs-spinner-fade{0%{border-top-color:#73859f}20%{border-top-color:#73859f}35%{border-top-color:#fff}60%{border-top-color:#73859f}100%{border-top-color:#73859f}}@-webkit-keyframes vjs-spinner-fade{0%{border-top-color:#73859f}20%{border-top-color:#73859f}35%{border-top-color:#fff}60%{border-top-color:#73859f}100%{border-top-color:#73859f}}.vjs-chapters-button .vjs-menu ul{width:24em}.video-js .vjs-subs-caps-button+.vjs-menu .vjs-captions-menu-item .vjs-menu-item-text .vjs-icon-placeholder{vertical-align:middle;display:inline-block;margin-bottom:-.1em}.video-js .vjs-subs-caps-button+.vjs-menu .vjs-captions-menu-item .vjs-menu-item-text .vjs-icon-placeholder:before{font-family:VideoJS;content:"";font-size:1.5em;line-height:inherit}.video-js .vjs-audio-button+.vjs-menu .vjs-main-desc-menu-item .vjs-menu-item-text .vjs-icon-placeholder{vertical-align:middle;display:inline-block;margin-bottom:-.1em}.video-js .vjs-audio-button+.vjs-menu .vjs-main-desc-menu-item .vjs-menu-item-text .vjs-icon-placeholder:before{font-family:VideoJS;content:" ";font-size:1.5em;line-height:inherit}.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-control,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-control,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-audio-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-captions-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-chapters-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-current-time,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-descriptions-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-duration,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-playback-rate,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-remaining-time,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-subtitles-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-time-divider,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-control{display:none}.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-small .vjs-volume-panel.vjs-volume-panel-horizontal:hover,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-volume-panel.vjs-volume-panel-horizontal:hover,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal.vjs-slider-active,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal:active,.video-js:not(.vjs-fullscreen).vjs-layout-x-small .vjs-volume-panel.vjs-volume-panel-horizontal:hover{width:auto;width:initial}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-subs-caps-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small:not(.vjs-live) .vjs-subs-caps-button,.video-js:not(.vjs-fullscreen).vjs-layout-x-small:not(.vjs-liveui) .vjs-subs-caps-button{display:none}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-custom-control-spacer,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui .vjs-custom-control-spacer{flex:auto;display:block}.video-js:not(.vjs-fullscreen).vjs-layout-tiny.vjs-no-flex .vjs-custom-control-spacer,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui.vjs-no-flex .vjs-custom-control-spacer{width:auto}.video-js:not(.vjs-fullscreen).vjs-layout-tiny .vjs-progress-control,.video-js:not(.vjs-fullscreen).vjs-layout-x-small.vjs-liveui .vjs-progress-control{display:none}.vjs-modal-dialog.vjs-text-track-settings{background-color:#2b333f;background-color:rgba(43,51,63,.75);color:#fff;height:70%}.vjs-text-track-settings .vjs-modal-dialog-content{display:table}.vjs-text-track-settings .vjs-track-settings-colors,.vjs-text-track-settings .vjs-track-settings-controls,.vjs-text-track-settings .vjs-track-settings-font{display:table-cell}.vjs-text-track-settings .vjs-track-settings-controls{text-align:right;vertical-align:bottom}@supports (display:grid){.vjs-text-track-settings .vjs-modal-dialog-content{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr;padding:20px 24px 0 24px}.vjs-track-settings-controls .vjs-default-button{margin-bottom:20px}.vjs-text-track-settings .vjs-track-settings-controls{grid-column:1/-1}.vjs-layout-small .vjs-text-track-settings .vjs-modal-dialog-content,.vjs-layout-tiny .vjs-text-track-settings .vjs-modal-dialog-content,.vjs-layout-x-small .vjs-text-track-settings .vjs-modal-dialog-content{grid-template-columns:1fr}}.vjs-track-setting>select{margin-right:1em;margin-bottom:.5em}.vjs-text-track-settings fieldset{margin:5px;padding:3px;border:none}.vjs-text-track-settings fieldset span{display:inline-block}.vjs-text-track-settings fieldset span>select{max-width:7.3em}.vjs-text-track-settings legend{color:#fff;margin:0 0 5px 0}.vjs-text-track-settings .vjs-label{position:absolute;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px);display:block;margin:0 0 5px 0;padding:0;border:0;height:1px;width:1px;overflow:hidden}.vjs-track-settings-controls button:active,.vjs-track-settings-controls button:focus{outline-style:solid;outline-width:medium;background-image:linear-gradient(0deg,#fff 88%,#73859f 100%)}.vjs-track-settings-controls button:hover{color:rgba(43,51,63,.75)}.vjs-track-settings-controls button{background-color:#fff;background-image:linear-gradient(-180deg,#fff 88%,#73859f 100%);color:#2b333f;cursor:pointer;border-radius:2px}.vjs-track-settings-controls .vjs-default-button{margin-right:1em}@media print{.video-js>:not(.vjs-tech):not(.vjs-poster){visibility:hidden}}.vjs-resize-manager{position:absolute;top:0;left:0;width:100%;height:100%;border:none;z-index:-1000}.js-focus-visible .video-js :focus:not(.focus-visible){outline:0;background:0 0}.video-js .vjs-menu :focus:not(:focus-visible),.video-js :focus:not(:focus-visible){outline:0;background:0 0}
\ No newline at end of file diff --git a/assets/css/videojs-http-source-selector.css b/assets/css/videojs-http-source-selector.css deleted file mode 100644 index 18ba17ce..00000000 --- a/assets/css/videojs-http-source-selector.css +++ /dev/null @@ -1,7 +0,0 @@ -/** - * videojs-http-source-selector - * @version 1.1.6 - * @copyright 2019 Justin Fujita <Justin@pivotshare.com> - * @license MIT - */ -.video-js.vjs-http-source-selector{display:block} diff --git a/assets/css/videojs-mobile-ui.css b/assets/css/videojs-mobile-ui.css deleted file mode 100644 index c307274a..00000000 --- a/assets/css/videojs-mobile-ui.css +++ /dev/null @@ -1,7 +0,0 @@ -/** - * videojs-mobile-ui - * @version 0.5.2 - * @copyright 2021 mister-ben <git@misterben.me> - * @license MIT - */ -@keyframes fadeAndScale{0%{opacity:0}25%{opacity:1}100%{opacity:0}}.video-js.vjs-has-started .vjs-touch-overlay{position:absolute;pointer-events:auto;top:0}.video-js .vjs-touch-overlay{display:block;width:100%;height:100%;pointer-events:none}.video-js .vjs-touch-overlay.skip{opacity:0;animation:fadeAndScale 0.6s linear;background-repeat:no-repeat;background-position:80% center;background-size:10%;background-image:url('data:image/svg+xml;utf8,<svg fill="%23FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/><path d="M0 0h24v24H0z" fill="none"/></svg>')}.video-js .vjs-touch-overlay.skip.reverse{background-position:20% center;background-image:url('data:image/svg+xml;utf8,<svg fill="%23FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z"/><path d="M0 0h24v24H0z" fill="none"/></svg>')}.video-js .vjs-touch-overlay .vjs-play-control{top:50%;left:50%;transform:translate(-50%, -50%);position:absolute;width:30%;height:80%;pointer-events:none;opacity:0;transition:opacity 0.3s ease}.video-js .vjs-touch-overlay .vjs-play-control .vjs-icon-placeholder::before{content:'';background-size:60%;background-position:center center;background-repeat:no-repeat;background-image:url('data:image/svg+xml;utf8,<svg fill="%23FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/><path d="M0 0h24v24H0z" fill="none"/></svg>')}.video-js .vjs-touch-overlay .vjs-play-control.vjs-paused .vjs-icon-placeholder::before{content:'';background-image:url('data:image/svg+xml;utf8,<svg fill="%23FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M8 5v14l11-7z"/><path d="M0 0h24v24H0z" fill="none"/></svg>')}.video-js .vjs-touch-overlay .vjs-play-control.vjs-ended .vjs-icon-placeholder::before{content:'';background-image:url('data:image/svg+xml;utf8,<svg fill="%23FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>')}.video-js .vjs-touch-overlay.show-play-toggle .vjs-play-control{opacity:1;pointer-events:auto}.video-js.vjs-mobile-ui-disable-end.vjs-ended .vjs-touch-overlay{display:none} diff --git a/assets/css/videojs-overlay.css b/assets/css/videojs-overlay.css deleted file mode 100644 index 3ba5a574..00000000 --- a/assets/css/videojs-overlay.css +++ /dev/null @@ -1 +0,0 @@ -.video-js .vjs-overlay{color:#fff;position:absolute;text-align:center}.video-js .vjs-overlay-no-background{max-width:33%}.video-js .vjs-overlay-background{background-color:#646464;background-color:rgba(255,255,255,0.4);border-radius:3px;padding:10px;width:33%}.video-js .vjs-overlay-top-left{top:5px;left:5px}.video-js .vjs-overlay-top{left:50%;margin-left:-16.5%;top:5px}.video-js .vjs-overlay-top-right{right:5px;top:5px}.video-js .vjs-overlay-right{right:5px;top:50%;transform:translateY(-50%)}.video-js .vjs-overlay-bottom-right{bottom:3.5em;right:5px}.video-js .vjs-overlay-bottom{bottom:3.5em;left:50%;margin-left:-16.5%}.video-js .vjs-overlay-bottom-left{bottom:3.5em;left:5px}.video-js .vjs-overlay-left{left:5px;top:50%;transform:translateY(-50%)}.video-js .vjs-overlay-center{left:50%;margin-left:-16.5%;top:50%;transform:translateY(-50%)}.video-js .vjs-no-flex .vjs-overlay-left,.video-js .vjs-no-flex .vjs-overlay-center,.video-js .vjs-no-flex .vjs-overlay-right{margin-top:-15px} diff --git a/assets/css/videojs-share.css b/assets/css/videojs-share.css deleted file mode 100644 index 3065ef70..00000000 --- a/assets/css/videojs-share.css +++ /dev/null @@ -1,7 +0,0 @@ -/** - * videojs-share - * @version 3.2.1 - * @copyright 2019 Mikhail Khazov <mkhazov.work@gmail.com> - * @license MIT - */ -.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-modal-dialog-content{display:flex;align-items:center;padding:0;background-image:linear-gradient(to bottom, rgba(0,0,0,0.77), rgba(0,0,0,0.75))}.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button{position:absolute;right:0;top:5px;width:30px;height:30px;color:#fff;cursor:pointer;opacity:0.9;transition:opacity 0.25s ease-out}.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button:before{content:'×';font-size:20px;line-height:15px}.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button:hover{opacity:1}.video-js .vjs-share{display:flex;flex-direction:column;justify-content:space-around;align-items:center;width:100%;height:100%;max-height:400px}.video-js .vjs-share__top,.video-js .vjs-share__middle,.video-js .vjs-share__bottom{display:flex}.video-js .vjs-share__top,.video-js .vjs-share__middle{flex-direction:column;justify-content:space-between}.video-js .vjs-share__middle{padding:0 25px}.video-js .vjs-share__title{align-self:center;font-size:22px;color:#fff}.video-js .vjs-share__subtitle{width:100%;margin:0 auto 12px;font-size:16px;color:#fff;opacity:0.7}.video-js .vjs-share__short-link-wrapper{position:relative;display:block;width:100%;height:40px;margin:0 auto;margin-bottom:15px;border:0;color:rgba(255,255,255,0.65);background-color:#363636;outline:none;overflow:hidden;flex-shrink:0}.video-js .vjs-share__short-link{display:block;width:100%;height:100%;padding:0 40px 0 15px;border:0;color:rgba(255,255,255,0.65);background-color:#363636;outline:none}.video-js .vjs-share__btn{position:absolute;right:0;bottom:0;height:40px;width:40px;display:flex;align-items:center;padding:0 11px;border:0;color:#fff;background-color:#2e2e2e;background-size:18px 19px;background-position:center;background-repeat:no-repeat;cursor:pointer;outline:none;transition:width 0.3s ease-out, padding 0.3s ease-out}.video-js .vjs-share__btn svg{flex-shrink:0}.video-js .vjs-share__btn span{position:relative;padding-left:10px;opacity:0;transition:opacity 0.3s ease-out}.video-js .vjs-share__btn:hover{justify-content:center;width:100%;padding:0 40px;background-image:none}.video-js .vjs-share__btn:hover span{opacity:1}.video-js .vjs-share__socials{display:flex;flex-wrap:wrap;justify-content:center;align-content:flex-start;transition:width 0.3s ease-out, height 0.3s ease-out}.video-js .vjs-share__social{display:flex;justify-content:center;align-items:center;flex-shrink:0;width:32px;height:32px;margin-right:6px;margin-bottom:6px;cursor:pointer;font-size:8px;transition:transform 0.3s ease-out, filter 0.2s ease-out;border:none;outline:none}.video-js .vjs-share__social:hover{filter:brightness(115%)}.video-js .vjs-share__social svg{overflow:visible;max-height:24px}.video-js .vjs-share__social_vk{background-color:#5d7294}.video-js .vjs-share__social_ok{background-color:#ed7c20}.video-js .vjs-share__social_mail,.video-js .vjs-share__social_email{background-color:#134785}.video-js .vjs-share__social_tw{background-color:#76aaeb}.video-js .vjs-share__social_reddit{background-color:#ff4500}.video-js .vjs-share__social_fbFeed{background-color:#475995}.video-js .vjs-share__social_messenger{background-color:#0084ff}.video-js .vjs-share__social_gp{background-color:#d53f35}.video-js .vjs-share__social_linkedin{background-color:#0077b5}.video-js .vjs-share__social_viber{background-color:#766db5}.video-js .vjs-share__social_telegram{background-color:#4bb0e2}.video-js .vjs-share__social_whatsapp{background-color:#78c870}.video-js .vjs-share__bottom{justify-content:center}@media (max-height: 220px){.video-js .vjs-share .hidden-xs{display:none}}@media (max-height: 350px){.video-js .vjs-share .hidden-sm{display:none}}@media (min-height: 400px){.video-js .vjs-share__title{margin-bottom:15px}.video-js .vjs-share__short-link-wrapper{margin-bottom:30px}}@media (min-width: 320px){.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button{right:5px;top:10px}}@media (min-width: 660px){.video-js.vjs-videojs-share_open .vjs-modal-dialog .vjs-close-button{right:20px;top:20px}.video-js .vjs-share__social{width:40px;height:40px}} diff --git a/assets/css/videojs-vtt-thumbnails.css b/assets/css/videojs-vtt-thumbnails.css deleted file mode 100644 index d0c15ab1..00000000 --- a/assets/css/videojs-vtt-thumbnails.css +++ /dev/null @@ -1,7 +0,0 @@ -/** - * videojs-vtt-thumbnails - * @version 0.0.13 - * @copyright 2019 Chris Boustead <chris@forgemotion.com> - * @license MIT - */ -.video-js.vjs-vtt-thumbnails{display:block}.video-js .vjs-vtt-thumbnail-display{position:absolute;bottom:85%;pointer-events:none;box-shadow:0 0 7px rgba(0,0,0,0.6)} diff --git a/assets/css/videojs.markers.min.css b/assets/css/videojs.markers.min.css deleted file mode 100644 index 4b148d37..00000000 --- a/assets/css/videojs.markers.min.css +++ /dev/null @@ -1 +0,0 @@ -.vjs-marker{position:absolute;left:0;bottom:0;opacity:1;height:100%;transition:opacity .2s ease;-webkit-transition:opacity .2s ease;-moz-transition:opacity .2s ease;z-index:100}.vjs-marker:hover{cursor:pointer;-webkit-transform:scale(1.3,1.3);-moz-transform:scale(1.3,1.3);-o-transform:scale(1.3,1.3);-ms-transform:scale(1.3,1.3);transform:scale(1.3,1.3)}.vjs-tip{visibility:hidden;display:block;opacity:.8;padding:5px;font-size:10px;position:absolute;bottom:14px;z-index:100000}.vjs-tip .vjs-tip-arrow{background:url(data:image/gif;base64,R0lGODlhCQAJAIABAAAAAAAAACH5BAEAAAEALAAAAAAJAAkAAAIRjAOnwIrcDJxvwkplPtchVQAAOw==) no-repeat top left;bottom:0;left:50%;margin-left:-4px;background-position:bottom left;position:absolute;width:9px;height:5px}.vjs-tip .vjs-tip-inner{border-radius:3px;-moz-border-radius:3px;-webkit-border-radius:3px;padding:5px 8px 4px 8px;background-color:#000;color:#fff;max-width:200px;text-align:center}.vjs-break-overlay{visibility:hidden;position:absolute;z-index:100000;top:0}.vjs-break-overlay .vjs-break-overlay-text{padding:9px;text-align:center}
\ No newline at end of file diff --git a/assets/hashtag.svg b/assets/hashtag.svg new file mode 100644 index 00000000..55109825 --- /dev/null +++ b/assets/hashtag.svg @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg width="128" height="128" viewBox="0 0 128 128" version="1.1" id="svg5" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> + <g> + <rect fill="#c84fff" width="128" height="128" x="0" y="0" /> + <g aria-label="#" transform="matrix(1.1326954,0,0,1.1326954,-20.255282,-23.528147)"> + <path d="m 87.780593,70.524217 -2.624999,13.666661 h 11.666662 v 5.708331 H 84.030595 L 80.61393,107.73253 H 74.488932 L 77.988931,89.899209 H 65.863936 L 62.447271,107.73253 H 56.447273 L 59.697272,89.899209 H 48.947276 V 84.190878 H 60.822271 L 63.530603,70.524217 H 52.113942 V 64.815886 H 64.57227 l 3.416665,-17.999993 h 6.124997 l -3.416665,17.999993 h 12.208328 l 3.499999,-17.999993 h 5.999997 l -3.499998,17.999993 h 10.916662 v 5.708331 z M 66.947269,84.190878 H 79.072264 L 81.738929,70.524217 H 69.613934 Z" /> + </g> + </g> +</svg> diff --git a/assets/invidious-colored-vector.svg b/assets/invidious-colored-vector.svg new file mode 100644 index 00000000..741a8fd8 --- /dev/null +++ b/assets/invidious-colored-vector.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="512pt" height="512pt" version="1.0" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g><rect x="-.0072516" y=".00056299" width="512.01" height="512.02" fill="#575757" stroke-width=".063019"/><path d="m247.17 455.95c-19.792-0.78921-38.719-4.2564-57.154-10.47-60.968-20.55-108.68-68.579-127-127.86-7.8955-25.538-10.062-53.943-6.2586-82.067 3.7105-27.439 13.603-53.515 29.342-77.344 12.069-18.273 29.138-36.277 47.228-49.816 36.891-27.61 85.944-42.49 132.38-40.157 25.88 1.3001 49.939 6.765 73.106 16.606 8.1948 3.481 20.024 9.6845 27.696 14.525 14.15 8.9272 22.367 15.498 34.482 27.573 13.254 13.211 22.128 24.276 30.398 37.906 7.2081 11.879 14.099 27.15 18.229 40.397 1.5996 5.1305 4.442 16.456 5.6852 22.653 2.3908 11.917 2.6998 15.722 2.7049 33.312 6e-3 18.515-0.46256 24.413-2.9166 36.758-9.3274 46.92-35.58 88.167-74.872 117.64-22.814 17.112-50.027 29.535-78.547 35.858-16.714 3.7059-35.421 5.2453-54.498 4.4846zm-35.1-78.786c-5.3e-4 -0.52647-0.0741-2.0564-0.16311-3.3999l-0.16178-2.4427-4.7018-0.26271c-4.0477-0.22614-4.7968-0.33363-5.3847-0.77253-2.0235-1.5108-1.4679-6.0695 2.2494-18.457 0.8637-2.8781 3.3371-11.321 5.4966-18.762 2.1594-7.4409 5.2002-17.836 6.7573-23.101 1.5571-5.2648 4.1948-14.282 5.8615-20.038 1.6667-5.7562 3.6145-12.4 4.3284-14.764 0.71391-2.3641 3.2583-11.037 5.6542-19.272 4.9475-17.007 8.1626-27.723 8.9438-29.811 0.51852-1.3858 0.54785-1.4139 0.99761-0.95317 0.25486 0.26106 3.8462 7.3667 7.9807 15.79 4.1345 8.4236 13.089 26.573 19.898 40.331 17.188 34.73 37.849 76.578 43.261 87.622l4.5356 9.257 11.359-0.0895c6.2475-0.0492 11.615-0.19623 11.929-0.32672 0.5614-0.23385 0.54167-0.2959-1.3723-4.3176-1.068-2.2442-8.1436-16.601-15.724-31.904-48.687-98.293-61.22-123.86-67.889-138.48-4.7022-10.309-6.9031-14.807-7.7139-15.762-0.82931-0.97742-1.6319-1.0638-2.3704-0.25525-1.1993 1.313-4.1046 10.063-9.3869 28.27-2.0569 7.0899-6.5372 22.425-9.9562 34.077-6.6396 22.629-8.5182 29.037-14.33 48.883-2.0354 6.9495-4.7977 16.369-6.1385 20.931-1.3408 4.5628-4.033 13.81-5.9826 20.549-4.304 14.877-6.136 20.889-7.3886 24.25-2.1371 5.7334-2.5723 6.3292-4.9216 6.7384-0.88855 0.15472-2.4102 0.28196-3.3815 0.28275-2.1993 3e-3 -3.5494 0.36339-4.0558 1.0863-0.42176 0.60215-0.56421 4.8802-0.18251 5.4812 0.20573 0.32388 2.4672 0.37414 23.34 0.51873l8.6151 0.0597-7e-4 -0.95723zm36.751-205.59c4.3282-0.92335 8.4607-4.943 9.4374-9.1796 0.36569-1.5862 0.32543-4.9758-0.077-6.4799-0.85108-3.1813-3.2688-6.291-6.039-7.7675-3.8111-2.0313-9.456-2.0295-13.272 5e-3 -5.9828 3.1888-8.1556 11.089-4.7878 17.408 2.6995 5.0648 8.3611 7.3754 14.738 6.015z" fill="#f0f0f0" stroke-width=".025526"/></g><g transform="matrix(.069892 0 0 -.069892 44.236 474.48)"><path d="m2787 4669c-124-65-123-255 3-319 86-44 196-16 247 62 58 87 26 211-67 258-51 26-132 26-183-1z" fill="#00b6f0" stroke="#00b6f0" stroke-width="4.25"/><path d="m2882 4108c-12-16-63-166-102-303-30-104-101-350-165-565-20-69-58-199-85-290-26-91-64-221-85-290-20-69-58-199-85-290-26-91-64-221-85-290-20-69-57-195-81-280-59-207-93-299-115-310-10-6-35-10-56-10-73 0-84-8-81-54l3-41 228-3 228-2-3 47-3 48-73 3c-66 3-74 5-84 27-13 28 0 104 37 225 13 41 47 156 75 255s66 230 85 290c18 61 56 191 85 290 28 99 66 230 85 290 18 61 56 191 85 290 85 297 123 419 131 429 5 5 17-11 28-35 10-24 192-393 403-819s447-902 523-1058l139-282h168c92 0 168 4 168 8s-75 158-166 342c-588 1183-969 1958-1033 2100-29 63-69 151-89 195-44 95-58 110-80 83z" fill="#575757"/></g></svg> diff --git a/assets/js/_helpers.js b/assets/js/_helpers.js new file mode 100644 index 00000000..8e18169e --- /dev/null +++ b/assets/js/_helpers.js @@ -0,0 +1,254 @@ +'use strict'; +// Contains only auxiliary methods +// May be included and executed unlimited number of times without any consequences + +// Polyfills for IE11 +Array.prototype.find = Array.prototype.find || function (condition) { + return this.filter(condition)[0]; +}; + +Array.from = Array.from || function (source) { + return Array.prototype.slice.call(source); +}; +NodeList.prototype.forEach = NodeList.prototype.forEach || function (callback) { + Array.from(this).forEach(callback); +}; +String.prototype.includes = String.prototype.includes || function (searchString) { + return this.indexOf(searchString) >= 0; +}; +String.prototype.startsWith = String.prototype.startsWith || function (prefix) { + return this.substr(0, prefix.length) === prefix; +}; +Math.sign = Math.sign || function(x) { + x = +x; + if (!x) return x; // 0 and NaN + return x > 0 ? 1 : -1; +}; +if (!window.hasOwnProperty('HTMLDetailsElement') && !window.hasOwnProperty('mockHTMLDetailsElement')) { + window.mockHTMLDetailsElement = true; + const style = 'details:not([open]) > :not(summary) {display: none}'; + document.head.appendChild(document.createElement('style')).textContent = style; + + addEventListener('click', function (e) { + if (e.target.nodeName !== 'SUMMARY') return; + const details = e.target.parentElement; + if (details.hasAttribute('open')) + details.removeAttribute('open'); + else + details.setAttribute('open', ''); + }); +} + +// Monstrous global variable for handy code +// Includes: clamp, xhr, storage.{get,set,remove} +window.helpers = window.helpers || { + /** + * https://en.wikipedia.org/wiki/Clamping_(graphics) + * @param {Number} num Source number + * @param {Number} min Low border + * @param {Number} max High border + * @returns {Number} Clamped value + */ + clamp: function (num, min, max) { + if (max < min) { + var t = max; max = min; min = t; // swap max and min + } + + if (max < num) + return max; + if (min > num) + return min; + return num; + }, + + /** @private */ + _xhr: function (method, url, options, callbacks) { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + + // Default options + xhr.responseType = 'json'; + xhr.timeout = 10000; + // Default options redefining + if (options.responseType) + xhr.responseType = options.responseType; + if (options.timeout) + xhr.timeout = options.timeout; + + if (method === 'POST') + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + + // better than onreadystatechange because of 404 codes https://stackoverflow.com/a/36182963 + xhr.onloadend = function () { + if (xhr.status === 200) { + if (callbacks.on200) { + // fix for IE11. It doesn't convert response to JSON + if (xhr.responseType === '' && typeof(xhr.response) === 'string') + callbacks.on200(JSON.parse(xhr.response)); + else + callbacks.on200(xhr.response); + } + } else { + // handled by onerror + if (xhr.status === 0) return; + + if (callbacks.onNon200) + callbacks.onNon200(xhr); + } + }; + + xhr.ontimeout = function () { + if (callbacks.onTimeout) + callbacks.onTimeout(xhr); + }; + + xhr.onerror = function () { + if (callbacks.onError) + callbacks.onError(xhr); + }; + + if (options.payload) + xhr.send(options.payload); + else + xhr.send(); + }, + /** @private */ + _xhrRetry: function(method, url, options, callbacks) { + if (options.retries <= 0) { + console.warn('Failed to pull', options.entity_name); + if (callbacks.onTotalFail) + callbacks.onTotalFail(); + return; + } + helpers._xhr(method, url, options, callbacks); + }, + /** + * @callback callbackXhrOn200 + * @param {Object} response - xhr.response + */ + /** + * @callback callbackXhrError + * @param {XMLHttpRequest} xhr + */ + /** + * @param {'GET'|'POST'} method - 'GET' or 'POST' + * @param {String} url - URL to send request to + * @param {Object} options - other XHR options + * @param {XMLHttpRequestBodyInit} [options.payload=null] - payload for POST-requests + * @param {'arraybuffer'|'blob'|'document'|'json'|'text'} [options.responseType=json] + * @param {Number} [options.timeout=10000] + * @param {Number} [options.retries=1] + * @param {String} [options.entity_name='unknown'] - string to log + * @param {Number} [options.retry_timeout=1000] + * @param {Object} callbacks - functions to execute on events fired + * @param {callbackXhrOn200} [callbacks.on200] + * @param {callbackXhrError} [callbacks.onNon200] + * @param {callbackXhrError} [callbacks.onTimeout] + * @param {callbackXhrError} [callbacks.onError] + * @param {callbackXhrError} [callbacks.onTotalFail] - if failed after all retries + */ + xhr: function(method, url, options, callbacks) { + if (!options.retries || options.retries <= 1) { + helpers._xhr(method, url, options, callbacks); + return; + } + + if (!options.entity_name) options.entity_name = 'unknown'; + if (!options.retry_timeout) options.retry_timeout = 1000; + const retries_total = options.retries; + let currentTry = 1; + + const retry = function () { + console.warn('Pulling ' + options.entity_name + ' failed... ' + (currentTry++) + '/' + retries_total); + setTimeout(function () { + options.retries--; + helpers._xhrRetry(method, url, options, callbacks); + }, options.retry_timeout); + }; + + // Pack retry() call into error handlers + callbacks._onError = callbacks.onError; + callbacks.onError = function (xhr) { + if (callbacks._onError) + callbacks._onError(xhr); + retry(); + }; + callbacks._onTimeout = callbacks.onTimeout; + callbacks.onTimeout = function (xhr) { + if (callbacks._onTimeout) + callbacks._onTimeout(xhr); + retry(); + }; + + helpers._xhrRetry(method, url, options, callbacks); + }, + + /** + * @typedef {Object} invidiousStorage + * @property {(key:String) => Object} get + * @property {(key:String, value:Object)} set + * @property {(key:String)} remove + */ + + /** + * Universal storage, stores and returns JS objects. Uses inside localStorage or cookies + * @type {invidiousStorage} + */ + storage: (function () { + // access to localStorage throws exception in Tor Browser, so try is needed + let localStorageIsUsable = false; + try{localStorageIsUsable = !!localStorage.setItem;}catch(e){} + + if (localStorageIsUsable) { + return { + get: function (key) { + let storageItem = localStorage.getItem(key) + if (!storageItem) return; + try { + return JSON.parse(decodeURIComponent(storageItem)); + } catch(e) { + // Erase non parsable value + helpers.storage.remove(key); + } + }, + set: function (key, value) { + let encoded_value = encodeURIComponent(JSON.stringify(value)) + localStorage.setItem(key, encoded_value); + }, + remove: function (key) { localStorage.removeItem(key); } + }; + } + + // TODO: fire 'storage' event for cookies + console.info('Storage: localStorage is disabled or unaccessible. Cookies used as fallback'); + return { + get: function (key) { + const cookiePrefix = key + '='; + function findCallback(cookie) {return cookie.startsWith(cookiePrefix);} + const matchedCookie = document.cookie.split('; ').find(findCallback); + if (matchedCookie) { + const cookieBody = matchedCookie.replace(cookiePrefix, ''); + if (cookieBody.length === 0) return; + try { + return JSON.parse(decodeURIComponent(cookieBody)); + } catch(e) { + // Erase non parsable value + helpers.storage.remove(key); + } + } + }, + set: function (key, value) { + const cookie_data = encodeURIComponent(JSON.stringify(value)); + + // Set expiration in 2 year + const date = new Date(); + date.setFullYear(date.getFullYear()+2); + + document.cookie = key + '=' + cookie_data + '; expires=' + date.toGMTString(); + }, + remove: function (key) { + document.cookie = key + '=; Max-Age=0'; + } + }; + })() +}; diff --git a/assets/js/comments.js b/assets/js/comments.js new file mode 100644 index 00000000..35ffa96e --- /dev/null +++ b/assets/js/comments.js @@ -0,0 +1,174 @@ +var video_data = JSON.parse(document.getElementById('video_data').textContent); + +var spinnerHTML = '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>'; +var spinnerHTMLwithHR = spinnerHTML + '<hr>'; + +String.prototype.supplant = function (o) { + return this.replace(/{([^{}]*)}/g, function (a, b) { + var r = o[b]; + return typeof r === 'string' || typeof r === 'number' ? r : a; + }); +}; + +function toggle_comments(event) { + var target = event.target; + var body = target.parentNode.parentNode.parentNode.children[1]; + if (body.style.display === 'none') { + target.textContent = '[ − ]'; + body.style.display = ''; + } else { + target.textContent = '[ + ]'; + body.style.display = 'none'; + } +} + +function hide_youtube_replies(event) { + var target = event.target; + + var sub_text = target.getAttribute('data-inner-text'); + var inner_text = target.getAttribute('data-sub-text'); + + var body = target.parentNode.parentNode.children[1]; + body.style.display = 'none'; + + target.textContent = sub_text; + target.onclick = show_youtube_replies; + target.setAttribute('data-inner-text', inner_text); + target.setAttribute('data-sub-text', sub_text); +} + +function show_youtube_replies(event) { + var target = event.target; + + var sub_text = target.getAttribute('data-inner-text'); + var inner_text = target.getAttribute('data-sub-text'); + + var body = target.parentNode.parentNode.children[1]; + body.style.display = ''; + + target.textContent = sub_text; + target.onclick = hide_youtube_replies; + target.setAttribute('data-inner-text', inner_text); + target.setAttribute('data-sub-text', sub_text); +} + +function get_youtube_comments() { + var comments = document.getElementById('comments'); + + var fallback = comments.innerHTML; + comments.innerHTML = spinnerHTML; + + var baseUrl = video_data.base_url || '/api/v1/comments/'+ video_data.id + var url = baseUrl + + '?format=html' + + '&hl=' + video_data.preferences.locale + + '&thin_mode=' + video_data.preferences.thin_mode; + + if (video_data.ucid) { + url += '&ucid=' + video_data.ucid + } + + var onNon200 = function (xhr) { comments.innerHTML = fallback; }; + if (video_data.params.comments[1] === 'youtube') + onNon200 = function (xhr) {}; + + helpers.xhr('GET', url, {retries: 5, entity_name: 'comments'}, { + on200: function (response) { + var commentInnerHtml = ' \ + <div> \ + <h3> \ + <a href="javascript:void(0)">[ − ]</a> \ + {commentsText} \ + </h3> \ + <b> \ + ' + if (video_data.support_reddit) { + commentInnerHtml += ' <a href="javascript:void(0)" data-comments="reddit"> \ + {redditComments} \ + </a> \ + ' + } + commentInnerHtml += ' </b> \ + </div> \ + <div>{contentHtml}</div> \ + <hr>' + commentInnerHtml = commentInnerHtml.supplant({ + contentHtml: response.contentHtml, + redditComments: video_data.reddit_comments_text, + commentsText: video_data.comments_text.supplant({ + // toLocaleString correctly splits number with local thousands separator. e.g.: + // '1,234,567.89' for user with English locale + // '1 234 567,89' for user with Russian locale + // '1.234.567,89' for user with Portuguese locale + commentCount: response.commentCount.toLocaleString() + }) + }); + comments.innerHTML = commentInnerHtml; + comments.children[0].children[0].children[0].onclick = toggle_comments; + if (video_data.support_reddit) { + comments.children[0].children[1].children[0].onclick = swap_comments; + } + }, + onNon200: onNon200, // declared above + onError: function (xhr) { + comments.innerHTML = spinnerHTML; + }, + onTimeout: function (xhr) { + comments.innerHTML = spinnerHTML; + } + }); +} + +function get_youtube_replies(target, load_more, load_replies) { + var continuation = target.getAttribute('data-continuation'); + + var body = target.parentNode.parentNode; + var fallback = body.innerHTML; + body.innerHTML = spinnerHTML; + var baseUrl = video_data.base_url || '/api/v1/comments/'+ video_data.id + var url = baseUrl + + '?format=html' + + '&hl=' + video_data.preferences.locale + + '&thin_mode=' + video_data.preferences.thin_mode + + '&continuation=' + continuation; + + if (video_data.ucid) { + url += '&ucid=' + video_data.ucid + } + if (load_replies) url += '&action=action_get_comment_replies'; + + helpers.xhr('GET', url, {}, { + on200: function (response) { + if (load_more) { + body = body.parentNode.parentNode; + body.removeChild(body.lastElementChild); + body.insertAdjacentHTML('beforeend', response.contentHtml); + } else { + body.removeChild(body.lastElementChild); + + var p = document.createElement('p'); + var a = document.createElement('a'); + p.appendChild(a); + + a.href = 'javascript:void(0)'; + a.onclick = hide_youtube_replies; + a.setAttribute('data-sub-text', video_data.hide_replies_text); + a.setAttribute('data-inner-text', video_data.show_replies_text); + a.textContent = video_data.hide_replies_text; + + var div = document.createElement('div'); + div.innerHTML = response.contentHtml; + + body.appendChild(p); + body.appendChild(div); + } + }, + onNon200: function (xhr) { + body.innerHTML = fallback; + }, + onTimeout: function (xhr) { + console.warn('Pulling comments failed'); + body.innerHTML = fallback; + } + }); +}
\ No newline at end of file diff --git a/assets/js/community.js b/assets/js/community.js index 4077f1cd..32fe4ebc 100644 --- a/assets/js/community.js +++ b/assets/js/community.js @@ -1,19 +1,13 @@ -var community_data = JSON.parse(document.getElementById('community_data').innerHTML); - -String.prototype.supplant = function (o) { - return this.replace(/{([^{}]*)}/g, function (a, b) { - var r = o[b]; - return typeof r === 'string' || typeof r === 'number' ? r : a; - }); -} +'use strict'; +var community_data = JSON.parse(document.getElementById('community_data').textContent); function hide_youtube_replies(event) { var target = event.target; - sub_text = target.getAttribute('data-inner-text'); - inner_text = target.getAttribute('data-sub-text'); + var sub_text = target.getAttribute('data-inner-text'); + var inner_text = target.getAttribute('data-sub-text'); - body = target.parentNode.parentNode.children[1]; + var body = target.parentNode.parentNode.children[1]; body.style.display = 'none'; target.innerHTML = sub_text; @@ -25,10 +19,10 @@ function hide_youtube_replies(event) { function show_youtube_replies(event) { var target = event.target; - sub_text = target.getAttribute('data-inner-text'); - inner_text = target.getAttribute('data-sub-text'); + var sub_text = target.getAttribute('data-inner-text'); + var inner_text = target.getAttribute('data-sub-text'); - body = target.parentNode.parentNode.children[1]; + var body = target.parentNode.parentNode.children[1]; body.style.display = ''; target.innerHTML = sub_text; @@ -37,13 +31,6 @@ function show_youtube_replies(event) { target.setAttribute('data-sub-text', sub_text); } -function number_with_separator(val) { - while (/(\d+)(\d{3})/.test(val.toString())) { - val = val.toString().replace(/(\d+)(\d{3})/, '$1' + ',' + '$2'); - } - return val; -} - function get_youtube_replies(target, load_more) { var continuation = target.getAttribute('data-continuation'); @@ -57,47 +44,39 @@ function get_youtube_replies(target, load_more) { '&hl=' + community_data.preferences.locale + '&thin_mode=' + community_data.preferences.thin_mode + '&continuation=' + continuation; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status == 200) { - if (load_more) { - body = body.parentNode.parentNode; - body.removeChild(body.lastElementChild); - body.innerHTML += xhr.response.contentHtml; - } else { - body.removeChild(body.lastElementChild); - - var p = document.createElement('p'); - var a = document.createElement('a'); - p.appendChild(a); - - a.href = 'javascript:void(0)'; - a.onclick = hide_youtube_replies; - a.setAttribute('data-sub-text', community_data.hide_replies_text); - a.setAttribute('data-inner-text', community_data.show_replies_text); - a.innerText = community_data.hide_replies_text; - - var div = document.createElement('div'); - div.innerHTML = xhr.response.contentHtml; - - body.appendChild(p); - body.appendChild(div); - } + + helpers.xhr('GET', url, {}, { + on200: function (response) { + if (load_more) { + body = body.parentNode.parentNode; + body.removeChild(body.lastElementChild); + body.innerHTML += response.contentHtml; } else { - body.innerHTML = fallback; - } - } - } + body.removeChild(body.lastElementChild); + + var p = document.createElement('p'); + var a = document.createElement('a'); + p.appendChild(a); + + a.href = 'javascript:void(0)'; + a.onclick = hide_youtube_replies; + a.setAttribute('data-sub-text', community_data.hide_replies_text); + a.setAttribute('data-inner-text', community_data.show_replies_text); + a.textContent = community_data.hide_replies_text; - xhr.ontimeout = function () { - console.log('Pulling comments failed.'); - body.innerHTML = fallback; - } + var div = document.createElement('div'); + div.innerHTML = response.contentHtml; - xhr.send(); + body.appendChild(p); + body.appendChild(div); + } + }, + onNon200: function (xhr) { + body.innerHTML = fallback; + }, + onTimeout: function (xhr) { + console.warn('Pulling comments failed'); + body.innerHTML = fallback; + } + }); } diff --git a/assets/js/embed.js b/assets/js/embed.js index 9d0be0ea..b11b5e5a 100644 --- a/assets/js/embed.js +++ b/assets/js/embed.js @@ -1,103 +1,62 @@ -var video_data = JSON.parse(document.getElementById('video_data').innerHTML); - -function get_playlist(plid, retries) { - if (retries == undefined) retries = 5; - - if (retries <= 0) { - console.log('Failed to pull playlist'); - return; - } +'use strict'; +var video_data = JSON.parse(document.getElementById('video_data').textContent); +function get_playlist(plid) { + var plid_url; if (plid.startsWith('RD')) { - var plid_url = '/api/v1/mixes/' + plid + + plid_url = '/api/v1/mixes/' + plid + '?continuation=' + video_data.id + '&format=html&hl=' + video_data.preferences.locale; } else { - var plid_url = '/api/v1/playlists/' + plid + + plid_url = '/api/v1/playlists/' + plid + '?index=' + video_data.index + '&continuation' + video_data.id + '&format=html&hl=' + video_data.preferences.locale; } - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', plid_url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - if (xhr.response.nextVideo) { - player.on('ended', function () { - var url = new URL('https://example.com/embed/' + xhr.response.nextVideo); - - url.searchParams.set('list', plid); - if (!plid.startsWith('RD')) { - url.searchParams.set('index', xhr.response.index); - } - - if (video_data.params.autoplay || video_data.params.continue_autoplay) { - url.searchParams.set('autoplay', '1'); - } - - if (video_data.params.listen !== video_data.preferences.listen) { - url.searchParams.set('listen', video_data.params.listen); - } - - if (video_data.params.speed !== video_data.preferences.speed) { - url.searchParams.set('speed', video_data.params.speed); - } - - if (video_data.params.local !== video_data.preferences.local) { - url.searchParams.set('local', video_data.params.local); - } - - location.assign(url.pathname + url.search); - }); - } - } + helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, { + on200: function (response) { + if (!response.nextVideo) + return; + + player.on('ended', function () { + var url = new URL('https://example.com/embed/' + response.nextVideo); + + url.searchParams.set('list', plid); + if (!plid.startsWith('RD')) + url.searchParams.set('index', response.index); + if (video_data.params.autoplay || video_data.params.continue_autoplay) + url.searchParams.set('autoplay', '1'); + if (video_data.params.listen !== video_data.preferences.listen) + url.searchParams.set('listen', video_data.params.listen); + if (video_data.params.speed !== video_data.preferences.speed) + url.searchParams.set('speed', video_data.params.speed); + if (video_data.params.local !== video_data.preferences.local) + url.searchParams.set('local', video_data.params.local); + + location.assign(url.pathname + url.search); + }); } - } - - xhr.onerror = function () { - console.log('Pulling playlist failed... ' + retries + '/5'); - setTimeout(function () { get_playlist(plid, retries - 1) }, 1000); - } - - xhr.ontimeout = function () { - console.log('Pulling playlist failed... ' + retries + '/5'); - get_playlist(plid, retries - 1); - } - - xhr.send(); + }); } -window.addEventListener('load', function (e) { +addEventListener('load', function (e) { if (video_data.plid) { get_playlist(video_data.plid); } else if (video_data.video_series) { player.on('ended', function () { var url = new URL('https://example.com/embed/' + video_data.video_series.shift()); - if (video_data.params.autoplay || video_data.params.continue_autoplay) { + if (video_data.params.autoplay || video_data.params.continue_autoplay) url.searchParams.set('autoplay', '1'); - } - - if (video_data.params.listen !== video_data.preferences.listen) { + if (video_data.params.listen !== video_data.preferences.listen) url.searchParams.set('listen', video_data.params.listen); - } - - if (video_data.params.speed !== video_data.preferences.speed) { + if (video_data.params.speed !== video_data.preferences.speed) url.searchParams.set('speed', video_data.params.speed); - } - - if (video_data.params.local !== video_data.preferences.local) { + if (video_data.params.local !== video_data.preferences.local) url.searchParams.set('local', video_data.params.local); - } - - if (video_data.video_series.length !== 0) { - url.searchParams.set('playlist', video_data.video_series.join(',')) - } + if (video_data.video_series.length !== 0) + url.searchParams.set('playlist', video_data.video_series.join(',')); location.assign(url.pathname + url.search); }); diff --git a/assets/js/handlers.js b/assets/js/handlers.js index 1498f39a..539974fb 100644 --- a/assets/js/handlers.js +++ b/assets/js/handlers.js @@ -1,8 +1,6 @@ 'use strict'; (function () { - var n2a = function (n) { return Array.prototype.slice.call(n); }; - var video_player = document.getElementById('player_html5_api'); if (video_player) { video_player.onmouseenter = function () { video_player['data-title'] = video_player['title']; video_player['title'] = ''; }; @@ -11,135 +9,141 @@ } // For dynamically inserted elements - document.addEventListener('click', function (e) { - if (!e || !e.target) { return; } - e = e.target; - var handler_name = e.getAttribute('data-onclick'); + addEventListener('click', function (e) { + if (!e || !e.target) return; + + var t = e.target; + var handler_name = t.getAttribute('data-onclick'); + switch (handler_name) { case 'jump_to_time': - var time = e.getAttribute('data-jump-time'); + e.preventDefault(); + var time = t.getAttribute('data-jump-time'); player.currentTime(time); break; case 'get_youtube_replies': - var load_more = e.getAttribute('data-load-more') !== null; - var load_replies = e.getAttribute('data-load-replies') !== null; - get_youtube_replies(e, load_more, load_replies); + var load_more = t.getAttribute('data-load-more') !== null; + var load_replies = t.getAttribute('data-load-replies') !== null; + get_youtube_replies(t, load_more, load_replies); break; case 'toggle_parent': - toggle_parent(e); + e.preventDefault(); + toggle_parent(t); break; default: break; } }); - n2a(document.querySelectorAll('[data-mouse="switch_classes"]')).forEach(function (e) { - var classes = e.getAttribute('data-switch-classes').split(','); - var ec = classes[0]; - var lc = classes[1]; - var onoff = function (on, off) { - var cs = e.getAttribute('class'); - cs = cs.split(off).join(on); - e.setAttribute('class', cs); - }; - e.onmouseenter = function () { onoff(ec, lc); }; - e.onmouseleave = function () { onoff(lc, ec); }; + document.querySelectorAll('[data-mouse="switch_classes"]').forEach(function (el) { + var classes = el.getAttribute('data-switch-classes').split(','); + var classOnEnter = classes[0]; + var classOnLeave = classes[1]; + function toggle_classes(toAdd, toRemove) { + el.classList.add(toAdd); + el.classList.remove(toRemove); + } + el.onmouseenter = function () { toggle_classes(classOnEnter, classOnLeave); }; + el.onmouseleave = function () { toggle_classes(classOnLeave, classOnEnter); }; }); - n2a(document.querySelectorAll('[data-onsubmit="return_false"]')).forEach(function (e) { - e.onsubmit = function () { return false; }; + document.querySelectorAll('[data-onsubmit="return_false"]').forEach(function (el) { + el.onsubmit = function () { return false; }; }); - n2a(document.querySelectorAll('[data-onclick="mark_watched"]')).forEach(function (e) { - e.onclick = function () { mark_watched(e); }; + document.querySelectorAll('[data-onclick="mark_watched"]').forEach(function (el) { + el.onclick = function () { mark_watched(el); }; }); - n2a(document.querySelectorAll('[data-onclick="mark_unwatched"]')).forEach(function (e) { - e.onclick = function () { mark_unwatched(e); }; + document.querySelectorAll('[data-onclick="mark_unwatched"]').forEach(function (el) { + el.onclick = function () { mark_unwatched(el); }; }); - n2a(document.querySelectorAll('[data-onclick="add_playlist_video"]')).forEach(function (e) { - e.onclick = function () { add_playlist_video(e); }; + document.querySelectorAll('[data-onclick="add_playlist_video"]').forEach(function (el) { + el.onclick = function () { add_playlist_video(el); }; }); - n2a(document.querySelectorAll('[data-onclick="add_playlist_item"]')).forEach(function (e) { - e.onclick = function () { add_playlist_item(e); }; + document.querySelectorAll('[data-onclick="add_playlist_item"]').forEach(function (el) { + el.onclick = function () { add_playlist_item(el); }; }); - n2a(document.querySelectorAll('[data-onclick="remove_playlist_item"]')).forEach(function (e) { - e.onclick = function () { remove_playlist_item(e); }; + document.querySelectorAll('[data-onclick="remove_playlist_item"]').forEach(function (el) { + el.onclick = function () { remove_playlist_item(el); }; }); - n2a(document.querySelectorAll('[data-onclick="revoke_token"]')).forEach(function (e) { - e.onclick = function () { revoke_token(e); }; + document.querySelectorAll('[data-onclick="revoke_token"]').forEach(function (el) { + el.onclick = function () { revoke_token(el); }; }); - n2a(document.querySelectorAll('[data-onclick="remove_subscription"]')).forEach(function (e) { - e.onclick = function () { remove_subscription(e); }; + document.querySelectorAll('[data-onclick="remove_subscription"]').forEach(function (el) { + el.onclick = function () { remove_subscription(el); }; }); - n2a(document.querySelectorAll('[data-onclick="notification_requestPermission"]')).forEach(function (e) { - e.onclick = function () { Notification.requestPermission(); }; + document.querySelectorAll('[data-onclick="notification_requestPermission"]').forEach(function (el) { + el.onclick = function () { Notification.requestPermission(); }; }); - n2a(document.querySelectorAll('[data-onrange="update_volume_value"]')).forEach(function (e) { - var cb = function () { update_volume_value(e); } - e.oninput = cb; - e.onchange = cb; + document.querySelectorAll('[data-onrange="update_volume_value"]').forEach(function (el) { + function update_volume_value() { + document.getElementById('volume-value').textContent = el.value; + } + el.oninput = update_volume_value; + el.onchange = update_volume_value; }); - function update_volume_value(element) { - document.getElementById('volume-value').innerText = element.value; - } function revoke_token(target) { var row = target.parentNode.parentNode.parentNode.parentNode.parentNode; row.style.display = 'none'; var count = document.getElementById('count'); - count.innerText = count.innerText - 1; + count.textContent--; - var referer = window.encodeURIComponent(document.location.href); var url = '/token_ajax?action_revoke_token=1&redirect=false' + - '&referer=' + referer + + '&referer=' + encodeURIComponent(location.href) + '&session=' + target.getAttribute('data-session'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - count.innerText = parseInt(count.innerText) + 1; - row.style.display = ''; - } - } - } - var csrf_token = target.parentNode.querySelector('input[name="csrf_token"]').value; - xhr.send('csrf_token=' + csrf_token); + var payload = 'csrf_token=' + target.parentNode.querySelector('input[name="csrf_token"]').value; + + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + count.textContent++; + row.style.display = ''; + } + }); } function remove_subscription(target) { var row = target.parentNode.parentNode.parentNode.parentNode.parentNode; row.style.display = 'none'; var count = document.getElementById('count'); - count.innerText = count.innerText - 1; + count.textContent--; - var referer = window.encodeURIComponent(document.location.href); var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' + - '&referer=' + referer + + '&referer=' + encodeURIComponent(location.href) + '&c=' + target.getAttribute('data-ucid'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - count.innerText = parseInt(count.innerText) + 1; - row.style.display = ''; - } + + var payload = 'csrf_token=' + target.parentNode.querySelector('input[name="csrf_token"]').value; + + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + count.textContent++; + row.style.display = ''; } + }); + } + + // Handle keypresses + addEventListener('keydown', function (event) { + // Ignore modifier keys + if (event.ctrlKey || event.metaKey) return; + + // Ignore shortcuts if any text input is focused + let focused_tag = document.activeElement.tagName.toLowerCase(); + const allowed = /^(button|checkbox|file|radio|submit)$/; + + if (focused_tag === 'textarea') return; + if (focused_tag === 'input') { + let focused_type = document.activeElement.type.toLowerCase(); + if (!allowed.test(focused_type)) return; } - var csrf_token = target.parentNode.querySelector('input[name="csrf_token"]').value; - xhr.send('csrf_token=' + csrf_token); - } + // Focus search bar on '/' + if (event.key === '/') { + document.getElementById('searchbox').focus(); + event.preventDefault(); + } + }); })(); diff --git a/assets/js/notifications.js b/assets/js/notifications.js index 3d1ec1ed..55b7a15c 100644 --- a/assets/js/notifications.js +++ b/assets/js/notifications.js @@ -1,46 +1,30 @@ -var notification_data = JSON.parse(document.getElementById('notification_data').innerHTML); +'use strict'; +var notification_data = JSON.parse(document.getElementById('notification_data').textContent); -var notifications, delivered; - -function get_subscriptions(callback, retries) { - if (retries == undefined) retries = 5; - - if (retries <= 0) { - return; - } - - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', '/api/v1/auth/subscriptions?fields=authorId', true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - subscriptions = xhr.response; - callback(subscriptions); - } - } - } +/** Boolean meaning 'some tab have stream' */ +const STORAGE_KEY_STREAM = 'stream'; +/** Number of notifications. May be increased or reset */ +const STORAGE_KEY_NOTIF_COUNT = 'notification_count'; - xhr.onerror = function () { - console.log('Pulling subscriptions failed... ' + retries + '/5'); - setTimeout(function () { get_subscriptions(callback, retries - 1) }, 1000); - } - - xhr.ontimeout = function () { - console.log('Pulling subscriptions failed... ' + retries + '/5'); - get_subscriptions(callback, retries - 1); - } - - xhr.send(); +var notifications, delivered; +var notifications_mock = { close: function () { } }; + +function get_subscriptions() { + helpers.xhr('GET', '/api/v1/auth/subscriptions', { + retries: 5, + entity_name: 'subscriptions' + }, { + on200: create_notification_stream + }); } function create_notification_stream(subscriptions) { + // sse.js can't be replaced to EventSource in place as it lack support of payload and headers + // see https://developer.mozilla.org/en-US/docs/Web/API/EventSource/EventSource notifications = new SSE( - '/api/v1/auth/notifications?fields=videoId,title,author,authorId,publishedText,published,authorThumbnails,liveNow', { + '/api/v1/auth/notifications', { withCredentials: true, - payload: 'topics=' + subscriptions.map(function (subscription) { return subscription.authorId }).join(','), + payload: 'topics=' + subscriptions.map(function (subscription) { return subscription.authorId; }).join(','), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); delivered = []; @@ -48,96 +32,100 @@ function create_notification_stream(subscriptions) { var start_time = Math.round(new Date() / 1000); notifications.onmessage = function (event) { - if (!event.id) { - return; - } + if (!event.id) return; var notification = JSON.parse(event.data); - console.log('Got notification:', notification); - - if (start_time < notification.published && !delivered.includes(notification.videoId)) { - if (Notification.permission === 'granted') { - var system_notification = - new Notification((notification.liveNow ? notification_data.live_now_text : notification_data.upload_text).replace('`x`', notification.author), { - body: notification.title, - icon: '/ggpht' + new URL(notification.authorThumbnails[2].url).pathname, - img: '/ggpht' + new URL(notification.authorThumbnails[4].url).pathname, - tag: notification.videoId - }); - - system_notification.onclick = function (event) { - window.open('/watch?v=' + event.currentTarget.tag, '_blank'); - } - } - - delivered.push(notification.videoId); - localStorage.setItem('notification_count', parseInt(localStorage.getItem('notification_count') || '0') + 1); - var notification_ticker = document.getElementById('notification_ticker'); - - if (parseInt(localStorage.getItem('notification_count')) > 0) { - notification_ticker.innerHTML = - '<span id="notification_count">' + localStorage.getItem('notification_count') + '</span> <i class="icon ion-ios-notifications"></i>'; - } else { - notification_ticker.innerHTML = - '<i class="icon ion-ios-notifications-outline"></i>'; - } + console.info('Got notification:', notification); + + // Ignore not actual and delivered notifications + if (start_time > notification.published || delivered.includes(notification.videoId)) return; + + delivered.push(notification.videoId); + + let notification_count = helpers.storage.get(STORAGE_KEY_NOTIF_COUNT) || 0; + notification_count++; + helpers.storage.set(STORAGE_KEY_NOTIF_COUNT, notification_count); + + update_ticker_count(); + + // permission for notifications handled on settings page. JS handler is in handlers.js + if (window.Notification && Notification.permission === 'granted') { + var notification_text = notification.liveNow ? notification_data.live_now_text : notification_data.upload_text; + notification_text = notification_text.replace('`x`', notification.author); + + var system_notification = new Notification(notification_text, { + body: notification.title, + icon: '/ggpht' + new URL(notification.authorThumbnails[2].url).pathname, + img: '/ggpht' + new URL(notification.authorThumbnails[4].url).pathname + }); + + system_notification.onclick = function (e) { + open('/watch?v=' + notification.videoId, '_blank'); + }; } - } + }; - notifications.addEventListener('error', handle_notification_error); - notifications.stream(); -} + notifications.addEventListener('error', function (e) { + console.warn('Something went wrong with notifications, trying to reconnect...'); + notifications = notifications_mock; + setTimeout(get_subscriptions, 1000); + }); -function handle_notification_error(event) { - console.log('Something went wrong with notifications, trying to reconnect...'); - notifications = { close: function () { } }; - setTimeout(function () { get_subscriptions(create_notification_stream) }, 1000); + notifications.stream(); } -window.addEventListener('load', function (e) { - localStorage.setItem('notification_count', document.getElementById('notification_count') ? document.getElementById('notification_count').innerText : '0'); +function update_ticker_count() { + var notification_ticker = document.getElementById('notification_ticker'); - if (localStorage.getItem('stream')) { - localStorage.removeItem('stream'); + const notification_count = helpers.storage.get(STORAGE_KEY_STREAM); + if (notification_count > 0) { + notification_ticker.innerHTML = + '<span id="notification_count">' + notification_count + '</span> <i class="icon ion-ios-notifications"></i>'; } else { - setTimeout(function () { - if (!localStorage.getItem('stream')) { - notifications = { close: function () { } }; - localStorage.setItem('stream', true); - get_subscriptions(create_notification_stream); - } - }, Math.random() * 1000 + 50); + notification_ticker.innerHTML = + '<i class="icon ion-ios-notifications-outline"></i>'; } +} - window.addEventListener('storage', function (e) { - if (e.key === 'stream' && !e.newValue) { - if (notifications) { - localStorage.setItem('stream', true); - } else { - setTimeout(function () { - if (!localStorage.getItem('stream')) { - notifications = { close: function () { } }; - localStorage.setItem('stream', true); - get_subscriptions(create_notification_stream); - } - }, Math.random() * 1000 + 50); - } - } else if (e.key === 'notification_count') { - var notification_ticker = document.getElementById('notification_ticker'); - - if (parseInt(e.newValue) > 0) { - notification_ticker.innerHTML = - '<span id="notification_count">' + e.newValue + '</span> <i class="icon ion-ios-notifications"></i>'; - } else { - notification_ticker.innerHTML = - '<i class="icon ion-ios-notifications-outline"></i>'; - } +function start_stream_if_needed() { + // random wait for other tabs set 'stream' flag + setTimeout(function () { + if (!helpers.storage.get(STORAGE_KEY_STREAM)) { + // if no one set 'stream', set it by yourself and start stream + helpers.storage.set(STORAGE_KEY_STREAM, true); + notifications = notifications_mock; + get_subscriptions(); } - }); -}); + }, Math.random() * 1000 + 50); // [0.050 .. 1.050) second +} + + +addEventListener('storage', function (e) { + if (e.key === STORAGE_KEY_NOTIF_COUNT) + update_ticker_count(); -window.addEventListener('unload', function (e) { - if (notifications) { - localStorage.removeItem('stream'); + // if 'stream' key was removed + if (e.key === STORAGE_KEY_STREAM && !helpers.storage.get(STORAGE_KEY_STREAM)) { + if (notifications) { + // restore it if we have active stream + helpers.storage.set(STORAGE_KEY_STREAM, true); + } else { + start_stream_if_needed(); + } } }); + +addEventListener('load', function () { + var notification_count_el = document.getElementById('notification_count'); + var notification_count = notification_count_el ? parseInt(notification_count_el.textContent) : 0; + helpers.storage.set(STORAGE_KEY_NOTIF_COUNT, notification_count); + + if (helpers.storage.get(STORAGE_KEY_STREAM)) + helpers.storage.remove(STORAGE_KEY_STREAM); + start_stream_if_needed(); +}); + +addEventListener('unload', function () { + // let chance to other tabs to be a streamer via firing 'storage' event + if (notifications) helpers.storage.remove(STORAGE_KEY_STREAM); +}); diff --git a/assets/js/player.js b/assets/js/player.js index a6d0c8c1..353a5296 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -1,8 +1,8 @@ -var player_data = JSON.parse(document.getElementById('player_data').innerHTML); -var video_data = JSON.parse(document.getElementById('video_data').innerHTML); +'use strict'; +var player_data = JSON.parse(document.getElementById('player_data').textContent); +var video_data = JSON.parse(document.getElementById('video_data').textContent); var options = { - preload: 'auto', liveui: true, playbackRates: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], controlBar: { @@ -16,6 +16,7 @@ var options = { 'remainingTimeDisplay', 'Spacer', 'captionsButton', + 'audioTrackButton', 'qualitySelector', 'playbackRateMenuButton', 'fullscreenToggle' @@ -23,11 +24,11 @@ var options = { }, html5: { preloadTextTracks: false, - hls: { + vhs: { overrideNative: true } } -} +}; if (player_data.aspect_ratio) { options.aspectRatio = player_data.aspect_ratio; @@ -35,118 +36,211 @@ if (player_data.aspect_ratio) { var embed_url = new URL(location); embed_url.searchParams.delete('v'); -short_url = location.origin + '/' + video_data.id + embed_url.search; +var short_url = location.origin + '/' + video_data.id + embed_url.search; embed_url = location.origin + '/embed/' + video_data.id + embed_url.search; +var save_player_pos_key = 'save_player_pos'; + +videojs.Vhs.xhr.beforeRequest = function(options) { + // set local if requested not videoplayback + if (!options.uri.includes('videoplayback')) { + if (!options.uri.includes('local=true')) + options.uri += '?local=true'; + } + return options; +}; + +var player = videojs('player', options); + +player.on('error', function () { + if (video_data.params.quality === 'dash') return; + + var localNotDisabled = ( + !player.currentSrc().includes('local=true') && !video_data.local_disabled + ); + var reloadMakesSense = ( + player.error().code === MediaError.MEDIA_ERR_NETWORK || + player.error().code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED + ); + + if (localNotDisabled) { + // add local=true to all current sources + player.src(player.currentSources().map(function (source) { + source.src += '&local=true'; + return source; + })); + } else if (reloadMakesSense) { + setTimeout(function () { + console.warn('An error occurred in the player, reloading...'); + + // After load() all parameters are reset. Save them + var currentTime = player.currentTime(); + var playbackRate = player.playbackRate(); + var paused = player.paused(); + + player.load(); + + if (currentTime > 0.5) currentTime -= 0.5; + + player.currentTime(currentTime); + player.playbackRate(playbackRate); + if (!paused) player.play(); + }, 5000); + } +}); + +if (video_data.params.quality === 'dash') { + player.reloadSourceOnError({ + errorInterval: 10 + }); +} + +/** + * Function for add time argument to url + * + * @param {String} url + * @param {String} [base] + * @returns {URL} urlWithTimeArg + */ +function addCurrentTimeToURL(url, base) { + var urlUsed = new URL(url, base); + urlUsed.searchParams.delete('start'); + var currentTime = Math.ceil(player.currentTime()); + if (currentTime > 0) + urlUsed.searchParams.set('t', currentTime); + else if (urlUsed.searchParams.has('t')) + urlUsed.searchParams.delete('t'); + return urlUsed; +} + +/** + * Global variable to save the last timestamp (in full seconds) at which the external + * links were updated by the 'timeupdate' callback below. + * + * It is initialized to 5s so that the video will always restart from the beginning + * if the user hasn't really started watching before switching to the other website. + */ +var timeupdate_last_ts = 5; + +/** + * Callback that updates the timestamp on all external links + */ +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; + else return; + + // YouTube links + + let elem_yt_watch = document.getElementById('link-yt-watch'); + let elem_yt_embed = document.getElementById('link-yt-embed'); + + let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url'); + let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url'); + + elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch); + elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed); + + // Invidious links + + let domain = window.location.origin; + + let elem_iv_embed = document.getElementById('link-iv-embed'); + let elem_iv_other = document.getElementById('link-iv-other'); + + let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url'); + let base_url_iv_other = elem_iv_other.getAttribute('data-base-url'); + + elem_iv_embed.href = addCurrentTimeToURL(base_url_iv_embed, domain); + elem_iv_other.href = addCurrentTimeToURL(base_url_iv_other, domain); +}); + + var shareOptions = { socials: ['fbFeed', 'tw', 'reddit', 'email'], - url: short_url, + get url() { + return addCurrentTimeToURL(short_url); + }, title: player_data.title, description: player_data.description, image: player_data.thumbnail, - embedCode: "<iframe id='ivplayer' width='640' height='360' src='" + embed_url + "' style='border:none;'></iframe>" -} - -videojs.Hls.xhr.beforeRequest = function(options) { - if (options.uri.indexOf('videoplayback') === -1 && options.uri.indexOf('local=true') === -1) { - options.uri = options.uri + '?local=true'; + get embedCode() { + // Single quotes inside here required. HTML inserted as is into value attribute of input + return "<iframe id='ivplayer' width='640' height='360' src='" + + addCurrentTimeToURL(embed_url) + "' style='border:none;'></iframe>"; } - return options; }; -var player = videojs('player', options); - - if (location.pathname.startsWith('/embed/')) { + var overlay_content = '<h1><a rel="noopener" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>'; player.overlay({ - overlays: [{ - start: 'loadstart', - content: '<h1><a rel="noopener" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>', - end: 'playing', - align: 'top' - }, { - start: 'pause', - content: '<h1><a rel="noopener" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>', - end: 'playing', - align: 'top' - }] + overlays: [ + { start: 'loadstart', content: overlay_content, end: 'playing', align: 'top'}, + { start: 'pause', content: overlay_content, end: 'playing', align: 'top'} + ] }); } -// Detect mobile users and initalize mobileUi for better UX +// Detect mobile users and initialize mobileUi for better UX // Detection code taken from https://stackoverflow.com/a/20293441 function isMobile() { - try{ document.createEvent("TouchEvent"); return true; } + try{ document.createEvent('TouchEvent'); return true; } catch(e){ return false; } } if (isMobile()) { - player.mobileUi(); + player.mobileUi({ touchControls: { seekSeconds: 5 * player.playbackRate() } }); - buttons = ["playToggle", "volumePanel", "captionsButton"]; + var buttons = ['playToggle', 'volumePanel', 'captionsButton']; - if (video_data.params.quality !== 'dash') { - buttons.push("qualitySelector") - } + if (!video_data.params.listen && video_data.params.quality === 'dash') buttons.push('audioTrackButton'); + if (video_data.params.listen || video_data.params.quality !== 'dash') buttons.push('qualitySelector'); // Create new control bar object for operation buttons - const ControlBar = videojs.getComponent("controlBar"); + const ControlBar = videojs.getComponent('controlBar'); let operations_bar = new ControlBar(player, { children: [], playbackRates: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] }); - buttons.slice(1).forEach(child => operations_bar.addChild(child)) + buttons.slice(1).forEach(function (child) {operations_bar.addChild(child);}); // Remove operation buttons from primary control bar - primary_control_bar = player.getChild("controlBar"); - buttons.forEach(child => primary_control_bar.removeChild(child)); + var primary_control_bar = player.getChild('controlBar'); + buttons.forEach(function (child) {primary_control_bar.removeChild(child);}); - operations_bar_element = operations_bar.el(); - operations_bar_element.className += " mobile-operations-bar" - player.addChild(operations_bar) + var operations_bar_element = operations_bar.el(); + operations_bar_element.classList.add('mobile-operations-bar'); + player.addChild(operations_bar); - // Playback menu doesn't work when its initalized outside of the primary control bar - playback_element = document.getElementsByClassName("vjs-playback-rate")[0] - operations_bar_element.append(playback_element) + // Playback menu doesn't work when it's initialized outside of the primary control bar + var playback_element = document.getElementsByClassName('vjs-playback-rate')[0]; + operations_bar_element.append(playback_element); // The share and http source selector element can't be fetched till the players ready. - player.one("playing", () => { - share_element = document.getElementsByClassName("vjs-share-control")[0] - operations_bar_element.append(share_element) - - if (video_data.params.quality === 'dash') { - http_source_selector = document.getElementsByClassName("vjs-http-source-selector vjs-menu-button")[0] - operations_bar_element.append(http_source_selector) - } - }) -} - -player.on('error', function (event) { - if (player.error().code === 2 || player.error().code === 4) { - setTimeout(function (event) { - console.log('An error occured in the player, reloading...'); - - var currentTime = player.currentTime(); - var playbackRate = player.playbackRate(); - var paused = player.paused(); - - player.load(); + player.one('playing', function () { + var share_element = document.getElementsByClassName('vjs-share-control')[0]; + operations_bar_element.append(share_element); - if (currentTime > 0.5) { - currentTime -= 0.5; - } - - player.currentTime(currentTime); - player.playbackRate(playbackRate); + if (!video_data.params.listen && video_data.params.quality === 'dash') { + var http_source_selector = document.getElementsByClassName('vjs-http-source-selector vjs-menu-button')[0]; + operations_bar_element.append(http_source_selector); + } + }); +} - if (!paused) { - player.play(); - } - }, 5000); +// Enable VR video support +if (!video_data.params.listen && video_data.vr && video_data.params.vr_mode) { + player.crossOrigin('anonymous'); + switch (video_data.projection_type) { + case 'EQUIRECTANGULAR': + player.vr({projection: 'equirectangular'}); + default: // Should only be 'MESH' but we'll use this as a fallback. + player.vr({projection: 'EAC'}); } -}); +} // Add markers if (video_data.params.video_start > 0 || video_data.params.video_end > 0) { @@ -160,13 +254,8 @@ if (video_data.params.video_start > 0 || video_data.params.video_end > 0) { player.markers({ onMarkerReached: function (marker) { - if (marker.text === 'End') { - if (player.loop()) { - player.markers.prev('Start'); - } else { - player.pause(); - } - } + if (marker.text === 'End') + player.loop() ? player.markers.prev('Start') : player.pause(); }, markers: markers }); @@ -177,9 +266,76 @@ if (video_data.params.video_start > 0 || video_data.params.video_end > 0) { player.volume(video_data.params.volume / 100); player.playbackRate(video_data.params.speed); +/** + * Method for getting the contents of a cookie + * + * @param {String} name Name of cookie + * @returns {String|null} cookieValue + */ +function getCookieValue(name) { + var cookiePrefix = name + '='; + var matchedCookie = document.cookie.split(';').find(function (item) {return item.includes(cookiePrefix);}); + if (matchedCookie) + return matchedCookie.replace(cookiePrefix, ''); + return null; +} + +/** + * Method for updating the 'PREFS' cookie (or creating it if missing) + * + * @param {number} newVolume New volume defined (null if unchanged) + * @param {number} newSpeed New speed defined (null if unchanged) + */ +function updateCookie(newVolume, newSpeed) { + var volumeValue = newVolume !== null ? newVolume : video_data.params.volume; + var speedValue = newSpeed !== null ? newSpeed : video_data.params.speed; + + var cookieValue = getCookieValue('PREFS'); + var cookieData; + + if (cookieValue !== null) { + var cookieJson = JSON.parse(decodeURIComponent(cookieValue)); + cookieJson.volume = volumeValue; + cookieJson.speed = speedValue; + cookieData = encodeURIComponent(JSON.stringify(cookieJson)); + } else { + cookieData = encodeURIComponent(JSON.stringify({ 'volume': volumeValue, 'speed': speedValue })); + } + + // Set expiration in 2 year + var date = new Date(); + date.setFullYear(date.getFullYear() + 2); + + var ipRegex = /^((\d+\.){3}\d+|[\dA-Fa-f]*:[\d:A-Fa-f]*:[\d:A-Fa-f]+)$/; + var domainUsed = location.hostname; + + // Fix for a bug in FF where the leading dot in the FQDN is not ignored + if (domainUsed.charAt(0) !== '.' && !ipRegex.test(domainUsed) && domainUsed !== 'localhost') + domainUsed = '.' + location.hostname; + + var secure = location.protocol.startsWith("https") ? " Secure;" : ""; + + document.cookie = 'PREFS=' + cookieData + '; SameSite=Lax; path=/; domain=' + + domainUsed + '; expires=' + date.toGMTString() + ';' + secure; + + video_data.params.volume = volumeValue; + video_data.params.speed = speedValue; +} + +player.on('ratechange', function () { + updateCookie(null, player.playbackRate()); + if (isMobile()) { + player.mobileUi({ touchControls: { seekSeconds: 5 * player.playbackRate() } }); + } +}); + +player.on('volumechange', function () { + updateCookie(Math.ceil(player.volume() * 100), null); +}); + player.on('waiting', function () { if (player.playbackRate() > 1 && player.liveTracker.isLive() && player.liveTracker.atLiveEdge()) { - console.log('Player has caught up to source, resetting playbackRate.') + console.info('Player has caught up to source, resetting playbackRate'); player.playbackRate(1); } }); @@ -188,19 +344,44 @@ if (video_data.premiere_timestamp && Math.round(new Date() / 1000) < video_data. player.getChild('bigPlayButton').hide(); } +if (video_data.params.save_player_pos) { + const url = new URL(location); + const hasTimeParam = url.searchParams.has('t'); + const rememberedTime = get_video_time(); + let lastUpdated = 0; + + if(!hasTimeParam) { + if (rememberedTime >= video_data.length_seconds - 20) + set_seconds_after_start(0); + else + set_seconds_after_start(rememberedTime); + } + + player.on('timeupdate', function () { + const raw = player.currentTime(); + const time = Math.floor(raw); + + if(lastUpdated !== time && raw <= video_data.length_seconds - 15) { + save_video_time(time); + lastUpdated = time; + } + }); +} +else remove_all_video_times(); + if (video_data.params.autoplay) { var bpb = player.getChild('bigPlayButton'); bpb.hide(); player.ready(function () { new Promise(function (resolve, reject) { - setTimeout(() => resolve(1), 1); + setTimeout(function () {resolve(1);}, 1); }).then(function (result) { var promise = player.play(); if (promise !== undefined) { - promise.then(_ => { - }).catch(error => { + promise.then(function () { + }).catch(function (error) { bpb.show(); }); } @@ -211,67 +392,47 @@ if (video_data.params.autoplay) { if (!video_data.params.listen && video_data.params.quality === 'dash') { player.httpSourceSelector(); - if (video_data.params.quality_dash != "auto") { - player.ready(() => { - player.on("loadedmetadata", () => { - const qualityLevels = Array.from(player.qualityLevels()).sort((a, b) => a.height - b.height); + if (video_data.params.quality_dash !== 'auto') { + player.ready(function () { + player.on('loadedmetadata', function () { + const qualityLevels = Array.from(player.qualityLevels()).sort(function (a, b) {return a.height - b.height;}); let targetQualityLevel; switch (video_data.params.quality_dash) { - case "best": + case 'best': targetQualityLevel = qualityLevels.length - 1; break; - case "worst": + case 'worst': targetQualityLevel = 0; break; default: - const targetHeight = Number.parseInt(video_data.params.quality_dash, 10); + const targetHeight = parseInt(video_data.params.quality_dash); for (let i = 0; i < qualityLevels.length; i++) { - if (qualityLevels[i].height <= targetHeight) { + if (qualityLevels[i].height <= targetHeight) targetQualityLevel = i; - } else { + else break; - } } } - for (let i = 0; i < qualityLevels.length; i++) { - qualityLevels[i].enabled = (i == targetQualityLevel); - } + qualityLevels.forEach(function (level, index) { + level.enabled = (index === targetQualityLevel); + }); }); }); } } player.vttThumbnails({ - src: location.origin + '/api/v1/storyboards/' + video_data.id + '?height=90', + src: '/api/v1/storyboards/' + video_data.id + '?height=90', showTimestamp: true }); // Enable annotations if (!video_data.params.listen && video_data.params.annotations) { - window.addEventListener('load', function (e) { - var video_container = document.getElementById('player'); - let xhr = new XMLHttpRequest(); - xhr.responseType = 'text'; - xhr.timeout = 60000; - xhr.open('GET', '/api/v1/annotations/' + video_data.id, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - videojs.registerPlugin('youtubeAnnotationsPlugin', youtubeAnnotationsPlugin); - if (!player.paused()) { - player.youtubeAnnotationsPlugin({ annotationXml: xhr.response, videoContainer: video_container }); - } else { - player.one('play', function (event) { - player.youtubeAnnotationsPlugin({ annotationXml: xhr.response, videoContainer: video_container }); - }); - } - } - } - } - - window.addEventListener('__ar_annotation_click', e => { - const { url, target, seconds } = e.detail; + addEventListener('load', function (e) { + addEventListener('__ar_annotation_click', function (e) { + const url = e.detail.url, + target = e.detail.target, + seconds = e.detail.seconds; var path = new URL(url); if (path.href.startsWith('https://www.youtube.com/watch?') && seconds) { @@ -281,88 +442,104 @@ if (!video_data.params.listen && video_data.params.annotations) { path = path.pathname + path.search; if (target === 'current') { - window.location.href = path; + location.href = path; } else if (target === 'new') { - window.open(path, '_blank'); + open(path, '_blank'); + } + }); + + helpers.xhr('GET', '/api/v1/annotations/' + video_data.id, { + responseType: 'text', + timeout: 60000 + }, { + on200: function (response) { + var video_container = document.getElementById('player'); + videojs.registerPlugin('youtubeAnnotationsPlugin', youtubeAnnotationsPlugin); + if (player.paused()) { + player.one('play', function (event) { + player.youtubeAnnotationsPlugin({ annotationXml: response, videoContainer: video_container }); + }); + } else { + player.youtubeAnnotationsPlugin({ annotationXml: response, videoContainer: video_container }); + } } }); - xhr.send(); }); } -function increase_volume(delta) { +function change_volume(delta) { const curVolume = player.volume(); let newVolume = curVolume + delta; - if (newVolume > 1) { - newVolume = 1; - } else if (newVolume < 0) { - newVolume = 0; - } + newVolume = helpers.clamp(newVolume, 0, 1); player.volume(newVolume); } function toggle_muted() { - const isMuted = player.muted(); - player.muted(!isMuted); + player.muted(!player.muted()); } function skip_seconds(delta) { const duration = player.duration(); const curTime = player.currentTime(); let newTime = curTime + delta; - if (newTime > duration) { - newTime = duration; - } else if (newTime < 0) { - newTime = 0; - } + newTime = helpers.clamp(newTime, 0, duration); player.currentTime(newTime); } -function set_time_percent(percent) { - const duration = player.duration(); - const newTime = duration * (percent / 100); - player.currentTime(newTime); +function set_seconds_after_start(delta) { + const start = video_data.params.video_start; + player.currentTime(start + delta); } -function play() { - player.play(); +function save_video_time(seconds) { + const all_video_times = get_all_video_times(); + all_video_times[video_data.id] = seconds; + helpers.storage.set(save_player_pos_key, all_video_times); } -function pause() { - player.pause(); +function get_video_time() { + return get_all_video_times()[video_data.id] || 0; } -function stop() { - player.pause(); - player.currentTime(0); +function get_all_video_times() { + return helpers.storage.get(save_player_pos_key) || {}; } -function toggle_play() { - if (player.paused()) { - play(); - } else { - pause(); - } +function remove_all_video_times() { + helpers.storage.remove(save_player_pos_key); +} + +function set_time_percent(percent) { + const duration = player.duration(); + const newTime = duration * (percent / 100); + player.currentTime(newTime); } +function play() { player.play(); } +function pause() { player.pause(); } +function stop() { player.pause(); player.currentTime(0); } +function toggle_play() { player.paused() ? play() : pause(); } + const toggle_captions = (function () { let toggledTrack = null; - const onChange = function (e) { - toggledTrack = null; - }; - const bindChange = function (onOrOff) { - player.textTracks()[onOrOff]('change', onChange); - }; + + function bindChange(onOrOff) { + player.textTracks()[onOrOff]('change', function (e) { + toggledTrack = null; + }); + } + // Wrapper function to ignore our own emitted events and only listen // to events emitted by Video.js on click on the captions menu items. - const setMode = function (track, mode) { + function setMode(track, mode) { bindChange('off'); track.mode = mode; - window.setTimeout(function () { + setTimeout(function () { bindChange('on'); }, 0); - }; + } + bindChange('on'); return function () { if (toggledTrack !== null) { @@ -382,9 +559,7 @@ const toggle_captions = (function () { const tracks = player.textTracks(); for (let i = 0; i < tracks.length; i++) { const track = tracks[i]; - if (track.kind !== 'captions') { - continue; - } + if (track.kind !== 'captions') continue; if (fallbackCaptionsTrack === null) { fallbackCaptionsTrack = track; @@ -405,26 +580,18 @@ const toggle_captions = (function () { })(); function toggle_fullscreen() { - if (player.isFullscreen()) { - player.exitFullscreen(); - } else { - player.requestFullscreen(); - } + player.isFullscreen() ? player.exitFullscreen() : player.requestFullscreen(); } function increase_playback_rate(steps) { const maxIndex = options.playbackRates.length - 1; const curIndex = options.playbackRates.indexOf(player.playbackRate()); let newIndex = curIndex + steps; - if (newIndex > maxIndex) { - newIndex = maxIndex; - } else if (newIndex < 0) { - newIndex = 0; - } + newIndex = helpers.clamp(newIndex, 0, maxIndex); player.playbackRate(options.playbackRates[newIndex]); } -window.addEventListener('keydown', e => { +addEventListener('keydown', function (e) { if (e.target.tagName.toLowerCase() === 'input') { // Ignore input when focus is on certain elements, e.g. form fields. return; @@ -452,27 +619,15 @@ window.addEventListener('keydown', e => { action = toggle_play; break; - case 'MediaPlay': - action = play; - break; - - case 'MediaPause': - action = pause; - break; - - case 'MediaStop': - action = stop; - break; + case 'MediaPlay': action = play; break; + case 'MediaPause': action = pause; break; + case 'MediaStop': action = stop; break; case 'ArrowUp': - if (isPlayerFocused) { - action = increase_volume.bind(this, 0.1); - } + if (isPlayerFocused) action = change_volume.bind(this, 0.1); break; case 'ArrowDown': - if (isPlayerFocused) { - action = increase_volume.bind(this, -0.1); - } + if (isPlayerFocused) action = change_volume.bind(this, -0.1); break; case 'm': @@ -504,16 +659,15 @@ window.addEventListener('keydown', e => { case '7': case '8': case '9': + // Ignore numpad numbers + if (code > 57) break; + const percent = (code - 48) * 10; action = set_time_percent.bind(this, percent); break; - case 'c': - action = toggle_captions; - break; - case 'f': - action = toggle_fullscreen; - break; + case 'c': action = toggle_captions; break; + case 'f': action = toggle_fullscreen; break; case 'N': case 'MediaTrackNext': @@ -524,19 +678,14 @@ window.addEventListener('keydown', e => { // TODO: Add support to play back previous video. break; - case '.': - // TODO: Add support for next-frame-stepping. - break; - case ',': - // TODO: Add support for previous-frame-stepping. - break; + // TODO: More precise step. Now FPS is taken equal to 29.97 + // Common FPS: https://forum.videohelp.com/threads/81868#post323588 + // Possible solution is new HTMLVideoElement.requestVideoFrameCallback() https://wicg.github.io/video-rvfc/ + case ',': action = function () { pause(); skip_seconds(-1/29.97); }; break; + case '.': action = function () { pause(); skip_seconds( 1/29.97); }; break; - case '>': - action = increase_playback_rate.bind(this, 1); - break; - case '<': - action = increase_playback_rate.bind(this, -1); - break; + case '>': action = increase_playback_rate.bind(this, 1); break; + case '<': action = increase_playback_rate.bind(this, -1); break; default: console.info('Unhandled key down event: %s:', decoratedKey, e); @@ -552,84 +701,88 @@ window.addEventListener('keydown', e => { // Add support for controlling the player volume by scrolling over it. Adapted from // https://github.com/ctd1500/videojs-hotkeys/blob/bb4a158b2e214ccab87c2e7b95f42bc45c6bfd87/videojs.hotkeys.js#L292-L328 (function () { - const volumeStep = 0.05; - const enableVolumeScroll = true; - const enableHoverScroll = true; - const doc = document; const pEl = document.getElementById('player'); var volumeHover = false; var volumeSelector = pEl.querySelector('.vjs-volume-menu-button') || pEl.querySelector('.vjs-volume-panel'); - if (volumeSelector != null) { + if (volumeSelector !== null) { volumeSelector.onmouseover = function () { volumeHover = true; }; volumeSelector.onmouseout = function () { volumeHover = false; }; } - var mouseScroll = function mouseScroll(event) { - var activeEl = doc.activeElement; - if (enableHoverScroll) { - // If we leave this undefined then it can match non-existent elements below - activeEl = 0; - } - + function mouseScroll(event) { // When controls are disabled, hotkeys will be disabled as well - if (player.controls()) { - if (volumeHover) { - if (enableVolumeScroll) { - event = window.event || event; - var delta = Math.max(-1, Math.min(1, (event.wheelDelta || -event.detail))); - event.preventDefault(); - - if (delta == 1) { - increase_volume(volumeStep); - } else if (delta == -1) { - increase_volume(-volumeStep); - } - } - } - } - }; + if (!player.controls() || !volumeHover) return; + + event.preventDefault(); + var wheelMove = event.wheelDelta || -event.detail; + var volumeSign = Math.sign(wheelMove); + + change_volume(volumeSign * 0.05); // decrease/increase by 5% + } player.on('mousewheel', mouseScroll); - player.on("DOMMouseScroll", mouseScroll); + player.on('DOMMouseScroll', mouseScroll); }()); // Since videojs-share can sometimes be blocked, we defer it until last -if (player.share) { - player.share(shareOptions); -} +if (player.share) player.share(shareOptions); // show the preferred caption by default if (player_data.preferred_caption_found) { - player.ready(() => { - player.textTracks()[1].mode = 'showing'; + player.ready(function () { + if (!video_data.params.listen && video_data.params.quality === 'dash') { + // play.textTracks()[0] on DASH mode is showing some debug messages + player.textTracks()[1].mode = 'showing'; + } else { + player.textTracks()[0].mode = 'showing'; + } }); } // Safari audio double duration fix -if (navigator.vendor == "Apple Computer, Inc." && video_data.params.listen) { +if (navigator.vendor === 'Apple Computer, Inc.' && video_data.params.listen) { player.on('loadedmetadata', function () { player.on('timeupdate', function () { - if (player.remainingTime() < player.duration() / 2) { - player.currentTime(player.duration() + 1); + if (player.remainingTime() < player.duration() / 2 && player.remainingTime() >= 2) { + player.currentTime(player.duration() - 1); } }); }); } +// Safari screen timeout on looped video playback fix +if (navigator.vendor === 'Apple Computer, Inc.' && !video_data.params.listen && video_data.params.video_loop) { + player.loop(false); + player.ready(function () { + player.on('ended', function () { + player.currentTime(0); + player.play(); + }); + }); +} + // Watch on Invidious link -if (window.location.pathname.startsWith("/embed/")) { +if (location.pathname.startsWith('/embed/')) { const Button = videojs.getComponent('Button'); let watch_on_invidious_button = new Button(player); // Create hyperlink for current instance - redirect_element = document.createElement("a"); - redirect_element.setAttribute("href", `http://${window.location.host}/watch?v=${window.location.pathname.replace("/embed/","")}`) - redirect_element.appendChild(document.createTextNode("Invidious")) + var redirect_element = document.createElement('a'); + redirect_element.setAttribute('href', location.pathname.replace('/embed/', '/watch?v=')); + redirect_element.appendChild(document.createTextNode('Invidious')); - watch_on_invidious_button.el().appendChild(redirect_element) - watch_on_invidious_button.addClass("watch-on-invidious") + watch_on_invidious_button.el().appendChild(redirect_element); + watch_on_invidious_button.addClass('watch-on-invidious'); - cb = player.getChild('ControlBar') - cb.addChild(watch_on_invidious_button) -}; + var cb = player.getChild('ControlBar'); + cb.addChild(watch_on_invidious_button); +} + +addEventListener('DOMContentLoaded', function () { + // Save time during redirection on another instance + const changeInstanceLink = document.querySelector('#watch-on-another-invidious-instance > a'); + if (changeInstanceLink) changeInstanceLink.addEventListener('click', function () { + changeInstanceLink.href = addCurrentTimeToURL(changeInstanceLink.href); + }); +}); diff --git a/assets/js/playlist_widget.js b/assets/js/playlist_widget.js index 0ec27859..c92592ac 100644 --- a/assets/js/playlist_widget.js +++ b/assets/js/playlist_widget.js @@ -1,4 +1,6 @@ -var playlist_data = JSON.parse(document.getElementById('playlist_data').innerHTML); +'use strict'; +var playlist_data = JSON.parse(document.getElementById('playlist_data').textContent); +var payload = 'csrf_token=' + playlist_data.csrf_token; function add_playlist_video(target) { var select = target.parentNode.children[0].children[1]; @@ -7,21 +9,12 @@ function add_playlist_video(target) { var url = '/playlist_ajax?action_add_video=1&redirect=false' + '&video_id=' + target.getAttribute('data-id') + '&playlist_id=' + option.getAttribute('data-plid'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status == 200) { - option.innerText = '✓' + option.innerText; - } + helpers.xhr('POST', url, {payload: payload}, { + on200: function (response) { + option.textContent = '✓' + option.textContent; } - } - - xhr.send('csrf_token=' + playlist_data.csrf_token); + }); } function add_playlist_item(target) { @@ -31,21 +24,12 @@ function add_playlist_item(target) { var url = '/playlist_ajax?action_add_video=1&redirect=false' + '&video_id=' + target.getAttribute('data-id') + '&playlist_id=' + target.getAttribute('data-plid'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - tile.style.display = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + tile.style.display = ''; } - } - - xhr.send('csrf_token=' + playlist_data.csrf_token); + }); } function remove_playlist_item(target) { @@ -55,19 +39,10 @@ function remove_playlist_item(target) { var url = '/playlist_ajax?action_remove_video=1&redirect=false' + '&set_video_id=' + target.getAttribute('data-index') + '&playlist_id=' + target.getAttribute('data-plid'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - tile.style.display = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + tile.style.display = ''; } - } - - xhr.send('csrf_token=' + playlist_data.csrf_token); -}
\ No newline at end of file + }); +} diff --git a/assets/js/post.js b/assets/js/post.js new file mode 100644 index 00000000..fcbc9155 --- /dev/null +++ b/assets/js/post.js @@ -0,0 +1,3 @@ +addEventListener('load', function (e) { + get_youtube_comments(); +}); diff --git a/assets/js/silvermine-videojs-quality-selector.min.js b/assets/js/silvermine-videojs-quality-selector.min.js index 88621e8d..1877047d 100644 --- a/assets/js/silvermine-videojs-quality-selector.min.js +++ b/assets/js/silvermine-videojs-quality-selector.min.js @@ -1,4 +1,4 @@ -/*! @silvermine/videojs-quality-selector 2020-03-02 v1.1.2-36-g64d620a-dirty */ +/*! @silvermine/videojs-quality-selector 2022-04-13 v1.1.2-43-gaa06e72-dirty */ -!function u(o,c,a){function l(e,n){if(!c[e]){if(!o[e]){var t="function"==typeof require&&require;if(!n&&t)return t(e,!0);if(s)return s(e,!0);var r=new Error("Cannot find module '"+e+"'");throw r.code="MODULE_NOT_FOUND",r}var i=c[e]={exports:{}};o[e][0].call(i.exports,function(n){return l(o[e][1][n]||n)},i,i.exports,u,o,c,a)}return c[e].exports}for(var s="function"==typeof require&&require,n=0;n<a.length;n++)l(a[n]);return l}({1:[function(n,e,t){!function(){var u=!1,o=/xyz/.test(function(){xyz})?/\b_super\b/:/.*/;this.Class=function(){},Class.extend=function(n){var i=this.prototype;u=!0;var e=new this;for(var t in u=!1,n)e[t]="function"==typeof n[t]&&"function"==typeof i[t]&&o.test(n[t])?function(t,r){return function(){var n=this._super;this._super=i[t];var e=r.apply(this,arguments);return this._super=n,e}}(t,n[t]):n[t];function r(){!u&&this.init&&this.init.apply(this,arguments)}return((r.prototype=e).constructor=r).extend=arguments.callee,r},e.exports=Class}()},{}],2:[function(n,J,$){(function(V){!function(){function t(){}var n="object"==typeof self&&self.self===self&&self||"object"==typeof V&&V.global===V&&V||this||{},e=n._,r=Array.prototype,o=Object.prototype,f="undefined"!=typeof Symbol?Symbol.prototype:null,i=r.push,a=r.slice,p=o.toString,u=o.hasOwnProperty,c=Array.isArray,l=Object.keys,s=Object.create,h=function(n){return n instanceof h?n:this instanceof h?void(this._wrapped=n):new h(n)};void 0===$||$.nodeType?n._=h:(void 0!==J&&!J.nodeType&&J.exports&&($=J.exports=h),$._=h),h.VERSION="1.9.1";function d(i,u,n){if(void 0===u)return i;switch(null==n?3:n){case 1:return function(n){return i.call(u,n)};case 3:return function(n,e,t){return i.call(u,n,e,t)};case 4:return function(n,e,t,r){return i.call(u,n,e,t,r)}}return function(){return i.apply(u,arguments)}}function v(n,e,t){return h.iteratee!==y?h.iteratee(n,e):null==n?h.identity:h.isFunction(n)?d(n,e,t):h.isObject(n)&&!h.isArray(n)?h.matcher(n):h.property(n)}var y;h.iteratee=y=function(n,e){return v(n,e,1/0)};function g(i,u){return u=null==u?i.length-1:+u,function(){for(var n=Math.max(arguments.length-u,0),e=Array(n),t=0;t<n;t++)e[t]=arguments[t+u];switch(u){case 0:return i.call(this,e);case 1:return i.call(this,arguments[0],e);case 2:return i.call(this,arguments[0],arguments[1],e)}var r=Array(u+1);for(t=0;t<u;t++)r[t]=arguments[t];return r[u]=e,i.apply(this,r)}}function S(n){if(!h.isObject(n))return{};if(s)return s(n);t.prototype=n;var e=new t;return t.prototype=null,e}function m(e){return function(n){return null==n?void 0:n[e]}}function _(n,e){return null!=n&&u.call(n,e)}function b(n,e){for(var t=e.length,r=0;r<t;r++){if(null==n)return;n=n[e[r]]}return t?n:void 0}function x(n){var e=j(n);return"number"==typeof e&&0<=e&&e<=k}var k=Math.pow(2,53)-1,j=m("length");h.each=h.forEach=function(n,e,t){var r,i;if(e=d(e,t),x(n))for(r=0,i=n.length;r<i;r++)e(n[r],r,n);else{var u=h.keys(n);for(r=0,i=u.length;r<i;r++)e(n[u[r]],u[r],n)}return n},h.map=h.collect=function(n,e,t){e=v(e,t);for(var r=!x(n)&&h.keys(n),i=(r||n).length,u=Array(i),o=0;o<i;o++){var c=r?r[o]:o;u[o]=e(n[c],c,n)}return u};function E(a){return function(n,e,t,r){var i=3<=arguments.length;return function(n,e,t,r){var i=!x(n)&&h.keys(n),u=(i||n).length,o=0<a?0:u-1;for(r||(t=n[i?i[o]:o],o+=a);0<=o&&o<u;o+=a){var c=i?i[o]:o;t=e(t,n[c],c,n)}return t}(n,d(e,r,4),t,i)}}h.reduce=h.foldl=h.inject=E(1),h.reduceRight=h.foldr=E(-1),h.find=h.detect=function(n,e,t){var r=(x(n)?h.findIndex:h.findKey)(n,e,t);if(void 0!==r&&-1!==r)return n[r]},h.filter=h.select=function(n,r,e){var i=[];return r=v(r,e),h.each(n,function(n,e,t){r(n,e,t)&&i.push(n)}),i},h.reject=function(n,e,t){return h.filter(n,h.negate(v(e)),t)},h.every=h.all=function(n,e,t){e=v(e,t);for(var r=!x(n)&&h.keys(n),i=(r||n).length,u=0;u<i;u++){var o=r?r[u]:u;if(!e(n[o],o,n))return!1}return!0},h.some=h.any=function(n,e,t){e=v(e,t);for(var r=!x(n)&&h.keys(n),i=(r||n).length,u=0;u<i;u++){var o=r?r[u]:u;if(e(n[o],o,n))return!0}return!1},h.contains=h.includes=h.include=function(n,e,t,r){return x(n)||(n=h.values(n)),"number"==typeof t&&!r||(t=0),0<=h.indexOf(n,e,t)},h.invoke=g(function(n,t,r){var i,u;return h.isFunction(t)?u=t:h.isArray(t)&&(i=t.slice(0,-1),t=t[t.length-1]),h.map(n,function(n){var e=u;if(!e){if(i&&i.length&&(n=b(n,i)),null==n)return;e=n[t]}return null==e?e:e.apply(n,r)})}),h.pluck=function(n,e){return h.map(n,h.property(e))},h.where=function(n,e){return h.filter(n,h.matcher(e))},h.findWhere=function(n,e){return h.find(n,h.matcher(e))},h.max=function(n,r,e){var t,i,u=-1/0,o=-1/0;if(null==r||"number"==typeof r&&"object"!=typeof n[0]&&null!=n)for(var c=0,a=(n=x(n)?n:h.values(n)).length;c<a;c++)null!=(t=n[c])&&u<t&&(u=t);else r=v(r,e),h.each(n,function(n,e,t){i=r(n,e,t),(o<i||i===-1/0&&u===-1/0)&&(u=n,o=i)});return u},h.min=function(n,r,e){var t,i,u=1/0,o=1/0;if(null==r||"number"==typeof r&&"object"!=typeof n[0]&&null!=n)for(var c=0,a=(n=x(n)?n:h.values(n)).length;c<a;c++)null!=(t=n[c])&&t<u&&(u=t);else r=v(r,e),h.each(n,function(n,e,t){((i=r(n,e,t))<o||i===1/0&&u===1/0)&&(u=n,o=i)});return u},h.shuffle=function(n){return h.sample(n,1/0)},h.sample=function(n,e,t){if(null==e||t)return x(n)||(n=h.values(n)),n[h.random(n.length-1)];var r=x(n)?h.clone(n):h.values(n),i=j(r);e=Math.max(Math.min(e,i),0);for(var u=i-1,o=0;o<e;o++){var c=h.random(o,u),a=r[o];r[o]=r[c],r[c]=a}return r.slice(0,e)},h.sortBy=function(n,r,e){var i=0;return r=v(r,e),h.pluck(h.map(n,function(n,e,t){return{value:n,index:i++,criteria:r(n,e,t)}}).sort(function(n,e){var t=n.criteria,r=e.criteria;if(t!==r){if(r<t||void 0===t)return 1;if(t<r||void 0===r)return-1}return n.index-e.index}),"value")};function w(o,e){return function(r,i,n){var u=e?[[],[]]:{};return i=v(i,n),h.each(r,function(n,e){var t=i(n,e,r);o(u,n,t)}),u}}h.groupBy=w(function(n,e,t){_(n,t)?n[t].push(e):n[t]=[e]}),h.indexBy=w(function(n,e,t){n[t]=e}),h.countBy=w(function(n,e,t){_(n,t)?n[t]++:n[t]=1});var A=/[^\ud800-\udfff]|[\ud800-\udbff][\udc00-\udfff]|[\ud800-\udfff]/g;h.toArray=function(n){return n?h.isArray(n)?a.call(n):h.isString(n)?n.match(A):x(n)?h.map(n,h.identity):h.values(n):[]},h.size=function(n){return null==n?0:x(n)?n.length:h.keys(n).length},h.partition=w(function(n,e,t){n[t?0:1].push(e)},!0),h.first=h.head=h.take=function(n,e,t){return null==n||n.length<1?null==e?void 0:[]:null==e||t?n[0]:h.initial(n,n.length-e)},h.initial=function(n,e,t){return a.call(n,0,Math.max(0,n.length-(null==e||t?1:e)))},h.last=function(n,e,t){return null==n||n.length<1?null==e?void 0:[]:null==e||t?n[n.length-1]:h.rest(n,Math.max(0,n.length-e))},h.rest=h.tail=h.drop=function(n,e,t){return a.call(n,null==e||t?1:e)},h.compact=function(n){return h.filter(n,Boolean)};var T=function(n,e,t,r){for(var i=(r=r||[]).length,u=0,o=j(n);u<o;u++){var c=n[u];if(x(c)&&(h.isArray(c)||h.isArguments(c)))if(e)for(var a=0,l=c.length;a<l;)r[i++]=c[a++];else T(c,e,t,r),i=r.length;else t||(r[i++]=c)}return r};h.flatten=function(n,e){return T(n,e,!1)},h.without=g(function(n,e){return h.difference(n,e)}),h.uniq=h.unique=function(n,e,t,r){h.isBoolean(e)||(r=t,t=e,e=!1),null!=t&&(t=v(t,r));for(var i=[],u=[],o=0,c=j(n);o<c;o++){var a=n[o],l=t?t(a,o,n):a;e&&!t?(o&&u===l||i.push(a),u=l):t?h.contains(u,l)||(u.push(l),i.push(a)):h.contains(i,a)||i.push(a)}return i},h.union=g(function(n){return h.uniq(T(n,!0,!0))}),h.intersection=function(n){for(var e=[],t=arguments.length,r=0,i=j(n);r<i;r++){var u=n[r];if(!h.contains(e,u)){var o;for(o=1;o<t&&h.contains(arguments[o],u);o++);o===t&&e.push(u)}}return e},h.difference=g(function(n,e){return e=T(e,!0,!0),h.filter(n,function(n){return!h.contains(e,n)})}),h.unzip=function(n){for(var e=n&&h.max(n,j).length||0,t=Array(e),r=0;r<e;r++)t[r]=h.pluck(n,r);return t},h.zip=g(h.unzip),h.object=function(n,e){for(var t={},r=0,i=j(n);r<i;r++)e?t[n[r]]=e[r]:t[n[r][0]]=n[r][1];return t};function O(u){return function(n,e,t){e=v(e,t);for(var r=j(n),i=0<u?0:r-1;0<=i&&i<r;i+=u)if(e(n[i],i,n))return i;return-1}}h.findIndex=O(1),h.findLastIndex=O(-1),h.sortedIndex=function(n,e,t,r){for(var i=(t=v(t,r,1))(e),u=0,o=j(n);u<o;){var c=Math.floor((u+o)/2);t(n[c])<i?u=c+1:o=c}return u};function C(u,o,c){return function(n,e,t){var r=0,i=j(n);if("number"==typeof t)0<u?r=0<=t?t:Math.max(t+i,r):i=0<=t?Math.min(t+1,i):t+i+1;else if(c&&t&&i)return n[t=c(n,e)]===e?t:-1;if(e!=e)return 0<=(t=o(a.call(n,r,i),h.isNaN))?t+r:-1;for(t=0<u?r:i-1;0<=t&&t<i;t+=u)if(n[t]===e)return t;return-1}}h.indexOf=C(1,h.findIndex,h.sortedIndex),h.lastIndexOf=C(-1,h.findLastIndex),h.range=function(n,e,t){null==e&&(e=n||0,n=0),t=t||(e<n?-1:1);for(var r=Math.max(Math.ceil((e-n)/t),0),i=Array(r),u=0;u<r;u++,n+=t)i[u]=n;return i},h.chunk=function(n,e){if(null==e||e<1)return[];for(var t=[],r=0,i=n.length;r<i;)t.push(a.call(n,r,r+=e));return t};function I(n,e,t,r,i){if(!(r instanceof e))return n.apply(t,i);var u=S(n.prototype),o=n.apply(u,i);return h.isObject(o)?o:u}h.bind=g(function(e,t,r){if(!h.isFunction(e))throw new TypeError("Bind must be called on a function");var i=g(function(n){return I(e,i,t,this,r.concat(n))});return i}),h.partial=g(function(i,u){var o=h.partial.placeholder,c=function(){for(var n=0,e=u.length,t=Array(e),r=0;r<e;r++)t[r]=u[r]===o?arguments[n++]:u[r];for(;n<arguments.length;)t.push(arguments[n++]);return I(i,c,this,this,t)};return c}),(h.partial.placeholder=h).bindAll=g(function(n,e){var t=(e=T(e,!1,!1)).length;if(t<1)throw new Error("bindAll must be passed function names");for(;t--;){var r=e[t];n[r]=h.bind(n[r],n)}}),h.memoize=function(r,i){var u=function(n){var e=u.cache,t=""+(i?i.apply(this,arguments):n);return _(e,t)||(e[t]=r.apply(this,arguments)),e[t]};return u.cache={},u},h.delay=g(function(n,e,t){return setTimeout(function(){return n.apply(null,t)},e)}),h.defer=h.partial(h.delay,h,1),h.throttle=function(t,r,i){var u,o,c,a,l=0;i=i||{};function s(){l=!1===i.leading?0:h.now(),u=null,a=t.apply(o,c),u||(o=c=null)}function n(){var n=h.now();l||!1!==i.leading||(l=n);var e=r-(n-l);return o=this,c=arguments,e<=0||r<e?(u&&(clearTimeout(u),u=null),l=n,a=t.apply(o,c),u||(o=c=null)):u||!1===i.trailing||(u=setTimeout(s,e)),a}return n.cancel=function(){clearTimeout(u),l=0,u=o=c=null},n},h.debounce=function(t,r,i){function u(n,e){o=null,e&&(c=t.apply(n,e))}var o,c,n=g(function(n){if(o&&clearTimeout(o),i){var e=!o;o=setTimeout(u,r),e&&(c=t.apply(this,n))}else o=h.delay(u,r,this,n);return c});return n.cancel=function(){clearTimeout(o),o=null},n},h.wrap=function(n,e){return h.partial(e,n)},h.negate=function(n){return function(){return!n.apply(this,arguments)}},h.compose=function(){var t=arguments,r=t.length-1;return function(){for(var n=r,e=t[r].apply(this,arguments);n--;)e=t[n].call(this,e);return e}},h.after=function(n,e){return function(){if(--n<1)return e.apply(this,arguments)}},h.before=function(n,e){var t;return function(){return 0<--n&&(t=e.apply(this,arguments)),n<=1&&(e=null),t}},h.once=h.partial(h.before,2),h.restArguments=g;function F(n,e){var t=M.length,r=n.constructor,i=h.isFunction(r)&&r.prototype||o,u="constructor";for(_(n,u)&&!h.contains(e,u)&&e.push(u);t--;)(u=M[t])in n&&n[u]!==i[u]&&!h.contains(e,u)&&e.push(u)}var q=!{toString:null}.propertyIsEnumerable("toString"),M=["valueOf","isPrototypeOf","toString","propertyIsEnumerable","hasOwnProperty","toLocaleString"];h.keys=function(n){if(!h.isObject(n))return[];if(l)return l(n);var e=[];for(var t in n)_(n,t)&&e.push(t);return q&&F(n,e),e},h.allKeys=function(n){if(!h.isObject(n))return[];var e=[];for(var t in n)e.push(t);return q&&F(n,e),e},h.values=function(n){for(var e=h.keys(n),t=e.length,r=Array(t),i=0;i<t;i++)r[i]=n[e[i]];return r},h.mapObject=function(n,e,t){e=v(e,t);for(var r=h.keys(n),i=r.length,u={},o=0;o<i;o++){var c=r[o];u[c]=e(n[c],c,n)}return u},h.pairs=function(n){for(var e=h.keys(n),t=e.length,r=Array(t),i=0;i<t;i++)r[i]=[e[i],n[e[i]]];return r},h.invert=function(n){for(var e={},t=h.keys(n),r=0,i=t.length;r<i;r++)e[n[t[r]]]=t[r];return e},h.functions=h.methods=function(n){var e=[];for(var t in n)h.isFunction(n[t])&&e.push(t);return e.sort()};function N(a,l){return function(n){var e=arguments.length;if(l&&(n=Object(n)),e<2||null==n)return n;for(var t=1;t<e;t++)for(var r=arguments[t],i=a(r),u=i.length,o=0;o<u;o++){var c=i[o];l&&void 0!==n[c]||(n[c]=r[c])}return n}}h.extend=N(h.allKeys),h.extendOwn=h.assign=N(h.keys),h.findKey=function(n,e,t){e=v(e,t);for(var r,i=h.keys(n),u=0,o=i.length;u<o;u++)if(e(n[r=i[u]],r,n))return r};function R(n,e,t){return e in t}var Q,L;h.pick=g(function(n,e){var t={},r=e[0];if(null==n)return t;h.isFunction(r)?(1<e.length&&(r=d(r,e[1])),e=h.allKeys(n)):(r=R,e=T(e,!1,!1),n=Object(n));for(var i=0,u=e.length;i<u;i++){var o=e[i],c=n[o];r(c,o,n)&&(t[o]=c)}return t}),h.omit=g(function(n,t){var e,r=t[0];return h.isFunction(r)?(r=h.negate(r),1<t.length&&(e=t[1])):(t=h.map(T(t,!1,!1),String),r=function(n,e){return!h.contains(t,e)}),h.pick(n,r,e)}),h.defaults=N(h.allKeys,!0),h.create=function(n,e){var t=S(n);return e&&h.extendOwn(t,e),t},h.clone=function(n){return h.isObject(n)?h.isArray(n)?n.slice():h.extend({},n):n},h.tap=function(n,e){return e(n),n},h.isMatch=function(n,e){var t=h.keys(e),r=t.length;if(null==n)return!r;for(var i=Object(n),u=0;u<r;u++){var o=t[u];if(e[o]!==i[o]||!(o in i))return!1}return!0},Q=function(n,e,t,r){if(n===e)return 0!==n||1/n==1/e;if(null==n||null==e)return!1;if(n!=n)return e!=e;var i=typeof n;return("function"==i||"object"==i||"object"==typeof e)&&L(n,e,t,r)},L=function(n,e,t,r){n instanceof h&&(n=n._wrapped),e instanceof h&&(e=e._wrapped);var i=p.call(n);if(i!==p.call(e))return!1;switch(i){case"[object RegExp]":case"[object String]":return""+n==""+e;case"[object Number]":return+n!=+n?+e!=+e:0==+n?1/+n==1/e:+n==+e;case"[object Date]":case"[object Boolean]":return+n==+e;case"[object Symbol]":return f.valueOf.call(n)===f.valueOf.call(e)}var u="[object Array]"===i;if(!u){if("object"!=typeof n||"object"!=typeof e)return!1;var o=n.constructor,c=e.constructor;if(o!==c&&!(h.isFunction(o)&&o instanceof o&&h.isFunction(c)&&c instanceof c)&&"constructor"in n&&"constructor"in e)return!1}r=r||[];for(var a=(t=t||[]).length;a--;)if(t[a]===n)return r[a]===e;if(t.push(n),r.push(e),u){if((a=n.length)!==e.length)return!1;for(;a--;)if(!Q(n[a],e[a],t,r))return!1}else{var l,s=h.keys(n);if(a=s.length,h.keys(e).length!==a)return!1;for(;a--;)if(l=s[a],!_(e,l)||!Q(n[l],e[l],t,r))return!1}return t.pop(),r.pop(),!0},h.isEqual=function(n,e){return Q(n,e)},h.isEmpty=function(n){return null==n||(x(n)&&(h.isArray(n)||h.isString(n)||h.isArguments(n))?0===n.length:0===h.keys(n).length)},h.isElement=function(n){return!(!n||1!==n.nodeType)},h.isArray=c||function(n){return"[object Array]"===p.call(n)},h.isObject=function(n){var e=typeof n;return"function"==e||"object"==e&&!!n},h.each(["Arguments","Function","String","Number","Date","RegExp","Error","Symbol","Map","WeakMap","Set","WeakSet"],function(e){h["is"+e]=function(n){return p.call(n)==="[object "+e+"]"}}),h.isArguments(arguments)||(h.isArguments=function(n){return _(n,"callee")});var U=n.document&&n.document.childNodes;"function"!=typeof/./&&"object"!=typeof Int8Array&&"function"!=typeof U&&(h.isFunction=function(n){return"function"==typeof n||!1}),h.isFinite=function(n){return!h.isSymbol(n)&&isFinite(n)&&!isNaN(parseFloat(n))},h.isNaN=function(n){return h.isNumber(n)&&isNaN(n)},h.isBoolean=function(n){return!0===n||!1===n||"[object Boolean]"===p.call(n)},h.isNull=function(n){return null===n},h.isUndefined=function(n){return void 0===n},h.has=function(n,e){if(!h.isArray(e))return _(n,e);for(var t=e.length,r=0;r<t;r++){var i=e[r];if(null==n||!u.call(n,i))return!1;n=n[i]}return!!t},h.noConflict=function(){return n._=e,this},h.identity=function(n){return n},h.constant=function(n){return function(){return n}},h.noop=function(){},h.property=function(e){return h.isArray(e)?function(n){return b(n,e)}:m(e)},h.propertyOf=function(e){return null==e?function(){}:function(n){return h.isArray(n)?b(e,n):e[n]}},h.matcher=h.matches=function(e){return e=h.extendOwn({},e),function(n){return h.isMatch(n,e)}},h.times=function(n,e,t){var r=Array(Math.max(0,n));e=d(e,t,1);for(var i=0;i<n;i++)r[i]=e(i);return r},h.random=function(n,e){return null==e&&(e=n,n=0),n+Math.floor(Math.random()*(e-n+1))},h.now=Date.now||function(){return(new Date).getTime()};function D(e){function t(n){return e[n]}var n="(?:"+h.keys(e).join("|")+")",r=RegExp(n),i=RegExp(n,"g");return function(n){return n=null==n?"":""+n,r.test(n)?n.replace(i,t):n}}var P={"&":"&","<":"<",">":">",'"':""","'":"'","`":"`"},W=h.invert(P);h.escape=D(P),h.unescape=D(W),h.result=function(n,e,t){h.isArray(e)||(e=[e]);var r=e.length;if(!r)return h.isFunction(t)?t.call(n):t;for(var i=0;i<r;i++){var u=null==n?void 0:n[e[i]];void 0===u&&(u=t,i=r),n=h.isFunction(u)?u.call(n):u}return n};var B=0;h.uniqueId=function(n){var e=++B+"";return n?n+e:e},h.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};function Y(n){return"\\"+K[n]}var z=/(.)^/,K={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},G=/\\|'|\r|\n|\u2028|\u2029/g;h.template=function(u,n,e){!n&&e&&(n=e),n=h.defaults({},n,h.templateSettings);var t,r=RegExp([(n.escape||z).source,(n.interpolate||z).source,(n.evaluate||z).source].join("|")+"|$","g"),o=0,c="__p+='";u.replace(r,function(n,e,t,r,i){return c+=u.slice(o,i).replace(G,Y),o=i+n.length,e?c+="'+\n((__t=("+e+"))==null?'':_.escape(__t))+\n'":t?c+="'+\n((__t=("+t+"))==null?'':__t)+\n'":r&&(c+="';\n"+r+"\n__p+='"),n}),c+="';\n",n.variable||(c="with(obj||{}){\n"+c+"}\n"),c="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+c+"return __p;\n";try{t=new Function(n.variable||"obj","_",c)}catch(n){throw n.source=c,n}function i(n){return t.call(this,n,h)}var a=n.variable||"obj";return i.source="function("+a+"){\n"+c+"}",i},h.chain=function(n){var e=h(n);return e._chain=!0,e};function H(n,e){return n._chain?h(e).chain():e}h.mixin=function(t){return h.each(h.functions(t),function(n){var e=h[n]=t[n];h.prototype[n]=function(){var n=[this._wrapped];return i.apply(n,arguments),H(this,e.apply(h,n))}}),h},h.mixin(h),h.each(["pop","push","reverse","shift","sort","splice","unshift"],function(e){var t=r[e];h.prototype[e]=function(){var n=this._wrapped;return t.apply(n,arguments),"shift"!==e&&"splice"!==e||0!==n.length||delete n[0],H(this,n)}}),h.each(["concat","join","slice"],function(n){var e=r[n];h.prototype[n]=function(){return H(this,e.apply(this._wrapped,arguments))}}),h.prototype.value=function(){return this._wrapped},h.prototype.valueOf=h.prototype.toJSON=h.prototype.value,h.prototype.toString=function(){return String(this._wrapped)},"function"==typeof define&&define.amd&&define("underscore",[],function(){return h})}()}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}],3:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events");e.exports=function(n){var r=n.getComponent("MenuItem");return n.extend(r,{constructor:function(n,e){var t=e.source;if(!i.isObject(t))throw new Error('was not provided a "source" object, but rather: '+typeof t);e=i.extend({selectable:!0,label:t.label},e),r.call(this,n,e),this.source=t},handleClick:function(n){r.prototype.handleClick.call(this,n),this.player().trigger(u.QUALITY_REQUESTED,this.source)}})}},{"../events":5,underscore:2}],4:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events"),o=n("./QualityOption"),c="vjs-quality-changing";e.exports=function(n){var e,r=n.getComponent("MenuButton"),t=o(n);return e=n.extend(r,{constructor:function(t,n){r.call(this,t,n),t.on(u.QUALITY_REQUESTED,function(n,e){this.setSelectedSource(e),t.addClass(c),t.one("loadeddata",function(){t.removeClass(c)})}.bind(this)),t.on(u.PLAYER_SOURCES_CHANGED,function(){this.update()}.bind(this)),t.on(u.QUALITY_SELECTED,function(n,e){this.setSelectedSource(e)}.bind(this)),t.one("ready",function(){this.selectedSrc=t.src(),this.update()}.bind(this)),this.controlText("Open quality selector menu")},setSelectedSource:function(n){var e=n?n.src:void 0;this.selectedSrc!==e&&(this.selectedSrc=e,i.each(this.items,function(n){n.selected(n.source.src===e)}))},createItems:function(){var e=this.player(),n=e.currentSources();return i.map(n,function(n){return new t(e,{source:n,selected:n.src===this.selectedSrc})}.bind(this))},buildWrapperCSSClass:function(){return"vjs-quality-selector "+r.prototype.buildWrapperCSSClass.call(this)}}),n.registerComponent("QualitySelector",e),e}},{"../events":5,"./QualityOption":3,underscore:2}],5:[function(n,e,t){"use strict";e.exports={QUALITY_REQUESTED:"qualityRequested",QUALITY_SELECTED:"qualitySelected",PLAYER_SOURCES_CHANGED:"playerSourcesChanged"}},{}],6:[function(n,e,t){"use strict";var c=n("underscore"),r=n("./events"),i=n("./components/QualitySelector"),u=n("./middleware/SourceInterceptor"),a=n("./util/SafeSeek");e.exports=function(n){n=n||window.videojs,i(n),u(n),n.hook("setup",function(o){o.on(r.QUALITY_REQUESTED,function(n,e){var t=o.currentSources(),r=o.currentTime(),i=o.playbackRate(),u=o.paused();c.each(t,function(n){n.selected=!1}),c.findWhere(t,{src:e.src}).selected=!0,o._qualitySelectorSafeSeek&&o._qualitySelectorSafeSeek.onQualitySelectionChange(),o.src(t),o.ready(function(){o._qualitySelectorSafeSeek&&!o._qualitySelectorSafeSeek.hasFinished()||(o._qualitySelectorSafeSeek=new a(o,r),o.playbackRate(i)),u||o.play()})})})},e.exports.EVENTS=r},{"./components/QualitySelector":4,"./events":5,"./middleware/SourceInterceptor":7,"./util/SafeSeek":9,underscore:2}],7:[function(n,e,t){"use strict";var u=n("underscore"),o=n("../events");e.exports=function(n){n.use("*",function(i){return{setSource:function(n,e){var t,r=i.currentSources();i._qualitySelectorSafeSeek&&i._qualitySelectorSafeSeek.onPlayerSourcesChange(),u.isEqual(r,i._qualitySelectorPreviousSources)||(i.trigger(o.PLAYER_SOURCES_CHANGED,r),i._qualitySelectorPreviousSources=r),t=u.find(r,function(n){return!0===n.selected||"true"===n.selected||"selected"===n.selected})||n,i.trigger(o.QUALITY_SELECTED,t),e(null,t)}}})}},{"../events":5,underscore:2}],8:[function(n,e,t){"use strict";n("./index")()},{"./index":6}],9:[function(n,e,t){"use strict";var r=n("class.extend");e.exports=r.extend({init:function(n,e){this._player=n,this._seekToTime=e,this._hasFinished=!1,this._keepThisInstanceWhenPlayerSourcesChange=!1,this._seekWhenSafe()},_seekWhenSafe:function(){this._player.readyState()<3?(this._seekFn=this._seek.bind(this),this._player.one("canplay",this._seekFn)):this._seek()},onPlayerSourcesChange:function(){this._keepThisInstanceWhenPlayerSourcesChange?this._keepThisInstanceWhenPlayerSourcesChange=!1:this.cancel()},onQualitySelectionChange:function(){this.hasFinished()||(this._keepThisInstanceWhenPlayerSourcesChange=!0)},_seek:function(){this._player.currentTime(this._seekToTime),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0},hasFinished:function(){return this._hasFinished},cancel:function(){this._player.off("canplay",this._seekFn),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0}})},{"class.extend":1}]},{},[8]); +!function u(o,c,a){function l(e,n){if(!c[e]){if(!o[e]){var t="function"==typeof require&&require;if(!n&&t)return t(e,!0);if(s)return s(e,!0);var r=new Error("Cannot find module '"+e+"'");throw r.code="MODULE_NOT_FOUND",r}var i=c[e]={exports:{}};o[e][0].call(i.exports,function(n){return l(o[e][1][n]||n)},i,i.exports,u,o,c,a)}return c[e].exports}for(var s="function"==typeof require&&require,n=0;n<a.length;n++)l(a[n]);return l}({1:[function(n,e,t){!function(){var u=!1,o=/xyz/.test(function(){xyz})?/\b_super\b/:/.*/;this.Class=function(){},Class.extend=function(n){var i=this.prototype;u=!0;var e=new this;for(var t in u=!1,n)e[t]="function"==typeof n[t]&&"function"==typeof i[t]&&o.test(n[t])?function(t,r){return function(){var n=this._super;this._super=i[t];var e=r.apply(this,arguments);return this._super=n,e}}(t,n[t]):n[t];function r(){!u&&this.init&&this.init.apply(this,arguments)}return((r.prototype=e).constructor=r).extend=arguments.callee,r},e.exports=Class}()},{}],2:[function(n,J,$){(function(V){!function(){function t(){}var n="object"==typeof self&&self.self===self&&self||"object"==typeof V&&V.global===V&&V||this||{},e=n._,r=Array.prototype,o=Object.prototype,f="undefined"!=typeof Symbol?Symbol.prototype:null,i=r.push,a=r.slice,p=o.toString,u=o.hasOwnProperty,c=Array.isArray,l=Object.keys,s=Object.create,h=function(n){return n instanceof h?n:this instanceof h?void(this._wrapped=n):new h(n)};void 0===$||$.nodeType?n._=h:(void 0!==J&&!J.nodeType&&J.exports&&($=J.exports=h),$._=h),h.VERSION="1.9.1";function d(i,u,n){if(void 0===u)return i;switch(null==n?3:n){case 1:return function(n){return i.call(u,n)};case 3:return function(n,e,t){return i.call(u,n,e,t)};case 4:return function(n,e,t,r){return i.call(u,n,e,t,r)}}return function(){return i.apply(u,arguments)}}function y(n,e,t){return h.iteratee!==v?h.iteratee(n,e):null==n?h.identity:h.isFunction(n)?d(n,e,t):h.isObject(n)&&!h.isArray(n)?h.matcher(n):h.property(n)}var v;h.iteratee=v=function(n,e){return y(n,e,1/0)};function g(i,u){return u=null==u?i.length-1:+u,function(){for(var n=Math.max(arguments.length-u,0),e=Array(n),t=0;t<n;t++)e[t]=arguments[t+u];switch(u){case 0:return i.call(this,e);case 1:return i.call(this,arguments[0],e);case 2:return i.call(this,arguments[0],arguments[1],e)}var r=Array(u+1);for(t=0;t<u;t++)r[t]=arguments[t];return r[u]=e,i.apply(this,r)}}function S(n){if(!h.isObject(n))return{};if(s)return s(n);t.prototype=n;var e=new t;return t.prototype=null,e}function m(e){return function(n){return null==n?void 0:n[e]}}function _(n,e){return null!=n&&u.call(n,e)}function b(n,e){for(var t=e.length,r=0;r<t;r++){if(null==n)return;n=n[e[r]]}return t?n:void 0}function x(n){var e=j(n);return"number"==typeof e&&0<=e&&e<=k}var k=Math.pow(2,53)-1,j=m("length");h.each=h.forEach=function(n,e,t){var r,i;if(e=d(e,t),x(n))for(r=0,i=n.length;r<i;r++)e(n[r],r,n);else{var u=h.keys(n);for(r=0,i=u.length;r<i;r++)e(n[u[r]],u[r],n)}return n},h.map=h.collect=function(n,e,t){e=y(e,t);for(var r=!x(n)&&h.keys(n),i=(r||n).length,u=Array(i),o=0;o<i;o++){var c=r?r[o]:o;u[o]=e(n[c],c,n)}return u};function E(a){return function(n,e,t,r){var i=3<=arguments.length;return function(n,e,t,r){var i=!x(n)&&h.keys(n),u=(i||n).length,o=0<a?0:u-1;for(r||(t=n[i?i[o]:o],o+=a);0<=o&&o<u;o+=a){var c=i?i[o]:o;t=e(t,n[c],c,n)}return t}(n,d(e,r,4),t,i)}}h.reduce=h.foldl=h.inject=E(1),h.reduceRight=h.foldr=E(-1),h.find=h.detect=function(n,e,t){var r=(x(n)?h.findIndex:h.findKey)(n,e,t);if(void 0!==r&&-1!==r)return n[r]},h.filter=h.select=function(n,r,e){var i=[];return r=y(r,e),h.each(n,function(n,e,t){r(n,e,t)&&i.push(n)}),i},h.reject=function(n,e,t){return h.filter(n,h.negate(y(e)),t)},h.every=h.all=function(n,e,t){e=y(e,t);for(var r=!x(n)&&h.keys(n),i=(r||n).length,u=0;u<i;u++){var o=r?r[u]:u;if(!e(n[o],o,n))return!1}return!0},h.some=h.any=function(n,e,t){e=y(e,t);for(var r=!x(n)&&h.keys(n),i=(r||n).length,u=0;u<i;u++){var o=r?r[u]:u;if(e(n[o],o,n))return!0}return!1},h.contains=h.includes=h.include=function(n,e,t,r){return x(n)||(n=h.values(n)),"number"==typeof t&&!r||(t=0),0<=h.indexOf(n,e,t)},h.invoke=g(function(n,t,r){var i,u;return h.isFunction(t)?u=t:h.isArray(t)&&(i=t.slice(0,-1),t=t[t.length-1]),h.map(n,function(n){var e=u;if(!e){if(i&&i.length&&(n=b(n,i)),null==n)return;e=n[t]}return null==e?e:e.apply(n,r)})}),h.pluck=function(n,e){return h.map(n,h.property(e))},h.where=function(n,e){return h.filter(n,h.matcher(e))},h.findWhere=function(n,e){return h.find(n,h.matcher(e))},h.max=function(n,r,e){var t,i,u=-1/0,o=-1/0;if(null==r||"number"==typeof r&&"object"!=typeof n[0]&&null!=n)for(var c=0,a=(n=x(n)?n:h.values(n)).length;c<a;c++)null!=(t=n[c])&&u<t&&(u=t);else r=y(r,e),h.each(n,function(n,e,t){i=r(n,e,t),(o<i||i===-1/0&&u===-1/0)&&(u=n,o=i)});return u},h.min=function(n,r,e){var t,i,u=1/0,o=1/0;if(null==r||"number"==typeof r&&"object"!=typeof n[0]&&null!=n)for(var c=0,a=(n=x(n)?n:h.values(n)).length;c<a;c++)null!=(t=n[c])&&t<u&&(u=t);else r=y(r,e),h.each(n,function(n,e,t){((i=r(n,e,t))<o||i===1/0&&u===1/0)&&(u=n,o=i)});return u},h.shuffle=function(n){return h.sample(n,1/0)},h.sample=function(n,e,t){if(null==e||t)return x(n)||(n=h.values(n)),n[h.random(n.length-1)];var r=x(n)?h.clone(n):h.values(n),i=j(r);e=Math.max(Math.min(e,i),0);for(var u=i-1,o=0;o<e;o++){var c=h.random(o,u),a=r[o];r[o]=r[c],r[c]=a}return r.slice(0,e)},h.sortBy=function(n,r,e){var i=0;return r=y(r,e),h.pluck(h.map(n,function(n,e,t){return{value:n,index:i++,criteria:r(n,e,t)}}).sort(function(n,e){var t=n.criteria,r=e.criteria;if(t!==r){if(r<t||void 0===t)return 1;if(t<r||void 0===r)return-1}return n.index-e.index}),"value")};function w(o,e){return function(r,i,n){var u=e?[[],[]]:{};return i=y(i,n),h.each(r,function(n,e){var t=i(n,e,r);o(u,n,t)}),u}}h.groupBy=w(function(n,e,t){_(n,t)?n[t].push(e):n[t]=[e]}),h.indexBy=w(function(n,e,t){n[t]=e}),h.countBy=w(function(n,e,t){_(n,t)?n[t]++:n[t]=1});var A=/[^\ud800-\udfff]|[\ud800-\udbff][\udc00-\udfff]|[\ud800-\udfff]/g;h.toArray=function(n){return n?h.isArray(n)?a.call(n):h.isString(n)?n.match(A):x(n)?h.map(n,h.identity):h.values(n):[]},h.size=function(n){return null==n?0:x(n)?n.length:h.keys(n).length},h.partition=w(function(n,e,t){n[t?0:1].push(e)},!0),h.first=h.head=h.take=function(n,e,t){return null==n||n.length<1?null==e?void 0:[]:null==e||t?n[0]:h.initial(n,n.length-e)},h.initial=function(n,e,t){return a.call(n,0,Math.max(0,n.length-(null==e||t?1:e)))},h.last=function(n,e,t){return null==n||n.length<1?null==e?void 0:[]:null==e||t?n[n.length-1]:h.rest(n,Math.max(0,n.length-e))},h.rest=h.tail=h.drop=function(n,e,t){return a.call(n,null==e||t?1:e)},h.compact=function(n){return h.filter(n,Boolean)};var T=function(n,e,t,r){for(var i=(r=r||[]).length,u=0,o=j(n);u<o;u++){var c=n[u];if(x(c)&&(h.isArray(c)||h.isArguments(c)))if(e)for(var a=0,l=c.length;a<l;)r[i++]=c[a++];else T(c,e,t,r),i=r.length;else t||(r[i++]=c)}return r};h.flatten=function(n,e){return T(n,e,!1)},h.without=g(function(n,e){return h.difference(n,e)}),h.uniq=h.unique=function(n,e,t,r){h.isBoolean(e)||(r=t,t=e,e=!1),null!=t&&(t=y(t,r));for(var i=[],u=[],o=0,c=j(n);o<c;o++){var a=n[o],l=t?t(a,o,n):a;e&&!t?(o&&u===l||i.push(a),u=l):t?h.contains(u,l)||(u.push(l),i.push(a)):h.contains(i,a)||i.push(a)}return i},h.union=g(function(n){return h.uniq(T(n,!0,!0))}),h.intersection=function(n){for(var e=[],t=arguments.length,r=0,i=j(n);r<i;r++){var u=n[r];if(!h.contains(e,u)){var o;for(o=1;o<t&&h.contains(arguments[o],u);o++);o===t&&e.push(u)}}return e},h.difference=g(function(n,e){return e=T(e,!0,!0),h.filter(n,function(n){return!h.contains(e,n)})}),h.unzip=function(n){for(var e=n&&h.max(n,j).length||0,t=Array(e),r=0;r<e;r++)t[r]=h.pluck(n,r);return t},h.zip=g(h.unzip),h.object=function(n,e){for(var t={},r=0,i=j(n);r<i;r++)e?t[n[r]]=e[r]:t[n[r][0]]=n[r][1];return t};function O(u){return function(n,e,t){e=y(e,t);for(var r=j(n),i=0<u?0:r-1;0<=i&&i<r;i+=u)if(e(n[i],i,n))return i;return-1}}h.findIndex=O(1),h.findLastIndex=O(-1),h.sortedIndex=function(n,e,t,r){for(var i=(t=y(t,r,1))(e),u=0,o=j(n);u<o;){var c=Math.floor((u+o)/2);t(n[c])<i?u=c+1:o=c}return u};function C(u,o,c){return function(n,e,t){var r=0,i=j(n);if("number"==typeof t)0<u?r=0<=t?t:Math.max(t+i,r):i=0<=t?Math.min(t+1,i):t+i+1;else if(c&&t&&i)return n[t=c(n,e)]===e?t:-1;if(e!=e)return 0<=(t=o(a.call(n,r,i),h.isNaN))?t+r:-1;for(t=0<u?r:i-1;0<=t&&t<i;t+=u)if(n[t]===e)return t;return-1}}h.indexOf=C(1,h.findIndex,h.sortedIndex),h.lastIndexOf=C(-1,h.findLastIndex),h.range=function(n,e,t){null==e&&(e=n||0,n=0),t=t||(e<n?-1:1);for(var r=Math.max(Math.ceil((e-n)/t),0),i=Array(r),u=0;u<r;u++,n+=t)i[u]=n;return i},h.chunk=function(n,e){if(null==e||e<1)return[];for(var t=[],r=0,i=n.length;r<i;)t.push(a.call(n,r,r+=e));return t};function I(n,e,t,r,i){if(!(r instanceof e))return n.apply(t,i);var u=S(n.prototype),o=n.apply(u,i);return h.isObject(o)?o:u}h.bind=g(function(e,t,r){if(!h.isFunction(e))throw new TypeError("Bind must be called on a function");var i=g(function(n){return I(e,i,t,this,r.concat(n))});return i}),h.partial=g(function(i,u){var o=h.partial.placeholder,c=function(){for(var n=0,e=u.length,t=Array(e),r=0;r<e;r++)t[r]=u[r]===o?arguments[n++]:u[r];for(;n<arguments.length;)t.push(arguments[n++]);return I(i,c,this,this,t)};return c}),(h.partial.placeholder=h).bindAll=g(function(n,e){var t=(e=T(e,!1,!1)).length;if(t<1)throw new Error("bindAll must be passed function names");for(;t--;){var r=e[t];n[r]=h.bind(n[r],n)}}),h.memoize=function(r,i){var u=function(n){var e=u.cache,t=""+(i?i.apply(this,arguments):n);return _(e,t)||(e[t]=r.apply(this,arguments)),e[t]};return u.cache={},u},h.delay=g(function(n,e,t){return setTimeout(function(){return n.apply(null,t)},e)}),h.defer=h.partial(h.delay,h,1),h.throttle=function(t,r,i){var u,o,c,a,l=0;i=i||{};function s(){l=!1===i.leading?0:h.now(),u=null,a=t.apply(o,c),u||(o=c=null)}function n(){var n=h.now();l||!1!==i.leading||(l=n);var e=r-(n-l);return o=this,c=arguments,e<=0||r<e?(u&&(clearTimeout(u),u=null),l=n,a=t.apply(o,c),u||(o=c=null)):u||!1===i.trailing||(u=setTimeout(s,e)),a}return n.cancel=function(){clearTimeout(u),l=0,u=o=c=null},n},h.debounce=function(t,r,i){function u(n,e){o=null,e&&(c=t.apply(n,e))}var o,c,n=g(function(n){if(o&&clearTimeout(o),i){var e=!o;o=setTimeout(u,r),e&&(c=t.apply(this,n))}else o=h.delay(u,r,this,n);return c});return n.cancel=function(){clearTimeout(o),o=null},n},h.wrap=function(n,e){return h.partial(e,n)},h.negate=function(n){return function(){return!n.apply(this,arguments)}},h.compose=function(){var t=arguments,r=t.length-1;return function(){for(var n=r,e=t[r].apply(this,arguments);n--;)e=t[n].call(this,e);return e}},h.after=function(n,e){return function(){if(--n<1)return e.apply(this,arguments)}},h.before=function(n,e){var t;return function(){return 0<--n&&(t=e.apply(this,arguments)),n<=1&&(e=null),t}},h.once=h.partial(h.before,2),h.restArguments=g;function F(n,e){var t=M.length,r=n.constructor,i=h.isFunction(r)&&r.prototype||o,u="constructor";for(_(n,u)&&!h.contains(e,u)&&e.push(u);t--;)(u=M[t])in n&&n[u]!==i[u]&&!h.contains(e,u)&&e.push(u)}var q=!{toString:null}.propertyIsEnumerable("toString"),M=["valueOf","isPrototypeOf","toString","propertyIsEnumerable","hasOwnProperty","toLocaleString"];h.keys=function(n){if(!h.isObject(n))return[];if(l)return l(n);var e=[];for(var t in n)_(n,t)&&e.push(t);return q&&F(n,e),e},h.allKeys=function(n){if(!h.isObject(n))return[];var e=[];for(var t in n)e.push(t);return q&&F(n,e),e},h.values=function(n){for(var e=h.keys(n),t=e.length,r=Array(t),i=0;i<t;i++)r[i]=n[e[i]];return r},h.mapObject=function(n,e,t){e=y(e,t);for(var r=h.keys(n),i=r.length,u={},o=0;o<i;o++){var c=r[o];u[c]=e(n[c],c,n)}return u},h.pairs=function(n){for(var e=h.keys(n),t=e.length,r=Array(t),i=0;i<t;i++)r[i]=[e[i],n[e[i]]];return r},h.invert=function(n){for(var e={},t=h.keys(n),r=0,i=t.length;r<i;r++)e[n[t[r]]]=t[r];return e},h.functions=h.methods=function(n){var e=[];for(var t in n)h.isFunction(n[t])&&e.push(t);return e.sort()};function N(a,l){return function(n){var e=arguments.length;if(l&&(n=Object(n)),e<2||null==n)return n;for(var t=1;t<e;t++)for(var r=arguments[t],i=a(r),u=i.length,o=0;o<u;o++){var c=i[o];l&&void 0!==n[c]||(n[c]=r[c])}return n}}h.extend=N(h.allKeys),h.extendOwn=h.assign=N(h.keys),h.findKey=function(n,e,t){e=y(e,t);for(var r,i=h.keys(n),u=0,o=i.length;u<o;u++)if(e(n[r=i[u]],r,n))return r};function R(n,e,t){return e in t}var Q,L;h.pick=g(function(n,e){var t={},r=e[0];if(null==n)return t;h.isFunction(r)?(1<e.length&&(r=d(r,e[1])),e=h.allKeys(n)):(r=R,e=T(e,!1,!1),n=Object(n));for(var i=0,u=e.length;i<u;i++){var o=e[i],c=n[o];r(c,o,n)&&(t[o]=c)}return t}),h.omit=g(function(n,t){var e,r=t[0];return h.isFunction(r)?(r=h.negate(r),1<t.length&&(e=t[1])):(t=h.map(T(t,!1,!1),String),r=function(n,e){return!h.contains(t,e)}),h.pick(n,r,e)}),h.defaults=N(h.allKeys,!0),h.create=function(n,e){var t=S(n);return e&&h.extendOwn(t,e),t},h.clone=function(n){return h.isObject(n)?h.isArray(n)?n.slice():h.extend({},n):n},h.tap=function(n,e){return e(n),n},h.isMatch=function(n,e){var t=h.keys(e),r=t.length;if(null==n)return!r;for(var i=Object(n),u=0;u<r;u++){var o=t[u];if(e[o]!==i[o]||!(o in i))return!1}return!0},Q=function(n,e,t,r){if(n===e)return 0!==n||1/n==1/e;if(null==n||null==e)return!1;if(n!=n)return e!=e;var i=typeof n;return("function"==i||"object"==i||"object"==typeof e)&&L(n,e,t,r)},L=function(n,e,t,r){n instanceof h&&(n=n._wrapped),e instanceof h&&(e=e._wrapped);var i=p.call(n);if(i!==p.call(e))return!1;switch(i){case"[object RegExp]":case"[object String]":return""+n==""+e;case"[object Number]":return+n!=+n?+e!=+e:0==+n?1/+n==1/e:+n==+e;case"[object Date]":case"[object Boolean]":return+n==+e;case"[object Symbol]":return f.valueOf.call(n)===f.valueOf.call(e)}var u="[object Array]"===i;if(!u){if("object"!=typeof n||"object"!=typeof e)return!1;var o=n.constructor,c=e.constructor;if(o!==c&&!(h.isFunction(o)&&o instanceof o&&h.isFunction(c)&&c instanceof c)&&"constructor"in n&&"constructor"in e)return!1}r=r||[];for(var a=(t=t||[]).length;a--;)if(t[a]===n)return r[a]===e;if(t.push(n),r.push(e),u){if((a=n.length)!==e.length)return!1;for(;a--;)if(!Q(n[a],e[a],t,r))return!1}else{var l,s=h.keys(n);if(a=s.length,h.keys(e).length!==a)return!1;for(;a--;)if(l=s[a],!_(e,l)||!Q(n[l],e[l],t,r))return!1}return t.pop(),r.pop(),!0},h.isEqual=function(n,e){return Q(n,e)},h.isEmpty=function(n){return null==n||(x(n)&&(h.isArray(n)||h.isString(n)||h.isArguments(n))?0===n.length:0===h.keys(n).length)},h.isElement=function(n){return!(!n||1!==n.nodeType)},h.isArray=c||function(n){return"[object Array]"===p.call(n)},h.isObject=function(n){var e=typeof n;return"function"==e||"object"==e&&!!n},h.each(["Arguments","Function","String","Number","Date","RegExp","Error","Symbol","Map","WeakMap","Set","WeakSet"],function(e){h["is"+e]=function(n){return p.call(n)==="[object "+e+"]"}}),h.isArguments(arguments)||(h.isArguments=function(n){return _(n,"callee")});var U=n.document&&n.document.childNodes;"function"!=typeof/./&&"object"!=typeof Int8Array&&"function"!=typeof U&&(h.isFunction=function(n){return"function"==typeof n||!1}),h.isFinite=function(n){return!h.isSymbol(n)&&isFinite(n)&&!isNaN(parseFloat(n))},h.isNaN=function(n){return h.isNumber(n)&&isNaN(n)},h.isBoolean=function(n){return!0===n||!1===n||"[object Boolean]"===p.call(n)},h.isNull=function(n){return null===n},h.isUndefined=function(n){return void 0===n},h.has=function(n,e){if(!h.isArray(e))return _(n,e);for(var t=e.length,r=0;r<t;r++){var i=e[r];if(null==n||!u.call(n,i))return!1;n=n[i]}return!!t},h.noConflict=function(){return n._=e,this},h.identity=function(n){return n},h.constant=function(n){return function(){return n}},h.noop=function(){},h.property=function(e){return h.isArray(e)?function(n){return b(n,e)}:m(e)},h.propertyOf=function(e){return null==e?function(){}:function(n){return h.isArray(n)?b(e,n):e[n]}},h.matcher=h.matches=function(e){return e=h.extendOwn({},e),function(n){return h.isMatch(n,e)}},h.times=function(n,e,t){var r=Array(Math.max(0,n));e=d(e,t,1);for(var i=0;i<n;i++)r[i]=e(i);return r},h.random=function(n,e){return null==e&&(e=n,n=0),n+Math.floor(Math.random()*(e-n+1))},h.now=Date.now||function(){return(new Date).getTime()};function D(e){function t(n){return e[n]}var n="(?:"+h.keys(e).join("|")+")",r=RegExp(n),i=RegExp(n,"g");return function(n){return n=null==n?"":""+n,r.test(n)?n.replace(i,t):n}}var P={"&":"&","<":"<",">":">",'"':""","'":"'","`":"`"},W=h.invert(P);h.escape=D(P),h.unescape=D(W),h.result=function(n,e,t){h.isArray(e)||(e=[e]);var r=e.length;if(!r)return h.isFunction(t)?t.call(n):t;for(var i=0;i<r;i++){var u=null==n?void 0:n[e[i]];void 0===u&&(u=t,i=r),n=h.isFunction(u)?u.call(n):u}return n};var B=0;h.uniqueId=function(n){var e=++B+"";return n?n+e:e},h.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};function Y(n){return"\\"+K[n]}var z=/(.)^/,K={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},G=/\\|'|\r|\n|\u2028|\u2029/g;h.template=function(u,n,e){!n&&e&&(n=e),n=h.defaults({},n,h.templateSettings);var t,r=RegExp([(n.escape||z).source,(n.interpolate||z).source,(n.evaluate||z).source].join("|")+"|$","g"),o=0,c="__p+='";u.replace(r,function(n,e,t,r,i){return c+=u.slice(o,i).replace(G,Y),o=i+n.length,e?c+="'+\n((__t=("+e+"))==null?'':_.escape(__t))+\n'":t?c+="'+\n((__t=("+t+"))==null?'':__t)+\n'":r&&(c+="';\n"+r+"\n__p+='"),n}),c+="';\n",n.variable||(c="with(obj||{}){\n"+c+"}\n"),c="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+c+"return __p;\n";try{t=new Function(n.variable||"obj","_",c)}catch(n){throw n.source=c,n}function i(n){return t.call(this,n,h)}var a=n.variable||"obj";return i.source="function("+a+"){\n"+c+"}",i},h.chain=function(n){var e=h(n);return e._chain=!0,e};function H(n,e){return n._chain?h(e).chain():e}h.mixin=function(t){return h.each(h.functions(t),function(n){var e=h[n]=t[n];h.prototype[n]=function(){var n=[this._wrapped];return i.apply(n,arguments),H(this,e.apply(h,n))}}),h},h.mixin(h),h.each(["pop","push","reverse","shift","sort","splice","unshift"],function(e){var t=r[e];h.prototype[e]=function(){var n=this._wrapped;return t.apply(n,arguments),"shift"!==e&&"splice"!==e||0!==n.length||delete n[0],H(this,n)}}),h.each(["concat","join","slice"],function(n){var e=r[n];h.prototype[n]=function(){return H(this,e.apply(this._wrapped,arguments))}}),h.prototype.value=function(){return this._wrapped},h.prototype.valueOf=h.prototype.toJSON=h.prototype.value,h.prototype.toString=function(){return String(this._wrapped)},"function"==typeof define&&define.amd&&define("underscore",[],function(){return h})}()}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}],3:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events");e.exports=function(n){var r=n.getComponent("MenuItem");return n.extend(r,{constructor:function(n,e){var t=e.source;if(!i.isObject(t))throw new Error('was not provided a "source" object, but rather: '+typeof t);e=i.extend({selectable:!0,label:t.label},e),r.call(this,n,e),this.source=t},handleClick:function(n){r.prototype.handleClick.call(this,n),this.player().trigger(u.QUALITY_REQUESTED,this.source)}})}},{"../events":5,underscore:2}],4:[function(n,e,t){"use strict";var i=n("underscore"),u=n("../events"),o=n("./QualityOption"),c="vjs-quality-changing";e.exports=function(n){var e,r=n.getComponent("MenuButton"),t=o(n);return e=n.extend(r,{constructor:function(t,n){r.call(this,t,n),t.on(u.QUALITY_REQUESTED,function(n,e){this.setSelectedSource(e),t.addClass(c),t.one("loadeddata",function(){t.removeClass(c)})}.bind(this)),t.on(u.PLAYER_SOURCES_CHANGED,function(){this.update()}.bind(this)),t.on(u.QUALITY_SELECTED,function(n,e){this.setSelectedSource(e)}.bind(this)),t.one("ready",function(){this.selectedSrc=t.src(),this.update()}.bind(this)),this.controlText("Open quality selector menu")},setSelectedSource:function(n){var e=n?n.src:void 0;this.selectedSrc!==e&&(this.selectedSrc=e,i.each(this.items,function(n){n.selected(n.source.src===e)}))},createItems:function(){var e=this.player(),n=e.currentSources();return n=n.filter(function(n){return null==n.hidequalityoption}),i.map(n,function(n){return new t(e,{source:n,selected:n.src===this.selectedSrc})}.bind(this))},buildWrapperCSSClass:function(){return"vjs-quality-selector "+r.prototype.buildWrapperCSSClass.call(this)}}),n.registerComponent("QualitySelector",e),e}},{"../events":5,"./QualityOption":3,underscore:2}],5:[function(n,e,t){"use strict";e.exports={QUALITY_REQUESTED:"qualityRequested",QUALITY_SELECTED:"qualitySelected",PLAYER_SOURCES_CHANGED:"playerSourcesChanged"}},{}],6:[function(n,e,t){"use strict";var c=n("underscore"),r=n("./events"),i=n("./components/QualitySelector"),u=n("./middleware/SourceInterceptor"),a=n("./util/SafeSeek");e.exports=function(n){n=n||window.videojs,i(n),u(n),n.hook("setup",function(o){o.on(r.QUALITY_REQUESTED,function(n,e){var t=o.currentSources(),r=o.currentTime(),i=o.playbackRate(),u=o.paused();c.each(t,function(n){n.selected=!1}),c.findWhere(t,{src:e.src}).selected=!0,o._qualitySelectorSafeSeek&&o._qualitySelectorSafeSeek.onQualitySelectionChange(),o.src(t),o.ready(function(){o._qualitySelectorSafeSeek&&!o._qualitySelectorSafeSeek.hasFinished()||(o._qualitySelectorSafeSeek=new a(o,r),o.playbackRate(i)),u||o.play()})})})},e.exports.EVENTS=r},{"./components/QualitySelector":4,"./events":5,"./middleware/SourceInterceptor":7,"./util/SafeSeek":9,underscore:2}],7:[function(n,e,t){"use strict";var u=n("underscore"),o=n("../events");e.exports=function(n){n.use("*",function(i){return{setSource:function(n,e){var t,r=i.currentSources();i._qualitySelectorSafeSeek&&i._qualitySelectorSafeSeek.onPlayerSourcesChange(),u.isEqual(r,i._qualitySelectorPreviousSources)||(i.trigger(o.PLAYER_SOURCES_CHANGED,r),i._qualitySelectorPreviousSources=r),t=u.find(r,function(n){return!0===n.selected||"true"===n.selected||"selected"===n.selected})||n,i.trigger(o.QUALITY_SELECTED,t),e(null,t)}}})}},{"../events":5,underscore:2}],8:[function(n,e,t){"use strict";n("./index")()},{"./index":6}],9:[function(n,e,t){"use strict";var r=n("class.extend");e.exports=r.extend({init:function(n,e){this._player=n,this._seekToTime=e,this._hasFinished=!1,this._keepThisInstanceWhenPlayerSourcesChange=!1,this._seekWhenSafe()},_seekWhenSafe:function(){this._player.readyState()<3?(this._seekFn=this._seek.bind(this),this._player.one("canplay",this._seekFn)):this._seek()},onPlayerSourcesChange:function(){this._keepThisInstanceWhenPlayerSourcesChange?this._keepThisInstanceWhenPlayerSourcesChange=!1:this.cancel()},onQualitySelectionChange:function(){this.hasFinished()||(this._keepThisInstanceWhenPlayerSourcesChange=!0)},_seek:function(){this._player.currentTime(this._seekToTime),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0},hasFinished:function(){return this._hasFinished},cancel:function(){this._player.off("canplay",this._seekFn),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0}})},{"class.extend":1}]},{},[8]); //# sourceMappingURL=silvermine-videojs-quality-selector.min.js.map
\ No newline at end of file diff --git a/assets/js/subscribe_widget.js b/assets/js/subscribe_widget.js index 216c36fe..7665a00b 100644 --- a/assets/js/subscribe_widget.js +++ b/assets/js/subscribe_widget.js @@ -1,7 +1,9 @@ -var subscribe_data = JSON.parse(document.getElementById('subscribe_data').innerHTML); +'use strict'; +var subscribe_data = JSON.parse(document.getElementById('subscribe_data').textContent); +var payload = 'csrf_token=' + subscribe_data.csrf_token; var subscribe_button = document.getElementById('subscribe'); -subscribe_button.parentNode['action'] = 'javascript:void(0)'; +subscribe_button.parentNode.action = 'javascript:void(0)'; if (subscribe_button.getAttribute('data-type') === 'subscribe') { subscribe_button.onclick = subscribe; @@ -9,82 +11,34 @@ if (subscribe_button.getAttribute('data-type') === 'subscribe') { subscribe_button.onclick = unsubscribe; } -function subscribe(retries = 5) { - if (retries <= 0) { - console.log('Failed to subscribe.'); - return; - } - - var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' + - '&c=' + subscribe_data.ucid; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - +function subscribe() { var fallback = subscribe_button.innerHTML; subscribe_button.onclick = unsubscribe; subscribe_button.innerHTML = '<b>' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>'; - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - subscribe_button.onclick = subscribe; - subscribe_button.innerHTML = fallback; - } - } - } - - xhr.onerror = function () { - console.log('Subscribing failed... ' + retries + '/5'); - setTimeout(function () { subscribe(retries - 1) }, 1000); - } - - xhr.ontimeout = function () { - console.log('Subscribing failed... ' + retries + '/5'); - subscribe(retries - 1); - } + var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' + + '&c=' + subscribe_data.ucid; - xhr.send('csrf_token=' + subscribe_data.csrf_token); + helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, { + onNon200: function (xhr) { + subscribe_button.onclick = subscribe; + subscribe_button.innerHTML = fallback; + } + }); } -function unsubscribe(retries = 5) { - if (retries <= 0) { - console.log('Failed to subscribe'); - return; - } - - var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' + - '&c=' + subscribe_data.ucid; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - +function unsubscribe() { var fallback = subscribe_button.innerHTML; subscribe_button.onclick = subscribe; subscribe_button.innerHTML = '<b>' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + '</b>'; - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - subscribe_button.onclick = unsubscribe; - subscribe_button.innerHTML = fallback; - } - } - } - - xhr.onerror = function () { - console.log('Unsubscribing failed... ' + retries + '/5'); - setTimeout(function () { unsubscribe(retries - 1) }, 1000); - } - - xhr.ontimeout = function () { - console.log('Unsubscribing failed... ' + retries + '/5'); - unsubscribe(retries - 1); - } + var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' + + '&c=' + subscribe_data.ucid; - xhr.send('csrf_token=' + subscribe_data.csrf_token); + helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, { + onNon200: function (xhr) { + subscribe_button.onclick = unsubscribe; + subscribe_button.innerHTML = fallback; + } + }); } diff --git a/assets/js/themes.js b/assets/js/themes.js index 543b849e..84a9f6d9 100644 --- a/assets/js/themes.js +++ b/assets/js/themes.js @@ -1,84 +1,46 @@ +'use strict'; var toggle_theme = document.getElementById('toggle_theme'); -toggle_theme.href = 'javascript:void(0);'; +toggle_theme.href = 'javascript:void(0)'; -toggle_theme.addEventListener('click', function () { - var dark_mode = document.body.classList.contains("light-theme"); - - var url = '/toggle_theme?redirect=false'; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - set_mode(dark_mode); - window.localStorage.setItem('dark_mode', dark_mode ? 'dark' : 'light'); +const STORAGE_KEY_THEME = 'dark_mode'; +const THEME_DARK = 'dark'; +const THEME_LIGHT = 'light'; - xhr.send(); -}); - -window.addEventListener('storage', function (e) { - if (e.key === 'dark_mode') { - update_mode(e.newValue); - } -}); - -window.addEventListener('DOMContentLoaded', function () { - window.localStorage.setItem('dark_mode', document.getElementById('dark_mode_pref').textContent); - // Update localStorage if dark mode preference changed on preferences page - update_mode(window.localStorage.dark_mode); +// TODO: theme state controlled by system +toggle_theme.addEventListener('click', function () { + const isDarkTheme = helpers.storage.get(STORAGE_KEY_THEME) === THEME_DARK; + const newTheme = isDarkTheme ? THEME_LIGHT : THEME_DARK; + setTheme(newTheme); + helpers.storage.set(STORAGE_KEY_THEME, newTheme); + helpers.xhr('GET', '/toggle_theme?redirect=false', {}, {}); }); - -var darkScheme = window.matchMedia('(prefers-color-scheme: dark)'); -var lightScheme = window.matchMedia('(prefers-color-scheme: light)'); - -darkScheme.addListener(scheme_switch); -lightScheme.addListener(scheme_switch); - -function scheme_switch (e) { - // ignore this method if we have a preference set - if (localStorage.getItem('dark_mode')) { - return; - } - if (e.matches) { - if (e.media.includes("dark")) { - set_mode(true); - } else if (e.media.includes("light")) { - set_mode(false); - } - } -} - -function set_mode (bool) { - if (bool) { - // dark - toggle_theme.children[0].setAttribute('class', 'icon ion-ios-sunny'); - document.body.classList.remove('no-theme'); - document.body.classList.remove('light-theme'); - document.body.classList.add('dark-theme'); +/** @param {THEME_DARK|THEME_LIGHT} theme */ +function setTheme(theme) { + // By default body element has .no-theme class that uses OS theme via CSS @media rules + // It rewrites using hard className below + if (theme === THEME_DARK) { + toggle_theme.children[0].className = 'icon ion-ios-sunny'; + document.body.className = 'dark-theme'; + } else if (theme === THEME_LIGHT) { + toggle_theme.children[0].className = 'icon ion-ios-moon'; + document.body.className = 'light-theme'; } else { - // light - toggle_theme.children[0].setAttribute('class', 'icon ion-ios-moon'); - document.body.classList.remove('no-theme'); - document.body.classList.remove('dark-theme'); - document.body.classList.add('light-theme'); + document.body.className = 'no-theme'; } } -function update_mode (mode) { - if (mode === 'true' /* for backwards compatibility */ || mode === 'dark') { - // If preference for dark mode indicated - set_mode(true); - } - else if (mode === 'false' /* for backwards compaibility */ || mode === 'light') { - // If preference for light mode indicated - set_mode(false); - } - else if (document.getElementById('dark_mode_pref').textContent === '' && window.matchMedia('(prefers-color-scheme: dark)').matches) { - // If no preference indicated here and no preference indicated on the preferences page (backend), but the browser tells us that the operating system has a dark theme - set_mode(true); - } - // else do nothing, falling back to the mode defined by the `dark_mode` preference on the preferences page (backend) -} - +// Handles theme change event caused by other tab +addEventListener('storage', function (e) { + if (e.key === STORAGE_KEY_THEME) + setTheme(helpers.storage.get(STORAGE_KEY_THEME)); +}); +// Set theme from preferences on page load +addEventListener('DOMContentLoaded', function () { + const prefTheme = document.getElementById('dark_mode_pref').textContent; + if (prefTheme) { + setTheme(prefTheme); + helpers.storage.set(STORAGE_KEY_THEME, prefTheme); + } +}); diff --git a/assets/js/video.min.js b/assets/js/video.min.js deleted file mode 100644 index 890a3c84..00000000 --- a/assets/js/video.min.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @license - * Video.js 7.10.2 <http://videojs.com/> - * Copyright Brightcove, Inc. <https://www.brightcove.com/> - * Available under Apache License Version 2.0 - * <https://github.com/videojs/video.js/blob/master/LICENSE> - * - * Includes vtt.js <https://github.com/mozilla/vtt.js> - * Available under Apache License Version 2.0 - * <https://github.com/mozilla/vtt.js/blob/master/LICENSE> - */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("global/window"),require("global/document")):"function"==typeof define&&define.amd?define(["global/window","global/document"],t):(e=e||self).videojs=t(e.window,e.document)}(this,function(T,d){"use strict";T=T&&Object.prototype.hasOwnProperty.call(T,"default")?T.default:T,d=d&&Object.prototype.hasOwnProperty.call(d,"default")?d.default:d;var h="7.10.2",l=[],e=function(o,u){return function(e,t,i){var n=u.levels[t],r=new RegExp("^("+n+")$");if("log"!==e&&i.unshift(e.toUpperCase()+":"),i.unshift(o+":"),l){l.push([].concat(i));var a=l.length-1e3;l.splice(0,0<a?a:0)}if(T.console){var s=T.console[e];s||"debug"!==e||(s=T.console.info||T.console.log),s&&n&&r.test(e)&&s[Array.isArray(i)?"apply":"call"](T.console,i)}}};var p=function t(i){function n(){for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];r("log",a,t)}var r,a="info";return r=e(i,n),n.createLogger=function(e){return t(i+": "+e)},n.levels={all:"debug|log|warn|error",off:"",debug:"debug|log|warn|error",info:"log|warn|error",warn:"warn|error",error:"error",DEFAULT:a},n.level=function(e){if("string"==typeof e){if(!n.levels.hasOwnProperty(e))throw new Error('"'+e+'" in not a valid log level');a=e}return a},(n.history=function(){return l?[].concat(l):[]}).filter=function(t){return(l||[]).filter(function(e){return new RegExp(".*"+t+".*").test(e[0])})},n.history.clear=function(){l&&(l.length=0)},n.history.disable=function(){null!==l&&(l.length=0,l=null)},n.history.enable=function(){null===l&&(l=[])},n.error=function(){for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];return r("error",a,t)},n.warn=function(){for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];return r("warn",a,t)},n.debug=function(){for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];return r("debug",a,t)},n}("VIDEOJS"),f=p.createLogger;function t(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}function i(e,t){return e(t={exports:{}},t.exports),t.exports}var m=i(function(e){function t(){return e.exports=t=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var i=arguments[t];for(var n in i)Object.prototype.hasOwnProperty.call(i,n)&&(e[n]=i[n])}return e},t.apply(this,arguments)}e.exports=t}),n=Object.prototype.toString,a=function(e){return s(e)?Object.keys(e):[]};function r(t,i){a(t).forEach(function(e){return i(t[e],e)})}function g(i){for(var e=arguments.length,t=new Array(1<e?e-1:0),n=1;n<e;n++)t[n-1]=arguments[n];return Object.assign?m.apply(void 0,[i].concat(t)):(t.forEach(function(e){e&&r(e,function(e,t){i[t]=e})}),i)}function s(e){return!!e&&"object"==typeof e}function o(e){return s(e)&&"[object Object]"===n.call(e)&&e.constructor===Object}function u(e,t){if(!e||!t)return"";if("function"!=typeof T.getComputedStyle)return"";var i=T.getComputedStyle(e);return i?i.getPropertyValue(t)||i[t]:""}function c(e){return"string"==typeof e&&Boolean(e.trim())}function v(e){if(0<=e.indexOf(" "))throw new Error("class has illegal whitespace characters")}function y(){return d===T.document}function _(e){return s(e)&&1===e.nodeType}function b(){try{return T.parent!==T.self}catch(e){return!0}}function S(n){return function(e,t){if(!c(e))return d[n](null);c(t)&&(t=d.querySelector(t));var i=_(t)?t:d;return i[n]&&i[n](e)}}function k(e,i,t,n){void 0===e&&(e="div"),void 0===i&&(i={}),void 0===t&&(t={});var r=d.createElement(e);return Object.getOwnPropertyNames(i).forEach(function(e){var t=i[e];-1!==e.indexOf("aria-")||"role"===e||"type"===e?(p.warn("Setting attributes in the second argument of createEl()\nhas been deprecated. Use the third argument instead.\ncreateEl(type, properties, attributes). Attempting to set "+e+" to "+t+"."),r.setAttribute(e,t)):"textContent"===e?C(r,t):r[e]!==t&&(r[e]=t)}),Object.getOwnPropertyNames(t).forEach(function(e){r.setAttribute(e,t[e])}),n&&H(r,n),r}function C(e,t){return"undefined"==typeof e.textContent?e.innerText=t:e.textContent=t,e}function E(e,t){t.firstChild?t.insertBefore(e,t.firstChild):t.appendChild(e)}function w(e,t){return v(t),e.classList?e.classList.contains(t):function(e){return new RegExp("(^|\\s)"+e+"($|\\s)")}(t).test(e.className)}function I(e,t){return e.classList?e.classList.add(t):w(e,t)||(e.className=(e.className+" "+t).trim()),e}function A(e,t){return e.classList?e.classList.remove(t):(v(t),e.className=e.className.split(/\s+/).filter(function(e){return e!==t}).join(" ")),e}function x(e,t,i){var n=w(e,t);if("function"==typeof i&&(i=i(e,t)),"boolean"!=typeof i&&(i=!n),i!==n)return i?I(e,t):A(e,t),e}function P(i,n){Object.getOwnPropertyNames(n).forEach(function(e){var t=n[e];null===t||"undefined"==typeof t||!1===t?i.removeAttribute(e):i.setAttribute(e,!0===t?"":t)})}function L(e){var t={},i=",autoplay,controls,playsinline,loop,muted,default,defaultMuted,";if(e&&e.attributes&&0<e.attributes.length)for(var n=e.attributes,r=n.length-1;0<=r;r--){var a=n[r].name,s=n[r].value;"boolean"!=typeof e[a]&&-1===i.indexOf(","+a+",")||(s=null!==s),t[a]=s}return t}function O(e,t){return e.getAttribute(t)}function D(e,t,i){e.setAttribute(t,i)}function M(e,t){e.removeAttribute(t)}function R(){d.body.focus(),d.onselectstart=function(){return!1}}function N(){d.onselectstart=function(){return!0}}function U(e){if(e&&e.getBoundingClientRect&&e.parentNode){var t=e.getBoundingClientRect(),i={};return["bottom","height","left","right","top","width"].forEach(function(e){void 0!==t[e]&&(i[e]=t[e])}),i.height||(i.height=parseFloat(u(e,"height"))),i.width||(i.width=parseFloat(u(e,"width"))),i}}function F(e){if(!e||e&&!e.offsetParent)return{left:0,top:0,width:0,height:0};for(var t=e.offsetWidth,i=e.offsetHeight,n=0,r=0;n+=e.offsetLeft,r+=e.offsetTop,e=e.offsetParent;);return{left:n,top:r,width:t,height:i}}function B(e,t){var i={},n=F(t.target),r=F(e),a=r.width,s=r.height,o=t.offsetY-(r.top-n.top),u=t.offsetX-(r.left-n.left);return t.changedTouches&&(u=t.changedTouches[0].pageX-r.left,o=t.changedTouches[0].pageY+r.top),i.y=1-Math.max(0,Math.min(1,o/s)),i.x=Math.max(0,Math.min(1,u/a)),i}function j(e){return s(e)&&3===e.nodeType}function V(e){for(;e.firstChild;)e.removeChild(e.firstChild);return e}function q(e){return"function"==typeof e&&(e=e()),(Array.isArray(e)?e:[e]).map(function(e){return"function"==typeof e&&(e=e()),_(e)||j(e)?e:"string"==typeof e&&/\S/.test(e)?d.createTextNode(e):void 0}).filter(function(e){return e})}function H(t,e){return q(e).forEach(function(e){return t.appendChild(e)}),t}function W(e,t){return H(V(e),t)}function z(e){return void 0===e.button&&void 0===e.buttons||(0===e.button&&void 0===e.buttons||("mouseup"===e.type&&0===e.button&&0===e.buttons||0===e.button&&1===e.buttons))}var G,X=S("querySelector"),K=S("querySelectorAll"),Y=Object.freeze({__proto__:null,isReal:y,isEl:_,isInFrame:b,createEl:k,textContent:C,prependTo:E,hasClass:w,addClass:I,removeClass:A,toggleClass:x,setAttributes:P,getAttributes:L,getAttribute:O,setAttribute:D,removeAttribute:M,blockTextSelection:R,unblockTextSelection:N,getBoundingClientRect:U,findPosition:F,getPointerPosition:B,isTextNode:j,emptyEl:V,normalizeContent:q,appendContent:H,insertContent:W,isSingleLeftClick:z,$:X,$$:K}),$=!1,Q=function(){if(y()&&!1!==G.options.autoSetup){var e=Array.prototype.slice.call(d.getElementsByTagName("video")),t=Array.prototype.slice.call(d.getElementsByTagName("audio")),i=Array.prototype.slice.call(d.getElementsByTagName("video-js")),n=e.concat(t,i);if(n&&0<n.length)for(var r=0,a=n.length;r<a;r++){var s=n[r];if(!s||!s.getAttribute){J(1);break}void 0===s.player&&null!==s.getAttribute("data-setup")&&G(s)}else $||J(1)}};function J(e,t){t&&(G=t),T.setTimeout(Q,e)}function Z(){$=!0,T.removeEventListener("load",Z)}y()&&("complete"===d.readyState?Z():T.addEventListener("load",Z));function ee(e){var t=d.createElement("style");return t.className=e,t}function te(e,t){e.styleSheet?e.styleSheet.cssText=t:e.textContent=t}var ie,ne=3;function re(){return ne++}T.WeakMap||(ie=function(){function e(){this.vdata="vdata"+Math.floor(T.performance&&T.performance.now()||Date.now()),this.data={}}var t=e.prototype;return t.set=function(e,t){var i=e[this.vdata]||re();return e[this.vdata]||(e[this.vdata]=i),this.data[i]=t,this},t.get=function(e){var t=e[this.vdata];if(t)return this.data[t];p("We have no data for this element",e)},t.has=function(e){return e[this.vdata]in this.data},t.delete=function(e){var t=e[this.vdata];t&&(delete this.data[t],delete e[this.vdata])},e}());var ae,se=T.WeakMap?new WeakMap:new ie;function oe(e,t){if(se.has(e)){var i=se.get(e);0===i.handlers[t].length&&(delete i.handlers[t],e.removeEventListener?e.removeEventListener(t,i.dispatcher,!1):e.detachEvent&&e.detachEvent("on"+t,i.dispatcher)),Object.getOwnPropertyNames(i.handlers).length<=0&&(delete i.handlers,delete i.dispatcher,delete i.disabled),0===Object.getOwnPropertyNames(i).length&&se.delete(e)}}function ue(t,i,e,n){e.forEach(function(e){t(i,e,n)})}function le(e){if(e.fixed_)return e;function t(){return!0}function i(){return!1}if(!e||!e.isPropagationStopped){var n=e||T.event;for(var r in e={},n)"layerX"!==r&&"layerY"!==r&&"keyLocation"!==r&&"webkitMovementX"!==r&&"webkitMovementY"!==r&&("returnValue"===r&&n.preventDefault||(e[r]=n[r]));if(e.target||(e.target=e.srcElement||d),e.relatedTarget||(e.relatedTarget=e.fromElement===e.target?e.toElement:e.fromElement),e.preventDefault=function(){n.preventDefault&&n.preventDefault(),e.returnValue=!1,n.returnValue=!1,e.defaultPrevented=!0},e.defaultPrevented=!1,e.stopPropagation=function(){n.stopPropagation&&n.stopPropagation(),e.cancelBubble=!0,n.cancelBubble=!0,e.isPropagationStopped=t},e.isPropagationStopped=i,e.stopImmediatePropagation=function(){n.stopImmediatePropagation&&n.stopImmediatePropagation(),e.isImmediatePropagationStopped=t,e.stopPropagation()},e.isImmediatePropagationStopped=i,null!==e.clientX&&void 0!==e.clientX){var a=d.documentElement,s=d.body;e.pageX=e.clientX+(a&&a.scrollLeft||s&&s.scrollLeft||0)-(a&&a.clientLeft||s&&s.clientLeft||0),e.pageY=e.clientY+(a&&a.scrollTop||s&&s.scrollTop||0)-(a&&a.clientTop||s&&s.clientTop||0)}e.which=e.charCode||e.keyCode,null!==e.button&&void 0!==e.button&&(e.button=1&e.button?0:4&e.button?1:2&e.button?2:0)}return e.fixed_=!0,e}var ce=function(){if("boolean"!=typeof ae){ae=!1;try{var e=Object.defineProperty({},"passive",{get:function(){ae=!0}});T.addEventListener("test",null,e),T.removeEventListener("test",null,e)}catch(e){}}return ae},de=["touchstart","touchmove"];function he(s,e,t){if(Array.isArray(e))return ue(he,s,e,t);se.has(s)||se.set(s,{});var o=se.get(s);if(o.handlers||(o.handlers={}),o.handlers[e]||(o.handlers[e]=[]),t.guid||(t.guid=re()),o.handlers[e].push(t),o.dispatcher||(o.disabled=!1,o.dispatcher=function(e,t){if(!o.disabled){e=le(e);var i=o.handlers[e.type];if(i)for(var n=i.slice(0),r=0,a=n.length;r<a&&!e.isImmediatePropagationStopped();r++)try{n[r].call(s,e,t)}catch(e){p.error(e)}}}),1===o.handlers[e].length)if(s.addEventListener){var i=!1;ce()&&-1<de.indexOf(e)&&(i={passive:!0}),s.addEventListener(e,o.dispatcher,i)}else s.attachEvent&&s.attachEvent("on"+e,o.dispatcher)}function pe(e,t,i){if(se.has(e)){var n=se.get(e);if(n.handlers){if(Array.isArray(t))return ue(pe,e,t,i);var r=function(e,t){n.handlers[t]=[],oe(e,t)};if(void 0!==t){var a=n.handlers[t];if(a)if(i){if(i.guid)for(var s=0;s<a.length;s++)a[s].guid===i.guid&&a.splice(s--,1);oe(e,t)}else r(e,t)}else for(var o in n.handlers)Object.prototype.hasOwnProperty.call(n.handlers||{},o)&&r(e,o)}}}function fe(e,t,i){var n=se.has(e)?se.get(e):{},r=e.parentNode||e.ownerDocument;if("string"==typeof t?t={type:t,target:e}:t.target||(t.target=e),t=le(t),n.dispatcher&&n.dispatcher.call(e,t,i),r&&!t.isPropagationStopped()&&!0===t.bubbles)fe.call(null,r,t,i);else if(!r&&!t.defaultPrevented&&t.target&&t.target[t.type]){se.has(t.target)||se.set(t.target,{});var a=se.get(t.target);t.target[t.type]&&(a.disabled=!0,"function"==typeof t.target[t.type]&&t.target[t.type](),a.disabled=!1)}return!t.defaultPrevented}function me(e,t,i){if(Array.isArray(t))return ue(me,e,t,i);function n(){pe(e,t,n),i.apply(this,arguments)}n.guid=i.guid=i.guid||re(),he(e,t,n)}function ge(e,t,i){function n(){pe(e,t,n),i.apply(this,arguments)}n.guid=i.guid=i.guid||re(),he(e,t,n)}function ve(e,t,i){t.guid||(t.guid=re());var n=t.bind(e);return n.guid=i?i+"_"+t.guid:t.guid,n}function ye(t,i){var n=T.performance.now();return function(){var e=T.performance.now();i<=e-n&&(t.apply(void 0,arguments),n=e)}}function _e(){}var be,Te=Object.freeze({__proto__:null,fixEvent:le,on:he,off:pe,trigger:fe,one:me,any:ge});_e.prototype.allowedEvents_={},_e.prototype.addEventListener=_e.prototype.on=function(e,t){var i=this.addEventListener;this.addEventListener=function(){},he(this,e,t),this.addEventListener=i},_e.prototype.removeEventListener=_e.prototype.off=function(e,t){pe(this,e,t)},_e.prototype.one=function(e,t){var i=this.addEventListener;this.addEventListener=function(){},me(this,e,t),this.addEventListener=i},_e.prototype.any=function(e,t){var i=this.addEventListener;this.addEventListener=function(){},ge(this,e,t),this.addEventListener=i},_e.prototype.dispatchEvent=_e.prototype.trigger=function(e){var t=e.type||e;"string"==typeof e&&(e={type:t}),e=le(e),this.allowedEvents_[t]&&this["on"+t]&&this["on"+t](e),fe(this,e)},_e.prototype.queueTrigger=function(e){var t=this;be=be||new Map;var i=e.type||e,n=be.get(this);n||(n=new Map,be.set(this,n));var r=n.get(i);n.delete(i),T.clearTimeout(r);var a=T.setTimeout(function(){0===n.size&&(n=null,be.delete(t)),t.trigger(e)},0);n.set(i,a)};function Se(e){return"string"==typeof e&&/\S/.test(e)||Array.isArray(e)&&!!e.length}function ke(e){if(!e.nodeName&&!Ae(e))throw new Error("Invalid target; must be a DOM node or evented object.")}function Ce(e){if(!Se(e))throw new Error("Invalid event type; must be a non-empty string or array.")}function Ee(e){if("function"!=typeof e)throw new Error("Invalid listener; must be a function.")}function we(e,t){var i,n,r,a=t.length<3||t[0]===e||t[0]===e.eventBusEl_;return r=a?(i=e.eventBusEl_,3<=t.length&&t.shift(),n=t[0],t[1]):(i=t[0],n=t[1],t[2]),ke(i),Ce(n),Ee(r),{isTargetingSelf:a,target:i,type:n,listener:r=ve(e,r)}}function Ie(e,t,i,n){ke(e),e.nodeName?Te[t](e,i,n):e[t](i,n)}var Ae=function(t){return t instanceof _e||!!t.eventBusEl_&&["on","one","off","trigger"].every(function(e){return"function"==typeof t[e]})},xe={on:function(){for(var e=this,t=arguments.length,i=new Array(t),n=0;n<t;n++)i[n]=arguments[n];var r=we(this,i),a=r.isTargetingSelf,s=r.target,o=r.type,u=r.listener;if(Ie(s,"on",o,u),!a){var l=function(){return e.off(s,o,u)};l.guid=u.guid;var c=function(){return e.off("dispose",l)};c.guid=u.guid,Ie(this,"on","dispose",l),Ie(s,"on","dispose",c)}},one:function(){for(var r=this,e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];var n=we(this,t),a=n.isTargetingSelf,s=n.target,o=n.type,u=n.listener;if(a)Ie(s,"one",o,u);else{var l=function e(){r.off(s,o,e);for(var t=arguments.length,i=new Array(t),n=0;n<t;n++)i[n]=arguments[n];u.apply(null,i)};l.guid=u.guid,Ie(s,"one",o,l)}},any:function(){for(var r=this,e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];var n=we(this,t),a=n.isTargetingSelf,s=n.target,o=n.type,u=n.listener;if(a)Ie(s,"any",o,u);else{var l=function e(){r.off(s,o,e);for(var t=arguments.length,i=new Array(t),n=0;n<t;n++)i[n]=arguments[n];u.apply(null,i)};l.guid=u.guid,Ie(s,"any",o,l)}},off:function(e,t,i){if(!e||Se(e))pe(this.eventBusEl_,e,t);else{var n=e,r=t;ke(n),Ce(r),Ee(i),i=ve(this,i),this.off("dispose",i),n.nodeName?(pe(n,r,i),pe(n,"dispose",i)):Ae(n)&&(n.off(r,i),n.off("dispose",i))}},trigger:function(e,t){return fe(this.eventBusEl_,e,t)}};function Pe(e,t){void 0===t&&(t={});var i=t.eventBusKey;if(i){if(!e[i].nodeName)throw new Error('The eventBusKey "'+i+'" does not refer to an element.');e.eventBusEl_=e[i]}else e.eventBusEl_=k("span",{className:"vjs-event-bus"});return g(e,xe),e.eventedCallbacks&&e.eventedCallbacks.forEach(function(e){e()}),e.on("dispose",function(){e.off(),T.setTimeout(function(){e.eventBusEl_=null},0)}),e}var Le={state:{},setState:function(e){var i,n=this;return"function"==typeof e&&(e=e()),r(e,function(e,t){n.state[t]!==e&&((i=i||{})[t]={from:n.state[t],to:e}),n.state[t]=e}),i&&Ae(this)&&this.trigger({changes:i,type:"statechanged"}),i}};function Oe(e,t){return g(e,Le),e.state=g({},e.state,t),"function"==typeof e.handleStateChanged&&Ae(e)&&e.on("statechanged",e.handleStateChanged),e}function De(e){return"string"!=typeof e?e:e.replace(/./,function(e){return e.toLowerCase()})}function Me(e){return"string"!=typeof e?e:e.replace(/./,function(e){return e.toUpperCase()})}function Re(){for(var i={},e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];return t.forEach(function(e){e&&r(e,function(e,t){o(e)?(o(i[t])||(i[t]={}),i[t]=Re(i[t],e)):i[t]=e})}),i}var Ne=function(){function e(){this.map_={}}var t=e.prototype;return t.has=function(e){return e in this.map_},t.delete=function(e){var t=this.has(e);return delete this.map_[e],t},t.set=function(e,t){return this.set_[e]=t,this},t.forEach=function(e,t){for(var i in this.map_)e.call(t,this.map_[i],i,this)},e}(),Ue=T.Map?T.Map:Ne,Fe=function(){function e(){this.set_={}}var t=e.prototype;return t.has=function(e){return e in this.set_},t.delete=function(e){var t=this.has(e);return delete this.set_[e],t},t.add=function(e){return this.set_[e]=1,this},t.forEach=function(e,t){for(var i in this.set_)e.call(t,i,i,this)},e}(),Be=T.Set?T.Set:Fe,je=function(){function l(e,t,i){if(!e&&this.play?this.player_=e=this:this.player_=e,this.isDisposed_=!1,this.parentComponent_=null,this.options_=Re({},this.options_),t=this.options_=Re(this.options_,t),this.id_=t.id||t.el&&t.el.id,!this.id_){var n=e&&e.id&&e.id()||"no_player";this.id_=n+"_component_"+re()}this.name_=t.name||null,t.el?this.el_=t.el:!1!==t.createEl&&(this.el_=this.createEl()),!1!==t.evented&&Pe(this,{eventBusKey:this.el_?"el_":null}),Oe(this,this.constructor.defaultState),this.children_=[],this.childIndex_={},this.childNameIndex_={},this.setTimeoutIds_=new Be,this.setIntervalIds_=new Be,this.rafIds_=new Be,this.namedRafs_=new Ue,(this.clearingTimersOnDispose_=!1)!==t.initChildren&&this.initChildren(),this.ready(i),!1!==t.reportTouchActivity&&this.enableTouchActivity()}var e=l.prototype;return e.dispose=function(){if(!this.isDisposed_){if(this.trigger({type:"dispose",bubbles:!1}),this.isDisposed_=!0,this.children_)for(var e=this.children_.length-1;0<=e;e--)this.children_[e].dispose&&this.children_[e].dispose();this.children_=null,this.childIndex_=null,this.childNameIndex_=null,this.parentComponent_=null,this.el_&&(this.el_.parentNode&&this.el_.parentNode.removeChild(this.el_),se.has(this.el_)&&se.delete(this.el_),this.el_=null),this.player_=null}},e.isDisposed=function(){return Boolean(this.isDisposed_)},e.player=function(){return this.player_},e.options=function(e){return e&&(this.options_=Re(this.options_,e)),this.options_},e.el=function(){return this.el_},e.createEl=function(e,t,i){return k(e,t,i)},e.localize=function(e,r,t){void 0===t&&(t=e);var i=this.player_.language&&this.player_.language(),n=this.player_.languages&&this.player_.languages(),a=n&&n[i],s=i&&i.split("-")[0],o=n&&n[s],u=t;return a&&a[e]?u=a[e]:o&&o[e]&&(u=o[e]),r&&(u=u.replace(/\{(\d+)\}/g,function(e,t){var i=r[t-1],n=i;return"undefined"==typeof i&&(n=e),n})),u},e.contentEl=function(){return this.contentEl_||this.el_},e.id=function(){return this.id_},e.name=function(){return this.name_},e.children=function(){return this.children_},e.getChildById=function(e){return this.childIndex_[e]},e.getChild=function(e){if(e)return this.childNameIndex_[e]},e.getDescendant=function(){for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];t=t.reduce(function(e,t){return e.concat(t)},[]);for(var n=this,r=0;r<t.length;r++)if(!(n=n.getChild(t[r]))||!n.getChild)return;return n},e.addChild=function(e,t,i){var n,r;if(void 0===t&&(t={}),void 0===i&&(i=this.children_.length),"string"==typeof e){r=Me(e);var a=t.componentClass||r;t.name=r;var s=l.getComponent(a);if(!s)throw new Error("Component "+a+" does not exist");if("function"!=typeof s)return null;n=new s(this.player_||this,t)}else n=e;if(n.parentComponent_&&n.parentComponent_.removeChild(n),this.children_.splice(i,0,n),n.parentComponent_=this,"function"==typeof n.id&&(this.childIndex_[n.id()]=n),(r=r||n.name&&Me(n.name()))&&(this.childNameIndex_[r]=n,this.childNameIndex_[De(r)]=n),"function"==typeof n.el&&n.el()){var o=null;this.children_[i+1]&&(this.children_[i+1].el_?o=this.children_[i+1].el_:_(this.children_[i+1])&&(o=this.children_[i+1])),this.contentEl().insertBefore(n.el(),o)}return n},e.removeChild=function(e){if("string"==typeof e&&(e=this.getChild(e)),e&&this.children_){for(var t=!1,i=this.children_.length-1;0<=i;i--)if(this.children_[i]===e){t=!0,this.children_.splice(i,1);break}if(t){e.parentComponent_=null,this.childIndex_[e.id()]=null,this.childNameIndex_[Me(e.name())]=null,this.childNameIndex_[De(e.name())]=null;var n=e.el();n&&n.parentNode===this.contentEl()&&this.contentEl().removeChild(e.el())}}},e.initChildren=function(){var r=this,n=this.options_.children;if(n){var e,a=this.options_,i=l.getComponent("Tech");(e=Array.isArray(n)?n:Object.keys(n)).concat(Object.keys(this.options_).filter(function(t){return!e.some(function(e){return"string"==typeof e?t===e:t===e.name})})).map(function(e){var t,i;return i="string"==typeof e?n[t=e]||r.options_[t]||{}:(t=e.name,e),{name:t,opts:i}}).filter(function(e){var t=l.getComponent(e.opts.componentClass||Me(e.name));return t&&!i.isTech(t)}).forEach(function(e){var t=e.name,i=e.opts;if(void 0!==a[t]&&(i=a[t]),!1!==i){!0===i&&(i={}),i.playerOptions=r.options_.playerOptions;var n=r.addChild(t,i);n&&(r[t]=n)}})}},e.buildCSSClass=function(){return""},e.ready=function(e,t){if(void 0===t&&(t=!1),e)return this.isReady_?void(t?e.call(this):this.setTimeout(e,1)):(this.readyQueue_=this.readyQueue_||[],void this.readyQueue_.push(e))},e.triggerReady=function(){this.isReady_=!0,this.setTimeout(function(){var e=this.readyQueue_;this.readyQueue_=[],e&&0<e.length&&e.forEach(function(e){e.call(this)},this),this.trigger("ready")},1)},e.$=function(e,t){return X(e,t||this.contentEl())},e.$$=function(e,t){return K(e,t||this.contentEl())},e.hasClass=function(e){return w(this.el_,e)},e.addClass=function(e){I(this.el_,e)},e.removeClass=function(e){A(this.el_,e)},e.toggleClass=function(e,t){x(this.el_,e,t)},e.show=function(){this.removeClass("vjs-hidden")},e.hide=function(){this.addClass("vjs-hidden")},e.lockShowing=function(){this.addClass("vjs-lock-showing")},e.unlockShowing=function(){this.removeClass("vjs-lock-showing")},e.getAttribute=function(e){return O(this.el_,e)},e.setAttribute=function(e,t){D(this.el_,e,t)},e.removeAttribute=function(e){M(this.el_,e)},e.width=function(e,t){return this.dimension("width",e,t)},e.height=function(e,t){return this.dimension("height",e,t)},e.dimensions=function(e,t){this.width(e,!0),this.height(t)},e.dimension=function(e,t,i){if(void 0!==t)return null!==t&&t==t||(t=0),-1!==(""+t).indexOf("%")||-1!==(""+t).indexOf("px")?this.el_.style[e]=t:this.el_.style[e]="auto"===t?"":t+"px",void(i||this.trigger("componentresize"));if(!this.el_)return 0;var n=this.el_.style[e],r=n.indexOf("px");return-1!==r?parseInt(n.slice(0,r),10):parseInt(this.el_["offset"+Me(e)],10)},e.currentDimension=function(e){var t=0;if("width"!==e&&"height"!==e)throw new Error("currentDimension only accepts width or height value");if(t=u(this.el_,e),0===(t=parseFloat(t))||isNaN(t)){var i="offset"+Me(e);t=this.el_[i]}return t},e.currentDimensions=function(){return{width:this.currentDimension("width"),height:this.currentDimension("height")}},e.currentWidth=function(){return this.currentDimension("width")},e.currentHeight=function(){return this.currentDimension("height")},e.focus=function(){this.el_.focus()},e.blur=function(){this.el_.blur()},e.handleKeyDown=function(e){this.player_&&(e.stopPropagation(),this.player_.handleKeyDown(e))},e.handleKeyPress=function(e){this.handleKeyDown(e)},e.emitTapEvents=function(){var n,t=0,r=null;this.on("touchstart",function(e){1===e.touches.length&&(r={pageX:e.touches[0].pageX,pageY:e.touches[0].pageY},t=T.performance.now(),n=!0)}),this.on("touchmove",function(e){if(1<e.touches.length)n=!1;else if(r){var t=e.touches[0].pageX-r.pageX,i=e.touches[0].pageY-r.pageY;10<Math.sqrt(t*t+i*i)&&(n=!1)}});function e(){n=!1}this.on("touchleave",e),this.on("touchcancel",e),this.on("touchend",function(e){!(r=null)===n&&T.performance.now()-t<200&&(e.preventDefault(),this.trigger("tap"))})},e.enableTouchActivity=function(){if(this.player()&&this.player().reportUserActivity){var t,i=ve(this.player(),this.player().reportUserActivity);this.on("touchstart",function(){i(),this.clearInterval(t),t=this.setInterval(i,250)});var e=function(e){i(),this.clearInterval(t)};this.on("touchmove",i),this.on("touchend",e),this.on("touchcancel",e)}},e.setTimeout=function(e,t){var i,n=this;return e=ve(this,e),this.clearTimersOnDispose_(),i=T.setTimeout(function(){n.setTimeoutIds_.has(i)&&n.setTimeoutIds_.delete(i),e()},t),this.setTimeoutIds_.add(i),i},e.clearTimeout=function(e){return this.setTimeoutIds_.has(e)&&(this.setTimeoutIds_.delete(e),T.clearTimeout(e)),e},e.setInterval=function(e,t){e=ve(this,e),this.clearTimersOnDispose_();var i=T.setInterval(e,t);return this.setIntervalIds_.add(i),i},e.clearInterval=function(e){return this.setIntervalIds_.has(e)&&(this.setIntervalIds_.delete(e),T.clearInterval(e)),e},e.requestAnimationFrame=function(e){var t,i=this;return this.supportsRaf_?(this.clearTimersOnDispose_(),e=ve(this,e),t=T.requestAnimationFrame(function(){i.rafIds_.has(t)&&i.rafIds_.delete(t),e()}),this.rafIds_.add(t),t):this.setTimeout(e,1e3/60)},e.requestNamedAnimationFrame=function(e,t){var i=this;if(!this.namedRafs_.has(e)){this.clearTimersOnDispose_(),t=ve(this,t);var n=this.requestAnimationFrame(function(){t(),i.namedRafs_.has(e)&&i.namedRafs_.delete(e)});return this.namedRafs_.set(e,n),e}},e.cancelNamedAnimationFrame=function(e){this.namedRafs_.has(e)&&(this.cancelAnimationFrame(this.namedRafs_.get(e)),this.namedRafs_.delete(e))},e.cancelAnimationFrame=function(e){return this.supportsRaf_?(this.rafIds_.has(e)&&(this.rafIds_.delete(e),T.cancelAnimationFrame(e)),e):this.clearTimeout(e)},e.clearTimersOnDispose_=function(){var n=this;this.clearingTimersOnDispose_||(this.clearingTimersOnDispose_=!0,this.one("dispose",function(){[["namedRafs_","cancelNamedAnimationFrame"],["rafIds_","cancelAnimationFrame"],["setTimeoutIds_","clearTimeout"],["setIntervalIds_","clearInterval"]].forEach(function(e){var t=e[0],i=e[1];n[t].forEach(function(e,t){return n[i](t)})}),n.clearingTimersOnDispose_=!1}))},l.registerComponent=function(e,t){if("string"!=typeof e||!e)throw new Error('Illegal component name, "'+e+'"; must be a non-empty string.');var i,n=l.getComponent("Tech"),r=n&&n.isTech(t),a=l===t||l.prototype.isPrototypeOf(t.prototype);if(r||!a)throw i=r?"techs must be registered using Tech.registerTech()":"must be a Component subclass",new Error('Illegal component, "'+e+'"; '+i+".");e=Me(e),l.components_||(l.components_={});var s=l.getComponent("Player");if("Player"===e&&s&&s.players){var o=s.players,u=Object.keys(o);if(o&&0<u.length&&u.map(function(e){return o[e]}).every(Boolean))throw new Error("Can not register Player component after player has been created.")}return l.components_[e]=t,l.components_[De(e)]=t},l.getComponent=function(e){if(e&&l.components_)return l.components_[e]},l}();je.prototype.supportsRaf_="function"==typeof T.requestAnimationFrame&&"function"==typeof T.cancelAnimationFrame,je.registerComponent("Component",je);var Ve=function(e){if(void 0===e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return e};i(function(t){function i(e){return"function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?t.exports=i=function(e){return typeof e}:t.exports=i=function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},i(e)}t.exports=i}),i(function(t){function i(e){return t.exports=i=Object.setPrototypeOf?Object.getPrototypeOf:function(e){return e.__proto__||Object.getPrototypeOf(e)},i(e)}t.exports=i});var qe,He,We,ze,Ge=function(e,t){e.prototype=Object.create(t.prototype),(e.prototype.constructor=e).__proto__=t},Xe=T.navigator&&T.navigator.userAgent||"",Ke=/AppleWebKit\/([\d.]+)/i.exec(Xe),Ye=Ke?parseFloat(Ke.pop()):null,$e=/iPod/i.test(Xe),Qe=(qe=Xe.match(/OS (\d+)_/i))&&qe[1]?qe[1]:null,Je=/Android/i.test(Xe),Ze=function(){var e=Xe.match(/Android (\d+)(?:\.(\d+))?(?:\.(\d+))*/i);if(!e)return null;var t=e[1]&&parseFloat(e[1]),i=e[2]&&parseFloat(e[2]);return t&&i?parseFloat(e[1]+"."+e[2]):t||null}(),et=Je&&Ze<5&&Ye<537,tt=/Firefox/i.test(Xe),it=/Edg/i.test(Xe),nt=!it&&(/Chrome/i.test(Xe)||/CriOS/i.test(Xe)),rt=(He=Xe.match(/(Chrome|CriOS)\/(\d+)/))&&He[2]?parseFloat(He[2]):null,at=(We=/MSIE\s(\d+)\.\d/.exec(Xe),!(ze=We&&parseFloat(We[1]))&&/Trident\/7.0/i.test(Xe)&&/rv:11.0/.test(Xe)&&(ze=11),ze),st=/Safari/i.test(Xe)&&!nt&&!Je&&!it,ot=/Windows/i.test(Xe),ut=y()&&("ontouchstart"in T||T.navigator.maxTouchPoints||T.DocumentTouch&&T.document instanceof T.DocumentTouch),lt=/iPad/i.test(Xe)||st&&ut&&!/iPhone/i.test(Xe),ct=/iPhone/i.test(Xe)&&!lt,dt=ct||lt||$e,ht=(st||dt)&&!nt,pt=Object.freeze({__proto__:null,IS_IPOD:$e,IOS_VERSION:Qe,IS_ANDROID:Je,ANDROID_VERSION:Ze,IS_NATIVE_ANDROID:et,IS_FIREFOX:tt,IS_EDGE:it,IS_CHROME:nt,CHROME_VERSION:rt,IE_VERSION:at,IS_SAFARI:st,IS_WINDOWS:ot,TOUCH_ENABLED:ut,IS_IPAD:lt,IS_IPHONE:ct,IS_IOS:dt,IS_ANY_SAFARI:ht});function ft(e,t,i,n){return function(e,t,i){if("number"!=typeof t||t<0||i<t)throw new Error("Failed to execute '"+e+"' on 'TimeRanges': The index provided ("+t+") is non-numeric or out of bounds (0-"+i+").")}(e,n,i.length-1),i[n][t]}function mt(e){return void 0===e||0===e.length?{length:0,start:function(){throw new Error("This TimeRanges object is empty")},end:function(){throw new Error("This TimeRanges object is empty")}}:{length:e.length,start:ft.bind(null,"start",0,e),end:ft.bind(null,"end",1,e)}}function gt(e,t){return Array.isArray(e)?mt(e):void 0===e||void 0===t?mt():mt([[e,t]])}function vt(e,t){var i,n,r=0;if(!t)return 0;e&&e.length||(e=gt(0,0));for(var a=0;a<e.length;a++)i=e.start(a),t<(n=e.end(a))&&(n=t),r+=n-i;return r/t}for(var yt,_t={prefixed:!0},bt=[["requestFullscreen","exitFullscreen","fullscreenElement","fullscreenEnabled","fullscreenchange","fullscreenerror","fullscreen"],["webkitRequestFullscreen","webkitExitFullscreen","webkitFullscreenElement","webkitFullscreenEnabled","webkitfullscreenchange","webkitfullscreenerror","-webkit-full-screen"],["mozRequestFullScreen","mozCancelFullScreen","mozFullScreenElement","mozFullScreenEnabled","mozfullscreenchange","mozfullscreenerror","-moz-full-screen"],["msRequestFullscreen","msExitFullscreen","msFullscreenElement","msFullscreenEnabled","MSFullscreenChange","MSFullscreenError","-ms-fullscreen"]],Tt=bt[0],St=0;St<bt.length;St++)if(bt[St][1]in d){yt=bt[St];break}if(yt){for(var kt=0;kt<yt.length;kt++)_t[Tt[kt]]=yt[kt];_t.prefixed=yt[0]!==Tt[0]}function Ct(e){if(e instanceof Ct)return e;"number"==typeof e?this.code=e:"string"==typeof e?this.message=e:s(e)&&("number"==typeof e.code&&(this.code=e.code),g(this,e)),this.message||(this.message=Ct.defaultMessages[this.code]||"")}Ct.prototype.code=0,Ct.prototype.message="",Ct.prototype.status=null,Ct.errorTypes=["MEDIA_ERR_CUSTOM","MEDIA_ERR_ABORTED","MEDIA_ERR_NETWORK","MEDIA_ERR_DECODE","MEDIA_ERR_SRC_NOT_SUPPORTED","MEDIA_ERR_ENCRYPTED"],Ct.defaultMessages={1:"You aborted the media playback",2:"A network error caused the media download to fail part-way.",3:"The media playback was aborted due to a corruption problem or because the media used features your browser did not support.",4:"The media could not be loaded, either because the server or network failed or because the format is not supported.",5:"The media is encrypted and we do not have the keys to decrypt it."};for(var Et=0;Et<Ct.errorTypes.length;Et++)Ct[Ct.errorTypes[Et]]=Et,Ct.prototype[Ct.errorTypes[Et]]=Et;var wt=function(e,t){var i,n=null;try{i=JSON.parse(e,t)}catch(e){n=e}return[n,i]};function It(e){return null!=e&&"function"==typeof e.then}function At(e){It(e)&&e.then(null,function(e){})}function xt(n){return["kind","label","language","id","inBandMetadataTrackDispatchType","mode","src"].reduce(function(e,t,i){return n[t]&&(e[t]=n[t]),e},{cues:n.cues&&Array.prototype.map.call(n.cues,function(e){return{startTime:e.startTime,endTime:e.endTime,text:e.text,id:e.id}})})}var Pt=function(e){var t=e.$$("track"),i=Array.prototype.map.call(t,function(e){return e.track});return Array.prototype.map.call(t,function(e){var t=xt(e.track);return e.src&&(t.src=e.src),t}).concat(Array.prototype.filter.call(e.textTracks(),function(e){return-1===i.indexOf(e)}).map(xt))},Lt=function(e,i){return e.forEach(function(e){var t=i.addRemoteTextTrack(e).track;!e.src&&e.cues&&e.cues.forEach(function(e){return t.addCue(e)})}),i.textTracks()},Ot=i(function(e,t){function i(e){if(e&&"object"==typeof e){var t=e.which||e.keyCode||e.charCode;t&&(e=t)}if("number"==typeof e)return s[e];var i,n=String(e);return(i=r[n.toLowerCase()])?i:(i=a[n.toLowerCase()])||(1===n.length?n.charCodeAt(0):void 0)}i.isEventKey=function(e,t){if(e&&"object"==typeof e){var i=e.which||e.keyCode||e.charCode;if(null==i)return!1;if("string"==typeof t){var n;if(n=r[t.toLowerCase()])return n===i;if(n=a[t.toLowerCase()])return n===i}else if("number"==typeof t)return t===i;return!1}};var r=(t=e.exports=i).code=t.codes={backspace:8,tab:9,enter:13,shift:16,ctrl:17,alt:18,"pause/break":19,"caps lock":20,esc:27,space:32,"page up":33,"page down":34,end:35,home:36,left:37,up:38,right:39,down:40,insert:45,delete:46,command:91,"left command":91,"right command":93,"numpad *":106,"numpad +":107,"numpad -":109,"numpad .":110,"numpad /":111,"num lock":144,"scroll lock":145,"my computer":182,"my calculator":183,";":186,"=":187,",":188,"-":189,".":190,"/":191,"`":192,"[":219,"\\":220,"]":221,"'":222},a=t.aliases={windows:91,"⇧":16,"⌥":18,"⌃":17,"⌘":91,ctl:17,control:17,option:18,pause:19,break:19,caps:20,return:13,escape:27,spc:32,spacebar:32,pgup:33,pgdn:34,ins:45,del:46,cmd:91};for(n=97;n<123;n++)r[String.fromCharCode(n)]=n-32;for(var n=48;n<58;n++)r[n-48]=n;for(n=1;n<13;n++)r["f"+n]=n+111;for(n=0;n<10;n++)r["numpad "+n]=n+96;var s=t.names=t.title={};for(n in r)s[r[n]]=n;for(var o in a)r[o]=a[o]}),Dt=(Ot.code,Ot.codes,Ot.aliases,Ot.names,Ot.title,"vjs-modal-dialog"),Mt=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).opened_=i.hasBeenOpened_=i.hasBeenFilled_=!1,i.closeable(!i.options_.uncloseable),i.content(i.options_.content),i.contentEl_=k("div",{className:Dt+"-content"},{role:"document"}),i.descEl_=k("p",{className:Dt+"-description vjs-control-text",id:i.el().getAttribute("aria-describedby")}),C(i.descEl_,i.description()),i.el_.appendChild(i.descEl_),i.el_.appendChild(i.contentEl_),i}Ge(e,n);var t=e.prototype;return t.createEl=function(){return n.prototype.createEl.call(this,"div",{className:this.buildCSSClass(),tabIndex:-1},{"aria-describedby":this.id()+"_description","aria-hidden":"true","aria-label":this.label(),role:"dialog"})},t.dispose=function(){this.contentEl_=null,this.descEl_=null,this.previouslyActiveEl_=null,n.prototype.dispose.call(this)},t.buildCSSClass=function(){return Dt+" vjs-hidden "+n.prototype.buildCSSClass.call(this)},t.label=function(){return this.localize(this.options_.label||"Modal Window")},t.description=function(){var e=this.options_.description||this.localize("This is a modal window.");return this.closeable()&&(e+=" "+this.localize("This modal can be closed by pressing the Escape key or activating the close button.")),e},t.open=function(){if(!this.opened_){var e=this.player();this.trigger("beforemodalopen"),this.opened_=!0,!this.options_.fillAlways&&(this.hasBeenOpened_||this.hasBeenFilled_)||this.fill(),this.wasPlaying_=!e.paused(),this.options_.pauseOnOpen&&this.wasPlaying_&&e.pause(),this.on("keydown",this.handleKeyDown),this.hadControls_=e.controls(),e.controls(!1),this.show(),this.conditionalFocus_(),this.el().setAttribute("aria-hidden","false"),this.trigger("modalopen"),this.hasBeenOpened_=!0}},t.opened=function(e){return"boolean"==typeof e&&this[e?"open":"close"](),this.opened_},t.close=function(){if(this.opened_){var e=this.player();this.trigger("beforemodalclose"),this.opened_=!1,this.wasPlaying_&&this.options_.pauseOnOpen&&e.play(),this.off("keydown",this.handleKeyDown),this.hadControls_&&e.controls(!0),this.hide(),this.el().setAttribute("aria-hidden","true"),this.trigger("modalclose"),this.conditionalBlur_(),this.options_.temporary&&this.dispose()}},t.closeable=function(e){if("boolean"==typeof e){var t=this.closeable_=!!e,i=this.getChild("closeButton");if(t&&!i){var n=this.contentEl_;this.contentEl_=this.el_,i=this.addChild("closeButton",{controlText:"Close Modal Dialog"}),this.contentEl_=n,this.on(i,"close",this.close)}!t&&i&&(this.off(i,"close",this.close),this.removeChild(i),i.dispose())}return this.closeable_},t.fill=function(){this.fillWith(this.content())},t.fillWith=function(e){var t=this.contentEl(),i=t.parentNode,n=t.nextSibling;this.trigger("beforemodalfill"),this.hasBeenFilled_=!0,i.removeChild(t),this.empty(),W(t,e),this.trigger("modalfill"),n?i.insertBefore(t,n):i.appendChild(t);var r=this.getChild("closeButton");r&&i.appendChild(r.el_)},t.empty=function(){this.trigger("beforemodalempty"),V(this.contentEl()),this.trigger("modalempty")},t.content=function(e){return"undefined"!=typeof e&&(this.content_=e),this.content_},t.conditionalFocus_=function(){var e=d.activeElement,t=this.player_.el_;this.previouslyActiveEl_=null,!t.contains(e)&&t!==e||(this.previouslyActiveEl_=e,this.focus())},t.conditionalBlur_=function(){this.previouslyActiveEl_&&(this.previouslyActiveEl_.focus(),this.previouslyActiveEl_=null)},t.handleKeyDown=function(e){if(e.stopPropagation(),Ot.isEventKey(e,"Escape")&&this.closeable())return e.preventDefault(),void this.close();if(Ot.isEventKey(e,"Tab")){for(var t,i=this.focusableEls_(),n=this.el_.querySelector(":focus"),r=0;r<i.length;r++)if(n===i[r]){t=r;break}d.activeElement===this.el_&&(t=0),e.shiftKey&&0===t?(i[i.length-1].focus(),e.preventDefault()):e.shiftKey||t!==i.length-1||(i[0].focus(),e.preventDefault())}},t.focusableEls_=function(){var e=this.el_.querySelectorAll("*");return Array.prototype.filter.call(e,function(e){return(e instanceof T.HTMLAnchorElement||e instanceof T.HTMLAreaElement)&&e.hasAttribute("href")||(e instanceof T.HTMLInputElement||e instanceof T.HTMLSelectElement||e instanceof T.HTMLTextAreaElement||e instanceof T.HTMLButtonElement)&&!e.hasAttribute("disabled")||e instanceof T.HTMLIFrameElement||e instanceof T.HTMLObjectElement||e instanceof T.HTMLEmbedElement||e.hasAttribute("tabindex")&&-1!==e.getAttribute("tabindex")||e.hasAttribute("contenteditable")})},e}(je);Mt.prototype.options_={pauseOnOpen:!0,temporary:!0},je.registerComponent("ModalDialog",Mt);var Rt=function(n){function e(e){var t;void 0===e&&(e=[]),(t=n.call(this)||this).tracks_=[],Object.defineProperty(Ve(t),"length",{get:function(){return this.tracks_.length}});for(var i=0;i<e.length;i++)t.addTrack(e[i]);return t}Ge(e,n);var t=e.prototype;return t.addTrack=function(e){var t=this.tracks_.length;""+t in this||Object.defineProperty(this,t,{get:function(){return this.tracks_[t]}}),-1===this.tracks_.indexOf(e)&&(this.tracks_.push(e),this.trigger({track:e,type:"addtrack",target:this}))},t.removeTrack=function(e){for(var t,i=0,n=this.length;i<n;i++)if(this[i]===e){(t=this[i]).off&&t.off(),this.tracks_.splice(i,1);break}t&&this.trigger({track:t,type:"removetrack",target:this})},t.getTrackById=function(e){for(var t=null,i=0,n=this.length;i<n;i++){var r=this[i];if(r.id===e){t=r;break}}return t},e}(_e);for(var Nt in Rt.prototype.allowedEvents_={change:"change",addtrack:"addtrack",removetrack:"removetrack"},Rt.prototype.allowedEvents_)Rt.prototype["on"+Nt]=null;function Ut(e,t){for(var i=0;i<e.length;i++)Object.keys(e[i]).length&&t.id!==e[i].id&&(e[i].enabled=!1)}function Ft(e,t){for(var i=0;i<e.length;i++)Object.keys(e[i]).length&&t.id!==e[i].id&&(e[i].selected=!1)}function Bt(e){var t=["protocol","hostname","port","pathname","search","hash","host"],i=d.createElement("a");i.href=e;var n,r=""===i.host&&"file:"!==i.protocol;r&&((n=d.createElement("div")).innerHTML='<a href="'+e+'"></a>',i=n.firstChild,n.setAttribute("style","display:none; position:absolute;"),d.body.appendChild(n));for(var a={},s=0;s<t.length;s++)a[t[s]]=i[t[s]];return"http:"===a.protocol&&(a.host=a.host.replace(/:80$/,"")),"https:"===a.protocol&&(a.host=a.host.replace(/:443$/,"")),a.protocol||(a.protocol=T.location.protocol),r&&d.body.removeChild(n),a}function jt(e){if(!e.match(/^https?:\/\//)){var t=d.createElement("div");t.innerHTML='<a href="'+e+'">x</a>',e=t.firstChild.href}return e}function Vt(e){if("string"==typeof e){var t=/^(\/?)([\s\S]*?)((?:\.{1,2}|[^\/]+?)(\.([^\.\/\?]+)))(?:[\/]*|[\?].*)$/.exec(e);if(t)return t.pop().toLowerCase()}return""}function qt(e,t){void 0===t&&(t=T.location);var i=Bt(e);return(":"===i.protocol?t.protocol:i.protocol)+i.host!==t.protocol+t.host}var Ht=function(n){function e(e){var t;void 0===e&&(e=[]);for(var i=e.length-1;0<=i;i--)if(e[i].enabled){Ut(e,e[i]);break}return(t=n.call(this,e)||this).changing_=!1,t}Ge(e,n);var t=e.prototype;return t.addTrack=function(e){var t=this;e.enabled&&Ut(this,e),n.prototype.addTrack.call(this,e),e.addEventListener&&(e.enabledChange_=function(){t.changing_||(t.changing_=!0,Ut(t,e),t.changing_=!1,t.trigger("change"))},e.addEventListener("enabledchange",e.enabledChange_))},t.removeTrack=function(e){n.prototype.removeTrack.call(this,e),e.removeEventListener&&e.enabledChange_&&(e.removeEventListener("enabledchange",e.enabledChange_),e.enabledChange_=null)},e}(Rt),Wt=function(n){function e(e){var t;void 0===e&&(e=[]);for(var i=e.length-1;0<=i;i--)if(e[i].selected){Ft(e,e[i]);break}return(t=n.call(this,e)||this).changing_=!1,Object.defineProperty(Ve(t),"selectedIndex",{get:function(){for(var e=0;e<this.length;e++)if(this[e].selected)return e;return-1},set:function(){}}),t}Ge(e,n);var t=e.prototype;return t.addTrack=function(e){var t=this;e.selected&&Ft(this,e),n.prototype.addTrack.call(this,e),e.addEventListener&&(e.selectedChange_=function(){t.changing_||(t.changing_=!0,Ft(t,e),t.changing_=!1,t.trigger("change"))},e.addEventListener("selectedchange",e.selectedChange_))},t.removeTrack=function(e){n.prototype.removeTrack.call(this,e),e.removeEventListener&&e.selectedChange_&&(e.removeEventListener("selectedchange",e.selectedChange_),e.selectedChange_=null)},e}(Rt),zt=function(i){function e(){return i.apply(this,arguments)||this}Ge(e,i);var t=e.prototype;return t.addTrack=function(e){var t=this;i.prototype.addTrack.call(this,e),this.queueChange_||(this.queueChange_=function(){return t.queueTrigger("change")}),this.triggerSelectedlanguagechange||(this.triggerSelectedlanguagechange_=function(){return t.trigger("selectedlanguagechange")}),e.addEventListener("modechange",this.queueChange_);-1===["metadata","chapters"].indexOf(e.kind)&&e.addEventListener("modechange",this.triggerSelectedlanguagechange_)},t.removeTrack=function(e){i.prototype.removeTrack.call(this,e),e.removeEventListener&&(this.queueChange_&&e.removeEventListener("modechange",this.queueChange_),this.selectedlanguagechange_&&e.removeEventListener("modechange",this.triggerSelectedlanguagechange_))},e}(Rt),Gt=function(){function e(e){void 0===e&&(e=[]),this.trackElements_=[],Object.defineProperty(this,"length",{get:function(){return this.trackElements_.length}});for(var t=0,i=e.length;t<i;t++)this.addTrackElement_(e[t])}var t=e.prototype;return t.addTrackElement_=function(e){var t=this.trackElements_.length;""+t in this||Object.defineProperty(this,t,{get:function(){return this.trackElements_[t]}}),-1===this.trackElements_.indexOf(e)&&this.trackElements_.push(e)},t.getTrackElementByTrack_=function(e){for(var t,i=0,n=this.trackElements_.length;i<n;i++)if(e===this.trackElements_[i].track){t=this.trackElements_[i];break}return t},t.removeTrackElement_=function(e){for(var t=0,i=this.trackElements_.length;t<i;t++)if(e===this.trackElements_[t]){this.trackElements_[t].track&&"function"==typeof this.trackElements_[t].track.off&&this.trackElements_[t].track.off(),"function"==typeof this.trackElements_[t].off&&this.trackElements_[t].off(),this.trackElements_.splice(t,1);break}},e}(),Xt=function(){function t(e){t.prototype.setCues_.call(this,e),Object.defineProperty(this,"length",{get:function(){return this.length_}})}var e=t.prototype;return e.setCues_=function(e){var t=this.length||0,i=0,n=e.length;this.cues_=e,this.length_=e.length;function r(e){""+e in this||Object.defineProperty(this,""+e,{get:function(){return this.cues_[e]}})}if(t<n)for(i=t;i<n;i++)r.call(this,i)},e.getCueById=function(e){for(var t=null,i=0,n=this.length;i<n;i++){var r=this[i];if(r.id===e){t=r;break}}return t},t}(),Kt={alternative:"alternative",captions:"captions",main:"main",sign:"sign",subtitles:"subtitles",commentary:"commentary"},Yt={alternative:"alternative",descriptions:"descriptions",main:"main","main-desc":"main-desc",translation:"translation",commentary:"commentary"},$t={subtitles:"subtitles",captions:"captions",descriptions:"descriptions",chapters:"chapters",metadata:"metadata"},Qt={disabled:"disabled",hidden:"hidden",showing:"showing"},Jt=function(a){function e(e){var t;void 0===e&&(e={}),t=a.call(this)||this;function i(e){Object.defineProperty(Ve(t),e,{get:function(){return n[e]},set:function(){}})}var n={id:e.id||"vjs_track_"+re(),kind:e.kind||"",label:e.label||"",language:e.language||""};for(var r in n)i(r);return t}return Ge(e,a),e}(_e),Zt=Object.freeze({__proto__:null,parseUrl:Bt,getAbsoluteURL:jt,getFileExtension:Vt,isCrossOrigin:qt}),ei=function(e){var t=ti.call(e);return"[object Function]"===t||"function"==typeof e&&"[object RegExp]"!==t||"undefined"!=typeof window&&(e===window.setTimeout||e===window.alert||e===window.confirm||e===window.prompt)} -/** - * @license - * slighly modified parse-headers 2.0.2 <https://github.com/kesla/parse-headers/> - * Copyright (c) 2014 David Björklund - * Available under the MIT license - * <https://github.com/kesla/parse-headers/blob/master/LICENCE> - */,ti=Object.prototype.toString;var ii=function(e){var r={};return e&&e.trim().split("\n").forEach(function(e){var t=e.indexOf(":"),i=e.slice(0,t).trim().toLowerCase(),n=e.slice(t+1).trim();"undefined"==typeof r[i]?r[i]=n:Array.isArray(r[i])?r[i].push(n):r[i]=[r[i],n]}),r},ni=si,ri=si;function ai(e,t,i){var n=e;return ei(t)?(i=t,"string"==typeof e&&(n={uri:e})):n=m({},t,{uri:e}),n.callback=i,n}function si(e,t,i){return oi(t=ai(e,t,i))}function oi(n){if("undefined"==typeof n.callback)throw new Error("callback argument missing");var r=!1,a=function(e,t,i){r||(r=!0,n.callback(e,t,i))};function t(e){return clearTimeout(o),e instanceof Error||(e=new Error(""+(e||"Unknown XMLHttpRequest Error"))),e.statusCode=0,a(e,m)}function e(){if(!s){var e;clearTimeout(o),e=n.useXDR&&void 0===u.status?200:1223===u.status?204:u.status;var t=m,i=null;return 0!==e?(t={body:function(){var e=void 0;if(e=u.response?u.response:u.responseText||function(e){try{if("document"===e.responseType)return e.responseXML;var t=e.responseXML&&"parsererror"===e.responseXML.documentElement.nodeName;if(""===e.responseType&&!t)return e.responseXML}catch(e){}return null}(u),f)try{e=JSON.parse(e)}catch(e){}return e}(),statusCode:e,method:c,headers:{},url:l,rawRequest:u},u.getAllResponseHeaders&&(t.headers=ii(u.getAllResponseHeaders()))):i=new Error("Internal XMLHttpRequest Error"),a(i,t,t.body)}}var i,s,o,u=n.xhr||null,l=(u=u||(n.cors||n.useXDR?new si.XDomainRequest:new si.XMLHttpRequest)).url=n.uri||n.url,c=u.method=n.method||"GET",d=n.body||n.data,h=u.headers=n.headers||{},p=!!n.sync,f=!1,m={body:void 0,headers:{},statusCode:0,method:c,url:l,rawRequest:u};if("json"in n&&!1!==n.json&&(f=!0,h.accept||h.Accept||(h.Accept="application/json"),"GET"!==c&&"HEAD"!==c&&(h["content-type"]||h["Content-Type"]||(h["Content-Type"]="application/json"),d=JSON.stringify(!0===n.json?d:n.json))),u.onreadystatechange=function(){4===u.readyState&&setTimeout(e,0)},u.onload=e,u.onerror=t,u.onprogress=function(){},u.onabort=function(){s=!0},u.ontimeout=t,u.open(c,l,!p,n.username,n.password),p||(u.withCredentials=!!n.withCredentials),!p&&0<n.timeout&&(o=setTimeout(function(){if(!s){s=!0,u.abort("timeout");var e=new Error("XMLHttpRequest timeout");e.code="ETIMEDOUT",t(e)}},n.timeout)),u.setRequestHeader)for(i in h)h.hasOwnProperty(i)&&u.setRequestHeader(i,h[i]);else if(n.headers&&!function(e){for(var t in e)if(e.hasOwnProperty(t))return!1;return!0}(n.headers))throw new Error("Headers cannot be set on an XDomainRequest object");return"responseType"in n&&(u.responseType=n.responseType),"beforeSend"in n&&"function"==typeof n.beforeSend&&n.beforeSend(u),u.send(d||null),u}si.XMLHttpRequest=T.XMLHttpRequest||function(){},si.XDomainRequest="withCredentials"in new si.XMLHttpRequest?si.XMLHttpRequest:T.XDomainRequest,function(e,t){for(var i=0;i<e.length;i++)t(e[i])}(["get","put","post","patch","head","delete"],function(n){si["delete"===n?"del":n]=function(e,t,i){return(t=ai(e,t,i)).method=n.toUpperCase(),oi(t)}}),ni.default=ri;function ui(e,t){var i=new T.WebVTT.Parser(T,T.vttjs,T.WebVTT.StringDecoder()),n=[];i.oncue=function(e){t.addCue(e)},i.onparsingerror=function(e){n.push(e)},i.onflush=function(){t.trigger({type:"loadeddata",target:t})},i.parse(e),0<n.length&&(T.console&&T.console.groupCollapsed&&T.console.groupCollapsed("Text Track parsing errors for "+t.src),n.forEach(function(e){return p.error(e)}),T.console&&T.console.groupEnd&&T.console.groupEnd()),i.flush()}function li(e,n){var t={uri:e},i=qt(e);i&&(t.cors=i);var r="use-credentials"===n.tech_.crossOrigin();r&&(t.withCredentials=r),ni(t,ve(this,function(e,t,i){if(e)return p.error(e,t);n.loaded_=!0,"function"!=typeof T.WebVTT?n.tech_&&n.tech_.any(["vttjsloaded","vttjserror"],function(e){if("vttjserror"!==e.type)return ui(i,n);p.error("vttjs failed to load, stopping trying to process "+n.src)}):ui(i,n)}))}var ci=function(l){function e(e){var t;if(void 0===e&&(e={}),!e.tech)throw new Error("A tech was not provided.");var i=Re(e,{kind:$t[e.kind]||"subtitles",language:e.language||e.srclang||""}),n=Qt[i.mode]||"disabled",r=i.default;"metadata"!==i.kind&&"chapters"!==i.kind||(n="hidden"),(t=l.call(this,i)||this).tech_=i.tech,t.cues_=[],t.activeCues_=[],t.preload_=!1!==t.tech_.preloadTextTracks;var a=new Xt(t.cues_),s=new Xt(t.activeCues_),o=!1,u=ve(Ve(t),function(){this.activeCues=this.activeCues,o&&(this.trigger("cuechange"),o=!1)});return"disabled"!==n&&t.tech_.ready(function(){t.tech_.on("timeupdate",u)},!0),Object.defineProperties(Ve(t),{default:{get:function(){return r},set:function(){}},mode:{get:function(){return n},set:function(e){var t=this;Qt[e]&&(n=e,this.preload_||"disabled"===n||0!==this.cues.length||li(this.src,this),"disabled"!==n?this.tech_.ready(function(){t.tech_.on("timeupdate",u)},!0):this.tech_.off("timeupdate",u),this.trigger("modechange"))}},cues:{get:function(){return this.loaded_?a:null},set:function(){}},activeCues:{get:function(){if(!this.loaded_)return null;if(0===this.cues.length)return s;for(var e=this.tech_.currentTime(),t=[],i=0,n=this.cues.length;i<n;i++){var r=this.cues[i];r.startTime<=e&&r.endTime>=e?t.push(r):r.startTime===r.endTime&&r.startTime<=e&&r.startTime+.5>=e&&t.push(r)}if(o=!1,t.length!==this.activeCues_.length)o=!0;else for(var a=0;a<t.length;a++)-1===this.activeCues_.indexOf(t[a])&&(o=!0);return this.activeCues_=t,s.setCues_(this.activeCues_),s},set:function(){}}}),i.src?(t.src=i.src,t.preload_||(t.loaded_=!0),(t.preload_||r||"subtitles"!==i.kind&&"captions"!==i.kind)&&li(t.src,Ve(t))):t.loaded_=!0,t}Ge(e,l);var t=e.prototype;return t.addCue=function(e){var t=e;if(T.vttjs&&!(e instanceof T.vttjs.VTTCue)){for(var i in t=new T.vttjs.VTTCue(e.startTime,e.endTime,e.text),e)i in t||(t[i]=e[i]);t.id=e.id,t.originalCue_=e}for(var n=this.tech_.textTracks(),r=0;r<n.length;r++)n[r]!==this&&n[r].removeCue(t);this.cues_.push(t),this.cues.setCues_(this.cues_)},t.removeCue=function(e){for(var t=this.cues_.length;t--;){var i=this.cues_[t];if(i===e||i.originalCue_&&i.originalCue_===e){this.cues_.splice(t,1),this.cues.setCues_(this.cues_);break}}},e}(Jt);ci.prototype.allowedEvents_={cuechange:"cuechange"};var di=function(r){function e(e){var t;void 0===e&&(e={});var i=Re(e,{kind:Yt[e.kind]||""});t=r.call(this,i)||this;var n=!1;return Object.defineProperty(Ve(t),"enabled",{get:function(){return n},set:function(e){"boolean"==typeof e&&e!==n&&(n=e,this.trigger("enabledchange"))}}),i.enabled&&(t.enabled=i.enabled),t.loaded_=!0,t}return Ge(e,r),e}(Jt),hi=function(r){function e(e){var t;void 0===e&&(e={});var i=Re(e,{kind:Kt[e.kind]||""});t=r.call(this,i)||this;var n=!1;return Object.defineProperty(Ve(t),"selected",{get:function(){return n},set:function(e){"boolean"==typeof e&&e!==n&&(n=e,this.trigger("selectedchange"))}}),i.selected&&(t.selected=i.selected),t}return Ge(e,r),e}(Jt),pi=function(r){function e(e){var t,i;void 0===e&&(e={}),t=r.call(this)||this;var n=new ci(e);return t.kind=n.kind,t.src=n.src,t.srclang=n.language,t.label=n.label,t.default=n.default,Object.defineProperties(Ve(t),{readyState:{get:function(){return i}},track:{get:function(){return n}}}),i=0,n.addEventListener("loadeddata",function(){i=2,t.trigger({type:"load",target:Ve(t)})}),t}return Ge(e,r),e}(_e);pi.prototype.allowedEvents_={load:"load"},pi.NONE=0,pi.LOADING=1,pi.LOADED=2,pi.ERROR=3;var fi={audio:{ListClass:Ht,TrackClass:di,capitalName:"Audio"},video:{ListClass:Wt,TrackClass:hi,capitalName:"Video"},text:{ListClass:zt,TrackClass:ci,capitalName:"Text"}};Object.keys(fi).forEach(function(e){fi[e].getterName=e+"Tracks",fi[e].privateName=e+"Tracks_"});var mi={remoteText:{ListClass:zt,TrackClass:ci,capitalName:"RemoteText",getterName:"remoteTextTracks",privateName:"remoteTextTracks_"},remoteTextEl:{ListClass:Gt,TrackClass:pi,capitalName:"RemoteTextTrackEls",getterName:"remoteTextTrackEls",privateName:"remoteTextTrackEls_"}},gi=m({},fi,mi);mi.names=Object.keys(mi),fi.names=Object.keys(fi),gi.names=[].concat(mi.names).concat(fi.names);var vi=Object.create||function(e){if(1!==arguments.length)throw new Error("Object.create shim only accepts one parameter.");return yi.prototype=e,new yi};function yi(){}function _i(e,t){this.name="ParsingError",this.code=e.code,this.message=t||e.message}function bi(e){function t(e,t,i,n){return 3600*(0|e)+60*(0|t)+(0|i)+(0|n)/1e3}var i=e.match(/^(\d+):(\d{1,2})(:\d{1,2})?\.(\d{3})/);return i?i[3]?t(i[1],i[2],i[3].replace(":",""),i[4]):59<i[1]?t(i[1],i[2],0,i[4]):t(0,i[1],i[2],i[4]):null}function Ti(){this.values=vi(null)}function Si(e,t,i,n){var r=n?e.split(n):[e];for(var a in r)if("string"==typeof r[a]){var s=r[a].split(i);if(2===s.length)t(s[0],s[1])}}function ki(t,e,s){var i=t;function n(){var e=bi(t);if(null===e)throw new _i(_i.Errors.BadTimeStamp,"Malformed timestamp: "+i);return t=t.replace(/^[^\sa-zA-Z-]+/,""),e}function r(){t=t.replace(/^\s+/,"")}if(r(),e.startTime=n(),r(),"--\x3e"!==t.substr(0,3))throw new _i(_i.Errors.BadTimeStamp,"Malformed time stamp (time stamps must be separated by '--\x3e'): "+i);t=t.substr(3),r(),e.endTime=n(),r(),function(e,t){var a=new Ti;Si(e,function(e,t){switch(e){case"region":for(var i=s.length-1;0<=i;i--)if(s[i].id===t){a.set(e,s[i].region);break}break;case"vertical":a.alt(e,t,["rl","lr"]);break;case"line":var n=t.split(","),r=n[0];a.integer(e,r),a.percent(e,r)&&a.set("snapToLines",!1),a.alt(e,r,["auto"]),2===n.length&&a.alt("lineAlign",n[1],["start","center","end"]);break;case"position":n=t.split(","),a.percent(e,n[0]),2===n.length&&a.alt("positionAlign",n[1],["start","center","end"]);break;case"size":a.percent(e,t);break;case"align":a.alt(e,t,["start","center","end","left","right"])}},/:/,/\s/),t.region=a.get("region",null),t.vertical=a.get("vertical","");try{t.line=a.get("line","auto")}catch(e){}t.lineAlign=a.get("lineAlign","start"),t.snapToLines=a.get("snapToLines",!0),t.size=a.get("size",100);try{t.align=a.get("align","center")}catch(e){t.align=a.get("align","middle")}try{t.position=a.get("position","auto")}catch(e){t.position=a.get("position",{start:0,left:0,center:50,middle:50,end:100,right:100},t.align)}t.positionAlign=a.get("positionAlign",{start:"start",left:"start",center:"center",middle:"center",end:"end",right:"end"},t.align)}(t,e)}((_i.prototype=vi(Error.prototype)).constructor=_i).Errors={BadSignature:{code:0,message:"Malformed WebVTT signature."},BadTimeStamp:{code:1,message:"Malformed time stamp."}},Ti.prototype={set:function(e,t){this.get(e)||""===t||(this.values[e]=t)},get:function(e,t,i){return i?this.has(e)?this.values[e]:t[i]:this.has(e)?this.values[e]:t},has:function(e){return e in this.values},alt:function(e,t,i){for(var n=0;n<i.length;++n)if(t===i[n]){this.set(e,t);break}},integer:function(e,t){/^-?\d+$/.test(t)&&this.set(e,parseInt(t,10))},percent:function(e,t){return!!(t.match(/^([\d]{1,3})(\.[\d]*)?%$/)&&0<=(t=parseFloat(t))&&t<=100)&&(this.set(e,t),!0)}};var Ci=d.createElement("textarea"),Ei={c:"span",i:"i",b:"b",u:"u",ruby:"ruby",rt:"rt",v:"span",lang:"span"},wi={white:"rgba(255,255,255,1)",lime:"rgba(0,255,0,1)",cyan:"rgba(0,255,255,1)",red:"rgba(255,0,0,1)",yellow:"rgba(255,255,0,1)",magenta:"rgba(255,0,255,1)",blue:"rgba(0,0,255,1)",black:"rgba(0,0,0,1)"},Ii={v:"title",lang:"lang"},Ai={rt:"ruby"};function xi(a,i){function e(){if(!i)return null;var e,t=i.match(/^([^<]*)(<[^>]*>?)?/);return e=t[1]?t[1]:t[2],i=i.substr(e.length),e}function t(e,t){var i=Ei[e];if(!i)return null;var n=a.document.createElement(i),r=Ii[e];return r&&t&&(n[r]=t.trim()),n}for(var n,r,s,o,u=a.document.createElement("div"),l=u,c=[];null!==(n=e());)if("<"!==n[0])l.appendChild(a.document.createTextNode((r=n,Ci.innerHTML=r,r=Ci.textContent,Ci.textContent="",r)));else{if("/"===n[1]){c.length&&c[c.length-1]===n.substr(2).replace(">","")&&(c.pop(),l=l.parentNode);continue}var d,h=bi(n.substr(1,n.length-2));if(h){d=a.document.createProcessingInstruction("timestamp",h),l.appendChild(d);continue}var p=n.match(/^<([^.\s/0-9>]+)(\.[^\s\\>]+)?([^>\\]+)?(\\?)>?$/);if(!p)continue;if(!(d=t(p[1],p[3])))continue;if(s=l,Ai[(o=d).localName]&&Ai[o.localName]!==s.localName)continue;if(p[2]){var f=p[2].split(".");f.forEach(function(e){var t=/^bg_/.test(e),i=t?e.slice(3):e;if(wi.hasOwnProperty(i)){var n=t?"background-color":"color",r=wi[i];d.style[n]=r}}),d.className=f.join(" ")}c.push(p[1]),l.appendChild(d),l=d}return u}var Pi=[[1470,1470],[1472,1472],[1475,1475],[1478,1478],[1488,1514],[1520,1524],[1544,1544],[1547,1547],[1549,1549],[1563,1563],[1566,1610],[1645,1647],[1649,1749],[1765,1766],[1774,1775],[1786,1805],[1807,1808],[1810,1839],[1869,1957],[1969,1969],[1984,2026],[2036,2037],[2042,2042],[2048,2069],[2074,2074],[2084,2084],[2088,2088],[2096,2110],[2112,2136],[2142,2142],[2208,2208],[2210,2220],[8207,8207],[64285,64285],[64287,64296],[64298,64310],[64312,64316],[64318,64318],[64320,64321],[64323,64324],[64326,64449],[64467,64829],[64848,64911],[64914,64967],[65008,65020],[65136,65140],[65142,65276],[67584,67589],[67592,67592],[67594,67637],[67639,67640],[67644,67644],[67647,67669],[67671,67679],[67840,67867],[67872,67897],[67903,67903],[67968,68023],[68030,68031],[68096,68096],[68112,68115],[68117,68119],[68121,68147],[68160,68167],[68176,68184],[68192,68223],[68352,68405],[68416,68437],[68440,68466],[68472,68479],[68608,68680],[126464,126467],[126469,126495],[126497,126498],[126500,126500],[126503,126503],[126505,126514],[126516,126519],[126521,126521],[126523,126523],[126530,126530],[126535,126535],[126537,126537],[126539,126539],[126541,126543],[126545,126546],[126548,126548],[126551,126551],[126553,126553],[126555,126555],[126557,126557],[126559,126559],[126561,126562],[126564,126564],[126567,126570],[126572,126578],[126580,126583],[126585,126588],[126590,126590],[126592,126601],[126603,126619],[126625,126627],[126629,126633],[126635,126651],[1114109,1114109]];function Li(e){for(var t=0;t<Pi.length;t++){var i=Pi[t];if(e>=i[0]&&e<=i[1])return!0}return!1}function Oi(){}function Di(e,t,i){Oi.call(this),this.cue=t,this.cueDiv=xi(e,t.text);var n={color:"rgba(255, 255, 255, 1)",backgroundColor:"rgba(0, 0, 0, 0.8)",position:"relative",left:0,right:0,top:0,bottom:0,display:"inline",writingMode:""===t.vertical?"horizontal-tb":"lr"===t.vertical?"vertical-lr":"vertical-rl",unicodeBidi:"plaintext"};this.applyStyles(n,this.cueDiv),this.div=e.document.createElement("div"),n={direction:function(e){var t=[],i="";if(!e||!e.childNodes)return"ltr";function r(e,t){for(var i=t.childNodes.length-1;0<=i;i--)e.push(t.childNodes[i])}function a(e){if(!e||!e.length)return null;var t=e.pop(),i=t.textContent||t.innerText;if(i){var n=i.match(/^.*(\n|\r)/);return n?n[e.length=0]:i}return"ruby"===t.tagName?a(e):t.childNodes?(r(e,t),a(e)):void 0}for(r(t,e);i=a(t);)for(var n=0;n<i.length;n++)if(Li(i.charCodeAt(n)))return"rtl";return"ltr"}(this.cueDiv),writingMode:""===t.vertical?"horizontal-tb":"lr"===t.vertical?"vertical-lr":"vertical-rl",unicodeBidi:"plaintext",textAlign:"middle"===t.align?"center":t.align,font:i.font,whiteSpace:"pre-line",position:"absolute"},this.applyStyles(n),this.div.appendChild(this.cueDiv);var r=0;switch(t.positionAlign){case"start":r=t.position;break;case"center":r=t.position-t.size/2;break;case"end":r=t.position-t.size}""===t.vertical?this.applyStyles({left:this.formatStyle(r,"%"),width:this.formatStyle(t.size,"%")}):this.applyStyles({top:this.formatStyle(r,"%"),height:this.formatStyle(t.size,"%")}),this.move=function(e){this.applyStyles({top:this.formatStyle(e.top,"px"),bottom:this.formatStyle(e.bottom,"px"),left:this.formatStyle(e.left,"px"),right:this.formatStyle(e.right,"px"),height:this.formatStyle(e.height,"px"),width:this.formatStyle(e.width,"px")})}}function Mi(e){var t,i,n,r;if(e.div){i=e.div.offsetHeight,n=e.div.offsetWidth,r=e.div.offsetTop;var a=(a=e.div.childNodes)&&(a=a[0])&&a.getClientRects&&a.getClientRects();e=e.div.getBoundingClientRect(),t=a?Math.max(a[0]&&a[0].height||0,e.height/a.length):0}this.left=e.left,this.right=e.right,this.top=e.top||r,this.height=e.height||i,this.bottom=e.bottom||r+(e.height||i),this.width=e.width||n,this.lineHeight=void 0!==t?t:e.lineHeight}function Ri(e,t,o,u){var i=new Mi(t),n=t.cue,r=function(e){if("number"==typeof e.line&&(e.snapToLines||0<=e.line&&e.line<=100))return e.line;if(!e.track||!e.track.textTrackList||!e.track.textTrackList.mediaElement)return-1;for(var t=e.track,i=t.textTrackList,n=0,r=0;r<i.length&&i[r]!==t;r++)"showing"===i[r].mode&&n++;return-1*++n}(n),a=[];if(n.snapToLines){var s;switch(n.vertical){case"":a=["+y","-y"],s="height";break;case"rl":a=["+x","-x"],s="width";break;case"lr":a=["-x","+x"],s="width"}var l=i.lineHeight,c=l*Math.round(r),d=o[s]+l,h=a[0];Math.abs(c)>d&&(c=c<0?-1:1,c*=Math.ceil(d/l)*l),r<0&&(c+=""===n.vertical?o.height:o.width,a=a.reverse()),i.move(h,c)}else{var p=i.lineHeight/o.height*100;switch(n.lineAlign){case"center":r-=p/2;break;case"end":r-=p}switch(n.vertical){case"":t.applyStyles({top:t.formatStyle(r,"%")});break;case"rl":t.applyStyles({left:t.formatStyle(r,"%")});break;case"lr":t.applyStyles({right:t.formatStyle(r,"%")})}a=["+y","-x","+x","-y"],i=new Mi(t)}var f=function(e,t){for(var i,n=new Mi(e),r=1,a=0;a<t.length;a++){for(;e.overlapsOppositeAxis(o,t[a])||e.within(o)&&e.overlapsAny(u);)e.move(t[a]);if(e.within(o))return e;var s=e.intersectPercentage(o);s<r&&(i=new Mi(e),r=s),e=new Mi(n)}return i||n}(i,a);t.move(f.toCSSCompatValues(o))}function Ni(){}Oi.prototype.applyStyles=function(e,t){for(var i in t=t||this.div,e)e.hasOwnProperty(i)&&(t.style[i]=e[i])},Oi.prototype.formatStyle=function(e,t){return 0===e?0:e+t},(Di.prototype=vi(Oi.prototype)).constructor=Di,Mi.prototype.move=function(e,t){switch(t=void 0!==t?t:this.lineHeight,e){case"+x":this.left+=t,this.right+=t;break;case"-x":this.left-=t,this.right-=t;break;case"+y":this.top+=t,this.bottom+=t;break;case"-y":this.top-=t,this.bottom-=t}},Mi.prototype.overlaps=function(e){return this.left<e.right&&this.right>e.left&&this.top<e.bottom&&this.bottom>e.top},Mi.prototype.overlapsAny=function(e){for(var t=0;t<e.length;t++)if(this.overlaps(e[t]))return!0;return!1},Mi.prototype.within=function(e){return this.top>=e.top&&this.bottom<=e.bottom&&this.left>=e.left&&this.right<=e.right},Mi.prototype.overlapsOppositeAxis=function(e,t){switch(t){case"+x":return this.left<e.left;case"-x":return this.right>e.right;case"+y":return this.top<e.top;case"-y":return this.bottom>e.bottom}},Mi.prototype.intersectPercentage=function(e){return Math.max(0,Math.min(this.right,e.right)-Math.max(this.left,e.left))*Math.max(0,Math.min(this.bottom,e.bottom)-Math.max(this.top,e.top))/(this.height*this.width)},Mi.prototype.toCSSCompatValues=function(e){return{top:this.top-e.top,bottom:e.bottom-this.bottom,left:this.left-e.left,right:e.right-this.right,height:this.height,width:this.width}},Mi.getSimpleBoxPosition=function(e){var t=e.div?e.div.offsetHeight:e.tagName?e.offsetHeight:0,i=e.div?e.div.offsetWidth:e.tagName?e.offsetWidth:0,n=e.div?e.div.offsetTop:e.tagName?e.offsetTop:0;return{left:(e=e.div?e.div.getBoundingClientRect():e.tagName?e.getBoundingClientRect():e).left,right:e.right,top:e.top||n,height:e.height||t,bottom:e.bottom||n+(e.height||t),width:e.width||i}},Ni.StringDecoder=function(){return{decode:function(e){if(!e)return"";if("string"!=typeof e)throw new Error("Error - expected string data.");return decodeURIComponent(encodeURIComponent(e))}}},Ni.convertCueToDOMTree=function(e,t){return e&&t?xi(e,t):null};Ni.processCues=function(n,r,e){if(!n||!r||!e)return null;for(;e.firstChild;)e.removeChild(e.firstChild);var a=n.document.createElement("div");if(a.style.position="absolute",a.style.left="0",a.style.right="0",a.style.top="0",a.style.bottom="0",a.style.margin="1.5%",e.appendChild(a),function(e){for(var t=0;t<e.length;t++)if(e[t].hasBeenReset||!e[t].displayState)return!0;return!1}(r)){var s=[],o=Mi.getSimpleBoxPosition(a),u={font:Math.round(.05*o.height*100)/100+"px sans-serif"};!function(){for(var e,t,i=0;i<r.length;i++)t=r[i],e=new Di(n,t,u),a.appendChild(e.div),Ri(0,e,o,s),t.displayState=e.div,s.push(Mi.getSimpleBoxPosition(e))}()}else for(var t=0;t<r.length;t++)a.appendChild(r[t].displayState)},(Ni.Parser=function(e,t,i){i||(i=t,t={}),t=t||{},this.window=e,this.vttjs=t,this.state="INITIAL",this.buffer="",this.decoder=i||new TextDecoder("utf8"),this.regionList=[]}).prototype={reportOrThrowError:function(e){if(!(e instanceof _i))throw e;this.onparsingerror&&this.onparsingerror(e)},parse:function(e){var n=this;function t(){for(var e=n.buffer,t=0;t<e.length&&"\r"!==e[t]&&"\n"!==e[t];)++t;var i=e.substr(0,t);return"\r"===e[t]&&++t,"\n"===e[t]&&++t,n.buffer=e.substr(t),i}function i(e){e.match(/X-TIMESTAMP-MAP/)?Si(e,function(e,t){switch(e){case"X-TIMESTAMP-MAP":!function(e){var i=new Ti;Si(e,function(e,t){switch(e){case"MPEGT":i.integer(e+"S",t);break;case"LOCA":i.set(e+"L",bi(t))}},/[^\d]:/,/,/),n.ontimestampmap&&n.ontimestampmap({MPEGTS:i.get("MPEGTS"),LOCAL:i.get("LOCAL")})}(t)}},/=/):Si(e,function(e,t){switch(e){case"Region":!function(e){var r=new Ti;if(Si(e,function(e,t){switch(e){case"id":r.set(e,t);break;case"width":r.percent(e,t);break;case"lines":r.integer(e,t);break;case"regionanchor":case"viewportanchor":var i=t.split(",");if(2!==i.length)break;var n=new Ti;if(n.percent("x",i[0]),n.percent("y",i[1]),!n.has("x")||!n.has("y"))break;r.set(e+"X",n.get("x")),r.set(e+"Y",n.get("y"));break;case"scroll":r.alt(e,t,["up"])}},/=/,/\s/),r.has("id")){var t=new(n.vttjs.VTTRegion||n.window.VTTRegion);t.width=r.get("width",100),t.lines=r.get("lines",3),t.regionAnchorX=r.get("regionanchorX",0),t.regionAnchorY=r.get("regionanchorY",100),t.viewportAnchorX=r.get("viewportanchorX",0),t.viewportAnchorY=r.get("viewportanchorY",100),t.scroll=r.get("scroll",""),n.onregion&&n.onregion(t),n.regionList.push({id:r.get("id"),region:t})}}(t)}},/:/)}e&&(n.buffer+=n.decoder.decode(e,{stream:!0}));try{var r;if("INITIAL"===n.state){if(!/\r\n|\n/.test(n.buffer))return this;var a=(r=t()).match(/^WEBVTT([ \t].*)?$/);if(!a||!a[0])throw new _i(_i.Errors.BadSignature);n.state="HEADER"}for(var s=!1;n.buffer;){if(!/\r\n|\n/.test(n.buffer))return this;switch(s?s=!1:r=t(),n.state){case"HEADER":/:/.test(r)?i(r):r||(n.state="ID");continue;case"NOTE":r||(n.state="ID");continue;case"ID":if(/^NOTE($|[ \t])/.test(r)){n.state="NOTE";break}if(!r)continue;n.cue=new(n.vttjs.VTTCue||n.window.VTTCue)(0,0,"");try{n.cue.align="center"}catch(e){n.cue.align="middle"}if(n.state="CUE",-1===r.indexOf("--\x3e")){n.cue.id=r;continue}case"CUE":try{ki(r,n.cue,n.regionList)}catch(e){n.reportOrThrowError(e),n.cue=null,n.state="BADCUE";continue}n.state="CUETEXT";continue;case"CUETEXT":var o=-1!==r.indexOf("--\x3e");if(!r||o&&(s=!0)){n.oncue&&n.oncue(n.cue),n.cue=null,n.state="ID";continue}n.cue.text&&(n.cue.text+="\n"),n.cue.text+=r.replace(/\u2028/g,"\n").replace(/u2029/g,"\n");continue;case"BADCUE":r||(n.state="ID");continue}}}catch(e){n.reportOrThrowError(e),"CUETEXT"===n.state&&n.cue&&n.oncue&&n.oncue(n.cue),n.cue=null,n.state="INITIAL"===n.state?"BADWEBVTT":"BADCUE"}return this},flush:function(){var t=this;try{if(t.buffer+=t.decoder.decode(),!t.cue&&"HEADER"!==t.state||(t.buffer+="\n\n",t.parse()),"INITIAL"===t.state)throw new _i(_i.Errors.BadSignature)}catch(e){t.reportOrThrowError(e)}return t.onflush&&t.onflush(),this}};var Ui=Ni,Fi={"":1,lr:1,rl:1},Bi={start:1,center:1,end:1,left:1,right:1,auto:1,"line-left":1,"line-right":1};function ji(e){return"string"==typeof e&&(!!Bi[e.toLowerCase()]&&e.toLowerCase())}function Vi(e,t,i){this.hasBeenReset=!1;var n="",r=!1,a=e,s=t,o=i,u=null,l="",c=!0,d="auto",h="start",p="auto",f="auto",m=100,g="center";Object.defineProperties(this,{id:{enumerable:!0,get:function(){return n},set:function(e){n=""+e}},pauseOnExit:{enumerable:!0,get:function(){return r},set:function(e){r=!!e}},startTime:{enumerable:!0,get:function(){return a},set:function(e){if("number"!=typeof e)throw new TypeError("Start time must be set to a number.");a=e,this.hasBeenReset=!0}},endTime:{enumerable:!0,get:function(){return s},set:function(e){if("number"!=typeof e)throw new TypeError("End time must be set to a number.");s=e,this.hasBeenReset=!0}},text:{enumerable:!0,get:function(){return o},set:function(e){o=""+e,this.hasBeenReset=!0}},region:{enumerable:!0,get:function(){return u},set:function(e){u=e,this.hasBeenReset=!0}},vertical:{enumerable:!0,get:function(){return l},set:function(e){var t=function(e){return"string"==typeof e&&(!!Fi[e.toLowerCase()]&&e.toLowerCase())}(e);if(!1===t)throw new SyntaxError("Vertical: an invalid or illegal direction string was specified.");l=t,this.hasBeenReset=!0}},snapToLines:{enumerable:!0,get:function(){return c},set:function(e){c=!!e,this.hasBeenReset=!0}},line:{enumerable:!0,get:function(){return d},set:function(e){if("number"!=typeof e&&"auto"!==e)throw new SyntaxError("Line: an invalid number or illegal string was specified.");d=e,this.hasBeenReset=!0}},lineAlign:{enumerable:!0,get:function(){return h},set:function(e){var t=ji(e);t&&(h=t,this.hasBeenReset=!0)}},position:{enumerable:!0,get:function(){return p},set:function(e){if(e<0||100<e)throw new Error("Position must be between 0 and 100.");p=e,this.hasBeenReset=!0}},positionAlign:{enumerable:!0,get:function(){return f},set:function(e){var t=ji(e);t&&(f=t,this.hasBeenReset=!0)}},size:{enumerable:!0,get:function(){return m},set:function(e){if(e<0||100<e)throw new Error("Size must be between 0 and 100.");m=e,this.hasBeenReset=!0}},align:{enumerable:!0,get:function(){return g},set:function(e){var t=ji(e);if(!t)throw new SyntaxError("align: an invalid or illegal alignment string was specified.");g=t,this.hasBeenReset=!0}}}),this.displayState=void 0}Vi.prototype.getCueAsHTML=function(){return WebVTT.convertCueToDOMTree(window,this.text)};var qi=Vi,Hi={"":!0,up:!0};function Wi(e){return"number"==typeof e&&0<=e&&e<=100}function zi(){var t=100,i=3,n=0,r=100,a=0,s=100,o="";Object.defineProperties(this,{width:{enumerable:!0,get:function(){return t},set:function(e){if(!Wi(e))throw new Error("Width must be between 0 and 100.");t=e}},lines:{enumerable:!0,get:function(){return i},set:function(e){if("number"!=typeof e)throw new TypeError("Lines must be set to a number.");i=e}},regionAnchorY:{enumerable:!0,get:function(){return r},set:function(e){if(!Wi(e))throw new Error("RegionAnchorX must be between 0 and 100.");r=e}},regionAnchorX:{enumerable:!0,get:function(){return n},set:function(e){if(!Wi(e))throw new Error("RegionAnchorY must be between 0 and 100.");n=e}},viewportAnchorY:{enumerable:!0,get:function(){return s},set:function(e){if(!Wi(e))throw new Error("ViewportAnchorY must be between 0 and 100.");s=e}},viewportAnchorX:{enumerable:!0,get:function(){return a},set:function(e){if(!Wi(e))throw new Error("ViewportAnchorX must be between 0 and 100.");a=e}},scroll:{enumerable:!0,get:function(){return o},set:function(e){var t=function(e){return"string"==typeof e&&(!!Hi[e.toLowerCase()]&&e.toLowerCase())}(e);!1===t||(o=t)}}})}var Gi=i(function(e){var t=e.exports={WebVTT:Ui,VTTCue:qi,VTTRegion:zi};T.vttjs=t,T.WebVTT=t.WebVTT;var i=t.VTTCue,n=t.VTTRegion,r=T.VTTCue,a=T.VTTRegion;t.shim=function(){T.VTTCue=i,T.VTTRegion=n},t.restore=function(){T.VTTCue=r,T.VTTRegion=a},T.VTTCue||t.shim()});Gi.WebVTT,Gi.VTTCue,Gi.VTTRegion;var Xi=function(t){function i(i,e){var n;return void 0===i&&(i={}),void 0===e&&(e=function(){}),i.reportTouchActivity=!1,(n=t.call(this,null,i,e)||this).hasStarted_=!1,n.on("playing",function(){this.hasStarted_=!0}),n.on("loadstart",function(){this.hasStarted_=!1}),gi.names.forEach(function(e){var t=gi[e];i&&i[t.getterName]&&(n[t.privateName]=i[t.getterName])}),n.featuresProgressEvents||n.manualProgressOn(),n.featuresTimeupdateEvents||n.manualTimeUpdatesOn(),["Text","Audio","Video"].forEach(function(e){!1===i["native"+e+"Tracks"]&&(n["featuresNative"+e+"Tracks"]=!1)}),!1===i.nativeCaptions||!1===i.nativeTextTracks?n.featuresNativeTextTracks=!1:!0!==i.nativeCaptions&&!0!==i.nativeTextTracks||(n.featuresNativeTextTracks=!0),n.featuresNativeTextTracks||n.emulateTextTracks(),n.preloadTextTracks=!1!==i.preloadTextTracks,n.autoRemoteTextTracks_=new gi.text.ListClass,n.initTrackListeners(),i.nativeControlsForTouch||n.emitTapEvents(),n.constructor&&(n.name_=n.constructor.name||"Unknown Tech"),n}Ge(i,t);var e=i.prototype;return e.triggerSourceset=function(e){var t=this;this.isReady_||this.one("ready",function(){return t.setTimeout(function(){return t.triggerSourceset(e)},1)}),this.trigger({src:e,type:"sourceset"})},e.manualProgressOn=function(){this.on("durationchange",this.onDurationChange),this.manualProgress=!0,this.one("ready",this.trackProgress)},e.manualProgressOff=function(){this.manualProgress=!1,this.stopTrackingProgress(),this.off("durationchange",this.onDurationChange)},e.trackProgress=function(e){this.stopTrackingProgress(),this.progressInterval=this.setInterval(ve(this,function(){var e=this.bufferedPercent();this.bufferedPercent_!==e&&this.trigger("progress"),1===(this.bufferedPercent_=e)&&this.stopTrackingProgress()}),500)},e.onDurationChange=function(e){this.duration_=this.duration()},e.buffered=function(){return gt(0,0)},e.bufferedPercent=function(){return vt(this.buffered(),this.duration_)},e.stopTrackingProgress=function(){this.clearInterval(this.progressInterval)},e.manualTimeUpdatesOn=function(){this.manualTimeUpdates=!0,this.on("play",this.trackCurrentTime),this.on("pause",this.stopTrackingCurrentTime)},e.manualTimeUpdatesOff=function(){this.manualTimeUpdates=!1,this.stopTrackingCurrentTime(),this.off("play",this.trackCurrentTime),this.off("pause",this.stopTrackingCurrentTime)},e.trackCurrentTime=function(){this.currentTimeInterval&&this.stopTrackingCurrentTime(),this.currentTimeInterval=this.setInterval(function(){this.trigger({type:"timeupdate",target:this,manuallyTriggered:!0})},250)},e.stopTrackingCurrentTime=function(){this.clearInterval(this.currentTimeInterval),this.trigger({type:"timeupdate",target:this,manuallyTriggered:!0})},e.dispose=function(){this.clearTracks(fi.names),this.manualProgress&&this.manualProgressOff(),this.manualTimeUpdates&&this.manualTimeUpdatesOff(),t.prototype.dispose.call(this)},e.clearTracks=function(e){var r=this;(e=[].concat(e)).forEach(function(e){for(var t=r[e+"Tracks"]()||[],i=t.length;i--;){var n=t[i];"text"===e&&r.removeRemoteTextTrack(n),t.removeTrack(n)}})},e.cleanupAutoTextTracks=function(){for(var e=this.autoRemoteTextTracks_||[],t=e.length;t--;){var i=e[t];this.removeRemoteTextTrack(i)}},e.reset=function(){},e.crossOrigin=function(){},e.setCrossOrigin=function(){},e.error=function(e){return void 0!==e&&(this.error_=new Ct(e),this.trigger("error")),this.error_},e.played=function(){return this.hasStarted_?gt(0,0):gt()},e.setScrubbing=function(){},e.setCurrentTime=function(){this.manualTimeUpdates&&this.trigger({type:"timeupdate",target:this,manuallyTriggered:!0})},e.initTrackListeners=function(){var r=this;fi.names.forEach(function(e){function t(){r.trigger(e+"trackchange")}var i=fi[e],n=r[i.getterName]();n.addEventListener("removetrack",t),n.addEventListener("addtrack",t),r.on("dispose",function(){n.removeEventListener("removetrack",t),n.removeEventListener("addtrack",t)})})},e.addWebVttScript_=function(){var e=this;if(!T.WebVTT)if(d.body.contains(this.el())){if(!this.options_["vtt.js"]&&o(Gi)&&0<Object.keys(Gi).length)return void this.trigger("vttjsloaded");var t=d.createElement("script");t.src=this.options_["vtt.js"]||"https://vjs.zencdn.net/vttjs/0.14.1/vtt.min.js",t.onload=function(){e.trigger("vttjsloaded")},t.onerror=function(){e.trigger("vttjserror")},this.on("dispose",function(){t.onload=null,t.onerror=null}),T.WebVTT=!0,this.el().parentNode.appendChild(t)}else this.ready(this.addWebVttScript_)},e.emulateTextTracks=function(){function t(e){return n.addTrack(e.track)}function i(e){return n.removeTrack(e.track)}var e=this,n=this.textTracks(),r=this.remoteTextTracks();r.on("addtrack",t),r.on("removetrack",i),this.addWebVttScript_();function a(){return e.trigger("texttrackchange")}function s(){a();for(var e=0;e<n.length;e++){var t=n[e];t.removeEventListener("cuechange",a),"showing"===t.mode&&t.addEventListener("cuechange",a)}}s(),n.addEventListener("change",s),n.addEventListener("addtrack",s),n.addEventListener("removetrack",s),this.on("dispose",function(){r.off("addtrack",t),r.off("removetrack",i),n.removeEventListener("change",s),n.removeEventListener("addtrack",s),n.removeEventListener("removetrack",s);for(var e=0;e<n.length;e++){n[e].removeEventListener("cuechange",a)}})},e.addTextTrack=function(e,t,i){if(!e)throw new Error("TextTrack kind is required but was not provided");return function(e,t,i,n,r){void 0===r&&(r={});var a=e.textTracks();r.kind=t,i&&(r.label=i),n&&(r.language=n),r.tech=e;var s=new gi.text.TrackClass(r);return a.addTrack(s),s}(this,e,t,i)},e.createRemoteTextTrack=function(e){var t=Re(e,{tech:this});return new mi.remoteTextEl.TrackClass(t)},e.addRemoteTextTrack=function(e,t){var i=this;void 0===e&&(e={});var n=this.createRemoteTextTrack(e);return!0!==t&&!1!==t&&(p.warn('Calling addRemoteTextTrack without explicitly setting the "manualCleanup" parameter to `true` is deprecated and default to `false` in future version of video.js'),t=!0),this.remoteTextTrackEls().addTrackElement_(n),this.remoteTextTracks().addTrack(n.track),!0!==t&&this.ready(function(){return i.autoRemoteTextTracks_.addTrack(n.track)}),n},e.removeRemoteTextTrack=function(e){var t=this.remoteTextTrackEls().getTrackElementByTrack_(e);this.remoteTextTrackEls().removeTrackElement_(t),this.remoteTextTracks().removeTrack(e),this.autoRemoteTextTracks_.removeTrack(e)},e.getVideoPlaybackQuality=function(){return{}},e.requestPictureInPicture=function(){var e=this.options_.Promise||T.Promise;if(e)return e.reject()},e.disablePictureInPicture=function(){return!0},e.setDisablePictureInPicture=function(){},e.setPoster=function(){},e.playsinline=function(){},e.setPlaysinline=function(){},e.overrideNativeAudioTracks=function(){},e.overrideNativeVideoTracks=function(){},e.canPlayType=function(){return""},i.canPlayType=function(){return""},i.canPlaySource=function(e,t){return i.canPlayType(e.type)},i.isTech=function(e){return e.prototype instanceof i||e instanceof i||e===i},i.registerTech=function(e,t){if(i.techs_||(i.techs_={}),!i.isTech(t))throw new Error("Tech "+e+" must be a Tech");if(!i.canPlayType)throw new Error("Techs must have a static canPlayType method on them");if(!i.canPlaySource)throw new Error("Techs must have a static canPlaySource method on them");return e=Me(e),i.techs_[e]=t,i.techs_[De(e)]=t,"Tech"!==e&&i.defaultTechOrder_.push(e),t},i.getTech=function(e){if(e)return i.techs_&&i.techs_[e]?i.techs_[e]:(e=Me(e),T&&T.videojs&&T.videojs[e]?(p.warn("The "+e+" tech was added to the videojs object when it should be registered using videojs.registerTech(name, tech)"),T.videojs[e]):void 0)},i}(je);gi.names.forEach(function(e){var t=gi[e];Xi.prototype[t.getterName]=function(){return this[t.privateName]=this[t.privateName]||new t.ListClass,this[t.privateName]}}),Xi.prototype.featuresVolumeControl=!0,Xi.prototype.featuresMuteControl=!0,Xi.prototype.featuresFullscreenResize=!1,Xi.prototype.featuresPlaybackRate=!1,Xi.prototype.featuresProgressEvents=!1,Xi.prototype.featuresSourceset=!1,Xi.prototype.featuresTimeupdateEvents=!1,Xi.prototype.featuresNativeTextTracks=!1,Xi.withSourceHandlers=function(r){r.registerSourceHandler=function(e,t){var i=r.sourceHandlers;i=i||(r.sourceHandlers=[]),void 0===t&&(t=i.length),i.splice(t,0,e)},r.canPlayType=function(e){for(var t,i=r.sourceHandlers||[],n=0;n<i.length;n++)if(t=i[n].canPlayType(e))return t;return""},r.selectSourceHandler=function(e,t){for(var i=r.sourceHandlers||[],n=0;n<i.length;n++)if(i[n].canHandleSource(e,t))return i[n];return null},r.canPlaySource=function(e,t){var i=r.selectSourceHandler(e,t);return i?i.canHandleSource(e,t):""};["seekable","seeking","duration"].forEach(function(e){var t=this[e];"function"==typeof t&&(this[e]=function(){return this.sourceHandler_&&this.sourceHandler_[e]?this.sourceHandler_[e].apply(this.sourceHandler_,arguments):t.apply(this,arguments)})},r.prototype),r.prototype.setSource=function(e){var t=r.selectSourceHandler(e,this.options_);t||(r.nativeSourceHandler?t=r.nativeSourceHandler:p.error("No source handler found for the current source.")),this.disposeSourceHandler(),this.off("dispose",this.disposeSourceHandler),t!==r.nativeSourceHandler&&(this.currentSource_=e),this.sourceHandler_=t.handleSource(e,this,this.options_),this.one("dispose",this.disposeSourceHandler)},r.prototype.disposeSourceHandler=function(){this.currentSource_&&(this.clearTracks(["audio","video"]),this.currentSource_=null),this.cleanupAutoTextTracks(),this.sourceHandler_&&(this.sourceHandler_.dispose&&this.sourceHandler_.dispose(),this.sourceHandler_=null)}},je.registerComponent("Tech",Xi),Xi.registerTech("Tech",Xi),Xi.defaultTechOrder_=[];var Ki={},Yi={},$i={};function Qi(e,t,i){e.setTimeout(function(){return function i(n,e,r,a,s,o){void 0===n&&(n={});void 0===e&&(e=[]);void 0===s&&(s=[]);void 0===o&&(o=!1);var t=e,u=t[0],l=t.slice(1);if("string"==typeof u)i(n,Ki[u],r,a,s,o);else if(u){var c=rn(a,u);if(!c.setSource)return s.push(c),i(n,l,r,a,s,o);c.setSource(g({},n),function(e,t){if(e)return i(n,l,r,a,s,o);s.push(c),i(t,n.type===t.type?l:Ki[t.type],r,a,s,o)})}else l.length?i(n,l,r,a,s,o):o?r(n,s):i(n,Ki["*"],r,a,s,!0)}(t,Ki[t.type],i,e)},1)}function Ji(e,t,i,n){void 0===n&&(n=null);var r="call"+Me(i),a=e.reduce(nn(r),n),s=a===$i,o=s?null:t[i](a);return function(e,t,i,n){for(var r=e.length-1;0<=r;r--){var a=e[r];a[t]&&a[t](n,i)}}(e,i,o,s),o}var Zi={buffered:1,currentTime:1,duration:1,muted:1,played:1,paused:1,seekable:1,volume:1},en={setCurrentTime:1,setMuted:1,setVolume:1},tn={play:1,pause:1};function nn(i){return function(e,t){return e===$i?$i:t[i]?t[i](e):e}}function rn(e,t){var i=Yi[e.id()],n=null;if(null==i)return n=t(e),Yi[e.id()]=[[t,n]],n;for(var r=0;r<i.length;r++){var a=i[r],s=a[0],o=a[1];s===t&&(n=o)}return null===n&&(n=t(e),i.push([t,n])),n}function an(e){void 0===e&&(e="");var t=Vt(e);return sn[t.toLowerCase()]||""}var sn={opus:"video/ogg",ogv:"video/ogg",mp4:"video/mp4",mov:"video/mp4",m4v:"video/mp4",mkv:"video/x-matroska",m4a:"audio/mp4",mp3:"audio/mpeg",aac:"audio/aac",caf:"audio/x-caf",flac:"audio/flac",oga:"audio/ogg",wav:"audio/wav",m3u8:"application/x-mpegURL",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",png:"image/png",svg:"image/svg+xml",webp:"image/webp"};function on(e){if(!e.type){var t=an(e.src);t&&(e.type=t)}return e}var un=function(l){function e(e,t,i){var n,r=Re({createEl:!1},t);if(n=l.call(this,e,r,i)||this,t.playerOptions.sources&&0!==t.playerOptions.sources.length)e.src(t.playerOptions.sources);else for(var a=0,s=t.playerOptions.techOrder;a<s.length;a++){var o=Me(s[a]),u=Xi.getTech(o);if(o||(u=je.getComponent(o)),u&&u.isSupported()){e.loadTech_(o);break}}return n}return Ge(e,l),e}(je);je.registerComponent("MediaLoader",un);var ln=function(r){function e(e,t){var i;return(i=r.call(this,e,t)||this).emitTapEvents(),i.enable(),i}Ge(e,r);var t=e.prototype;return t.createEl=function(e,t,i){void 0===e&&(e="div"),void 0===t&&(t={}),void 0===i&&(i={}),t=g({innerHTML:'<span aria-hidden="true" class="vjs-icon-placeholder"></span>',className:this.buildCSSClass(),tabIndex:0},t),"button"===e&&p.error("Creating a ClickableComponent with an HTML element of "+e+" is not supported; use a Button instead."),i=g({role:"button"},i),this.tabIndex_=t.tabIndex;var n=r.prototype.createEl.call(this,e,t,i);return this.createControlTextEl(n),n},t.dispose=function(){this.controlTextEl_=null,r.prototype.dispose.call(this)},t.createControlTextEl=function(e){return this.controlTextEl_=k("span",{className:"vjs-control-text"},{"aria-live":"polite"}),e&&e.appendChild(this.controlTextEl_),this.controlText(this.controlText_,e),this.controlTextEl_},t.controlText=function(e,t){if(void 0===t&&(t=this.el()),void 0===e)return this.controlText_||"Need Text";var i=this.localize(e);this.controlText_=e,C(this.controlTextEl_,i),this.nonIconControl||t.setAttribute("title",i)},t.buildCSSClass=function(){return"vjs-control vjs-button "+r.prototype.buildCSSClass.call(this)},t.enable=function(){this.enabled_||(this.enabled_=!0,this.removeClass("vjs-disabled"),this.el_.setAttribute("aria-disabled","false"),"undefined"!=typeof this.tabIndex_&&this.el_.setAttribute("tabIndex",this.tabIndex_),this.on(["tap","click"],this.handleClick),this.on("keydown",this.handleKeyDown))},t.disable=function(){this.enabled_=!1,this.addClass("vjs-disabled"),this.el_.setAttribute("aria-disabled","true"),"undefined"!=typeof this.tabIndex_&&this.el_.removeAttribute("tabIndex"),this.off("mouseover",this.handleMouseOver),this.off("mouseout",this.handleMouseOut),this.off(["tap","click"],this.handleClick),this.off("keydown",this.handleKeyDown)},t.handleClick=function(e){this.options_.clickHandler&&this.options_.clickHandler.call(this,arguments)},t.handleKeyDown=function(e){Ot.isEventKey(e,"Space")||Ot.isEventKey(e,"Enter")?(e.preventDefault(),e.stopPropagation(),this.trigger("click")):r.prototype.handleKeyDown.call(this,e)},e}(je);je.registerComponent("ClickableComponent",ln);var cn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).update(),e.on("posterchange",ve(Ve(i),i.update)),i}Ge(e,n);var t=e.prototype;return t.dispose=function(){this.player().off("posterchange",this.update),n.prototype.dispose.call(this)},t.createEl=function(){return k("div",{className:"vjs-poster",tabIndex:-1})},t.update=function(e){var t=this.player().poster();this.setSrc(t),t?this.show():this.hide()},t.setSrc=function(e){var t="";e&&(t='url("'+e+'")'),this.el_.style.backgroundImage=t},t.handleClick=function(e){if(this.player_.controls()){var t=this.player_.usingPlugin("eme")&&this.player_.eme.sessions&&0<this.player_.eme.sessions.length;!this.player_.tech(!0)||(at||it)&&t||this.player_.tech(!0).focus(),this.player_.paused()?At(this.player_.play()):this.player_.pause()}},e}(ln);je.registerComponent("PosterImage",cn);var dn="#222",hn={monospace:"monospace",sansSerif:"sans-serif",serif:"serif",monospaceSansSerif:'"Andale Mono", "Lucida Console", monospace',monospaceSerif:'"Courier New", monospace',proportionalSansSerif:"sans-serif",proportionalSerif:"serif",casual:'"Comic Sans MS", Impact, fantasy',script:'"Monotype Corsiva", cursive',smallcaps:'"Andale Mono", "Lucida Console", monospace, sans-serif'};function pn(e,t){var i;if(4===e.length)i=e[1]+e[1]+e[2]+e[2]+e[3]+e[3];else{if(7!==e.length)throw new Error("Invalid color code provided, "+e+"; must be formatted as e.g. #f0e or #f604e2.");i=e.slice(1)}return"rgba("+parseInt(i.slice(0,2),16)+","+parseInt(i.slice(2,4),16)+","+parseInt(i.slice(4,6),16)+","+t+")"}function fn(e,t,i){try{e.style[t]=i}catch(e){return}}var mn=function(a){function e(i,e,t){var n;n=a.call(this,i,e,t)||this;var r=ve(Ve(n),n.updateDisplay);return i.on("loadstart",ve(Ve(n),n.toggleDisplay)),i.on("texttrackchange",r),i.on("loadedmetadata",ve(Ve(n),n.preselectTrack)),i.ready(ve(Ve(n),function(){if(i.tech_&&i.tech_.featuresNativeTextTracks)this.hide();else{i.on("fullscreenchange",r),i.on("playerresize",r),T.addEventListener("orientationchange",r),i.on("dispose",function(){return T.removeEventListener("orientationchange",r)});for(var e=this.options_.playerOptions.tracks||[],t=0;t<e.length;t++)this.player_.addRemoteTextTrack(e[t],!0);this.preselectTrack()}})),n}Ge(e,a);var t=e.prototype;return t.preselectTrack=function(){for(var e,t,i,n={captions:1,subtitles:1},r=this.player_.textTracks(),a=this.player_.cache_.selectedLanguage,s=0;s<r.length;s++){var o=r[s];a&&a.enabled&&a.language&&a.language===o.language&&o.kind in n?i=o.kind===a.kind?o:i||o:a&&!a.enabled?t=e=i=null:o.default&&("descriptions"!==o.kind||e?o.kind in n&&!t&&(t=o):e=o)}i?i.mode="showing":t?t.mode="showing":e&&(e.mode="showing")},t.toggleDisplay=function(){this.player_.tech_&&this.player_.tech_.featuresNativeTextTracks?this.hide():this.show()},t.createEl=function(){return a.prototype.createEl.call(this,"div",{className:"vjs-text-track-display"},{"aria-live":"off","aria-atomic":"true"})},t.clearDisplay=function(){"function"==typeof T.WebVTT&&T.WebVTT.processCues(T,[],this.el_)},t.updateDisplay=function(){var e=this.player_.textTracks(),t=this.options_.allowMultipleShowingTracks;if(this.clearDisplay(),t){for(var i=[],n=0;n<e.length;++n){var r=e[n];"showing"===r.mode&&i.push(r)}this.updateForTrack(i)}else{for(var a=null,s=null,o=e.length;o--;){var u=e[o];"showing"===u.mode&&("descriptions"===u.kind?a=u:s=u)}s?("off"!==this.getAttribute("aria-live")&&this.setAttribute("aria-live","off"),this.updateForTrack(s)):a&&("assertive"!==this.getAttribute("aria-live")&&this.setAttribute("aria-live","assertive"),this.updateForTrack(a))}},t.updateDisplayState=function(e){for(var t=this.player_.textTrackSettings.getValues(),i=e.activeCues,n=i.length;n--;){var r=i[n];if(r){var a=r.displayState;if(t.color&&(a.firstChild.style.color=t.color),t.textOpacity&&fn(a.firstChild,"color",pn(t.color||"#fff",t.textOpacity)),t.backgroundColor&&(a.firstChild.style.backgroundColor=t.backgroundColor),t.backgroundOpacity&&fn(a.firstChild,"backgroundColor",pn(t.backgroundColor||"#000",t.backgroundOpacity)),t.windowColor&&(t.windowOpacity?fn(a,"backgroundColor",pn(t.windowColor,t.windowOpacity)):a.style.backgroundColor=t.windowColor),t.edgeStyle&&("dropshadow"===t.edgeStyle?a.firstChild.style.textShadow="2px 2px 3px #222, 2px 2px 4px #222, 2px 2px 5px "+dn:"raised"===t.edgeStyle?a.firstChild.style.textShadow="1px 1px #222, 2px 2px #222, 3px 3px "+dn:"depressed"===t.edgeStyle?a.firstChild.style.textShadow="1px 1px #ccc, 0 1px #ccc, -1px -1px #222, 0 -1px "+dn:"uniform"===t.edgeStyle&&(a.firstChild.style.textShadow="0 0 4px #222, 0 0 4px #222, 0 0 4px #222, 0 0 4px "+dn)),t.fontPercent&&1!==t.fontPercent){var s=T.parseFloat(a.style.fontSize);a.style.fontSize=s*t.fontPercent+"px",a.style.height="auto",a.style.top="auto"}t.fontFamily&&"default"!==t.fontFamily&&("small-caps"===t.fontFamily?a.firstChild.style.fontVariant="small-caps":a.firstChild.style.fontFamily=hn[t.fontFamily])}}},t.updateForTrack=function(e){if(Array.isArray(e)||(e=[e]),"function"==typeof T.WebVTT&&!e.every(function(e){return!e.activeCues})){for(var t=[],i=0;i<e.length;++i)for(var n=e[i],r=0;r<n.activeCues.length;++r)t.push(n.activeCues[r]);T.WebVTT.processCues(T,t,this.el_);for(var a=0;a<e.length;++a){for(var s=e[a],o=0;o<s.activeCues.length;++o){var u=s.activeCues[o].displayState;I(u,"vjs-text-track-cue"),I(u,"vjs-text-track-cue-"+(s.language?s.language:a))}this.player_.textTrackSettings&&this.updateDisplayState(s)}}},e}(je);je.registerComponent("TextTrackDisplay",mn);var gn=function(r){function e(){return r.apply(this,arguments)||this}return Ge(e,r),e.prototype.createEl=function(){var e=this.player_.isAudio(),t=this.localize(e?"Audio Player":"Video Player"),i=k("span",{className:"vjs-control-text",innerHTML:this.localize("{1} is loading.",[t])}),n=r.prototype.createEl.call(this,"div",{className:"vjs-loading-spinner",dir:"ltr"});return n.appendChild(i),n},e}(je);je.registerComponent("LoadingSpinner",gn);var vn=function(t){function e(){return t.apply(this,arguments)||this}Ge(e,t);var i=e.prototype;return i.createEl=function(e,t,i){void 0===t&&(t={}),void 0===i&&(i={}),t=g({innerHTML:'<span aria-hidden="true" class="vjs-icon-placeholder"></span>',className:this.buildCSSClass()},t),i=g({type:"button"},i);var n=je.prototype.createEl.call(this,"button",t,i);return this.createControlTextEl(n),n},i.addChild=function(e,t){void 0===t&&(t={});var i=this.constructor.name;return p.warn("Adding an actionable (user controllable) child to a Button ("+i+") is not supported; use a ClickableComponent instead."),je.prototype.addChild.call(this,e,t)},i.enable=function(){t.prototype.enable.call(this),this.el_.removeAttribute("disabled")},i.disable=function(){t.prototype.disable.call(this),this.el_.setAttribute("disabled","disabled")},i.handleKeyDown=function(e){Ot.isEventKey(e,"Space")||Ot.isEventKey(e,"Enter")?e.stopPropagation():t.prototype.handleKeyDown.call(this,e)},e}(ln);je.registerComponent("Button",vn);var yn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).mouseused_=!1,i.on("mousedown",i.handleMouseDown),i}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-big-play-button"},t.handleClick=function(e){var t=this.player_.play();if(this.mouseused_&&e.clientX&&e.clientY){var i=this.player_.usingPlugin("eme")&&this.player_.eme.sessions&&0<this.player_.eme.sessions.length;return At(t),void(!this.player_.tech(!0)||(at||it)&&i||this.player_.tech(!0).focus())}var n=this.player_.getChild("controlBar"),r=n&&n.getChild("playToggle");if(r){var a=function(){return r.focus()};It(t)?t.then(a,function(){}):this.setTimeout(a,1)}else this.player_.tech(!0).focus()},t.handleKeyDown=function(e){this.mouseused_=!1,n.prototype.handleKeyDown.call(this,e)},t.handleMouseDown=function(e){this.mouseused_=!0},e}(vn);yn.prototype.controlText_="Play Video",je.registerComponent("BigPlayButton",yn);var _n=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).controlText(t&&t.controlText||i.localize("Close")),i}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-close-button "+n.prototype.buildCSSClass.call(this)},t.handleClick=function(e){this.trigger({type:"close",bubbles:!1})},t.handleKeyDown=function(e){Ot.isEventKey(e,"Esc")?(e.preventDefault(),e.stopPropagation(),this.trigger("click")):n.prototype.handleKeyDown.call(this,e)},e}(vn);je.registerComponent("CloseButton",_n);var bn=function(n){function e(e,t){var i;return void 0===t&&(t={}),i=n.call(this,e,t)||this,t.replay=void 0===t.replay||t.replay,i.on(e,"play",i.handlePlay),i.on(e,"pause",i.handlePause),t.replay&&i.on(e,"ended",i.handleEnded),i}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-play-control "+n.prototype.buildCSSClass.call(this)},t.handleClick=function(e){this.player_.paused()?this.player_.play():this.player_.pause()},t.handleSeeked=function(e){this.removeClass("vjs-ended"),this.player_.paused()?this.handlePause(e):this.handlePlay(e)},t.handlePlay=function(e){this.removeClass("vjs-ended"),this.removeClass("vjs-paused"),this.addClass("vjs-playing"),this.controlText("Pause")},t.handlePause=function(e){this.removeClass("vjs-playing"),this.addClass("vjs-paused"),this.controlText("Play")},t.handleEnded=function(e){this.removeClass("vjs-playing"),this.addClass("vjs-ended"),this.controlText("Replay"),this.one(this.player_,"seeked",this.handleSeeked)},e}(vn);bn.prototype.controlText_="Play",je.registerComponent("PlayToggle",bn);function Tn(e,t){e=e<0?0:e;var i=Math.floor(e%60),n=Math.floor(e/60%60),r=Math.floor(e/3600),a=Math.floor(t/60%60),s=Math.floor(t/3600);return!isNaN(e)&&e!==1/0||(r=n=i="-"),(r=0<r||0<s?r+":":"")+(n=((r||10<=a)&&n<10?"0"+n:n)+":")+(i=i<10?"0"+i:i)}var Sn=Tn;function kn(e,t){return void 0===t&&(t=e),Sn(e,t)}var Cn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).on(e,["timeupdate","ended"],i.updateContent),i.updateTextNode_(),i}Ge(e,n);var t=e.prototype;return t.createEl=function(){var e=this.buildCSSClass(),t=n.prototype.createEl.call(this,"div",{className:e+" vjs-time-control vjs-control",innerHTML:'<span class="vjs-control-text" role="presentation">'+this.localize(this.labelText_)+" </span>"});return this.contentEl_=k("span",{className:e+"-display"},{"aria-live":"off",role:"presentation"}),t.appendChild(this.contentEl_),t},t.dispose=function(){this.contentEl_=null,this.textNode_=null,n.prototype.dispose.call(this)},t.updateTextNode_=function(e){var t=this;void 0===e&&(e=0),e=kn(e),this.formattedTime_!==e&&(this.formattedTime_=e,this.requestNamedAnimationFrame("TimeDisplay#updateTextNode_",function(){if(t.contentEl_){var e=t.textNode_;t.textNode_=d.createTextNode(t.formattedTime_),t.textNode_&&(e?t.contentEl_.replaceChild(t.textNode_,e):t.contentEl_.appendChild(t.textNode_))}}))},t.updateContent=function(e){},e}(je);Cn.prototype.labelText_="Time",Cn.prototype.controlText_="Time",je.registerComponent("TimeDisplay",Cn);var En=function(e){function t(){return e.apply(this,arguments)||this}Ge(t,e);var i=t.prototype;return i.buildCSSClass=function(){return"vjs-current-time"},i.updateContent=function(e){var t;t=this.player_.ended()?this.player_.duration():this.player_.scrubbing()?this.player_.getCache().currentTime:this.player_.currentTime(),this.updateTextNode_(t)},t}(Cn);En.prototype.labelText_="Current Time",En.prototype.controlText_="Current Time",je.registerComponent("CurrentTimeDisplay",En);var wn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).on(e,"durationchange",i.updateContent),i.on(e,"loadstart",i.updateContent),i.on(e,"loadedmetadata",i.updateContent),i}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-duration"},t.updateContent=function(e){var t=this.player_.duration();this.updateTextNode_(t)},e}(Cn);wn.prototype.labelText_="Duration",wn.prototype.controlText_="Duration",je.registerComponent("DurationDisplay",wn);var In=function(e){function t(){return e.apply(this,arguments)||this}return Ge(t,e),t.prototype.createEl=function(){return e.prototype.createEl.call(this,"div",{className:"vjs-time-control vjs-time-divider",innerHTML:"<div><span>/</span></div>"},{"aria-hidden":!0})},t}(je);je.registerComponent("TimeDivider",In);var An=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).on(e,"durationchange",i.updateContent),i}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-remaining-time"},t.createEl=function(){var e=n.prototype.createEl.call(this);return e.insertBefore(k("span",{},{"aria-hidden":!0},"-"),this.contentEl_),e},t.updateContent=function(e){var t;"number"==typeof this.player_.duration()&&(t=this.player_.ended()?0:this.player_.remainingTimeDisplay?this.player_.remainingTimeDisplay():this.player_.remainingTime(),this.updateTextNode_(t))},e}(Cn);An.prototype.labelText_="Remaining Time",An.prototype.controlText_="Remaining Time",je.registerComponent("RemainingTimeDisplay",An);var xn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).updateShowing(),i.on(i.player(),"durationchange",i.updateShowing),i}Ge(e,n);var t=e.prototype;return t.createEl=function(){var e=n.prototype.createEl.call(this,"div",{className:"vjs-live-control vjs-control"});return this.contentEl_=k("div",{className:"vjs-live-display",innerHTML:'<span class="vjs-control-text">'+this.localize("Stream Type")+" </span>"+this.localize("LIVE")},{"aria-live":"off"}),e.appendChild(this.contentEl_),e},t.dispose=function(){this.contentEl_=null,n.prototype.dispose.call(this)},t.updateShowing=function(e){this.player().duration()===1/0?this.show():this.hide()},e}(je);je.registerComponent("LiveDisplay",xn);var Pn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).updateLiveEdgeStatus(),i.player_.liveTracker&&i.on(i.player_.liveTracker,"liveedgechange",i.updateLiveEdgeStatus),i}Ge(e,n);var t=e.prototype;return t.createEl=function(){var e=n.prototype.createEl.call(this,"button",{className:"vjs-seek-to-live-control vjs-control"});return this.textEl_=k("span",{className:"vjs-seek-to-live-text",innerHTML:this.localize("LIVE")},{"aria-hidden":"true"}),e.appendChild(this.textEl_),e},t.updateLiveEdgeStatus=function(){!this.player_.liveTracker||this.player_.liveTracker.atLiveEdge()?(this.setAttribute("aria-disabled",!0),this.addClass("vjs-at-live-edge"),this.controlText("Seek to live, currently playing live")):(this.setAttribute("aria-disabled",!1),this.removeClass("vjs-at-live-edge"),this.controlText("Seek to live, currently behind live"))},t.handleClick=function(){this.player_.liveTracker.seekToLiveEdge()},t.dispose=function(){this.player_.liveTracker&&this.off(this.player_.liveTracker,"liveedgechange",this.updateLiveEdgeStatus),this.textEl_=null,n.prototype.dispose.call(this)},e}(vn);Pn.prototype.controlText_="Seek to live, currently playing live",je.registerComponent("SeekToLive",Pn);function Ln(e,t,i){return e=Number(e),Math.min(i,Math.max(t,isNaN(e)?t:e))}var On=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).bar=i.getChild(i.options_.barName),i.vertical(!!i.options_.vertical),i.enable(),i}Ge(e,n);var t=e.prototype;return t.enabled=function(){return this.enabled_},t.enable=function(){this.enabled()||(this.on("mousedown",this.handleMouseDown),this.on("touchstart",this.handleMouseDown),this.on("keydown",this.handleKeyDown),this.on("click",this.handleClick),this.on(this.player_,"controlsvisible",this.update),this.playerEvent&&this.on(this.player_,this.playerEvent,this.update),this.removeClass("disabled"),this.setAttribute("tabindex",0),this.enabled_=!0)},t.disable=function(){if(this.enabled()){var e=this.bar.el_.ownerDocument;this.off("mousedown",this.handleMouseDown),this.off("touchstart",this.handleMouseDown),this.off("keydown",this.handleKeyDown),this.off("click",this.handleClick),this.off(this.player_,"controlsvisible",this.update),this.off(e,"mousemove",this.handleMouseMove),this.off(e,"mouseup",this.handleMouseUp),this.off(e,"touchmove",this.handleMouseMove),this.off(e,"touchend",this.handleMouseUp),this.removeAttribute("tabindex"),this.addClass("disabled"),this.playerEvent&&this.off(this.player_,this.playerEvent,this.update),this.enabled_=!1}},t.createEl=function(e,t,i){return void 0===t&&(t={}),void 0===i&&(i={}),t.className=t.className+" vjs-slider",t=g({tabIndex:0},t),i=g({role:"slider","aria-valuenow":0,"aria-valuemin":0,"aria-valuemax":100,tabIndex:0},i),n.prototype.createEl.call(this,e,t,i)},t.handleMouseDown=function(e){var t=this.bar.el_.ownerDocument;"mousedown"===e.type&&e.preventDefault(),"touchstart"!==e.type||nt||e.preventDefault(),R(),this.addClass("vjs-sliding"),this.trigger("slideractive"),this.on(t,"mousemove",this.handleMouseMove),this.on(t,"mouseup",this.handleMouseUp),this.on(t,"touchmove",this.handleMouseMove),this.on(t,"touchend",this.handleMouseUp),this.handleMouseMove(e)},t.handleMouseMove=function(e){},t.handleMouseUp=function(){var e=this.bar.el_.ownerDocument;N(),this.removeClass("vjs-sliding"),this.trigger("sliderinactive"),this.off(e,"mousemove",this.handleMouseMove),this.off(e,"mouseup",this.handleMouseUp),this.off(e,"touchmove",this.handleMouseMove),this.off(e,"touchend",this.handleMouseUp),this.update()},t.update=function(){var t=this;if(this.el_&&this.bar){var i=this.getProgress();return i===this.progress_||(this.progress_=i,this.requestNamedAnimationFrame("Slider#update",function(){var e=t.vertical()?"height":"width";t.bar.el().style[e]=(100*i).toFixed(2)+"%"})),i}},t.getProgress=function(){return Number(Ln(this.getPercent(),0,1).toFixed(4))},t.calculateDistance=function(e){var t=B(this.el_,e);return this.vertical()?t.y:t.x},t.handleKeyDown=function(e){Ot.isEventKey(e,"Left")||Ot.isEventKey(e,"Down")?(e.preventDefault(),e.stopPropagation(),this.stepBack()):Ot.isEventKey(e,"Right")||Ot.isEventKey(e,"Up")?(e.preventDefault(),e.stopPropagation(),this.stepForward()):n.prototype.handleKeyDown.call(this,e)},t.handleClick=function(e){e.stopPropagation(),e.preventDefault()},t.vertical=function(e){if(void 0===e)return this.vertical_||!1;this.vertical_=!!e,this.vertical_?this.addClass("vjs-slider-vertical"):this.addClass("vjs-slider-horizontal")},e}(je);je.registerComponent("Slider",On);function Dn(e,t){return Ln(e/t*100,0,100).toFixed(2)+"%"}var Mn=function(r){function e(e,t){var i;return(i=r.call(this,e,t)||this).partEls_=[],i.on(e,"progress",i.update),i}Ge(e,r);var t=e.prototype;return t.createEl=function(){var e=r.prototype.createEl.call(this,"div",{className:"vjs-load-progress"}),t=k("span",{className:"vjs-control-text"}),i=k("span",{textContent:this.localize("Loaded")}),n=d.createTextNode(": ");return this.percentageEl_=k("span",{className:"vjs-control-text-loaded-percentage",textContent:"0%"}),e.appendChild(t),t.appendChild(i),t.appendChild(n),t.appendChild(this.percentageEl_),e},t.dispose=function(){this.partEls_=null,this.percentageEl_=null,r.prototype.dispose.call(this)},t.update=function(e){var d=this;this.requestNamedAnimationFrame("LoadProgressBar#update",function(){var e=d.player_.liveTracker,t=d.player_.buffered(),i=e&&e.isLive()?e.seekableEnd():d.player_.duration(),n=d.player_.bufferedEnd(),r=d.partEls_,a=Dn(n,i);d.percent_!==a&&(d.el_.style.width=a,C(d.percentageEl_,a),d.percent_=a);for(var s=0;s<t.length;s++){var o=t.start(s),u=t.end(s),l=r[s];l||(l=d.el_.appendChild(k()),r[s]=l),l.dataset.start===o&&l.dataset.end===u||(l.dataset.start=o,l.dataset.end=u,l.style.left=Dn(o,n),l.style.width=Dn(u-o,n))}for(var c=r.length;c>t.length;c--)d.el_.removeChild(r[c-1]);r.length=t.length})},e}(je);je.registerComponent("LoadProgressBar",Mn);var Rn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).update=ye(ve(Ve(i),i.update),30),i}Ge(e,n);var t=e.prototype;return t.createEl=function(){return n.prototype.createEl.call(this,"div",{className:"vjs-time-tooltip"},{"aria-hidden":"true"})},t.update=function(e,t,i){var n=F(this.el_),r=U(this.player_.el()),a=e.width*t;if(r&&n){var s=e.left-r.left+a,o=e.width-a+(r.right-e.right),u=n.width/2;s<u?u+=u-s:o<u&&(u=o),u<0?u=0:u>n.width&&(u=n.width),this.el_.style.right="-"+u+"px",this.write(i)}},t.write=function(e){C(this.el_,e)},t.updateTime=function(r,a,s,o){var u=this;this.requestNamedAnimationFrame("TimeTooltip#updateTime",function(){var e,t=u.player_.duration();if(u.player_.liveTracker&&u.player_.liveTracker.isLive()){var i=u.player_.liveTracker.liveWindow(),n=i-a*i;e=(n<1?"":"-")+kn(n,i)}else e=kn(s,t);u.update(r,a,e),o&&o()})},e}(je);je.registerComponent("TimeTooltip",Rn);var Nn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).update=ye(ve(Ve(i),i.update),30),i}Ge(e,n);var t=e.prototype;return t.createEl=function(){return n.prototype.createEl.call(this,"div",{className:"vjs-play-progress vjs-slider-bar"},{"aria-hidden":"true"})},t.update=function(e,t){var i=this.getChild("timeTooltip");if(i){var n=this.player_.scrubbing()?this.player_.getCache().currentTime:this.player_.currentTime();i.updateTime(e,t,n)}},e}(je);Nn.prototype.options_={children:[]},dt||Je||Nn.prototype.options_.children.push("timeTooltip"),je.registerComponent("PlayProgressBar",Nn);var Un=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).update=ye(ve(Ve(i),i.update),30),i}Ge(e,n);var t=e.prototype;return t.createEl=function(){return n.prototype.createEl.call(this,"div",{className:"vjs-mouse-display"})},t.update=function(e,t){var i=this,n=t*this.player_.duration();this.getChild("timeTooltip").updateTime(e,t,n,function(){i.el_.style.left=e.width*t+"px"})},e}(je);Un.prototype.options_={children:["timeTooltip"]},je.registerComponent("MouseTimeDisplay",Un);var Fn=function(a){function e(e,t){var i;return(i=a.call(this,e,t)||this).setEventHandlers_(),i}Ge(e,a);var t=e.prototype;return t.setEventHandlers_=function(){this.update_=ve(this,this.update),this.update=ye(this.update_,30),this.on(this.player_,["ended","durationchange","timeupdate"],this.update),this.player_.liveTracker&&this.on(this.player_.liveTracker,"liveedgechange",this.update),this.updateInterval=null,this.on(this.player_,["playing"],this.enableInterval_),this.on(this.player_,["ended","pause","waiting"],this.disableInterval_),"hidden"in d&&"visibilityState"in d&&this.on(d,"visibilitychange",this.toggleVisibility_)},t.toggleVisibility_=function(e){d.hidden?this.disableInterval_(e):(this.enableInterval_(),this.update())},t.enableInterval_=function(){this.updateInterval||(this.updateInterval=this.setInterval(this.update,30))},t.disableInterval_=function(e){this.player_.liveTracker&&this.player_.liveTracker.isLive()&&e&&"ended"!==e.type||this.updateInterval&&(this.clearInterval(this.updateInterval),this.updateInterval=null)},t.createEl=function(){return a.prototype.createEl.call(this,"div",{className:"vjs-progress-holder"},{"aria-label":this.localize("Progress Bar")})},t.update=function(e){var n=this,r=a.prototype.update.call(this);return this.requestNamedAnimationFrame("SeekBar#update",function(){var e=n.player_.ended()?n.player_.duration():n.getCurrentTime_(),t=n.player_.liveTracker,i=n.player_.duration();t&&t.isLive()&&(i=n.player_.liveTracker.liveCurrentTime()),n.percent_!==r&&(n.el_.setAttribute("aria-valuenow",(100*r).toFixed(2)),n.percent_=r),n.currentTime_===e&&n.duration_===i||(n.el_.setAttribute("aria-valuetext",n.localize("progress bar timing: currentTime={1} duration={2}",[kn(e,i),kn(i,i)],"{1} of {2}")),n.currentTime_=e,n.duration_=i),n.bar&&n.bar.update(U(n.el()),n.getProgress())}),r},t.getCurrentTime_=function(){return this.player_.scrubbing()?this.player_.getCache().currentTime:this.player_.currentTime()},t.getPercent=function(){var e,t=this.getCurrentTime_(),i=this.player_.liveTracker;return i&&i.isLive()?(e=(t-i.seekableStart())/i.liveWindow(),i.atLiveEdge()&&(e=1)):e=t/this.player_.duration(),e},t.handleMouseDown=function(e){z(e)&&(e.stopPropagation(),this.player_.scrubbing(!0),this.videoWasPlaying=!this.player_.paused(),this.player_.pause(),a.prototype.handleMouseDown.call(this,e))},t.handleMouseMove=function(e){if(z(e)){var t,i=this.calculateDistance(e),n=this.player_.liveTracker;if(n&&n.isLive()){if(.99<=i)return void n.seekToLiveEdge();var r=n.seekableStart(),a=n.liveCurrentTime();if(a<=(t=r+i*n.liveWindow())&&(t=a),t<=r&&(t=r+.1),t===1/0)return}else(t=i*this.player_.duration())===this.player_.duration()&&(t-=.1);this.player_.currentTime(t)}},t.enable=function(){a.prototype.enable.call(this);var e=this.getChild("mouseTimeDisplay");e&&e.show()},t.disable=function(){a.prototype.disable.call(this);var e=this.getChild("mouseTimeDisplay");e&&e.hide()},t.handleMouseUp=function(e){a.prototype.handleMouseUp.call(this,e),e&&e.stopPropagation(),this.player_.scrubbing(!1),this.player_.trigger({type:"timeupdate",target:this,manuallyTriggered:!0}),this.videoWasPlaying?At(this.player_.play()):this.update_()},t.stepForward=function(){this.player_.currentTime(this.player_.currentTime()+5)},t.stepBack=function(){this.player_.currentTime(this.player_.currentTime()-5)},t.handleAction=function(e){this.player_.paused()?this.player_.play():this.player_.pause()},t.handleKeyDown=function(e){if(Ot.isEventKey(e,"Space")||Ot.isEventKey(e,"Enter"))e.preventDefault(),e.stopPropagation(),this.handleAction(e);else if(Ot.isEventKey(e,"Home"))e.preventDefault(),e.stopPropagation(),this.player_.currentTime(0);else if(Ot.isEventKey(e,"End"))e.preventDefault(),e.stopPropagation(),this.player_.currentTime(this.player_.duration());else if(/^[0-9]$/.test(Ot(e))){e.preventDefault(),e.stopPropagation();var t=10*(Ot.codes[Ot(e)]-Ot.codes[0])/100;this.player_.currentTime(this.player_.duration()*t)}else Ot.isEventKey(e,"PgDn")?(e.preventDefault(),e.stopPropagation(),this.player_.currentTime(this.player_.currentTime()-60)):Ot.isEventKey(e,"PgUp")?(e.preventDefault(),e.stopPropagation(),this.player_.currentTime(this.player_.currentTime()+60)):a.prototype.handleKeyDown.call(this,e)},t.dispose=function(){this.disableInterval_(),this.off(this.player_,["ended","durationchange","timeupdate"],this.update),this.player_.liveTracker&&this.on(this.player_.liveTracker,"liveedgechange",this.update),this.off(this.player_,["playing"],this.enableInterval_),this.off(this.player_,["ended","pause","waiting"],this.disableInterval_),"hidden"in d&&"visibilityState"in d&&this.off(d,"visibilitychange",this.toggleVisibility_),a.prototype.dispose.call(this)},e}(On);Fn.prototype.options_={children:["loadProgressBar","playProgressBar"],barName:"playProgressBar"},dt||Je||Fn.prototype.options_.children.splice(1,0,"mouseTimeDisplay"),je.registerComponent("SeekBar",Fn);var Bn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).handleMouseMove=ye(ve(Ve(i),i.handleMouseMove),30),i.throttledHandleMouseSeek=ye(ve(Ve(i),i.handleMouseSeek),30),i.enable(),i}Ge(e,n);var t=e.prototype;return t.createEl=function(){return n.prototype.createEl.call(this,"div",{className:"vjs-progress-control vjs-control"})},t.handleMouseMove=function(e){var t=this.getChild("seekBar");if(t){var i=t.getChild("playProgressBar"),n=t.getChild("mouseTimeDisplay");if(i||n){var r=t.el(),a=F(r),s=B(r,e).x;s=Ln(s,0,1),n&&n.update(a,s),i&&i.update(a,t.getProgress())}}},t.handleMouseSeek=function(e){var t=this.getChild("seekBar");t&&t.handleMouseMove(e)},t.enabled=function(){return this.enabled_},t.disable=function(){this.children().forEach(function(e){return e.disable&&e.disable()}),this.enabled()&&(this.off(["mousedown","touchstart"],this.handleMouseDown),this.off(this.el_,"mousemove",this.handleMouseMove),this.handleMouseUp(),this.addClass("disabled"),this.enabled_=!1)},t.enable=function(){this.children().forEach(function(e){return e.enable&&e.enable()}),this.enabled()||(this.on(["mousedown","touchstart"],this.handleMouseDown),this.on(this.el_,"mousemove",this.handleMouseMove),this.removeClass("disabled"),this.enabled_=!0)},t.handleMouseDown=function(e){var t=this.el_.ownerDocument,i=this.getChild("seekBar");i&&i.handleMouseDown(e),this.on(t,"mousemove",this.throttledHandleMouseSeek),this.on(t,"touchmove",this.throttledHandleMouseSeek),this.on(t,"mouseup",this.handleMouseUp),this.on(t,"touchend",this.handleMouseUp)},t.handleMouseUp=function(e){var t=this.el_.ownerDocument,i=this.getChild("seekBar");i&&i.handleMouseUp(e),this.off(t,"mousemove",this.throttledHandleMouseSeek),this.off(t,"touchmove",this.throttledHandleMouseSeek),this.off(t,"mouseup",this.handleMouseUp),this.off(t,"touchend",this.handleMouseUp)},e}(je);Bn.prototype.options_={children:["seekBar"]},je.registerComponent("ProgressControl",Bn);var jn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).on(e,["enterpictureinpicture","leavepictureinpicture"],i.handlePictureInPictureChange),i.on(e,["disablepictureinpicturechanged","loadedmetadata"],i.handlePictureInPictureEnabledChange),i.disable(),i}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-picture-in-picture-control "+n.prototype.buildCSSClass.call(this)},t.handlePictureInPictureEnabledChange=function(){d.pictureInPictureEnabled&&!1===this.player_.disablePictureInPicture()?this.enable():this.disable()},t.handlePictureInPictureChange=function(e){this.player_.isInPictureInPicture()?this.controlText("Exit Picture-in-Picture"):this.controlText("Picture-in-Picture"),this.handlePictureInPictureEnabledChange()},t.handleClick=function(e){this.player_.isInPictureInPicture()?this.player_.exitPictureInPicture():this.player_.requestPictureInPicture()},e}(vn);jn.prototype.controlText_="Picture-in-Picture",je.registerComponent("PictureInPictureToggle",jn);var Vn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).on(e,"fullscreenchange",i.handleFullscreenChange),!1===d[e.fsApi_.fullscreenEnabled]&&i.disable(),i}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-fullscreen-control "+n.prototype.buildCSSClass.call(this)},t.handleFullscreenChange=function(e){this.player_.isFullscreen()?this.controlText("Non-Fullscreen"):this.controlText("Fullscreen")},t.handleClick=function(e){this.player_.isFullscreen()?this.player_.exitFullscreen():this.player_.requestFullscreen()},e}(vn);Vn.prototype.controlText_="Fullscreen",je.registerComponent("FullscreenToggle",Vn);var qn=function(e){function t(){return e.apply(this,arguments)||this}return Ge(t,e),t.prototype.createEl=function(){return e.prototype.createEl.call(this,"div",{className:"vjs-volume-level",innerHTML:'<span class="vjs-control-text"></span>'})},t}(je);je.registerComponent("VolumeLevel",qn);var Hn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).on("slideractive",i.updateLastVolume_),i.on(e,"volumechange",i.updateARIAAttributes),e.ready(function(){return i.updateARIAAttributes()}),i}Ge(e,n);var t=e.prototype;return t.createEl=function(){return n.prototype.createEl.call(this,"div",{className:"vjs-volume-bar vjs-slider-bar"},{"aria-label":this.localize("Volume Level"),"aria-live":"polite"})},t.handleMouseDown=function(e){z(e)&&n.prototype.handleMouseDown.call(this,e)},t.handleMouseMove=function(e){z(e)&&(this.checkMuted(),this.player_.volume(this.calculateDistance(e)))},t.checkMuted=function(){this.player_.muted()&&this.player_.muted(!1)},t.getPercent=function(){return this.player_.muted()?0:this.player_.volume()},t.stepForward=function(){this.checkMuted(),this.player_.volume(this.player_.volume()+.1)},t.stepBack=function(){this.checkMuted(),this.player_.volume(this.player_.volume()-.1)},t.updateARIAAttributes=function(e){var t=this.player_.muted()?0:this.volumeAsPercentage_();this.el_.setAttribute("aria-valuenow",t),this.el_.setAttribute("aria-valuetext",t+"%")},t.volumeAsPercentage_=function(){return Math.round(100*this.player_.volume())},t.updateLastVolume_=function(){var e=this,t=this.player_.volume();this.one("sliderinactive",function(){0===e.player_.volume()&&e.player_.lastVolume_(t)})},e}(On);Hn.prototype.options_={children:["volumeLevel"],barName:"volumeLevel"},Hn.prototype.playerEvent="volumechange",je.registerComponent("VolumeBar",Hn);var Wn=function(n){function e(e,t){var i;return void 0===t&&(t={}),t.vertical=t.vertical||!1,"undefined"!=typeof t.volumeBar&&!o(t.volumeBar)||(t.volumeBar=t.volumeBar||{},t.volumeBar.vertical=t.vertical),i=n.call(this,e,t)||this,function(e,t){t.tech_&&!t.tech_.featuresVolumeControl&&e.addClass("vjs-hidden"),e.on(t,"loadstart",function(){t.tech_.featuresVolumeControl?e.removeClass("vjs-hidden"):e.addClass("vjs-hidden")})}(Ve(i),e),i.throttledHandleMouseMove=ye(ve(Ve(i),i.handleMouseMove),30),i.on("mousedown",i.handleMouseDown),i.on("touchstart",i.handleMouseDown),i.on(i.volumeBar,["focus","slideractive"],function(){i.volumeBar.addClass("vjs-slider-active"),i.addClass("vjs-slider-active"),i.trigger("slideractive")}),i.on(i.volumeBar,["blur","sliderinactive"],function(){i.volumeBar.removeClass("vjs-slider-active"),i.removeClass("vjs-slider-active"),i.trigger("sliderinactive")}),i}Ge(e,n);var t=e.prototype;return t.createEl=function(){var e="vjs-volume-horizontal";return this.options_.vertical&&(e="vjs-volume-vertical"),n.prototype.createEl.call(this,"div",{className:"vjs-volume-control vjs-control "+e})},t.handleMouseDown=function(e){var t=this.el_.ownerDocument;this.on(t,"mousemove",this.throttledHandleMouseMove),this.on(t,"touchmove",this.throttledHandleMouseMove),this.on(t,"mouseup",this.handleMouseUp),this.on(t,"touchend",this.handleMouseUp)},t.handleMouseUp=function(e){var t=this.el_.ownerDocument;this.off(t,"mousemove",this.throttledHandleMouseMove),this.off(t,"touchmove",this.throttledHandleMouseMove),this.off(t,"mouseup",this.handleMouseUp),this.off(t,"touchend",this.handleMouseUp)},t.handleMouseMove=function(e){this.volumeBar.handleMouseMove(e)},e}(je);Wn.prototype.options_={children:["volumeBar"]},je.registerComponent("VolumeControl",Wn);var zn=function(n){function e(e,t){var i;return i=n.call(this,e,t)||this,function(e,t){t.tech_&&!t.tech_.featuresMuteControl&&e.addClass("vjs-hidden"),e.on(t,"loadstart",function(){t.tech_.featuresMuteControl?e.removeClass("vjs-hidden"):e.addClass("vjs-hidden")})}(Ve(i),e),i.on(e,["loadstart","volumechange"],i.update),i}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-mute-control "+n.prototype.buildCSSClass.call(this)},t.handleClick=function(e){var t=this.player_.volume(),i=this.player_.lastVolume_();if(0===t){var n=i<.1?.1:i;this.player_.volume(n),this.player_.muted(!1)}else this.player_.muted(!this.player_.muted())},t.update=function(e){this.updateIcon_(),this.updateControlText_()},t.updateIcon_=function(){var e=this.player_.volume(),t=3;dt&&this.player_.tech_&&this.player_.tech_.el_&&this.player_.muted(this.player_.tech_.el_.muted),0===e||this.player_.muted()?t=0:e<.33?t=1:e<.67&&(t=2);for(var i=0;i<4;i++)A(this.el_,"vjs-vol-"+i);I(this.el_,"vjs-vol-"+t)},t.updateControlText_=function(){var e=this.player_.muted()||0===this.player_.volume()?"Unmute":"Mute";this.controlText()!==e&&this.controlText(e)},e}(vn);zn.prototype.controlText_="Mute",je.registerComponent("MuteToggle",zn);var Gn=function(n){function e(e,t){var i;return void 0===t&&(t={}),"undefined"!=typeof t.inline?t.inline=t.inline:t.inline=!0,"undefined"!=typeof t.volumeControl&&!o(t.volumeControl)||(t.volumeControl=t.volumeControl||{},t.volumeControl.vertical=!t.inline),(i=n.call(this,e,t)||this).on(e,["loadstart"],i.volumePanelState_),i.on(i.muteToggle,"keyup",i.handleKeyPress),i.on(i.volumeControl,"keyup",i.handleVolumeControlKeyUp),i.on("keydown",i.handleKeyPress),i.on("mouseover",i.handleMouseOver),i.on("mouseout",i.handleMouseOut),i.on(i.volumeControl,["slideractive"],i.sliderActive_),i.on(i.volumeControl,["sliderinactive"],i.sliderInactive_),i}Ge(e,n);var t=e.prototype;return t.sliderActive_=function(){this.addClass("vjs-slider-active")},t.sliderInactive_=function(){this.removeClass("vjs-slider-active")},t.volumePanelState_=function(){this.volumeControl.hasClass("vjs-hidden")&&this.muteToggle.hasClass("vjs-hidden")&&this.addClass("vjs-hidden"),this.volumeControl.hasClass("vjs-hidden")&&!this.muteToggle.hasClass("vjs-hidden")&&this.addClass("vjs-mute-toggle-only")},t.createEl=function(){var e="vjs-volume-panel-horizontal";return this.options_.inline||(e="vjs-volume-panel-vertical"),n.prototype.createEl.call(this,"div",{className:"vjs-volume-panel vjs-control "+e})},t.dispose=function(){this.handleMouseOut(),n.prototype.dispose.call(this)},t.handleVolumeControlKeyUp=function(e){Ot.isEventKey(e,"Esc")&&this.muteToggle.focus()},t.handleMouseOver=function(e){this.addClass("vjs-hover"),he(d,"keyup",ve(this,this.handleKeyPress))},t.handleMouseOut=function(e){this.removeClass("vjs-hover"),pe(d,"keyup",ve(this,this.handleKeyPress))},t.handleKeyPress=function(e){Ot.isEventKey(e,"Esc")&&this.handleMouseOut()},e}(je);Gn.prototype.options_={children:["muteToggle","volumeControl"]},je.registerComponent("VolumePanel",Gn);var Xn=function(n){function e(e,t){var i;return i=n.call(this,e,t)||this,t&&(i.menuButton_=t.menuButton),i.focusedChild_=-1,i.on("keydown",i.handleKeyDown),i.boundHandleBlur_=ve(Ve(i),i.handleBlur),i.boundHandleTapClick_=ve(Ve(i),i.handleTapClick),i}Ge(e,n);var t=e.prototype;return t.addEventListenerForItem=function(e){e instanceof je&&(this.on(e,"blur",this.boundHandleBlur_),this.on(e,["tap","click"],this.boundHandleTapClick_))},t.removeEventListenerForItem=function(e){e instanceof je&&(this.off(e,"blur",this.boundHandleBlur_),this.off(e,["tap","click"],this.boundHandleTapClick_))},t.removeChild=function(e){"string"==typeof e&&(e=this.getChild(e)),this.removeEventListenerForItem(e),n.prototype.removeChild.call(this,e)},t.addItem=function(e){var t=this.addChild(e);t&&this.addEventListenerForItem(t)},t.createEl=function(){var e=this.options_.contentElType||"ul";this.contentEl_=k(e,{className:"vjs-menu-content"}),this.contentEl_.setAttribute("role","menu");var t=n.prototype.createEl.call(this,"div",{append:this.contentEl_,className:"vjs-menu"});return t.appendChild(this.contentEl_),he(t,"click",function(e){e.preventDefault(),e.stopImmediatePropagation()}),t},t.dispose=function(){this.contentEl_=null,this.boundHandleBlur_=null,this.boundHandleTapClick_=null,n.prototype.dispose.call(this)},t.handleBlur=function(e){var t=e.relatedTarget||d.activeElement;if(!this.children().some(function(e){return e.el()===t})){var i=this.menuButton_;i&&i.buttonPressed_&&t!==i.el().firstChild&&i.unpressButton()}},t.handleTapClick=function(t){if(this.menuButton_){this.menuButton_.unpressButton();var e=this.children();if(!Array.isArray(e))return;var i=e.filter(function(e){return e.el()===t.target})[0];if(!i)return;"CaptionSettingsMenuItem"!==i.name()&&this.menuButton_.focus()}},t.handleKeyDown=function(e){Ot.isEventKey(e,"Left")||Ot.isEventKey(e,"Down")?(e.preventDefault(),e.stopPropagation(),this.stepForward()):(Ot.isEventKey(e,"Right")||Ot.isEventKey(e,"Up"))&&(e.preventDefault(),e.stopPropagation(),this.stepBack())},t.stepForward=function(){var e=0;void 0!==this.focusedChild_&&(e=this.focusedChild_+1),this.focus(e)},t.stepBack=function(){var e=0;void 0!==this.focusedChild_&&(e=this.focusedChild_-1),this.focus(e)},t.focus=function(e){void 0===e&&(e=0);var t=this.children().slice();t.length&&t[0].className&&/vjs-menu-title/.test(t[0].className)&&t.shift(),0<t.length&&(e<0?e=0:e>=t.length&&(e=t.length-1),t[this.focusedChild_=e].el_.focus())},e}(je);je.registerComponent("Menu",Xn);var Kn=function(r){function e(e,t){var i;void 0===t&&(t={}),(i=r.call(this,e,t)||this).menuButton_=new vn(e,t),i.menuButton_.controlText(i.controlText_),i.menuButton_.el_.setAttribute("aria-haspopup","true");var n=vn.prototype.buildCSSClass();return i.menuButton_.el_.className=i.buildCSSClass()+" "+n,i.menuButton_.removeClass("vjs-control"),i.addChild(i.menuButton_),i.update(),i.enabled_=!0,i.on(i.menuButton_,"tap",i.handleClick),i.on(i.menuButton_,"click",i.handleClick),i.on(i.menuButton_,"keydown",i.handleKeyDown),i.on(i.menuButton_,"mouseenter",function(){i.addClass("vjs-hover"),i.menu.show(),he(d,"keyup",ve(Ve(i),i.handleMenuKeyUp))}),i.on("mouseleave",i.handleMouseLeave),i.on("keydown",i.handleSubmenuKeyDown),i}Ge(e,r);var t=e.prototype;return t.update=function(){var e=this.createMenu();this.menu&&(this.menu.dispose(),this.removeChild(this.menu)),this.menu=e,this.addChild(e),this.buttonPressed_=!1,this.menuButton_.el_.setAttribute("aria-expanded","false"),this.items&&this.items.length<=this.hideThreshold_?this.hide():this.show()},t.createMenu=function(){var e=new Xn(this.player_,{menuButton:this});if(this.hideThreshold_=0,this.options_.title){var t=k("li",{className:"vjs-menu-title",innerHTML:Me(this.options_.title),tabIndex:-1});this.hideThreshold_+=1;var i=new je(this.player_,{el:t});e.addItem(i)}if(this.items=this.createItems(),this.items)for(var n=0;n<this.items.length;n++)e.addItem(this.items[n]);return e},t.createItems=function(){},t.createEl=function(){return r.prototype.createEl.call(this,"div",{className:this.buildWrapperCSSClass()},{})},t.buildWrapperCSSClass=function(){var e="vjs-menu-button";return!0===this.options_.inline?e+="-inline":e+="-popup","vjs-menu-button "+e+" "+vn.prototype.buildCSSClass()+" "+r.prototype.buildCSSClass.call(this)},t.buildCSSClass=function(){var e="vjs-menu-button";return!0===this.options_.inline?e+="-inline":e+="-popup","vjs-menu-button "+e+" "+r.prototype.buildCSSClass.call(this)},t.controlText=function(e,t){return void 0===t&&(t=this.menuButton_.el()),this.menuButton_.controlText(e,t)},t.dispose=function(){this.handleMouseLeave(),r.prototype.dispose.call(this)},t.handleClick=function(e){this.buttonPressed_?this.unpressButton():this.pressButton()},t.handleMouseLeave=function(e){this.removeClass("vjs-hover"),pe(d,"keyup",ve(this,this.handleMenuKeyUp))},t.focus=function(){this.menuButton_.focus()},t.blur=function(){this.menuButton_.blur()},t.handleKeyDown=function(e){Ot.isEventKey(e,"Esc")||Ot.isEventKey(e,"Tab")?(this.buttonPressed_&&this.unpressButton(),Ot.isEventKey(e,"Tab")||(e.preventDefault(),this.menuButton_.focus())):(Ot.isEventKey(e,"Up")||Ot.isEventKey(e,"Down"))&&(this.buttonPressed_||(e.preventDefault(),this.pressButton()))},t.handleMenuKeyUp=function(e){(Ot.isEventKey(e,"Esc")||Ot.isEventKey(e,"Tab"))&&this.removeClass("vjs-hover")},t.handleSubmenuKeyPress=function(e){this.handleSubmenuKeyDown(e)},t.handleSubmenuKeyDown=function(e){(Ot.isEventKey(e,"Esc")||Ot.isEventKey(e,"Tab"))&&(this.buttonPressed_&&this.unpressButton(),Ot.isEventKey(e,"Tab")||(e.preventDefault(),this.menuButton_.focus()))},t.pressButton=function(){if(this.enabled_){if(this.buttonPressed_=!0,this.menu.show(),this.menu.lockShowing(),this.menuButton_.el_.setAttribute("aria-expanded","true"),dt&&b())return;this.menu.focus()}},t.unpressButton=function(){this.enabled_&&(this.buttonPressed_=!1,this.menu.unlockShowing(),this.menu.hide(),this.menuButton_.el_.setAttribute("aria-expanded","false"))},t.disable=function(){this.unpressButton(),this.enabled_=!1,this.addClass("vjs-disabled"),this.menuButton_.disable()},t.enable=function(){this.enabled_=!0,this.removeClass("vjs-disabled"),this.menuButton_.enable()},e}(je);je.registerComponent("MenuButton",Kn);var Yn=function(a){function e(e,t){var i,n=t.tracks;if((i=a.call(this,e,t)||this).items.length<=1&&i.hide(),!n)return Ve(i);var r=ve(Ve(i),i.update);return n.addEventListener("removetrack",r),n.addEventListener("addtrack",r),i.player_.on("ready",r),i.player_.on("dispose",function(){n.removeEventListener("removetrack",r),n.removeEventListener("addtrack",r)}),i}return Ge(e,a),e}(Kn);je.registerComponent("TrackButton",Yn);var $n=["Tab","Esc","Up","Down","Right","Left"],Qn=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).selectable=t.selectable,i.isSelected_=t.selected||!1,i.multiSelectable=t.multiSelectable,i.selected(i.isSelected_),i.selectable?i.multiSelectable?i.el_.setAttribute("role","menuitemcheckbox"):i.el_.setAttribute("role","menuitemradio"):i.el_.setAttribute("role","menuitem"),i}Ge(e,n);var t=e.prototype;return t.createEl=function(e,t,i){return this.nonIconControl=!0,n.prototype.createEl.call(this,"li",g({className:"vjs-menu-item",innerHTML:'<span class="vjs-menu-item-text">'+this.localize(this.options_.label)+"</span>",tabIndex:-1},t),i)},t.handleKeyDown=function(t){$n.some(function(e){return Ot.isEventKey(t,e)})||n.prototype.handleKeyDown.call(this,t)},t.handleClick=function(e){this.selected(!0)},t.selected=function(e){this.selectable&&(e?(this.addClass("vjs-selected"),this.el_.setAttribute("aria-checked","true"),this.controlText(", selected"),this.isSelected_=!0):(this.removeClass("vjs-selected"),this.el_.setAttribute("aria-checked","false"),this.controlText(""),this.isSelected_=!1))},e}(ln);je.registerComponent("MenuItem",Qn);var Jn=function(u){function e(e,t){var n,i=t.track,r=e.textTracks();t.label=i.label||i.language||"Unknown",t.selected="showing"===i.mode,(n=u.call(this,e,t)||this).track=i,n.kinds=(t.kinds||[t.kind||n.track.kind]).filter(Boolean);function a(){for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];n.handleTracksChange.apply(Ve(n),t)}function s(){for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];n.handleSelectedLanguageChange.apply(Ve(n),t)}var o;e.on(["loadstart","texttrackchange"],a),r.addEventListener("change",a),r.addEventListener("selectedlanguagechange",s),n.on("dispose",function(){e.off(["loadstart","texttrackchange"],a),r.removeEventListener("change",a),r.removeEventListener("selectedlanguagechange",s)}),void 0===r.onchange&&n.on(["tap","click"],function(){if("object"!=typeof T.Event)try{o=new T.Event("change")}catch(e){}o||(o=d.createEvent("Event")).initEvent("change",!0,!0),r.dispatchEvent(o)});return n.handleTracksChange(),n}Ge(e,u);var t=e.prototype;return t.handleClick=function(e){var t=this.track,i=this.player_.textTracks();if(u.prototype.handleClick.call(this,e),i)for(var n=0;n<i.length;n++){var r=i[n];-1!==this.kinds.indexOf(r.kind)&&(r===t?"showing"!==r.mode&&(r.mode="showing"):"disabled"!==r.mode&&(r.mode="disabled"))}},t.handleTracksChange=function(e){var t="showing"===this.track.mode;t!==this.isSelected_&&this.selected(t)},t.handleSelectedLanguageChange=function(e){if("showing"===this.track.mode){var t=this.player_.cache_.selectedLanguage;if(t&&t.enabled&&t.language===this.track.language&&t.kind!==this.track.kind)return;this.player_.cache_.selectedLanguage={enabled:!0,language:this.track.language,kind:this.track.kind}}},t.dispose=function(){this.track=null,u.prototype.dispose.call(this)},e}(Qn);je.registerComponent("TextTrackMenuItem",Jn);var Zn=function(i){function e(e,t){return t.track={player:e,kind:t.kind,kinds:t.kinds,default:!1,mode:"disabled"},t.kinds||(t.kinds=[t.kind]),t.label?t.track.label=t.label:t.track.label=t.kinds.join(" and ")+" off",t.selectable=!0,t.multiSelectable=!1,i.call(this,e,t)||this}Ge(e,i);var t=e.prototype;return t.handleTracksChange=function(e){for(var t=this.player().textTracks(),i=!0,n=0,r=t.length;n<r;n++){var a=t[n];if(-1<this.options_.kinds.indexOf(a.kind)&&"showing"===a.mode){i=!1;break}}i!==this.isSelected_&&this.selected(i)},t.handleSelectedLanguageChange=function(e){for(var t=this.player().textTracks(),i=!0,n=0,r=t.length;n<r;n++){var a=t[n];if(-1<["captions","descriptions","subtitles"].indexOf(a.kind)&&"showing"===a.mode){i=!1;break}}i&&(this.player_.cache_.selectedLanguage={enabled:!1})},e}(Jn);je.registerComponent("OffTextTrackMenuItem",Zn);var er=function(i){function e(e,t){return void 0===t&&(t={}),t.tracks=e.textTracks(),i.call(this,e,t)||this}return Ge(e,i),e.prototype.createItems=function(e,t){var i;void 0===e&&(e=[]),void 0===t&&(t=Jn),this.label_&&(i=this.label_+" off"),e.push(new Zn(this.player_,{kinds:this.kinds_,kind:this.kind_,label:i})),this.hideThreshold_+=1;var n=this.player_.textTracks();Array.isArray(this.kinds_)||(this.kinds_=[this.kind_]);for(var r=0;r<n.length;r++){var a=n[r];if(-1<this.kinds_.indexOf(a.kind)){var s=new t(this.player_,{track:a,kinds:this.kinds_,kind:this.kind_,selectable:!0,multiSelectable:!1});s.addClass("vjs-"+a.kind+"-menu-item"),e.push(s)}}return e},e}(Yn);je.registerComponent("TextTrackButton",er);var tr=function(s){function e(e,t){var i,n=t.track,r=t.cue,a=e.currentTime();return t.selectable=!0,t.multiSelectable=!1,t.label=r.text,t.selected=r.startTime<=a&&a<r.endTime,(i=s.call(this,e,t)||this).track=n,i.cue=r,n.addEventListener("cuechange",ve(Ve(i),i.update)),i}Ge(e,s);var t=e.prototype;return t.handleClick=function(e){s.prototype.handleClick.call(this),this.player_.currentTime(this.cue.startTime),this.update(this.cue.startTime)},t.update=function(e){var t=this.cue,i=this.player_.currentTime();this.selected(t.startTime<=i&&i<t.endTime)},e}(Qn);je.registerComponent("ChaptersTrackMenuItem",tr);var ir=function(n){function e(e,t,i){return n.call(this,e,t,i)||this}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-chapters-button "+n.prototype.buildCSSClass.call(this)},t.buildWrapperCSSClass=function(){return"vjs-chapters-button "+n.prototype.buildWrapperCSSClass.call(this)},t.update=function(e){this.track_&&(!e||"addtrack"!==e.type&&"removetrack"!==e.type)||this.setTrack(this.findChaptersTrack()),n.prototype.update.call(this)},t.setTrack=function(e){if(this.track_!==e){if(this.updateHandler_||(this.updateHandler_=this.update.bind(this)),this.track_){var t=this.player_.remoteTextTrackEls().getTrackElementByTrack_(this.track_);t&&t.removeEventListener("load",this.updateHandler_),this.track_=null}if(this.track_=e,this.track_){this.track_.mode="hidden";var i=this.player_.remoteTextTrackEls().getTrackElementByTrack_(this.track_);i&&i.addEventListener("load",this.updateHandler_)}}},t.findChaptersTrack=function(){for(var e=this.player_.textTracks()||[],t=e.length-1;0<=t;t--){var i=e[t];if(i.kind===this.kind_)return i}},t.getMenuCaption=function(){return this.track_&&this.track_.label?this.track_.label:this.localize(Me(this.kind_))},t.createMenu=function(){return this.options_.title=this.getMenuCaption(),n.prototype.createMenu.call(this)},t.createItems=function(){var e=[];if(!this.track_)return e;var t=this.track_.cues;if(!t)return e;for(var i=0,n=t.length;i<n;i++){var r=t[i],a=new tr(this.player_,{track:this.track_,cue:r});e.push(a)}return e},e}(er);ir.prototype.kind_="chapters",ir.prototype.controlText_="Chapters",je.registerComponent("ChaptersButton",ir);var nr=function(s){function e(e,t,i){var n;n=s.call(this,e,t,i)||this;var r=e.textTracks(),a=ve(Ve(n),n.handleTracksChange);return r.addEventListener("change",a),n.on("dispose",function(){r.removeEventListener("change",a)}),n}Ge(e,s);var t=e.prototype;return t.handleTracksChange=function(e){for(var t=this.player().textTracks(),i=!1,n=0,r=t.length;n<r;n++){var a=t[n];if(a.kind!==this.kind_&&"showing"===a.mode){i=!0;break}}i?this.disable():this.enable()},t.buildCSSClass=function(){return"vjs-descriptions-button "+s.prototype.buildCSSClass.call(this)},t.buildWrapperCSSClass=function(){return"vjs-descriptions-button "+s.prototype.buildWrapperCSSClass.call(this)},e}(er);nr.prototype.kind_="descriptions",nr.prototype.controlText_="Descriptions",je.registerComponent("DescriptionsButton",nr);var rr=function(n){function e(e,t,i){return n.call(this,e,t,i)||this}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-subtitles-button "+n.prototype.buildCSSClass.call(this)},t.buildWrapperCSSClass=function(){return"vjs-subtitles-button "+n.prototype.buildWrapperCSSClass.call(this)},e}(er);rr.prototype.kind_="subtitles",rr.prototype.controlText_="Subtitles",je.registerComponent("SubtitlesButton",rr);var ar=function(n){function e(e,t){var i;return t.track={player:e,kind:t.kind,label:t.kind+" settings",selectable:!1,default:!1,mode:"disabled"},t.selectable=!1,t.name="CaptionSettingsMenuItem",(i=n.call(this,e,t)||this).addClass("vjs-texttrack-settings"),i.controlText(", opens "+t.kind+" settings dialog"),i}return Ge(e,n),e.prototype.handleClick=function(e){this.player().getChild("textTrackSettings").open()},e}(Jn);je.registerComponent("CaptionSettingsMenuItem",ar);var sr=function(n){function e(e,t,i){return n.call(this,e,t,i)||this}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-captions-button "+n.prototype.buildCSSClass.call(this)},t.buildWrapperCSSClass=function(){return"vjs-captions-button "+n.prototype.buildWrapperCSSClass.call(this)},t.createItems=function(){var e=[];return this.player().tech_&&this.player().tech_.featuresNativeTextTracks||!this.player().getChild("textTrackSettings")||(e.push(new ar(this.player_,{kind:this.kind_})),this.hideThreshold_+=1),n.prototype.createItems.call(this,e)},e}(er);sr.prototype.kind_="captions",sr.prototype.controlText_="Captions",je.registerComponent("CaptionsButton",sr);var or=function(r){function e(){return r.apply(this,arguments)||this}return Ge(e,r),e.prototype.createEl=function(e,t,i){var n='<span class="vjs-menu-item-text">'+this.localize(this.options_.label);return"captions"===this.options_.track.kind&&(n+='\n <span aria-hidden="true" class="vjs-icon-placeholder"></span>\n <span class="vjs-control-text"> '+this.localize("Captions")+"</span>\n "),n+="</span>",r.prototype.createEl.call(this,e,g({innerHTML:n},t),i)},e}(Jn);je.registerComponent("SubsCapsMenuItem",or);var ur=function(n){function e(e,t){var i;return void 0===t&&(t={}),(i=n.call(this,e,t)||this).label_="subtitles",-1<["en","en-us","en-ca","fr-ca"].indexOf(i.player_.language_)&&(i.label_="captions"),i.menuButton_.controlText(Me(i.label_)),i}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-subs-caps-button "+n.prototype.buildCSSClass.call(this)},t.buildWrapperCSSClass=function(){return"vjs-subs-caps-button "+n.prototype.buildWrapperCSSClass.call(this)},t.createItems=function(){var e=[];return this.player().tech_&&this.player().tech_.featuresNativeTextTracks||!this.player().getChild("textTrackSettings")||(e.push(new ar(this.player_,{kind:this.label_})),this.hideThreshold_+=1),e=n.prototype.createItems.call(this,e,or)},e}(er);ur.prototype.kinds_=["captions","subtitles"],ur.prototype.controlText_="Subtitles",je.registerComponent("SubsCapsButton",ur);var lr=function(s){function e(e,t){var n,i=t.track,r=e.audioTracks();t.label=i.label||i.language||"Unknown",t.selected=i.enabled,(n=s.call(this,e,t)||this).track=i,n.addClass("vjs-"+i.kind+"-menu-item");function a(){for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];n.handleTracksChange.apply(Ve(n),t)}return r.addEventListener("change",a),n.on("dispose",function(){r.removeEventListener("change",a)}),n}Ge(e,s);var t=e.prototype;return t.createEl=function(e,t,i){var n='<span class="vjs-menu-item-text">'+this.localize(this.options_.label);return"main-desc"===this.options_.track.kind&&(n+='\n <span aria-hidden="true" class="vjs-icon-placeholder"></span>\n <span class="vjs-control-text"> '+this.localize("Descriptions")+"</span>\n "),n+="</span>",s.prototype.createEl.call(this,e,g({innerHTML:n},t),i)},t.handleClick=function(e){var t=this.player_.audioTracks();s.prototype.handleClick.call(this,e);for(var i=0;i<t.length;i++){var n=t[i];n.enabled=n===this.track}},t.handleTracksChange=function(e){this.selected(this.track.enabled)},e}(Qn);je.registerComponent("AudioTrackMenuItem",lr);var cr=function(i){function e(e,t){return void 0===t&&(t={}),t.tracks=e.audioTracks(),i.call(this,e,t)||this}Ge(e,i);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-audio-button "+i.prototype.buildCSSClass.call(this)},t.buildWrapperCSSClass=function(){return"vjs-audio-button "+i.prototype.buildWrapperCSSClass.call(this)},t.createItems=function(e){void 0===e&&(e=[]),this.hideThreshold_=1;for(var t=this.player_.audioTracks(),i=0;i<t.length;i++){var n=t[i];e.push(new lr(this.player_,{track:n,selectable:!0,multiSelectable:!1}))}return e},e}(Yn);cr.prototype.controlText_="Audio Track",je.registerComponent("AudioTrackButton",cr);var dr=function(a){function e(e,t){var i,n=t.rate,r=parseFloat(n,10);return t.label=n,t.selected=1===r,t.selectable=!0,t.multiSelectable=!1,(i=a.call(this,e,t)||this).label=n,i.rate=r,i.on(e,"ratechange",i.update),i}Ge(e,a);var t=e.prototype;return t.handleClick=function(e){a.prototype.handleClick.call(this),this.player().playbackRate(this.rate)},t.update=function(e){this.selected(this.player().playbackRate()===this.rate)},e}(Qn);dr.prototype.contentElType="button",je.registerComponent("PlaybackRateMenuItem",dr);var hr=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).updateVisibility(),i.updateLabel(),i.on(e,"loadstart",i.updateVisibility),i.on(e,"ratechange",i.updateLabel),i}Ge(e,n);var t=e.prototype;return t.createEl=function(){var e=n.prototype.createEl.call(this);return this.labelEl_=k("div",{className:"vjs-playback-rate-value",innerHTML:"1x"}),e.appendChild(this.labelEl_),e},t.dispose=function(){this.labelEl_=null,n.prototype.dispose.call(this)},t.buildCSSClass=function(){return"vjs-playback-rate "+n.prototype.buildCSSClass.call(this)},t.buildWrapperCSSClass=function(){return"vjs-playback-rate "+n.prototype.buildWrapperCSSClass.call(this)},t.createMenu=function(){var e=new Xn(this.player()),t=this.playbackRates();if(t)for(var i=t.length-1;0<=i;i--)e.addChild(new dr(this.player(),{rate:t[i]+"x"}));return e},t.updateARIAAttributes=function(){this.el().setAttribute("aria-valuenow",this.player().playbackRate())},t.handleClick=function(e){for(var t=this.player().playbackRate(),i=this.playbackRates(),n=i[0],r=0;r<i.length;r++)if(i[r]>t){n=i[r];break}this.player().playbackRate(n)},t.playbackRates=function(){return this.options_.playbackRates||this.options_.playerOptions&&this.options_.playerOptions.playbackRates},t.playbackRateSupported=function(){return this.player().tech_&&this.player().tech_.featuresPlaybackRate&&this.playbackRates()&&0<this.playbackRates().length},t.updateVisibility=function(e){this.playbackRateSupported()?this.removeClass("vjs-hidden"):this.addClass("vjs-hidden")},t.updateLabel=function(e){this.playbackRateSupported()&&(this.labelEl_.innerHTML=this.player().playbackRate()+"x")},e}(Kn);hr.prototype.controlText_="Playback Rate",je.registerComponent("PlaybackRateMenuButton",hr);var pr=function(e){function t(){return e.apply(this,arguments)||this}Ge(t,e);var i=t.prototype;return i.buildCSSClass=function(){return"vjs-spacer "+e.prototype.buildCSSClass.call(this)},i.createEl=function(){return e.prototype.createEl.call(this,"div",{className:this.buildCSSClass()})},t}(je);je.registerComponent("Spacer",pr);var fr=function(t){function e(){return t.apply(this,arguments)||this}Ge(e,t);var i=e.prototype;return i.buildCSSClass=function(){return"vjs-custom-control-spacer "+t.prototype.buildCSSClass.call(this)},i.createEl=function(){var e=t.prototype.createEl.call(this,{className:this.buildCSSClass()});return e.innerHTML=" ",e},e}(pr);je.registerComponent("CustomControlSpacer",fr);var mr=function(e){function t(){return e.apply(this,arguments)||this}return Ge(t,e),t.prototype.createEl=function(){return e.prototype.createEl.call(this,"div",{className:"vjs-control-bar",dir:"ltr"})},t}(je);mr.prototype.options_={children:["playToggle","volumePanel","currentTimeDisplay","timeDivider","durationDisplay","progressControl","liveDisplay","seekToLive","remainingTimeDisplay","customControlSpacer","playbackRateMenuButton","chaptersButton","descriptionsButton","subsCapsButton","audioTrackButton","fullscreenToggle"]},"exitPictureInPicture"in d&&mr.prototype.options_.children.splice(mr.prototype.options_.children.length-1,0,"pictureInPictureToggle"),je.registerComponent("ControlBar",mr);var gr=function(n){function e(e,t){var i;return(i=n.call(this,e,t)||this).on(e,"error",i.open),i}Ge(e,n);var t=e.prototype;return t.buildCSSClass=function(){return"vjs-error-display "+n.prototype.buildCSSClass.call(this)},t.content=function(){var e=this.player().error();return e?this.localize(e.message):""},e}(Mt);gr.prototype.options_=m({},Mt.prototype.options_,{pauseOnOpen:!1,fillAlways:!0,temporary:!1,uncloseable:!0}),je.registerComponent("ErrorDisplay",gr);var vr="vjs-text-track-settings",yr=["#000","Black"],_r=["#00F","Blue"],br=["#0FF","Cyan"],Tr=["#0F0","Green"],Sr=["#F0F","Magenta"],kr=["#F00","Red"],Cr=["#FFF","White"],Er=["#FF0","Yellow"],wr=["1","Opaque"],Ir=["0.5","Semi-Transparent"],Ar=["0","Transparent"],xr={backgroundColor:{selector:".vjs-bg-color > select",id:"captions-background-color-%s",label:"Color",options:[yr,Cr,kr,Tr,_r,Er,Sr,br]},backgroundOpacity:{selector:".vjs-bg-opacity > select",id:"captions-background-opacity-%s",label:"Transparency",options:[wr,Ir,Ar]},color:{selector:".vjs-fg-color > select",id:"captions-foreground-color-%s",label:"Color",options:[Cr,yr,kr,Tr,_r,Er,Sr,br]},edgeStyle:{selector:".vjs-edge-style > select",id:"%s",label:"Text Edge Style",options:[["none","None"],["raised","Raised"],["depressed","Depressed"],["uniform","Uniform"],["dropshadow","Dropshadow"]]},fontFamily:{selector:".vjs-font-family > select",id:"captions-font-family-%s",label:"Font Family",options:[["proportionalSansSerif","Proportional Sans-Serif"],["monospaceSansSerif","Monospace Sans-Serif"],["proportionalSerif","Proportional Serif"],["monospaceSerif","Monospace Serif"],["casual","Casual"],["script","Script"],["small-caps","Small Caps"]]},fontPercent:{selector:".vjs-font-percent > select",id:"captions-font-size-%s",label:"Font Size",options:[["0.50","50%"],["0.75","75%"],["1.00","100%"],["1.25","125%"],["1.50","150%"],["1.75","175%"],["2.00","200%"],["3.00","300%"],["4.00","400%"]],default:2,parser:function(e){return"1.00"===e?null:Number(e)}},textOpacity:{selector:".vjs-text-opacity > select",id:"captions-foreground-opacity-%s",label:"Transparency",options:[wr,Ir]},windowColor:{selector:".vjs-window-color > select",id:"captions-window-color-%s",label:"Color"},windowOpacity:{selector:".vjs-window-opacity > select",id:"captions-window-opacity-%s",label:"Transparency",options:[Ar,Ir,wr]}};function Pr(e,t){if(t&&(e=t(e)),e&&"none"!==e)return e}xr.windowColor.options=xr.backgroundColor.options;var Lr=function(n){function e(e,t){var i;return t.temporary=!1,(i=n.call(this,e,t)||this).updateDisplay=ve(Ve(i),i.updateDisplay),i.fill(),i.hasBeenOpened_=i.hasBeenFilled_=!0,i.endDialog=k("p",{className:"vjs-control-text",textContent:i.localize("End of dialog window.")}),i.el().appendChild(i.endDialog),i.setDefaults(),void 0===t.persistTextTrackSettings&&(i.options_.persistTextTrackSettings=i.options_.playerOptions.persistTextTrackSettings),i.on(i.$(".vjs-done-button"),"click",function(){i.saveSettings(),i.close()}),i.on(i.$(".vjs-default-button"),"click",function(){i.setDefaults(),i.updateDisplay()}),r(xr,function(e){i.on(i.$(e.selector),"change",i.updateDisplay)}),i.options_.persistTextTrackSettings&&i.restoreSettings(),i}Ge(e,n);var t=e.prototype;return t.dispose=function(){this.endDialog=null,n.prototype.dispose.call(this)},t.createElSelect_=function(e,t,i){var n=this;void 0===t&&(t=""),void 0===i&&(i="label");var r=xr[e],a=r.id.replace("%s",this.id_),s=[t,a].join(" ").trim();return["<"+i+' id="'+a+'" class="'+("label"===i?"vjs-label":"")+'">',this.localize(r.label),"</"+i+">",'<select aria-labelledby="'+s+'">'].concat(r.options.map(function(e){var t=a+"-"+e[1].replace(/\W+/g,"");return['<option id="'+t+'" value="'+e[0]+'" ','aria-labelledby="'+s+" "+t+'">',n.localize(e[1]),"</option>"].join("")})).concat("</select>").join("")},t.createElFgColor_=function(){var e="captions-text-legend-"+this.id_;return['<fieldset class="vjs-fg-color vjs-track-setting">','<legend id="'+e+'">',this.localize("Text"),"</legend>",this.createElSelect_("color",e),'<span class="vjs-text-opacity vjs-opacity">',this.createElSelect_("textOpacity",e),"</span>","</fieldset>"].join("")},t.createElBgColor_=function(){var e="captions-background-"+this.id_;return['<fieldset class="vjs-bg-color vjs-track-setting">','<legend id="'+e+'">',this.localize("Background"),"</legend>",this.createElSelect_("backgroundColor",e),'<span class="vjs-bg-opacity vjs-opacity">',this.createElSelect_("backgroundOpacity",e),"</span>","</fieldset>"].join("")},t.createElWinColor_=function(){var e="captions-window-"+this.id_;return['<fieldset class="vjs-window-color vjs-track-setting">','<legend id="'+e+'">',this.localize("Window"),"</legend>",this.createElSelect_("windowColor",e),'<span class="vjs-window-opacity vjs-opacity">',this.createElSelect_("windowOpacity",e),"</span>","</fieldset>"].join("")},t.createElColors_=function(){return k("div",{className:"vjs-track-settings-colors",innerHTML:[this.createElFgColor_(),this.createElBgColor_(),this.createElWinColor_()].join("")})},t.createElFont_=function(){return k("div",{className:"vjs-track-settings-font",innerHTML:['<fieldset class="vjs-font-percent vjs-track-setting">',this.createElSelect_("fontPercent","","legend"),"</fieldset>",'<fieldset class="vjs-edge-style vjs-track-setting">',this.createElSelect_("edgeStyle","","legend"),"</fieldset>",'<fieldset class="vjs-font-family vjs-track-setting">',this.createElSelect_("fontFamily","","legend"),"</fieldset>"].join("")})},t.createElControls_=function(){var e=this.localize("restore all settings to the default values");return k("div",{className:"vjs-track-settings-controls",innerHTML:['<button type="button" class="vjs-default-button" title="'+e+'">',this.localize("Reset"),'<span class="vjs-control-text"> '+e+"</span>","</button>",'<button type="button" class="vjs-done-button">'+this.localize("Done")+"</button>"].join("")})},t.content=function(){return[this.createElColors_(),this.createElFont_(),this.createElControls_()]},t.label=function(){return this.localize("Caption Settings Dialog")},t.description=function(){return this.localize("Beginning of dialog window. Escape will cancel and close the window.")},t.buildCSSClass=function(){return n.prototype.buildCSSClass.call(this)+" vjs-text-track-settings"},t.getValues=function(){var r=this;return function(i,n,e){return void 0===e&&(e=0),a(i).reduce(function(e,t){return n(e,i[t],t)},e)}(xr,function(e,t,i){var n=function(e,t){return Pr(e.options[e.options.selectedIndex].value,t)}(r.$(t.selector),t.parser);return void 0!==n&&(e[i]=n),e},{})},t.setValues=function(i){var n=this;r(xr,function(e,t){!function(e,t,i){if(t)for(var n=0;n<e.options.length;n++)if(Pr(e.options[n].value,i)===t){e.selectedIndex=n;break}}(n.$(e.selector),i[t],e.parser)})},t.setDefaults=function(){var i=this;r(xr,function(e){var t=e.hasOwnProperty("default")?e.default:0;i.$(e.selector).selectedIndex=t})},t.restoreSettings=function(){var e;try{e=JSON.parse(T.localStorage.getItem(vr))}catch(e){p.warn(e)}e&&this.setValues(e)},t.saveSettings=function(){if(this.options_.persistTextTrackSettings){var e=this.getValues();try{Object.keys(e).length?T.localStorage.setItem(vr,JSON.stringify(e)):T.localStorage.removeItem(vr)}catch(e){p.warn(e)}}},t.updateDisplay=function(){var e=this.player_.getChild("textTrackDisplay");e&&e.updateDisplay()},t.conditionalBlur_=function(){this.previouslyActiveEl_=null;var e=this.player_.controlBar,t=e&&e.subsCapsButton,i=e&&e.captionsButton;t?t.focus():i&&i.focus()},e}(Mt);je.registerComponent("TextTrackSettings",Lr);var Or=function(a){function e(e,t){var i,n=t.ResizeObserver||T.ResizeObserver;null===t.ResizeObserver&&(n=!1);var r=Re({createEl:!n,reportTouchActivity:!1},t);return(i=a.call(this,e,r)||this).ResizeObserver=t.ResizeObserver||T.ResizeObserver,i.loadListener_=null,i.resizeObserver_=null,i.debouncedHandler_=function(n,r,a,s){var o;void 0===s&&(s=T);function e(){var e=this,t=arguments,i=function(){i=o=null,a||n.apply(e,t)};!o&&a&&n.apply(e,t),s.clearTimeout(o),o=s.setTimeout(i,r)}return e.cancel=function(){s.clearTimeout(o),o=null},e}(function(){i.resizeHandler()},100,!1,Ve(i)),n?(i.resizeObserver_=new i.ResizeObserver(i.debouncedHandler_),i.resizeObserver_.observe(e.el())):(i.loadListener_=function(){if(i.el_&&i.el_.contentWindow){var e=i.debouncedHandler_,t=i.unloadListener_=function(){pe(this,"resize",e),pe(this,"unload",t),t=null};he(i.el_.contentWindow,"unload",t),he(i.el_.contentWindow,"resize",e)}},i.one("load",i.loadListener_)),i}Ge(e,a);var t=e.prototype;return t.createEl=function(){return a.prototype.createEl.call(this,"iframe",{className:"vjs-resize-manager",tabIndex:-1},{"aria-hidden":"true"})},t.resizeHandler=function(){this.player_&&this.player_.trigger&&this.player_.trigger("playerresize")},t.dispose=function(){this.debouncedHandler_&&this.debouncedHandler_.cancel(),this.resizeObserver_&&(this.player_.el()&&this.resizeObserver_.unobserve(this.player_.el()),this.resizeObserver_.disconnect()),this.loadListener_&&this.off("load",this.loadListener_),this.el_&&this.el_.contentWindow&&this.unloadListener_&&this.unloadListener_.call(this.el_.contentWindow),this.ResizeObserver=null,this.resizeObserver=null,this.debouncedHandler_=null,this.loadListener_=null,a.prototype.dispose.call(this)},e}(je);je.registerComponent("ResizeManager",Or);var Dr={trackingThreshold:30,liveTolerance:15},Mr=function(r){function e(e,t){var i,n=Re(Dr,t,{createEl:!1});return(i=r.call(this,e,n)||this).reset_(),i.on(i.player_,"durationchange",i.handleDurationchange),at&&"hidden"in d&&"visibilityState"in d&&i.on(d,"visibilitychange",i.handleVisibilityChange),i}Ge(e,r);var t=e.prototype;return t.handleVisibilityChange=function(){this.player_.duration()===1/0&&(d.hidden?this.stopTracking():this.startTracking())},t.trackLive_=function(){var e=this.player_.seekable();if(e&&e.length){var t=Number(T.performance.now().toFixed(4)),i=-1===this.lastTime_?0:(t-this.lastTime_)/1e3;this.lastTime_=t,this.pastSeekEnd_=this.pastSeekEnd()+i;var n=this.liveCurrentTime(),r=this.player_.currentTime(),a=this.player_.paused()||this.seekedBehindLive_||Math.abs(n-r)>this.options_.liveTolerance;this.timeupdateSeen_&&n!==1/0||(a=!1),a!==this.behindLiveEdge_&&(this.behindLiveEdge_=a,this.trigger("liveedgechange"))}},t.handleDurationchange=function(){this.player_.duration()===1/0&&this.liveWindow()>=this.options_.trackingThreshold?(this.player_.options_.liveui&&this.player_.addClass("vjs-liveui"),this.startTracking()):(this.player_.removeClass("vjs-liveui"),this.stopTracking())},t.startTracking=function(){this.isTracking()||(this.timeupdateSeen_||(this.timeupdateSeen_=this.player_.hasStarted()),this.trackingInterval_=this.setInterval(this.trackLive_,30),this.trackLive_(),this.on(this.player_,["play","pause"],this.trackLive_),this.timeupdateSeen_?this.on(this.player_,"seeked",this.handleSeeked):(this.one(this.player_,"play",this.handlePlay),this.one(this.player_,"timeupdate",this.handleFirstTimeupdate)))},t.handleFirstTimeupdate=function(){this.timeupdateSeen_=!0,this.on(this.player_,"seeked",this.handleSeeked)},t.handleSeeked=function(){var e=Math.abs(this.liveCurrentTime()-this.player_.currentTime());this.seekedBehindLive_=!this.skipNextSeeked_&&2<e,this.skipNextSeeked_=!1,this.trackLive_()},t.handlePlay=function(){this.one(this.player_,"timeupdate",this.seekToLiveEdge)},t.reset_=function(){this.lastTime_=-1,this.pastSeekEnd_=0,this.lastSeekEnd_=-1,this.behindLiveEdge_=!0,this.timeupdateSeen_=!1,this.seekedBehindLive_=!1,this.skipNextSeeked_=!1,this.clearInterval(this.trackingInterval_),this.trackingInterval_=null,this.off(this.player_,["play","pause"],this.trackLive_),this.off(this.player_,"seeked",this.handleSeeked),this.off(this.player_,"play",this.handlePlay),this.off(this.player_,"timeupdate",this.handleFirstTimeupdate),this.off(this.player_,"timeupdate",this.seekToLiveEdge)},t.stopTracking=function(){this.isTracking()&&(this.reset_(),this.trigger("liveedgechange"))},t.seekableEnd=function(){for(var e=this.player_.seekable(),t=[],i=e?e.length:0;i--;)t.push(e.end(i));return t.length?t.sort()[t.length-1]:1/0},t.seekableStart=function(){for(var e=this.player_.seekable(),t=[],i=e?e.length:0;i--;)t.push(e.start(i));return t.length?t.sort()[0]:0},t.liveWindow=function(){var e=this.liveCurrentTime();return e===1/0?1/0:e-this.seekableStart()},t.isLive=function(){return this.isTracking()},t.atLiveEdge=function(){return!this.behindLiveEdge()},t.liveCurrentTime=function(){return this.pastSeekEnd()+this.seekableEnd()},t.pastSeekEnd=function(){var e=this.seekableEnd();return-1!==this.lastSeekEnd_&&e!==this.lastSeekEnd_&&(this.pastSeekEnd_=0),this.lastSeekEnd_=e,this.pastSeekEnd_},t.behindLiveEdge=function(){return this.behindLiveEdge_},t.isTracking=function(){return"number"==typeof this.trackingInterval_},t.seekToLiveEdge=function(){this.seekedBehindLive_=!1,this.atLiveEdge()||(this.skipNextSeeked_=!0,this.player_.currentTime(this.liveCurrentTime()))},t.dispose=function(){this.off(d,"visibilitychange",this.handleVisibilityChange),this.stopTracking(),r.prototype.dispose.call(this)},e}(je);je.registerComponent("LiveTracker",Mr);function Rr(e){var t=e.el();if(t.hasAttribute("src"))return e.triggerSourceset(t.src),!0;var i=e.$$("source"),n=[],r="";if(!i.length)return!1;for(var a=0;a<i.length;a++){var s=i[a].src;s&&-1===n.indexOf(s)&&n.push(s)}return!!n.length&&(1===n.length&&(r=n[0]),e.triggerSourceset(r),!0)}function Nr(e,t){for(var i={},n=0;n<e.length&&!((i=Object.getOwnPropertyDescriptor(e[n],t))&&i.set&&i.get);n++);return i.enumerable=!0,i.configurable=!0,i}function Ur(a){var s=a.el();if(!s.resetSourceWatch_){var t={},e=function(e){return Nr([e.el(),T.HTMLMediaElement.prototype,T.Element.prototype,Vr],"innerHTML")}(a),i=function(r){return function(){for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];var n=r.apply(s,t);return Rr(a),n}};["append","appendChild","insertAdjacentHTML"].forEach(function(e){s[e]&&(t[e]=s[e],s[e]=i(t[e]))}),Object.defineProperty(s,"innerHTML",Re(e,{set:i(e.set)})),s.resetSourceWatch_=function(){s.resetSourceWatch_=null,Object.keys(t).forEach(function(e){s[e]=t[e]}),Object.defineProperty(s,"innerHTML",e)},a.one("sourceset",s.resetSourceWatch_)}}function Fr(n){if(n.featuresSourceset){var r=n.el();if(!r.resetSourceset_){var i=function(e){return Nr([e.el(),T.HTMLMediaElement.prototype,qr],"src")}(n),a=r.setAttribute,t=r.load;Object.defineProperty(r,"src",Re(i,{set:function(e){var t=i.set.call(r,e);return n.triggerSourceset(r.src),t}})),r.setAttribute=function(e,t){var i=a.call(r,e,t);return/src/i.test(e)&&n.triggerSourceset(r.src),i},r.load=function(){var e=t.call(r);return Rr(n)||(n.triggerSourceset(""),Ur(n)),e},r.currentSrc?n.triggerSourceset(r.currentSrc):Rr(n)||Ur(n),r.resetSourceset_=function(){r.resetSourceset_=null,r.load=t,r.setAttribute=a,Object.defineProperty(r,"src",i),r.resetSourceWatch_&&r.resetSourceWatch_()}}}}function Br(t,i,n,e){function r(e){return Object.defineProperty(t,i,{value:e,enumerable:!0,writable:!0})}void 0===e&&(e=!0);var a={configurable:!0,enumerable:!0,get:function(){var e=n();return r(e),e}};return e&&(a.set=r),Object.defineProperty(t,i,a)}var jr,Vr=Object.defineProperty({},"innerHTML",{get:function(){return this.cloneNode(!0).innerHTML},set:function(e){var t=d.createElement(this.nodeName.toLowerCase());t.innerHTML=e;for(var i=d.createDocumentFragment();t.childNodes.length;)i.appendChild(t.childNodes[0]);return this.innerText="",T.Element.prototype.appendChild.call(this,i),this.innerHTML}}),qr=Object.defineProperty({},"src",{get:function(){return this.hasAttribute("src")?jt(T.Element.prototype.getAttribute.call(this,"src")):""},set:function(e){return T.Element.prototype.setAttribute.call(this,"src",e),e}}),Hr=function(c){function o(e,t){var i;i=c.call(this,e,t)||this;var n=e.source,r=!1;if(n&&(i.el_.currentSrc!==n.src||e.tag&&3===e.tag.initNetworkState_)?i.setSource(n):i.handleLateInit_(i.el_),e.enableSourceset&&i.setupSourcesetHandling_(),i.el_.hasChildNodes()){for(var a=i.el_.childNodes,s=a.length,o=[];s--;){var u=a[s];"track"===u.nodeName.toLowerCase()&&(i.featuresNativeTextTracks?(i.remoteTextTrackEls().addTrackElement_(u),i.remoteTextTracks().addTrack(u.track),i.textTracks().addTrack(u.track),r||i.el_.hasAttribute("crossorigin")||!qt(u.src)||(r=!0)):o.push(u))}for(var l=0;l<o.length;l++)i.el_.removeChild(o[l])}return i.proxyNativeTracks_(),i.featuresNativeTextTracks&&r&&p.warn("Text Tracks are being loaded from another origin but the crossorigin attribute isn't used.\nThis may prevent text tracks from loading."),i.restoreMetadataTracksInIOSNativePlayer_(),(ut||ct||et)&&!0===e.nativeControlsForTouch&&i.setControls(!0),i.proxyWebkitFullscreen_(),i.triggerReady(),i}Ge(o,c);var e=o.prototype;return e.dispose=function(){this.el_&&this.el_.resetSourceset_&&this.el_.resetSourceset_(),o.disposeMediaElement(this.el_),this.options_=null,c.prototype.dispose.call(this)},e.setupSourcesetHandling_=function(){Fr(this)},e.restoreMetadataTracksInIOSNativePlayer_=function(){function e(){i=[];for(var e=0;e<n.length;e++){var t=n[e];"metadata"===t.kind&&i.push({track:t,storedMode:t.mode})}}var i,n=this.textTracks();e(),n.addEventListener("change",e),this.on("dispose",function(){return n.removeEventListener("change",e)});function r(){for(var e=0;e<i.length;e++){var t=i[e];"disabled"===t.track.mode&&t.track.mode!==t.storedMode&&(t.track.mode=t.storedMode)}n.removeEventListener("change",r)}this.on("webkitbeginfullscreen",function(){n.removeEventListener("change",e),n.removeEventListener("change",r),n.addEventListener("change",r)}),this.on("webkitendfullscreen",function(){n.removeEventListener("change",e),n.addEventListener("change",e),n.removeEventListener("change",r)})},e.overrideNative_=function(e,t){var i=this;if(t===this["featuresNative"+e+"Tracks"]){var n=e.toLowerCase();this[n+"TracksListeners_"]&&Object.keys(this[n+"TracksListeners_"]).forEach(function(e){i.el()[n+"Tracks"].removeEventListener(e,i[n+"TracksListeners_"][e])}),this["featuresNative"+e+"Tracks"]=!t,this[n+"TracksListeners_"]=null,this.proxyNativeTracksForType_(n)}},e.overrideNativeAudioTracks=function(e){this.overrideNative_("Audio",e)},e.overrideNativeVideoTracks=function(e){this.overrideNative_("Video",e)},e.proxyNativeTracksForType_=function(i){var n=this,e=fi[i],r=this.el()[e.getterName],a=this[e.getterName]();if(this["featuresNative"+e.capitalName+"Tracks"]&&r&&r.addEventListener){var s={change:function(e){var t={type:"change",target:a,currentTarget:a,srcElement:a};a.trigger(t),"text"===i&&n[mi.remoteText.getterName]().trigger(t)},addtrack:function(e){a.addTrack(e.track)},removetrack:function(e){a.removeTrack(e.track)}},t=function(){for(var e=[],t=0;t<a.length;t++){for(var i=!1,n=0;n<r.length;n++)if(r[n]===a[t]){i=!0;break}i||e.push(a[t])}for(;e.length;)a.removeTrack(e.shift())};this[e.getterName+"Listeners_"]=s,Object.keys(s).forEach(function(t){var i=s[t];r.addEventListener(t,i),n.on("dispose",function(e){return r.removeEventListener(t,i)})}),this.on("loadstart",t),this.on("dispose",function(e){return n.off("loadstart",t)})}},e.proxyNativeTracks_=function(){var t=this;fi.names.forEach(function(e){t.proxyNativeTracksForType_(e)})},e.createEl=function(){var e=this.options_.tag;if(!e||!this.options_.playerElIngest&&!this.movingMediaElementInDOM){if(e){var t=e.cloneNode(!0);e.parentNode&&e.parentNode.insertBefore(t,e),o.disposeMediaElement(e),e=t}else{e=d.createElement("video");var i=Re({},this.options_.tag&&L(this.options_.tag));ut&&!0===this.options_.nativeControlsForTouch||delete i.controls,P(e,g(i,{id:this.options_.techId,class:"vjs-tech"}))}e.playerId=this.options_.playerId}"undefined"!=typeof this.options_.preload&&D(e,"preload",this.options_.preload),void 0!==this.options_.disablePictureInPicture&&(e.disablePictureInPicture=this.options_.disablePictureInPicture);for(var n=["loop","muted","playsinline","autoplay"],r=0;r<n.length;r++){var a=n[r],s=this.options_[a];"undefined"!=typeof s&&(s?D(e,a,a):M(e,a),e[a]=s)}return e},e.handleLateInit_=function(e){if(0!==e.networkState&&3!==e.networkState){if(0===e.readyState){var t=!1,i=function(){t=!0};this.on("loadstart",i);var n=function(){t||this.trigger("loadstart")};return this.on("loadedmetadata",n),void this.ready(function(){this.off("loadstart",i),this.off("loadedmetadata",n),t||this.trigger("loadstart")})}var r=["loadstart"];r.push("loadedmetadata"),2<=e.readyState&&r.push("loadeddata"),3<=e.readyState&&r.push("canplay"),4<=e.readyState&&r.push("canplaythrough"),this.ready(function(){r.forEach(function(e){this.trigger(e)},this)})}},e.setScrubbing=function(e){this.isScrubbing_=e},e.setCurrentTime=function(e){try{this.isScrubbing_&&this.el_.fastSeek&&ht?this.el_.fastSeek(e):this.el_.currentTime=e}catch(e){p(e,"Video is not ready. (Video.js)")}},e.duration=function(){var t=this;if(this.el_.duration===1/0&&Je&&nt&&0===this.el_.currentTime){return this.on("timeupdate",function e(){0<t.el_.currentTime&&(t.el_.duration===1/0&&t.trigger("durationchange"),t.off("timeupdate",e))}),NaN}return this.el_.duration||NaN},e.width=function(){return this.el_.offsetWidth},e.height=function(){return this.el_.offsetHeight},e.proxyWebkitFullscreen_=function(){var e=this;if("webkitDisplayingFullscreen"in this.el_){var t=function(){this.trigger("fullscreenchange",{isFullscreen:!1})},i=function(){"webkitPresentationMode"in this.el_&&"picture-in-picture"!==this.el_.webkitPresentationMode&&(this.one("webkitendfullscreen",t),this.trigger("fullscreenchange",{isFullscreen:!0,nativeIOSFullscreen:!0}))};this.on("webkitbeginfullscreen",i),this.on("dispose",function(){e.off("webkitbeginfullscreen",i),e.off("webkitendfullscreen",t)})}},e.supportsFullScreen=function(){if("function"==typeof this.el_.webkitEnterFullScreen){var e=T.navigator&&T.navigator.userAgent||"";if(/Android/.test(e)||!/Chrome|Mac OS X 10.5/.test(e))return!0}return!1},e.enterFullScreen=function(){var e=this.el_;if(e.paused&&e.networkState<=e.HAVE_METADATA)At(this.el_.play()),this.setTimeout(function(){e.pause();try{e.webkitEnterFullScreen()}catch(e){this.trigger("fullscreenerror",e)}},0);else try{e.webkitEnterFullScreen()}catch(e){this.trigger("fullscreenerror",e)}},e.exitFullScreen=function(){this.el_.webkitDisplayingFullscreen?this.el_.webkitExitFullScreen():this.trigger("fullscreenerror",new Error("The video is not fullscreen"))},e.requestPictureInPicture=function(){return this.el_.requestPictureInPicture()},e.src=function(e){if(void 0===e)return this.el_.src;this.setSrc(e)},e.reset=function(){o.resetMediaElement(this.el_)},e.currentSrc=function(){return this.currentSource_?this.currentSource_.src:this.el_.currentSrc},e.setControls=function(e){this.el_.controls=!!e},e.addTextTrack=function(e,t,i){return this.featuresNativeTextTracks?this.el_.addTextTrack(e,t,i):c.prototype.addTextTrack.call(this,e,t,i)},e.createRemoteTextTrack=function(e){if(!this.featuresNativeTextTracks)return c.prototype.createRemoteTextTrack.call(this,e);var t=d.createElement("track");return e.kind&&(t.kind=e.kind),e.label&&(t.label=e.label),(e.language||e.srclang)&&(t.srclang=e.language||e.srclang),e.default&&(t.default=e.default),e.id&&(t.id=e.id),e.src&&(t.src=e.src),t},e.addRemoteTextTrack=function(e,t){var i=c.prototype.addRemoteTextTrack.call(this,e,t);return this.featuresNativeTextTracks&&this.el().appendChild(i),i},e.removeRemoteTextTrack=function(e){if(c.prototype.removeRemoteTextTrack.call(this,e),this.featuresNativeTextTracks)for(var t=this.$$("track"),i=t.length;i--;)e!==t[i]&&e!==t[i].track||this.el().removeChild(t[i])},e.getVideoPlaybackQuality=function(){if("function"==typeof this.el().getVideoPlaybackQuality)return this.el().getVideoPlaybackQuality();var e={};return"undefined"!=typeof this.el().webkitDroppedFrameCount&&"undefined"!=typeof this.el().webkitDecodedFrameCount&&(e.droppedVideoFrames=this.el().webkitDroppedFrameCount,e.totalVideoFrames=this.el().webkitDecodedFrameCount),T.performance&&"function"==typeof T.performance.now?e.creationTime=T.performance.now():T.performance&&T.performance.timing&&"number"==typeof T.performance.timing.navigationStart&&(e.creationTime=T.Date.now()-T.performance.timing.navigationStart),e},o}(Xi);Br(Hr,"TEST_VID",function(){if(y()){var e=d.createElement("video"),t=d.createElement("track");return t.kind="captions",t.srclang="en",t.label="English",e.appendChild(t),e}}),Hr.isSupported=function(){try{Hr.TEST_VID.volume=.5}catch(e){return!1}return!(!Hr.TEST_VID||!Hr.TEST_VID.canPlayType)},Hr.canPlayType=function(e){return Hr.TEST_VID.canPlayType(e)},Hr.canPlaySource=function(e,t){return Hr.canPlayType(e.type)},Hr.canControlVolume=function(){try{var e=Hr.TEST_VID.volume;return Hr.TEST_VID.volume=e/2+.1,e!==Hr.TEST_VID.volume}catch(e){return!1}},Hr.canMuteVolume=function(){try{var e=Hr.TEST_VID.muted;return Hr.TEST_VID.muted=!e,Hr.TEST_VID.muted?D(Hr.TEST_VID,"muted","muted"):M(Hr.TEST_VID,"muted"),e!==Hr.TEST_VID.muted}catch(e){return!1}},Hr.canControlPlaybackRate=function(){if(Je&&nt&&rt<58)return!1;try{var e=Hr.TEST_VID.playbackRate;return Hr.TEST_VID.playbackRate=e/2+.1,e!==Hr.TEST_VID.playbackRate}catch(e){return!1}},Hr.canOverrideAttributes=function(){try{var e=function(){};Object.defineProperty(d.createElement("video"),"src",{get:e,set:e}),Object.defineProperty(d.createElement("audio"),"src",{get:e,set:e}),Object.defineProperty(d.createElement("video"),"innerHTML",{get:e,set:e}),Object.defineProperty(d.createElement("audio"),"innerHTML",{get:e,set:e})}catch(e){return!1}return!0},Hr.supportsNativeTextTracks=function(){return ht||dt&&nt},Hr.supportsNativeVideoTracks=function(){return!(!Hr.TEST_VID||!Hr.TEST_VID.videoTracks)},Hr.supportsNativeAudioTracks=function(){return!(!Hr.TEST_VID||!Hr.TEST_VID.audioTracks)},Hr.Events=["loadstart","suspend","abort","error","emptied","stalled","loadedmetadata","loadeddata","canplay","canplaythrough","playing","waiting","seeking","seeked","ended","durationchange","timeupdate","progress","play","pause","ratechange","resize","volumechange"],[["featuresVolumeControl","canControlVolume"],["featuresMuteControl","canMuteVolume"],["featuresPlaybackRate","canControlPlaybackRate"],["featuresSourceset","canOverrideAttributes"],["featuresNativeTextTracks","supportsNativeTextTracks"],["featuresNativeVideoTracks","supportsNativeVideoTracks"],["featuresNativeAudioTracks","supportsNativeAudioTracks"]].forEach(function(e){var t=e[0],i=e[1];Br(Hr.prototype,t,function(){return Hr[i]()},!0)}),Hr.prototype.movingMediaElementInDOM=!dt,Hr.prototype.featuresFullscreenResize=!0,Hr.prototype.featuresProgressEvents=!0,Hr.prototype.featuresTimeupdateEvents=!0,Hr.patchCanPlayType=function(){4<=Ze&&!tt&&!nt&&(jr=Hr.TEST_VID&&Hr.TEST_VID.constructor.prototype.canPlayType,Hr.TEST_VID.constructor.prototype.canPlayType=function(e){return e&&/^application\/(?:x-|vnd\.apple\.)mpegurl/i.test(e)?"maybe":jr.call(this,e)})},Hr.unpatchCanPlayType=function(){var e=Hr.TEST_VID.constructor.prototype.canPlayType;return jr&&(Hr.TEST_VID.constructor.prototype.canPlayType=jr),e},Hr.patchCanPlayType(),Hr.disposeMediaElement=function(e){if(e){for(e.parentNode&&e.parentNode.removeChild(e);e.hasChildNodes();)e.removeChild(e.firstChild);e.removeAttribute("src"),"function"==typeof e.load&&function(){try{e.load()}catch(e){}}()}},Hr.resetMediaElement=function(e){if(e){for(var t=e.querySelectorAll("source"),i=t.length;i--;)e.removeChild(t[i]);e.removeAttribute("src"),"function"==typeof e.load&&function(){try{e.load()}catch(e){}}()}},["muted","defaultMuted","autoplay","controls","loop","playsinline"].forEach(function(e){Hr.prototype[e]=function(){return this.el_[e]||this.el_.hasAttribute(e)}}),["muted","defaultMuted","autoplay","loop","playsinline"].forEach(function(t){Hr.prototype["set"+Me(t)]=function(e){(this.el_[t]=e)?this.el_.setAttribute(t,t):this.el_.removeAttribute(t)}}),["paused","currentTime","buffered","volume","poster","preload","error","seeking","seekable","ended","playbackRate","defaultPlaybackRate","disablePictureInPicture","played","networkState","readyState","videoWidth","videoHeight","crossOrigin"].forEach(function(e){Hr.prototype[e]=function(){return this.el_[e]}}),["volume","src","poster","preload","playbackRate","defaultPlaybackRate","disablePictureInPicture","crossOrigin"].forEach(function(t){Hr.prototype["set"+Me(t)]=function(e){this.el_[t]=e}}),["pause","load","play"].forEach(function(e){Hr.prototype[e]=function(){return this.el_[e]()}}),Xi.withSourceHandlers(Hr),Hr.nativeSourceHandler={},Hr.nativeSourceHandler.canPlayType=function(e){try{return Hr.TEST_VID.canPlayType(e)}catch(e){return""}},Hr.nativeSourceHandler.canHandleSource=function(e,t){if(e.type)return Hr.nativeSourceHandler.canPlayType(e.type);if(e.src){var i=Vt(e.src);return Hr.nativeSourceHandler.canPlayType("video/"+i)}return""},Hr.nativeSourceHandler.handleSource=function(e,t,i){t.setSrc(e.src)},Hr.nativeSourceHandler.dispose=function(){},Hr.registerSourceHandler(Hr.nativeSourceHandler),Xi.registerTech("Html5",Hr);var Wr=["progress","abort","suspend","emptied","stalled","loadedmetadata","loadeddata","timeupdate","resize","volumechange","texttrackchange"],zr={canplay:"CanPlay",canplaythrough:"CanPlayThrough",playing:"Playing",seeked:"Seeked"},Gr=["tiny","xsmall","small","medium","large","xlarge","huge"],Xr={};Gr.forEach(function(e){var t="x"===e.charAt(0)?"x-"+e.substring(1):e;Xr[e]="vjs-layout-"+t});var Kr={tiny:210,xsmall:320,small:425,medium:768,large:1440,xlarge:2560,huge:1/0},Yr=function(c){function l(e,t,i){var n;if(e.id=e.id||t.id||"vjs_video_"+re(),(t=g(l.getTagSettings(e),t)).initChildren=!1,t.createEl=!1,t.evented=!1,t.reportTouchActivity=!1,!t.language)if("function"==typeof e.closest){var r=e.closest("[lang]");r&&r.getAttribute&&(t.language=r.getAttribute("lang"))}else for(var a=e;a&&1===a.nodeType;){if(L(a).hasOwnProperty("lang")){t.language=a.getAttribute("lang");break}a=a.parentNode}if((n=c.call(this,null,t,i)||this).boundDocumentFullscreenChange_=ve(Ve(n),n.documentFullscreenChange_),n.boundFullWindowOnEscKey_=ve(Ve(n),n.fullWindowOnEscKey),n.isFullscreen_=!1,n.log=f(n.id_),n.fsApi_=_t,n.isPosterFromTech_=!1,n.queuedCallbacks_=[],n.isReady_=!1,n.hasStarted_=!1,n.userActive_=!1,n.debugEnabled_=!1,!n.options_||!n.options_.techOrder||!n.options_.techOrder.length)throw new Error("No techOrder specified. Did you overwrite videojs.options instead of just changing the properties you want to override?");if(n.tag=e,n.tagAttributes=e&&L(e),n.language(n.options_.language),t.languages){var s={};Object.getOwnPropertyNames(t.languages).forEach(function(e){s[e.toLowerCase()]=t.languages[e]}),n.languages_=s}else n.languages_=l.prototype.options_.languages;n.resetCache_(),n.poster_=t.poster||"",n.controls_=!!t.controls,e.controls=!1,e.removeAttribute("controls"),n.changingSrc_=!1,n.playCallbacks_=[],n.playTerminatedQueue_=[],e.hasAttribute("autoplay")?n.autoplay(!0):n.autoplay(n.options_.autoplay),t.plugins&&Object.keys(t.plugins).forEach(function(e){if("function"!=typeof n[e])throw new Error('plugin "'+e+'" does not exist')}),n.scrubbing_=!1,n.el_=n.createEl(),Pe(Ve(n),{eventBusKey:"el_"}),n.fsApi_.requestFullscreen&&(he(d,n.fsApi_.fullscreenchange,n.boundDocumentFullscreenChange_),n.on(n.fsApi_.fullscreenchange,n.boundDocumentFullscreenChange_)),n.fluid_&&n.on("playerreset",n.updateStyleEl_);var o=Re(n.options_);t.plugins&&Object.keys(t.plugins).forEach(function(e){n[e](t.plugins[e])}),t.debug&&n.debug(!0),n.options_.playerOptions=o,n.middleware_=[],n.initChildren(),n.isAudio("audio"===e.nodeName.toLowerCase()),n.controls()?n.addClass("vjs-controls-enabled"):n.addClass("vjs-controls-disabled"),n.el_.setAttribute("role","region"),n.isAudio()?n.el_.setAttribute("aria-label",n.localize("Audio Player")):n.el_.setAttribute("aria-label",n.localize("Video Player")),n.isAudio()&&n.addClass("vjs-audio"),n.flexNotSupported_()&&n.addClass("vjs-no-flex"),ut&&n.addClass("vjs-touch-enabled"),dt||n.addClass("vjs-workinghover"),l.players[n.id_]=Ve(n);var u=h.split(".")[0];return n.addClass("vjs-v"+u),n.userActive(!0),n.reportUserActivity(),n.one("play",n.listenForUserActivity_),n.on("stageclick",n.handleStageClick_),n.on("keydown",n.handleKeyDown),n.breakpoints(n.options_.breakpoints),n.responsive(n.options_.responsive),n}Ge(l,c);var e=l.prototype;return e.dispose=function(){var n=this;this.trigger("dispose"),this.off("dispose"),pe(d,this.fsApi_.fullscreenchange,this.boundDocumentFullscreenChange_),pe(d,"keydown",this.boundFullWindowOnEscKey_),this.styleEl_&&this.styleEl_.parentNode&&(this.styleEl_.parentNode.removeChild(this.styleEl_),this.styleEl_=null),l.players[this.id_]=null,this.tag&&this.tag.player&&(this.tag.player=null),this.el_&&this.el_.player&&(this.el_.player=null),this.tech_&&(this.tech_.dispose(),this.isPosterFromTech_=!1,this.poster_=""),this.playerElIngest_&&(this.playerElIngest_=null),this.tag&&(this.tag=null),function(e){Yi[e.id()]=null}(this),gi.names.forEach(function(e){var t=gi[e],i=n[t.getterName]();i&&i.off&&i.off()}),c.prototype.dispose.call(this)},e.createEl=function(){var t,i=this.tag,e=this.playerElIngest_=i.parentNode&&i.parentNode.hasAttribute&&i.parentNode.hasAttribute("data-vjs-player"),n="video-js"===this.tag.tagName.toLowerCase();e?t=this.el_=i.parentNode:n||(t=this.el_=c.prototype.createEl.call(this,"div"));var r=L(i);if(n){for(t=this.el_=i,i=this.tag=d.createElement("video");t.children.length;)i.appendChild(t.firstChild);w(t,"video-js")||I(t,"video-js"),t.appendChild(i),e=this.playerElIngest_=t,Object.keys(t).forEach(function(e){try{i[e]=t[e]}catch(e){}})}if(i.setAttribute("tabindex","-1"),r.tabindex="-1",(at||nt&&ot)&&(i.setAttribute("role","application"),r.role="application"),i.removeAttribute("width"),i.removeAttribute("height"),"width"in r&&delete r.width,"height"in r&&delete r.height,Object.getOwnPropertyNames(r).forEach(function(e){n&&"class"===e||t.setAttribute(e,r[e]),n&&i.setAttribute(e,r[e])}),i.playerId=i.id,i.id+="_html5_api",i.className="vjs-tech",i.player=t.player=this,this.addClass("vjs-paused"),!0!==T.VIDEOJS_NO_DYNAMIC_STYLE){this.styleEl_=ee("vjs-styles-dimensions");var a=X(".vjs-styles-defaults"),s=X("head");s.insertBefore(this.styleEl_,a?a.nextSibling:s.firstChild)}this.fill_=!1,this.fluid_=!1,this.width(this.options_.width),this.height(this.options_.height),this.fill(this.options_.fill),this.fluid(this.options_.fluid),this.aspectRatio(this.options_.aspectRatio),this.crossOrigin(this.options_.crossOrigin||this.options_.crossorigin);for(var o=i.getElementsByTagName("a"),u=0;u<o.length;u++){var l=o.item(u);I(l,"vjs-hidden"),l.setAttribute("hidden","hidden")}return i.initNetworkState_=i.networkState,i.parentNode&&!e&&i.parentNode.insertBefore(t,i),E(i,t),this.children_.unshift(i),this.el_.setAttribute("lang",this.language_),this.el_=t},e.crossOrigin=function(e){if(!e)return this.techGet_("crossOrigin");"anonymous"===e||"use-credentials"===e?this.techCall_("setCrossOrigin",e):p.warn('crossOrigin must be "anonymous" or "use-credentials", given "'+e+'"')},e.width=function(e){return this.dimension("width",e)},e.height=function(e){return this.dimension("height",e)},e.dimension=function(e,t){var i=e+"_";if(void 0===t)return this[i]||0;if(""===t||"auto"===t)return this[i]=void 0,void this.updateStyleEl_();var n=parseFloat(t);isNaN(n)?p.error('Improper value "'+t+'" supplied for for '+e):(this[i]=n,this.updateStyleEl_())},e.fluid=function(e){if(void 0===e)return!!this.fluid_;this.fluid_=!!e,Ae(this)&&this.off("playerreset",this.updateStyleEl_),e?(this.addClass("vjs-fluid"),this.fill(!1),function(e,t){Ae(e)?t():(e.eventedCallbacks||(e.eventedCallbacks=[]),e.eventedCallbacks.push(t))}(function(){this.on("playerreset",this.updateStyleEl_)})):this.removeClass("vjs-fluid"),this.updateStyleEl_()},e.fill=function(e){if(void 0===e)return!!this.fill_;this.fill_=!!e,e?(this.addClass("vjs-fill"),this.fluid(!1)):this.removeClass("vjs-fill")},e.aspectRatio=function(e){if(void 0===e)return this.aspectRatio_;if(!/^\d+\:\d+$/.test(e))throw new Error("Improper value supplied for aspect ratio. The format should be width:height, for example 16:9.");this.aspectRatio_=e,this.fluid(!0),this.updateStyleEl_()},e.updateStyleEl_=function(){if(!0!==T.VIDEOJS_NO_DYNAMIC_STYLE){var e,t,i,n=(void 0!==this.aspectRatio_&&"auto"!==this.aspectRatio_?this.aspectRatio_:0<this.videoWidth()?this.videoWidth()+":"+this.videoHeight():"16:9").split(":"),r=n[1]/n[0];e=void 0!==this.width_?this.width_:void 0!==this.height_?this.height_/r:this.videoWidth()||300,t=void 0!==this.height_?this.height_:e*r,i=/^[^a-zA-Z]/.test(this.id())?"dimensions-"+this.id():this.id()+"-dimensions",this.addClass(i),te(this.styleEl_,"\n ."+i+" {\n width: "+e+"px;\n height: "+t+"px;\n }\n\n ."+i+".vjs-fluid {\n padding-top: "+100*r+"%;\n }\n ")}else{var a="number"==typeof this.width_?this.width_:this.options_.width,s="number"==typeof this.height_?this.height_:this.options_.height,o=this.tech_&&this.tech_.el();o&&(0<=a&&(o.width=a),0<=s&&(o.height=s))}},e.loadTech_=function(e,t){var i=this;this.tech_&&this.unloadTech_();var n=Me(e),r=e.charAt(0).toLowerCase()+e.slice(1);"Html5"!==n&&this.tag&&(Xi.getTech("Html5").disposeMediaElement(this.tag),this.tag.player=null,this.tag=null),this.techName_=n,this.isReady_=!1;var a={source:t,autoplay:"string"!=typeof this.autoplay()&&this.autoplay(),nativeControlsForTouch:this.options_.nativeControlsForTouch,playerId:this.id(),techId:this.id()+"_"+r+"_api",playsinline:this.options_.playsinline,preload:this.options_.preload,loop:this.options_.loop,disablePictureInPicture:this.options_.disablePictureInPicture,muted:this.options_.muted,poster:this.poster(),language:this.language(),playerElIngest:this.playerElIngest_||!1,"vtt.js":this.options_["vtt.js"],canOverridePoster:!!this.options_.techCanOverridePoster,enableSourceset:this.options_.enableSourceset,Promise:this.options_.Promise};gi.names.forEach(function(e){var t=gi[e];a[t.getterName]=i[t.privateName]}),g(a,this.options_[n]),g(a,this.options_[r]),g(a,this.options_[e.toLowerCase()]),this.tag&&(a.tag=this.tag),t&&t.src===this.cache_.src&&0<this.cache_.currentTime&&(a.startTime=this.cache_.currentTime);var s=Xi.getTech(e);if(!s)throw new Error("No Tech named '"+n+"' exists! '"+n+"' should be registered using videojs.registerTech()'");this.tech_=new s(a),this.tech_.ready(ve(this,this.handleTechReady_),!0),Lt(this.textTracksJson_||[],this.tech_),Wr.forEach(function(e){i.on(i.tech_,e,i["handleTech"+Me(e)+"_"])}),Object.keys(zr).forEach(function(t){i.on(i.tech_,t,function(e){0===i.tech_.playbackRate()&&i.tech_.seeking()?i.queuedCallbacks_.push({callback:i["handleTech"+zr[t]+"_"].bind(i),event:e}):i["handleTech"+zr[t]+"_"](e)})}),this.on(this.tech_,"loadstart",this.handleTechLoadStart_),this.on(this.tech_,"sourceset",this.handleTechSourceset_),this.on(this.tech_,"waiting",this.handleTechWaiting_),this.on(this.tech_,"ended",this.handleTechEnded_),this.on(this.tech_,"seeking",this.handleTechSeeking_),this.on(this.tech_,"play",this.handleTechPlay_),this.on(this.tech_,"firstplay",this.handleTechFirstPlay_),this.on(this.tech_,"pause",this.handleTechPause_),this.on(this.tech_,"durationchange",this.handleTechDurationChange_),this.on(this.tech_,"fullscreenchange",this.handleTechFullscreenChange_),this.on(this.tech_,"fullscreenerror",this.handleTechFullscreenError_),this.on(this.tech_,"enterpictureinpicture",this.handleTechEnterPictureInPicture_),this.on(this.tech_,"leavepictureinpicture",this.handleTechLeavePictureInPicture_),this.on(this.tech_,"error",this.handleTechError_),this.on(this.tech_,"loadedmetadata",this.updateStyleEl_),this.on(this.tech_,"posterchange",this.handleTechPosterChange_),this.on(this.tech_,"textdata",this.handleTechTextData_),this.on(this.tech_,"ratechange",this.handleTechRateChange_),this.usingNativeControls(this.techGet_("controls")),this.controls()&&!this.usingNativeControls()&&this.addTechControlsListeners_(),this.tech_.el().parentNode===this.el()||"Html5"===n&&this.tag||E(this.tech_.el(),this.el()),this.tag&&(this.tag.player=null,this.tag=null)},e.unloadTech_=function(){var i=this;gi.names.forEach(function(e){var t=gi[e];i[t.privateName]=i[t.getterName]()}),this.textTracksJson_=Pt(this.tech_),this.isReady_=!1,this.tech_.dispose(),this.tech_=!1,this.isPosterFromTech_&&(this.poster_="",this.trigger("posterchange")),this.isPosterFromTech_=!1},e.tech=function(e){return void 0===e&&p.warn("Using the tech directly can be dangerous. I hope you know what you're doing.\nSee https://github.com/videojs/video.js/issues/2617 for more info.\n"),this.tech_},e.addTechControlsListeners_=function(){this.removeTechControlsListeners_(),this.on(this.tech_,"mouseup",this.handleTechClick_),this.on(this.tech_,"dblclick",this.handleTechDoubleClick_),this.on(this.tech_,"touchstart",this.handleTechTouchStart_),this.on(this.tech_,"touchmove",this.handleTechTouchMove_),this.on(this.tech_,"touchend",this.handleTechTouchEnd_),this.on(this.tech_,"tap",this.handleTechTap_)},e.removeTechControlsListeners_=function(){this.off(this.tech_,"tap",this.handleTechTap_),this.off(this.tech_,"touchstart",this.handleTechTouchStart_),this.off(this.tech_,"touchmove",this.handleTechTouchMove_),this.off(this.tech_,"touchend",this.handleTechTouchEnd_),this.off(this.tech_,"mouseup",this.handleTechClick_),this.off(this.tech_,"dblclick",this.handleTechDoubleClick_)},e.handleTechReady_=function(){this.triggerReady(),this.cache_.volume&&this.techCall_("setVolume",this.cache_.volume),this.handleTechPosterChange_(),this.handleTechDurationChange_()},e.handleTechLoadStart_=function(){this.removeClass("vjs-ended"),this.removeClass("vjs-seeking"),this.error(null),this.handleTechDurationChange_(),this.paused()?(this.hasStarted(!1),this.trigger("loadstart")):(this.trigger("loadstart"),this.trigger("firstplay")),this.manualAutoplay_(this.autoplay())},e.manualAutoplay_=function(t){var n=this;if(this.tech_&&"string"==typeof t){var e,i=function(){var e=n.muted();n.muted(!0);function t(){n.muted(e)}n.playTerminatedQueue_.push(t);var i=n.play();if(It(i))return i.catch(t)};if("any"===t&&!0!==this.muted()?It(e=this.play())&&(e=e.catch(i)):e="muted"===t&&!0!==this.muted()?i():this.play(),It(e))return e.then(function(){n.trigger({type:"autoplay-success",autoplay:t})}).catch(function(e){n.trigger({type:"autoplay-failure",autoplay:t})})}},e.updateSourceCaches_=function(e){void 0===e&&(e="");var t=e,i="";"string"!=typeof t&&(t=e.src,i=e.type),this.cache_.source=this.cache_.source||{},this.cache_.sources=this.cache_.sources||[],t&&!i&&(i=function(e,t){if(!t)return"";if(e.cache_.source.src===t&&e.cache_.source.type)return e.cache_.source.type;var i=e.cache_.sources.filter(function(e){return e.src===t});if(i.length)return i[0].type;for(var n=e.$$("source"),r=0;r<n.length;r++){var a=n[r];if(a.type&&a.src&&a.src===t)return a.type}return an(t)}(this,t)),this.cache_.source=Re({},e,{src:t,type:i});for(var n=this.cache_.sources.filter(function(e){return e.src&&e.src===t}),r=[],a=this.$$("source"),s=[],o=0;o<a.length;o++){var u=L(a[o]);r.push(u),u.src&&u.src===t&&s.push(u.src)}s.length&&!n.length?this.cache_.sources=r:n.length||(this.cache_.sources=[this.cache_.source]),this.cache_.src=t},e.handleTechSourceset_=function(e){var i=this;if(!this.changingSrc_){var t=function(e){return i.updateSourceCaches_(e)},n=this.currentSource().src,r=e.src;n&&!/^blob:/.test(n)&&/^blob:/.test(r)&&(this.lastSource_&&(this.lastSource_.tech===r||this.lastSource_.player===n)||(t=function(){})),t(r),e.src||this.tech_.any(["sourceset","loadstart"],function(e){if("sourceset"!==e.type){var t=i.techGet("currentSrc");i.lastSource_.tech=t,i.updateSourceCaches_(t)}})}this.lastSource_={player:this.currentSource().src,tech:e.src},this.trigger({src:e.src,type:"sourceset"})},e.hasStarted=function(e){if(void 0===e)return this.hasStarted_;e!==this.hasStarted_&&(this.hasStarted_=e,this.hasStarted_?(this.addClass("vjs-has-started"),this.trigger("firstplay")):this.removeClass("vjs-has-started"))},e.handleTechPlay_=function(){this.removeClass("vjs-ended"),this.removeClass("vjs-paused"),this.addClass("vjs-playing"),this.hasStarted(!0),this.trigger("play")},e.handleTechRateChange_=function(){0<this.tech_.playbackRate()&&0===this.cache_.lastPlaybackRate&&(this.queuedCallbacks_.forEach(function(e){return e.callback(e.event)}),this.queuedCallbacks_=[]),this.cache_.lastPlaybackRate=this.tech_.playbackRate(),this.trigger("ratechange")},e.handleTechWaiting_=function(){var t=this;this.addClass("vjs-waiting"),this.trigger("waiting");var i=this.currentTime();this.on("timeupdate",function e(){i!==t.currentTime()&&(t.removeClass("vjs-waiting"),t.off("timeupdate",e))})},e.handleTechCanPlay_=function(){this.removeClass("vjs-waiting"),this.trigger("canplay")},e.handleTechCanPlayThrough_=function(){this.removeClass("vjs-waiting"),this.trigger("canplaythrough")},e.handleTechPlaying_=function(){this.removeClass("vjs-waiting"),this.trigger("playing")},e.handleTechSeeking_=function(){this.addClass("vjs-seeking"),this.trigger("seeking")},e.handleTechSeeked_=function(){this.removeClass("vjs-seeking"),this.removeClass("vjs-ended"),this.trigger("seeked")},e.handleTechFirstPlay_=function(){this.options_.starttime&&(p.warn("Passing the `starttime` option to the player will be deprecated in 6.0"),this.currentTime(this.options_.starttime)),this.addClass("vjs-has-started"),this.trigger("firstplay")},e.handleTechPause_=function(){this.removeClass("vjs-playing"),this.addClass("vjs-paused"),this.trigger("pause")},e.handleTechEnded_=function(){this.addClass("vjs-ended"),this.options_.loop?(this.currentTime(0),this.play()):this.paused()||this.pause(),this.trigger("ended")},e.handleTechDurationChange_=function(){this.duration(this.techGet_("duration"))},e.handleTechClick_=function(e){z(e)&&this.controls_&&(this.paused()?At(this.play()):this.pause())},e.handleTechDoubleClick_=function(t){this.controls_&&(Array.prototype.some.call(this.$$(".vjs-control-bar, .vjs-modal-dialog"),function(e){return e.contains(t.target)})||void 0!==this.options_&&void 0!==this.options_.userActions&&void 0!==this.options_.userActions.doubleClick&&!1===this.options_.userActions.doubleClick||(void 0!==this.options_&&void 0!==this.options_.userActions&&"function"==typeof this.options_.userActions.doubleClick?this.options_.userActions.doubleClick.call(this,t):this.isFullscreen()?this.exitFullscreen():this.requestFullscreen()))},e.handleTechTap_=function(){this.userActive(!this.userActive())},e.handleTechTouchStart_=function(){this.userWasActive=this.userActive()},e.handleTechTouchMove_=function(){this.userWasActive&&this.reportUserActivity()},e.handleTechTouchEnd_=function(e){e.preventDefault()},e.handleStageClick_=function(){this.reportUserActivity()},e.toggleFullscreenClass_=function(){this.isFullscreen()?this.addClass("vjs-fullscreen"):this.removeClass("vjs-fullscreen")},e.documentFullscreenChange_=function(e){var t=e.target.player;if(!t||t===this){var i=this.el(),n=d[this.fsApi_.fullscreenElement]===i;!n&&i.matches?n=i.matches(":"+this.fsApi_.fullscreen):!n&&i.msMatchesSelector&&(n=i.msMatchesSelector(":"+this.fsApi_.fullscreen)),this.isFullscreen(n)}},e.handleTechFullscreenChange_=function(e,t){t&&(t.nativeIOSFullscreen&&this.toggleClass("vjs-ios-native-fs"),this.isFullscreen(t.isFullscreen))},e.handleTechFullscreenError_=function(e,t){this.trigger("fullscreenerror",t)},e.togglePictureInPictureClass_=function(){this.isInPictureInPicture()?this.addClass("vjs-picture-in-picture"):this.removeClass("vjs-picture-in-picture")},e.handleTechEnterPictureInPicture_=function(e){this.isInPictureInPicture(!0)},e.handleTechLeavePictureInPicture_=function(e){this.isInPictureInPicture(!1)},e.handleTechError_=function(){var e=this.tech_.error();this.error(e)},e.handleTechTextData_=function(e,t){var i=null;1<arguments.length&&(i=t),this.trigger("textdata",i)},e.getCache=function(){return this.cache_},e.resetCache_=function(){this.cache_={currentTime:0,initTime:0,inactivityTimeout:this.options_.inactivityTimeout,duration:NaN,lastVolume:1,lastPlaybackRate:this.defaultPlaybackRate(),media:null,src:"",source:{},sources:[],volume:1}},e.techCall_=function(e,t){this.ready(function(){if(e in en)return function(e,t,i,n){return t[i](e.reduce(nn(i),n))}(this.middleware_,this.tech_,e,t);if(e in tn)return Ji(this.middleware_,this.tech_,e,t);try{this.tech_&&this.tech_[e](t)}catch(e){throw p(e),e}},!0)},e.techGet_=function(t){if(this.tech_&&this.tech_.isReady_){if(t in Zi)return function(e,t,i){return e.reduceRight(nn(i),t[i]())}(this.middleware_,this.tech_,t);if(t in tn)return Ji(this.middleware_,this.tech_,t);try{return this.tech_[t]()}catch(e){if(void 0===this.tech_[t])throw p("Video.js: "+t+" method not defined for "+this.techName_+" playback technology.",e),e;if("TypeError"===e.name)throw p("Video.js: "+t+" unavailable on "+this.techName_+" playback technology element.",e),this.tech_.isReady_=!1,e;throw p(e),e}}},e.play=function(){var t=this,e=this.options_.Promise||T.Promise;return e?new e(function(e){t.play_(e)}):this.play_()},e.play_=function(e){var t=this;void 0===e&&(e=At),this.playCallbacks_.push(e);var i=Boolean(!this.changingSrc_&&(this.src()||this.currentSrc()));if(this.waitToPlay_&&(this.off(["ready","loadstart"],this.waitToPlay_),this.waitToPlay_=null),!this.isReady_||!i)return this.waitToPlay_=function(e){t.play_()},this.one(["ready","loadstart"],this.waitToPlay_),void(i||!ht&&!dt||this.load());var n=this.techGet_("play");null===n?this.runPlayTerminatedQueue_():this.runPlayCallbacks_(n)},e.runPlayTerminatedQueue_=function(){var e=this.playTerminatedQueue_.slice(0);this.playTerminatedQueue_=[],e.forEach(function(e){e()})},e.runPlayCallbacks_=function(t){var e=this.playCallbacks_.slice(0);this.playCallbacks_=[],this.playTerminatedQueue_=[],e.forEach(function(e){e(t)})},e.pause=function(){this.techCall_("pause")},e.paused=function(){return!1!==this.techGet_("paused")},e.played=function(){return this.techGet_("played")||gt(0,0)},e.scrubbing=function(e){if("undefined"==typeof e)return this.scrubbing_;this.scrubbing_=!!e,this.techCall_("setScrubbing",this.scrubbing_),e?this.addClass("vjs-scrubbing"):this.removeClass("vjs-scrubbing")},e.currentTime=function(e){return"undefined"!=typeof e?(e<0&&(e=0),this.isReady_&&!this.changingSrc_&&this.tech_&&this.tech_.isReady_?(this.techCall_("setCurrentTime",e),void(this.cache_.initTime=0)):(this.cache_.initTime=e,this.off("canplay",this.applyInitTime_),void this.one("canplay",this.applyInitTime_))):(this.cache_.currentTime=this.techGet_("currentTime")||0,this.cache_.currentTime)},e.applyInitTime_=function(){this.currentTime(this.cache_.initTime)},e.duration=function(e){if(void 0===e)return void 0!==this.cache_.duration?this.cache_.duration:NaN;(e=parseFloat(e))<0&&(e=1/0),e!==this.cache_.duration&&((this.cache_.duration=e)===1/0?this.addClass("vjs-live"):this.removeClass("vjs-live"),isNaN(e)||this.trigger("durationchange"))},e.remainingTime=function(){return this.duration()-this.currentTime()},e.remainingTimeDisplay=function(){return Math.floor(this.duration())-Math.floor(this.currentTime())},e.buffered=function(){var e=this.techGet_("buffered");return e&&e.length||(e=gt(0,0)),e},e.bufferedPercent=function(){return vt(this.buffered(),this.duration())},e.bufferedEnd=function(){var e=this.buffered(),t=this.duration(),i=e.end(e.length-1);return t<i&&(i=t),i},e.volume=function(e){var t;return void 0!==e?(t=Math.max(0,Math.min(1,parseFloat(e))),this.cache_.volume=t,this.techCall_("setVolume",t),void(0<t&&this.lastVolume_(t))):(t=parseFloat(this.techGet_("volume")),isNaN(t)?1:t)},e.muted=function(e){if(void 0===e)return this.techGet_("muted")||!1;this.techCall_("setMuted",e)},e.defaultMuted=function(e){return void 0!==e?this.techCall_("setDefaultMuted",e):this.techGet_("defaultMuted")||!1},e.lastVolume_=function(e){if(void 0===e||0===e)return this.cache_.lastVolume;this.cache_.lastVolume=e},e.supportsFullScreen=function(){return this.techGet_("supportsFullScreen")||!1},e.isFullscreen=function(e){if(void 0===e)return this.isFullscreen_;var t=this.isFullscreen_;return this.isFullscreen_=Boolean(e),this.isFullscreen_!==t&&this.fsApi_.prefixed&&this.trigger("fullscreenchange"),void this.toggleFullscreenClass_()},e.requestFullscreen=function(s){var e=this.options_.Promise||T.Promise;if(e){var o=this;return new e(function(e,i){function n(){o.off("fullscreenerror",r),o.off("fullscreenchange",t)}function t(){n(),e()}function r(e,t){n(),i(t)}o.one("fullscreenchange",t),o.one("fullscreenerror",r);var a=o.requestFullscreenHelper_(s);if(a)return a.then(n,n),a})}return this.requestFullscreenHelper_()},e.requestFullscreenHelper_=function(e){var t,i=this;if(this.fsApi_.prefixed||(t=this.options_.fullscreen&&this.options_.fullscreen.options||{},void 0!==e&&(t=e)),this.fsApi_.requestFullscreen){var n=this.el_[this.fsApi_.requestFullscreen](t);return n&&n.then(function(){return i.isFullscreen(!0)},function(){return i.isFullscreen(!1)}),n}this.tech_.supportsFullScreen()?this.techCall_("enterFullScreen"):this.enterFullWindow()},e.exitFullscreen=function(){var e=this.options_.Promise||T.Promise;if(e){var s=this;return new e(function(e,i){function n(){s.off("fullscreenerror",r),s.off("fullscreenchange",t)}function t(){n(),e()}function r(e,t){n(),i(t)}s.one("fullscreenchange",t),s.one("fullscreenerror",r);var a=s.exitFullscreenHelper_();if(a)return a.then(n,n),a})}return this.exitFullscreenHelper_()},e.exitFullscreenHelper_=function(){var e=this;if(this.fsApi_.requestFullscreen){var t=d[this.fsApi_.exitFullscreen]();return t&&t.then(function(){return e.isFullscreen(!1)}),t}this.tech_.supportsFullScreen()?this.techCall_("exitFullScreen"):this.exitFullWindow()},e.enterFullWindow=function(){this.isFullscreen(!0),this.isFullWindow=!0,this.docOrigOverflow=d.documentElement.style.overflow,he(d,"keydown",this.boundFullWindowOnEscKey_),d.documentElement.style.overflow="hidden",I(d.body,"vjs-full-window"),this.trigger("enterFullWindow")},e.fullWindowOnEscKey=function(e){Ot.isEventKey(e,"Esc")&&(!0===this.isFullscreen()?this.exitFullscreen():this.exitFullWindow())},e.exitFullWindow=function(){this.isFullscreen(!1),this.isFullWindow=!1,pe(d,"keydown",this.boundFullWindowOnEscKey_),d.documentElement.style.overflow=this.docOrigOverflow,A(d.body,"vjs-full-window"),this.trigger("exitFullWindow")},e.disablePictureInPicture=function(e){if(void 0===e)return this.techGet_("disablePictureInPicture");this.techCall_("setDisablePictureInPicture",e),this.options_.disablePictureInPicture=e,this.trigger("disablepictureinpicturechanged")},e.isInPictureInPicture=function(e){return void 0!==e?(this.isInPictureInPicture_=!!e,void this.togglePictureInPictureClass_()):!!this.isInPictureInPicture_},e.requestPictureInPicture=function(){if("pictureInPictureEnabled"in d&&!1===this.disablePictureInPicture())return this.techGet_("requestPictureInPicture")},e.exitPictureInPicture=function(){if("pictureInPictureEnabled"in d)return d.exitPictureInPicture()},e.handleKeyDown=function(e){var t=this.options_.userActions;if(t&&t.hotkeys){!function(e){var t=e.tagName.toLowerCase();if(e.isContentEditable)return!0;if("input"===t)return-1===["button","checkbox","hidden","radio","reset","submit"].indexOf(e.type);return-1!==["textarea"].indexOf(t)}(this.el_.ownerDocument.activeElement)&&("function"==typeof t.hotkeys?t.hotkeys.call(this,e):this.handleHotkeys(e))}},e.handleHotkeys=function(e){var t=this.options_.userActions?this.options_.userActions.hotkeys:{},i=t.fullscreenKey,n=void 0===i?function(e){return Ot.isEventKey(e,"f")}:i,r=t.muteKey,a=void 0===r?function(e){return Ot.isEventKey(e,"m")}:r,s=t.playPauseKey,o=void 0===s?function(e){return Ot.isEventKey(e,"k")||Ot.isEventKey(e,"Space")}:s;if(n.call(this,e)){e.preventDefault(),e.stopPropagation();var u=je.getComponent("FullscreenToggle");!1!==d[this.fsApi_.fullscreenEnabled]&&u.prototype.handleClick.call(this,e)}else if(a.call(this,e)){e.preventDefault(),e.stopPropagation(),je.getComponent("MuteToggle").prototype.handleClick.call(this,e)}else if(o.call(this,e)){e.preventDefault(),e.stopPropagation(),je.getComponent("PlayToggle").prototype.handleClick.call(this,e)}},e.canPlayType=function(e){for(var t,i=0,n=this.options_.techOrder;i<n.length;i++){var r=n[i],a=Xi.getTech(r);if(a=a||je.getComponent(r)){if(a.isSupported()&&(t=a.canPlayType(e)))return t}else p.error('The "'+r+'" tech is undefined. Skipped browser support check for that tech.')}return""},e.selectSource=function(e){function t(e,i,n){var r;return e.some(function(t){return i.some(function(e){if(r=n(t,e))return!0})}),r}function i(e,t){var i=e[0];if(e[1].canPlaySource(t,r.options_[i.toLowerCase()]))return{source:t,tech:i}}var n,r=this,a=this.options_.techOrder.map(function(e){return[e,Xi.getTech(e)]}).filter(function(e){var t=e[0],i=e[1];return i?i.isSupported():(p.error('The "'+t+'" tech is undefined. Skipped browser support check for that tech.'),!1)});return(this.options_.sourceOrder?t(e,a,(n=i,function(e,t){return n(t,e)})):t(a,e,i))||!1},e.src=function(e){var i=this;if("undefined"==typeof e)return this.cache_.src||"";var n=function t(e){if(Array.isArray(e)){var i=[];e.forEach(function(e){e=t(e),Array.isArray(e)?i=i.concat(e):s(e)&&i.push(e)}),e=i}else e="string"==typeof e&&e.trim()?[on({src:e})]:s(e)&&"string"==typeof e.src&&e.src&&e.src.trim()?[on(e)]:[];return e}(e);n.length?(this.changingSrc_=!0,this.cache_.sources=n,this.updateSourceCaches_(n[0]),Qi(this,n[0],function(e,t){if(i.middleware_=t,i.cache_.sources=n,i.updateSourceCaches_(e),i.src_(e))return 1<n.length?i.src(n.slice(1)):(i.changingSrc_=!1,i.setTimeout(function(){this.error({code:4,message:this.localize(this.options_.notSupportedMessage)})},0),void i.triggerReady());!function(e,t){e.forEach(function(e){return e.setTech&&e.setTech(t)})}(t,i.tech_)})):this.setTimeout(function(){this.error({code:4,message:this.localize(this.options_.notSupportedMessage)})},0)},e.src_=function(e){var t=this,i=this.selectSource([e]);return!i||(function(e,t){return Me(e)===Me(t)}(i.tech,this.techName_)?this.ready(function(){this.tech_.constructor.prototype.hasOwnProperty("setSource")?this.techCall_("setSource",e):this.techCall_("src",e.src),this.changingSrc_=!1},!0):(this.changingSrc_=!0,this.loadTech_(i.tech,i.source),this.tech_.ready(function(){t.changingSrc_=!1})),!1)},e.load=function(){this.techCall_("load")},e.reset=function(){var e=this,t=this.options_.Promise||T.Promise;this.paused()||!t?this.doReset_():At(this.play().then(function(){return e.doReset_()}))},e.doReset_=function(){this.tech_&&this.tech_.clearTracks("text"),this.resetCache_(),this.poster(""),this.loadTech_(this.options_.techOrder[0],null),this.techCall_("reset"),this.resetControlBarUI_(),Ae(this)&&this.trigger("playerreset")},e.resetControlBarUI_=function(){this.resetProgressBar_(),this.resetPlaybackRate_(),this.resetVolumeBar_()},e.resetProgressBar_=function(){this.currentTime(0);var e=this.controlBar,t=e.durationDisplay,i=e.remainingTimeDisplay;t&&t.updateContent(),i&&i.updateContent()},e.resetPlaybackRate_=function(){this.playbackRate(this.defaultPlaybackRate()),this.handleTechRateChange_()},e.resetVolumeBar_=function(){this.volume(1),this.trigger("volumechange")},e.currentSources=function(){var e=this.currentSource(),t=[];return 0!==Object.keys(e).length&&t.push(e),this.cache_.sources||t},e.currentSource=function(){return this.cache_.source||{}},e.currentSrc=function(){return this.currentSource()&&this.currentSource().src||""},e.currentType=function(){return this.currentSource()&&this.currentSource().type||""},e.preload=function(e){return void 0!==e?(this.techCall_("setPreload",e),void(this.options_.preload=e)):this.techGet_("preload")},e.autoplay=function(e){if(void 0===e)return this.options_.autoplay||!1;var t;"string"==typeof e&&/(any|play|muted)/.test(e)?(this.options_.autoplay=e,this.manualAutoplay_(e),t=!1):this.options_.autoplay=!!e,t="undefined"==typeof t?this.options_.autoplay:t,this.tech_&&this.techCall_("setAutoplay",t)},e.playsinline=function(e){return void 0!==e?(this.techCall_("setPlaysinline",e),this.options_.playsinline=e,this):this.techGet_("playsinline")},e.loop=function(e){return void 0!==e?(this.techCall_("setLoop",e),void(this.options_.loop=e)):this.techGet_("loop")},e.poster=function(e){if(void 0===e)return this.poster_;(e=e||"")!==this.poster_&&(this.poster_=e,this.techCall_("setPoster",e),this.isPosterFromTech_=!1,this.trigger("posterchange"))},e.handleTechPosterChange_=function(){if((!this.poster_||this.options_.techCanOverridePoster)&&this.tech_&&this.tech_.poster){var e=this.tech_.poster()||"";e!==this.poster_&&(this.poster_=e,this.isPosterFromTech_=!0,this.trigger("posterchange"))}},e.controls=function(e){if(void 0===e)return!!this.controls_;e=!!e,this.controls_!==e&&(this.controls_=e,this.usingNativeControls()&&this.techCall_("setControls",e),this.controls_?(this.removeClass("vjs-controls-disabled"),this.addClass("vjs-controls-enabled"),this.trigger("controlsenabled"),this.usingNativeControls()||this.addTechControlsListeners_()):(this.removeClass("vjs-controls-enabled"),this.addClass("vjs-controls-disabled"),this.trigger("controlsdisabled"),this.usingNativeControls()||this.removeTechControlsListeners_()))},e.usingNativeControls=function(e){if(void 0===e)return!!this.usingNativeControls_;e=!!e,this.usingNativeControls_!==e&&(this.usingNativeControls_=e,this.usingNativeControls_?(this.addClass("vjs-using-native-controls"),this.trigger("usingnativecontrols")):(this.removeClass("vjs-using-native-controls"),this.trigger("usingcustomcontrols")))},e.error=function(e){if(void 0===e)return this.error_||null;if(this.options_.suppressNotSupportedError&&e&&4===e.code){var t=function(){this.error(e)};return this.options_.suppressNotSupportedError=!1,this.any(["click","touchstart"],t),void this.one("loadstart",function(){this.off(["click","touchstart"],t)})}if(null===e)return this.error_=e,this.removeClass("vjs-error"),void(this.errorDisplay&&this.errorDisplay.close());this.error_=new Ct(e),this.addClass("vjs-error"),p.error("(CODE:"+this.error_.code+" "+Ct.errorTypes[this.error_.code]+")",this.error_.message,this.error_),this.trigger("error")},e.reportUserActivity=function(e){this.userActivity_=!0},e.userActive=function(e){if(void 0===e)return this.userActive_;if((e=!!e)!==this.userActive_){if(this.userActive_=e,this.userActive_)return this.userActivity_=!0,this.removeClass("vjs-user-inactive"),this.addClass("vjs-user-active"),void this.trigger("useractive");this.tech_&&this.tech_.one("mousemove",function(e){e.stopPropagation(),e.preventDefault()}),this.userActivity_=!1,this.removeClass("vjs-user-active"),this.addClass("vjs-user-inactive"),this.trigger("userinactive")}},e.listenForUserActivity_=function(){function e(e){r(),this.clearInterval(t)}var t,i,n,r=ve(this,this.reportUserActivity);this.on("mousedown",function(){r(),this.clearInterval(t),t=this.setInterval(r,250)}),this.on("mousemove",function(e){e.screenX===i&&e.screenY===n||(i=e.screenX,n=e.screenY,r())}),this.on("mouseup",e),this.on("mouseleave",e);var a,s=this.getChild("controlBar");!s||dt||Je||(s.on("mouseenter",function(e){this.player().cache_.inactivityTimeout=this.player().options_.inactivityTimeout,this.player().options_.inactivityTimeout=0}),s.on("mouseleave",function(e){this.player().options_.inactivityTimeout=this.player().cache_.inactivityTimeout})),this.on("keydown",r),this.on("keyup",r),this.setInterval(function(){if(this.userActivity_){this.userActivity_=!1,this.userActive(!0),this.clearTimeout(a);var e=this.options_.inactivityTimeout;e<=0||(a=this.setTimeout(function(){this.userActivity_||this.userActive(!1)},e))}},250)},e.playbackRate=function(e){if(void 0===e)return this.tech_&&this.tech_.featuresPlaybackRate?this.cache_.lastPlaybackRate||this.techGet_("playbackRate"):1;this.techCall_("setPlaybackRate",e)},e.defaultPlaybackRate=function(e){return void 0!==e?this.techCall_("setDefaultPlaybackRate",e):this.tech_&&this.tech_.featuresPlaybackRate?this.techGet_("defaultPlaybackRate"):1},e.isAudio=function(e){if(void 0===e)return!!this.isAudio_;this.isAudio_=!!e},e.addTextTrack=function(e,t,i){if(this.tech_)return this.tech_.addTextTrack(e,t,i)},e.addRemoteTextTrack=function(e,t){if(this.tech_)return this.tech_.addRemoteTextTrack(e,t)},e.removeRemoteTextTrack=function(e){void 0===e&&(e={});var t=e.track;if(t=t||e,this.tech_)return this.tech_.removeRemoteTextTrack(t)},e.getVideoPlaybackQuality=function(){return this.techGet_("getVideoPlaybackQuality")},e.videoWidth=function(){return this.tech_&&this.tech_.videoWidth&&this.tech_.videoWidth()||0},e.videoHeight=function(){return this.tech_&&this.tech_.videoHeight&&this.tech_.videoHeight()||0},e.language=function(e){if(void 0===e)return this.language_;this.language_=String(e).toLowerCase()},e.languages=function(){return Re(l.prototype.options_.languages,this.languages_)},e.toJSON=function(){var e=Re(this.options_),t=e.tracks;e.tracks=[];for(var i=0;i<t.length;i++){var n=t[i];(n=Re(n)).player=void 0,e.tracks[i]=n}return e},e.createModal=function(e,t){var i=this;(t=t||{}).content=e||"";var n=new Mt(this,t);return this.addChild(n),n.on("dispose",function(){i.removeChild(n)}),n.open(),n},e.updateCurrentBreakpoint_=function(){if(this.responsive())for(var e=this.currentBreakpoint(),t=this.currentWidth(),i=0;i<Gr.length;i++){var n=Gr[i];if(t<=this.breakpoints_[n]){if(e===n)return;e&&this.removeClass(Xr[e]),this.addClass(Xr[n]),this.breakpoint_=n;break}}},e.removeCurrentBreakpoint_=function(){var e=this.currentBreakpointClass();this.breakpoint_="",e&&this.removeClass(e)},e.breakpoints=function(e){return void 0===e||(this.breakpoint_="",this.breakpoints_=g({},Kr,e),this.updateCurrentBreakpoint_()),g(this.breakpoints_)},e.responsive=function(e){return void 0===e?this.responsive_:(e=Boolean(e))!==this.responsive_?((this.responsive_=e)?(this.on("playerresize",this.updateCurrentBreakpoint_),this.updateCurrentBreakpoint_()):(this.off("playerresize",this.updateCurrentBreakpoint_),this.removeCurrentBreakpoint_()),e):void 0},e.currentBreakpoint=function(){return this.breakpoint_},e.currentBreakpointClass=function(){return Xr[this.breakpoint_]||""},e.loadMedia=function(e,t){var i=this;if(e&&"object"==typeof e){this.reset(),this.cache_.media=Re(e);var n=this.cache_.media,r=n.artwork,a=n.poster,s=n.src,o=n.textTracks;!r&&a&&(this.cache_.media.artwork=[{src:a,type:an(a)}]),s&&this.src(s),a&&this.poster(a),Array.isArray(o)&&o.forEach(function(e){return i.addRemoteTextTrack(e,!1)}),this.ready(t)}},e.getMedia=function(){if(this.cache_.media)return Re(this.cache_.media);var e=this.poster(),t={src:this.currentSources(),textTracks:Array.prototype.map.call(this.remoteTextTracks(),function(e){return{kind:e.kind,label:e.label,language:e.language,src:e.src}})};return e&&(t.poster=e,t.artwork=[{src:t.poster,type:an(t.poster)}]),t},l.getTagSettings=function(e){var t={sources:[],tracks:[]},i=L(e),n=i["data-setup"];if(w(e,"vjs-fill")&&(i.fill=!0),w(e,"vjs-fluid")&&(i.fluid=!0),null!==n){var r=wt(n||"{}"),a=r[0],s=r[1];a&&p.error(a),g(i,s)}if(g(t,i),e.hasChildNodes())for(var o=e.childNodes,u=0,l=o.length;u<l;u++){var c=o[u],d=c.nodeName.toLowerCase();"source"===d?t.sources.push(L(c)):"track"===d&&t.tracks.push(L(c))}return t},e.flexNotSupported_=function(){var e=d.createElement("i");return!("flexBasis"in e.style||"webkitFlexBasis"in e.style||"mozFlexBasis"in e.style||"msFlexBasis"in e.style||"msFlexOrder"in e.style)},e.debug=function(e){if(void 0===e)return this.debugEnabled_;e?(this.trigger("debugon"),this.previousLogLevel_=this.log.level,this.log.level("debug"),this.debugEnabled_=!0):(this.trigger("debugoff"),this.log.level(this.previousLogLevel_),this.previousLogLevel_=void 0,this.debugEnabled_=!1)},l}(je);gi.names.forEach(function(e){var t=gi[e];Yr.prototype[t.getterName]=function(){return this.tech_?this.tech_[t.getterName]():(this[t.privateName]=this[t.privateName]||new t.ListClass,this[t.privateName])}}),Yr.prototype.crossorigin=Yr.prototype.crossOrigin,Yr.players={};var $r=T.navigator;Yr.prototype.options_={techOrder:Xi.defaultTechOrder_,html5:{},flash:{},inactivityTimeout:2e3,playbackRates:[],liveui:!1,children:["mediaLoader","posterImage","textTrackDisplay","loadingSpinner","bigPlayButton","liveTracker","controlBar","errorDisplay","textTrackSettings","resizeManager"],language:$r&&($r.languages&&$r.languages[0]||$r.userLanguage||$r.language)||"en",languages:{},notSupportedMessage:"No compatible source was found for this media.",fullscreen:{options:{navigationUI:"hide"}},breakpoints:{},responsive:!1},["ended","seeking","seekable","networkState","readyState"].forEach(function(e){Yr.prototype[e]=function(){return this.techGet_(e)}}),Wr.forEach(function(e){Yr.prototype["handleTech"+Me(e)+"_"]=function(){return this.trigger(e)}}),je.registerComponent("Player",Yr);var Qr=i(function(i){function n(e,t){return i.exports=n=Object.setPrototypeOf||function(e,t){return e.__proto__=t,e},n(e,t)}i.exports=n});function Jr(e){return oa.hasOwnProperty(e)}function Zr(e){return Jr(e)?oa[e]:void 0}function ea(e,t){e[sa]=e[sa]||{},e[sa][t]=!0}function ta(e,t,i){var n=(i?"before":"")+"pluginsetup";e.trigger(n,t),e.trigger(n+":"+t.name,t)}function ia(r,a){return a.prototype.name=r,function(){ta(this,{name:r,plugin:a,instance:null},!0);for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];var n=ra(a,[this].concat(t));return this[r]=function(){return n},ta(this,n.getEventHash()),n}}var na=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Date.prototype.toString.call(Reflect.construct(Date,[],function(){})),!0}catch(e){return!1}},ra=i(function(n){function r(e,t,i){return na()?n.exports=r=Reflect.construct:n.exports=r=function(e,t,i){var n=[null];n.push.apply(n,t);var r=new(Function.bind.apply(e,n));return i&&Qr(r,i.prototype),r},r.apply(null,arguments)}n.exports=r}),aa="plugin",sa="activePlugins_",oa={},ua=function(){function i(e){if(this.constructor===i)throw new Error("Plugin must be sub-classed; not directly instantiated.");this.player=e,this.log||(this.log=this.player.log.createLogger(this.name)),Pe(this),delete this.trigger,Oe(this,this.constructor.defaultState),ea(e,this.name),this.dispose=ve(this,this.dispose),e.on("dispose",this.dispose)}var e=i.prototype;return e.version=function(){return this.constructor.VERSION},e.getEventHash=function(e){return void 0===e&&(e={}),e.name=this.name,e.plugin=this.constructor,e.instance=this,e},e.trigger=function(e,t){return void 0===t&&(t={}),fe(this.eventBusEl_,e,this.getEventHash(t))},e.handleStateChanged=function(e){},e.dispose=function(){var e=this.name,t=this.player;this.trigger("dispose"),this.off(),t.off("dispose",this.dispose),t[sa][e]=!1,this.player=this.state=null,t[e]=ia(e,oa[e])},i.isBasic=function(e){var t="string"==typeof e?Zr(e):e;return"function"==typeof t&&!i.prototype.isPrototypeOf(t.prototype)},i.registerPlugin=function(e,t){if("string"!=typeof e)throw new Error('Illegal plugin name, "'+e+'", must be a string, was '+typeof e+".");if(Jr(e))p.warn('A plugin named "'+e+'" already exists. You may want to avoid re-registering plugins!');else if(Yr.prototype.hasOwnProperty(e))throw new Error('Illegal plugin name, "'+e+'", cannot share a name with an existing player method!');if("function"!=typeof t)throw new Error('Illegal plugin for "'+e+'", must be a function, was '+typeof t+".");return oa[e]=t,e!==aa&&(i.isBasic(t)?Yr.prototype[e]=function(t,i){function n(){ta(this,{name:t,plugin:i,instance:null},!0);var e=i.apply(this,arguments);return ea(this,t),ta(this,{name:t,plugin:i,instance:e}),e}return Object.keys(i).forEach(function(e){n[e]=i[e]}),n}(e,t):Yr.prototype[e]=ia(e,t)),t},i.deregisterPlugin=function(e){if(e===aa)throw new Error("Cannot de-register base plugin.");Jr(e)&&(delete oa[e],delete Yr.prototype[e])},i.getPlugins=function(e){var i;return void 0===e&&(e=Object.keys(oa)),e.forEach(function(e){var t=Zr(e);t&&((i=i||{})[e]=t)}),i},i.getPluginVersion=function(e){var t=Zr(e);return t&&t.VERSION||""},i}();ua.getPlugin=Zr,ua.BASE_PLUGIN_NAME=aa,ua.registerPlugin(aa,ua),Yr.prototype.usingPlugin=function(e){return!!this[sa]&&!0===this[sa][e]},Yr.prototype.hasPlugin=function(e){return!!Jr(e)};var la=function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function");e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,writable:!0,configurable:!0}}),t&&Qr(e,t)},ca=function(e){return 0===e.indexOf("#")?e.slice(1):e};function da(e,i,t){var n=da.getPlayer(e);if(n)return i&&p.warn('Player "'+e+'" is already initialised. Options will not be applied.'),t&&n.ready(t),n;var r="string"==typeof e?X("#"+ca(e)):e;if(!_(r))throw new TypeError("The element or ID supplied is not valid. (videojs)");r.ownerDocument.defaultView&&r.ownerDocument.body.contains(r)||p.warn("The element supplied is not included in the DOM"),i=i||{},da.hooks("beforesetup").forEach(function(e){var t=e(r,Re(i));s(t)&&!Array.isArray(t)?i=Re(i,t):p.error("please return an object in beforesetup hooks")});var a=je.getComponent("Player");return n=new a(r,i,t),da.hooks("setup").forEach(function(e){return e(n)}),n}if(da.hooks_={},da.hooks=function(e,t){return da.hooks_[e]=da.hooks_[e]||[],t&&(da.hooks_[e]=da.hooks_[e].concat(t)),da.hooks_[e]},da.hook=function(e,t){da.hooks(e,t)},da.hookOnce=function(i,e){da.hooks(i,[].concat(e).map(function(t){return function e(){return da.removeHook(i,e),t.apply(void 0,arguments)}}))},da.removeHook=function(e,t){var i=da.hooks(e).indexOf(t);return!(i<=-1)&&(da.hooks_[e]=da.hooks_[e].slice(),da.hooks_[e].splice(i,1),!0)},!0!==T.VIDEOJS_NO_DYNAMIC_STYLE&&y()){var ha=X(".vjs-styles-defaults");if(!ha){ha=ee("vjs-styles-defaults");var pa=X("head");pa&&pa.insertBefore(ha,pa.firstChild),te(ha,"\n .video-js {\n width: 300px;\n height: 150px;\n }\n\n .vjs-fluid {\n padding-top: 56.25%\n }\n ")}}function fa(e){return e&&"object"==typeof e&&"default"in e?e:{default:e}}J(1,da),da.VERSION=h,da.options=Yr.prototype.options_,da.getPlayers=function(){return Yr.players},da.getPlayer=function(e){var t,i=Yr.players;if("string"==typeof e){var n=ca(e),r=i[n];if(r)return r;t=X("#"+n)}else t=e;if(_(t)){var a=t,s=a.player,o=a.playerId;if(s||i[o])return s||i[o]}},da.getAllPlayers=function(){return Object.keys(Yr.players).map(function(e){return Yr.players[e]}).filter(Boolean)},da.players=Yr.players,da.getComponent=je.getComponent,da.registerComponent=function(e,t){Xi.isTech(t)&&p.warn("The "+e+" tech was registered as a component. It should instead be registered using videojs.registerTech(name, tech)"),je.registerComponent.call(je,e,t)},da.getTech=Xi.getTech,da.registerTech=Xi.registerTech,da.use=function(e,t){Ki[e]=Ki[e]||[],Ki[e].push(t)},Object.defineProperty(da,"middleware",{value:{},writeable:!1,enumerable:!0}),Object.defineProperty(da.middleware,"TERMINATOR",{value:$i,writeable:!1,enumerable:!0}),da.browser=pt,da.TOUCH_ENABLED=ut,da.extend=function(e,t){void 0===t&&(t={});var i=function(){e.apply(this,arguments)},n={};for(var r in"object"==typeof t?(t.constructor!==Object.prototype.constructor&&(i=t.constructor),n=t):"function"==typeof t&&(i=t),la(i,e),e&&(i.super_=e),n)n.hasOwnProperty(r)&&(i.prototype[r]=n[r]);return i},da.mergeOptions=Re,da.bind=ve,da.registerPlugin=ua.registerPlugin,da.deregisterPlugin=ua.deregisterPlugin,da.plugin=function(e,t){return p.warn("videojs.plugin() is deprecated; use videojs.registerPlugin() instead"),ua.registerPlugin(e,t)},da.getPlugins=ua.getPlugins,da.getPlugin=ua.getPlugin,da.getPluginVersion=ua.getPluginVersion,da.addLanguage=function(e,t){var i;return e=(""+e).toLowerCase(),da.options.languages=Re(da.options.languages,((i={})[e]=t,i)),da.options.languages[e]},da.log=p,da.createLogger=f,da.createTimeRange=da.createTimeRanges=gt,da.formatTime=kn,da.setFormatTime=function(e){Sn=e},da.resetFormatTime=function(){Sn=Tn},da.parseUrl=Bt,da.isCrossOrigin=qt,da.EventTarget=_e,da.on=he,da.one=me,da.off=pe,da.trigger=fe,da.xhr=ni,da.TextTrack=ci,da.AudioTrack=di,da.VideoTrack=hi,["isEl","isTextNode","createEl","hasClass","addClass","removeClass","toggleClass","setAttributes","getAttributes","emptyEl","appendContent","insertContent"].forEach(function(e){da[e]=function(){return p.warn("videojs."+e+"() is deprecated; use videojs.dom."+e+"() instead"),Y[e].apply(null,arguments)}}),da.computedStyle=u,da.dom=Y,da.url=Zt,da.defineLazyProperty=Br;var ma=fa(i(function(e,t){var i,c,n,r,d;i=/^((?:[a-zA-Z0-9+\-.]+:)?)(\/\/[^\/?#]*)?((?:[^\/\?#]*\/)*.*?)??(;.*?)?(\?.*?)?(#.*?)?$/,c=/^([^\/?#]*)(.*)$/,n=/(?:\/|^)\.(?=\/)/g,r=/(?:\/|^)\.\.\/(?!\.\.\/).*?(?=\/)/g,d={buildAbsoluteURL:function(e,t,i){if(i=i||{},e=e.trim(),!(t=t.trim())){if(!i.alwaysNormalize)return e;var n=d.parseURL(e);if(!n)throw new Error("Error trying to parse base URL.");return n.path=d.normalizePath(n.path),d.buildURLFromParts(n)}var r=d.parseURL(t);if(!r)throw new Error("Error trying to parse relative URL.");if(r.scheme)return i.alwaysNormalize?(r.path=d.normalizePath(r.path),d.buildURLFromParts(r)):t;var a=d.parseURL(e);if(!a)throw new Error("Error trying to parse base URL.");if(!a.netLoc&&a.path&&"/"!==a.path[0]){var s=c.exec(a.path);a.netLoc=s[1],a.path=s[2]}a.netLoc&&!a.path&&(a.path="/");var o={scheme:a.scheme,netLoc:r.netLoc,path:null,params:r.params,query:r.query,fragment:r.fragment};if(!r.netLoc&&(o.netLoc=a.netLoc,"/"!==r.path[0]))if(r.path){var u=a.path,l=u.substring(0,u.lastIndexOf("/")+1)+r.path;o.path=d.normalizePath(l)}else o.path=a.path,r.params||(o.params=a.params,r.query||(o.query=a.query));return null===o.path&&(o.path=i.alwaysNormalize?d.normalizePath(r.path):r.path),d.buildURLFromParts(o)},parseURL:function(e){var t=i.exec(e);return t?{scheme:t[1]||"",netLoc:t[2]||"",path:t[3]||"",params:t[4]||"",query:t[5]||"",fragment:t[6]||""}:null},normalizePath:function(e){for(e=e.split("").reverse().join("").replace(n,"");e.length!==(e=e.replace(r,"")).length;);return e.split("").reverse().join("")},buildURLFromParts:function(e){return e.scheme+e.netLoc+e.path+e.params+e.query+e.fragment}},e.exports=d})),ga=fa(T),va=function(e,t){return/^[a-z]+:/i.test(t)?t:(/\/\//i.test(e)||(e=ma.default.buildAbsoluteURL(ga.default.location&&ga.default.location.href||"",e)),ma.default.buildAbsoluteURL(e,t))},ya=function(){function e(){this.listeners={}}var t=e.prototype;return t.on=function(e,t){this.listeners[e]||(this.listeners[e]=[]),this.listeners[e].push(t)},t.off=function(e,t){if(!this.listeners[e])return!1;var i=this.listeners[e].indexOf(t);return this.listeners[e]=this.listeners[e].slice(0),this.listeners[e].splice(i,1),-1<i},t.trigger=function(e,t){var i=this.listeners[e];if(i)if(2===arguments.length)for(var n=i.length,r=0;r<n;++r)i[r].call(this,t);else for(var a=Array.prototype.slice.call(arguments,1),s=i.length,o=0;o<s;++o)i[o].apply(this,a)},t.dispose=function(){this.listeners={}},t.pipe=function(t){this.on("data",function(e){t.push(e)})},e}();function _a(e){return e&&"object"==typeof e&&"default"in e?e:{default:e}}var ba=_a(T);function Ta(e){for(var t,i=e.split(new RegExp('(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))')),n={},r=i.length;r--;)""!==i[r]&&((t=/([^=]*)=(.*)/.exec(i[r]).slice(1))[0]=t[0].replace(/^\s+|\s+$/g,""),t[1]=t[1].replace(/^\s+|\s+$/g,""),t[1]=t[1].replace(/^['"](.*)['"]$/g,"$1"),n[t[0]]=t[1]);return n}var Sa=function(e){for(var t=function(e){return ba.default.atob?ba.default.atob(e):Buffer.from(e,"base64").toString("binary")}(e),i=new Uint8Array(t.length),n=0;n<t.length;n++)i[n]=t.charCodeAt(n);return i},ka=function(t){function e(){var e;return(e=t.call(this)||this).buffer="",e}return Ge(e,t),e.prototype.push=function(e){var t;for(this.buffer+=e,t=this.buffer.indexOf("\n");-1<t;t=this.buffer.indexOf("\n"))this.trigger("data",this.buffer.substring(0,t)),this.buffer=this.buffer.substring(t+1)},e}(ya),Ca=function(t){function e(){var e;return(e=t.call(this)||this).customParsers=[],e.tagMappers=[],e}Ge(e,t);var i=e.prototype;return i.push=function(n){var u,l,c=this;0!==(n=n.trim()).length&&("#"===n[0]?this.tagMappers.reduce(function(e,t){var i=t(n);return i===n?e:e.concat([i])},[n]).forEach(function(e){for(var t=0;t<c.customParsers.length;t++)if(c.customParsers[t].call(c,e))return;if(0===e.indexOf("#EXT"))if(e=e.replace("\r",""),u=/^#EXTM3U/.exec(e))c.trigger("data",{type:"tag",tagType:"m3u"});else{if(u=/^#EXTINF:?([0-9\.]*)?,?(.*)?$/.exec(e))return l={type:"tag",tagType:"inf"},u[1]&&(l.duration=parseFloat(u[1])),u[2]&&(l.title=u[2]),void c.trigger("data",l);if(u=/^#EXT-X-TARGETDURATION:?([0-9.]*)?/.exec(e))return l={type:"tag",tagType:"targetduration"},u[1]&&(l.duration=parseInt(u[1],10)),void c.trigger("data",l);if(u=/^#ZEN-TOTAL-DURATION:?([0-9.]*)?/.exec(e))return l={type:"tag",tagType:"totalduration"},u[1]&&(l.duration=parseInt(u[1],10)),void c.trigger("data",l);if(u=/^#EXT-X-VERSION:?([0-9.]*)?/.exec(e))return l={type:"tag",tagType:"version"},u[1]&&(l.version=parseInt(u[1],10)),void c.trigger("data",l);if(u=/^#EXT-X-MEDIA-SEQUENCE:?(\-?[0-9.]*)?/.exec(e))return l={type:"tag",tagType:"media-sequence"},u[1]&&(l.number=parseInt(u[1],10)),void c.trigger("data",l);if(u=/^#EXT-X-DISCONTINUITY-SEQUENCE:?(\-?[0-9.]*)?/.exec(e))return l={type:"tag",tagType:"discontinuity-sequence"},u[1]&&(l.number=parseInt(u[1],10)),void c.trigger("data",l);if(u=/^#EXT-X-PLAYLIST-TYPE:?(.*)?$/.exec(e))return l={type:"tag",tagType:"playlist-type"},u[1]&&(l.playlistType=u[1]),void c.trigger("data",l);if(u=/^#EXT-X-BYTERANGE:?([0-9.]*)?@?([0-9.]*)?/.exec(e))return l={type:"tag",tagType:"byterange"},u[1]&&(l.length=parseInt(u[1],10)),u[2]&&(l.offset=parseInt(u[2],10)),void c.trigger("data",l);if(u=/^#EXT-X-ALLOW-CACHE:?(YES|NO)?/.exec(e))return l={type:"tag",tagType:"allow-cache"},u[1]&&(l.allowed=!/NO/.test(u[1])),void c.trigger("data",l);if(u=/^#EXT-X-MAP:?(.*)$/.exec(e)){if(l={type:"tag",tagType:"map"},u[1]){var i=Ta(u[1]);if(i.URI&&(l.uri=i.URI),i.BYTERANGE){var n=i.BYTERANGE.split("@"),r=n[0],a=n[1];l.byterange={},r&&(l.byterange.length=parseInt(r,10)),a&&(l.byterange.offset=parseInt(a,10))}}c.trigger("data",l)}else if(u=/^#EXT-X-STREAM-INF:?(.*)$/.exec(e)){if(l={type:"tag",tagType:"stream-inf"},u[1]){if(l.attributes=Ta(u[1]),l.attributes.RESOLUTION){var s=l.attributes.RESOLUTION.split("x"),o={};s[0]&&(o.width=parseInt(s[0],10)),s[1]&&(o.height=parseInt(s[1],10)),l.attributes.RESOLUTION=o}l.attributes.BANDWIDTH&&(l.attributes.BANDWIDTH=parseInt(l.attributes.BANDWIDTH,10)),l.attributes["PROGRAM-ID"]&&(l.attributes["PROGRAM-ID"]=parseInt(l.attributes["PROGRAM-ID"],10))}c.trigger("data",l)}else{if(u=/^#EXT-X-MEDIA:?(.*)$/.exec(e))return l={type:"tag",tagType:"media"},u[1]&&(l.attributes=Ta(u[1])),void c.trigger("data",l);if(u=/^#EXT-X-ENDLIST/.exec(e))c.trigger("data",{type:"tag",tagType:"endlist"});else if(u=/^#EXT-X-DISCONTINUITY/.exec(e))c.trigger("data",{type:"tag",tagType:"discontinuity"});else{if(u=/^#EXT-X-PROGRAM-DATE-TIME:?(.*)$/.exec(e))return l={type:"tag",tagType:"program-date-time"},u[1]&&(l.dateTimeString=u[1],l.dateTimeObject=new Date(u[1])),void c.trigger("data",l);if(u=/^#EXT-X-KEY:?(.*)$/.exec(e))return l={type:"tag",tagType:"key"},u[1]&&(l.attributes=Ta(u[1]),l.attributes.IV&&("0x"===l.attributes.IV.substring(0,2).toLowerCase()&&(l.attributes.IV=l.attributes.IV.substring(2)),l.attributes.IV=l.attributes.IV.match(/.{8}/g),l.attributes.IV[0]=parseInt(l.attributes.IV[0],16),l.attributes.IV[1]=parseInt(l.attributes.IV[1],16),l.attributes.IV[2]=parseInt(l.attributes.IV[2],16),l.attributes.IV[3]=parseInt(l.attributes.IV[3],16),l.attributes.IV=new Uint32Array(l.attributes.IV))),void c.trigger("data",l);if(u=/^#EXT-X-START:?(.*)$/.exec(e))return l={type:"tag",tagType:"start"},u[1]&&(l.attributes=Ta(u[1]),l.attributes["TIME-OFFSET"]=parseFloat(l.attributes["TIME-OFFSET"]),l.attributes.PRECISE=/YES/.test(l.attributes.PRECISE)),void c.trigger("data",l);if(u=/^#EXT-X-CUE-OUT-CONT:?(.*)?$/.exec(e))return l={type:"tag",tagType:"cue-out-cont"},u[1]?l.data=u[1]:l.data="",void c.trigger("data",l);if(u=/^#EXT-X-CUE-OUT:?(.*)?$/.exec(e))return l={type:"tag",tagType:"cue-out"},u[1]?l.data=u[1]:l.data="",void c.trigger("data",l);if(u=/^#EXT-X-CUE-IN:?(.*)?$/.exec(e))return l={type:"tag",tagType:"cue-in"},u[1]?l.data=u[1]:l.data="",void c.trigger("data",l);c.trigger("data",{type:"tag",data:e.slice(4)})}}}else c.trigger("data",{type:"comment",text:e.slice(1)})}):this.trigger("data",{type:"uri",uri:n}))},i.addParser=function(e){var t=this,i=e.expression,n=e.customType,r=e.dataParser,a=e.segment;"function"!=typeof r&&(r=function(e){return e}),this.customParsers.push(function(e){if(i.exec(e))return t.trigger("data",{type:"custom",data:r(e),customType:n,segment:a}),!0})},i.addTagMapper=function(e){var t=e.expression,i=e.map;this.tagMappers.push(function(e){return t.test(e)?i(e):e})},e}(ya),Ea=function(t){function e(){var e;(e=t.call(this)||this).lineStream=new ka,e.parseStream=new Ca,e.lineStream.pipe(e.parseStream);var r,a,s=Ve(e),o=[],u={},l={AUDIO:{},VIDEO:{},"CLOSED-CAPTIONS":{},SUBTITLES:{}},c=0;e.manifest={allowCache:!0,discontinuityStarts:[],segments:[]};var d=0;return e.parseStream.on("data",function(t){var i,n;({tag:function(){({"allow-cache":function(){this.manifest.allowCache=t.allowed,"allowed"in t||(this.trigger("info",{message:"defaulting allowCache to YES"}),this.manifest.allowCache=!0)},byterange:function(){var e={};"length"in t&&((u.byterange=e).length=t.length,"offset"in t||(t.offset=d)),"offset"in t&&((u.byterange=e).offset=t.offset),d=e.offset+e.length},endlist:function(){this.manifest.endList=!0},inf:function(){"mediaSequence"in this.manifest||(this.manifest.mediaSequence=0,this.trigger("info",{message:"defaulting media sequence to zero"})),"discontinuitySequence"in this.manifest||(this.manifest.discontinuitySequence=0,this.trigger("info",{message:"defaulting discontinuity sequence to zero"})),0<t.duration&&(u.duration=t.duration),0===t.duration&&(u.duration=.01,this.trigger("info",{message:"updating zero segment duration to a small value"})),this.manifest.segments=o},key:function(){if(t.attributes)if("NONE"!==t.attributes.METHOD)if(t.attributes.URI){if("urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"===t.attributes.KEYFORMAT){return-1===["SAMPLE-AES","SAMPLE-AES-CTR","SAMPLE-AES-CENC"].indexOf(t.attributes.METHOD)?void this.trigger("warn",{message:"invalid key method provided for Widevine"}):("SAMPLE-AES-CENC"===t.attributes.METHOD&&this.trigger("warn",{message:"SAMPLE-AES-CENC is deprecated, please use SAMPLE-AES-CTR instead"}),"data:text/plain;base64,"!==t.attributes.URI.substring(0,23)?void this.trigger("warn",{message:"invalid key URI provided for Widevine"}):t.attributes.KEYID&&"0x"===t.attributes.KEYID.substring(0,2)?void(this.manifest.contentProtection={"com.widevine.alpha":{attributes:{schemeIdUri:t.attributes.KEYFORMAT,keyId:t.attributes.KEYID.substring(2)},pssh:Sa(t.attributes.URI.split(",")[1])}}):void this.trigger("warn",{message:"invalid key ID provided for Widevine"}))}t.attributes.METHOD||this.trigger("warn",{message:"defaulting key method to AES-128"}),a={method:t.attributes.METHOD||"AES-128",uri:t.attributes.URI},"undefined"!=typeof t.attributes.IV&&(a.iv=t.attributes.IV)}else this.trigger("warn",{message:"ignoring key declaration without URI"});else a=null;else this.trigger("warn",{message:"ignoring key declaration without attribute list"})},"media-sequence":function(){isFinite(t.number)?this.manifest.mediaSequence=t.number:this.trigger("warn",{message:"ignoring invalid media sequence: "+t.number})},"discontinuity-sequence":function(){isFinite(t.number)?(this.manifest.discontinuitySequence=t.number,c=t.number):this.trigger("warn",{message:"ignoring invalid discontinuity sequence: "+t.number})},"playlist-type":function(){/VOD|EVENT/.test(t.playlistType)?this.manifest.playlistType=t.playlistType:this.trigger("warn",{message:"ignoring unknown playlist type: "+t.playlist})},map:function(){r={},t.uri&&(r.uri=t.uri),t.byterange&&(r.byterange=t.byterange)},"stream-inf":function(){this.manifest.playlists=o,this.manifest.mediaGroups=this.manifest.mediaGroups||l,t.attributes?(u.attributes||(u.attributes={}),m(u.attributes,t.attributes)):this.trigger("warn",{message:"ignoring empty stream-inf attributes"})},media:function(){if(this.manifest.mediaGroups=this.manifest.mediaGroups||l,t.attributes&&t.attributes.TYPE&&t.attributes["GROUP-ID"]&&t.attributes.NAME){var e=this.manifest.mediaGroups[t.attributes.TYPE];e[t.attributes["GROUP-ID"]]=e[t.attributes["GROUP-ID"]]||{},i=e[t.attributes["GROUP-ID"]],(n={default:/yes/i.test(t.attributes.DEFAULT)}).default?n.autoselect=!0:n.autoselect=/yes/i.test(t.attributes.AUTOSELECT),t.attributes.LANGUAGE&&(n.language=t.attributes.LANGUAGE),t.attributes.URI&&(n.uri=t.attributes.URI),t.attributes["INSTREAM-ID"]&&(n.instreamId=t.attributes["INSTREAM-ID"]),t.attributes.CHARACTERISTICS&&(n.characteristics=t.attributes.CHARACTERISTICS),t.attributes.FORCED&&(n.forced=/yes/i.test(t.attributes.FORCED)),i[t.attributes.NAME]=n}else this.trigger("warn",{message:"ignoring incomplete or missing media group"})},discontinuity:function(){c+=1,u.discontinuity=!0,this.manifest.discontinuityStarts.push(o.length)},"program-date-time":function(){"undefined"==typeof this.manifest.dateTimeString&&(this.manifest.dateTimeString=t.dateTimeString,this.manifest.dateTimeObject=t.dateTimeObject),u.dateTimeString=t.dateTimeString,u.dateTimeObject=t.dateTimeObject},targetduration:function(){!isFinite(t.duration)||t.duration<0?this.trigger("warn",{message:"ignoring invalid target duration: "+t.duration}):this.manifest.targetDuration=t.duration},totalduration:function(){!isFinite(t.duration)||t.duration<0?this.trigger("warn",{message:"ignoring invalid total duration: "+t.duration}):this.manifest.totalDuration=t.duration},start:function(){t.attributes&&!isNaN(t.attributes["TIME-OFFSET"])?this.manifest.start={timeOffset:t.attributes["TIME-OFFSET"],precise:t.attributes.PRECISE}:this.trigger("warn",{message:"ignoring start declaration without appropriate attribute list"})},"cue-out":function(){u.cueOut=t.data},"cue-out-cont":function(){u.cueOutCont=t.data},"cue-in":function(){u.cueIn=t.data}}[t.tagType]||function(){}).call(s)},uri:function(){u.uri=t.uri,o.push(u),!this.manifest.targetDuration||"duration"in u||(this.trigger("warn",{message:"defaulting segment duration to the target duration"}),u.duration=this.manifest.targetDuration),a&&(u.key=a),u.timeline=c,r&&(u.map=r),u={}},comment:function(){},custom:function(){t.segment?(u.custom=u.custom||{},u.custom[t.customType]=t.data):(this.manifest.custom=this.manifest.custom||{},this.manifest.custom[t.customType]=t.data)}})[t.type].call(s)}),e}Ge(e,t);var i=e.prototype;return i.push=function(e){this.lineStream.push(e)},i.end=function(){this.lineStream.push("\n")},i.addParser=function(e){this.parseStream.addParser(e)},i.addTagMapper=function(e){this.parseStream.addTagMapper(e)},e}(ya),wa=i(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var i=/^(audio|video|application)\/(x-|vnd\.apple\.)?mpegurl/i,n=/^application\/dash\+xml/i;t.simpleTypeFromSourceType=function(e){return i.test(e)?"hls":n.test(e)?"dash":"application/vnd.videojs.vhs+json"===e?"vhs-json":null}}); -/*! @name m3u8-parser @version 4.5.0 @license Apache-2.0 */t(wa);var Ia=wa.simpleTypeFromSourceType,Aa=/[A-Z_a-z\xC0-\xD6\xD8-\xF6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]/,xa=new RegExp("[\\-\\.0-9"+Aa.source.slice(1,-1)+"\\u00B7\\u0300-\\u036F\\u203F-\\u2040]"),Pa=new RegExp("^"+Aa.source+xa.source+"*(?::"+Aa.source+xa.source+"*)?$"),La=0,Oa=1,Da=2,Ma=3,Ra=4,Na=5,Ua=6,Fa=7;function Ba(){}function ja(e,t){return t.lineNumber=e.lineNumber,t.columnNumber=e.columnNumber,t}function Va(e,t,i,n,r,a){for(var s,o=++t,u=La;;){var l=e.charAt(o);switch(l){case"=":if(u===Oa)s=e.slice(t,o),u=Ma;else{if(u!==Da)throw new Error("attribute equal must after attrName");u=Ma}break;case"'":case'"':if(u===Ma||u===Oa){if(u===Oa&&(a.warning('attribute value must after "="'),s=e.slice(t,o)),t=o+1,!(0<(o=e.indexOf(l,t))))throw new Error("attribute value no end '"+l+"' match");c=e.slice(t,o).replace(/&#?\w+;/g,r),i.add(s,c,t-1),u=Na}else{if(u!=Ra)throw new Error('attribute value must after "="');c=e.slice(t,o).replace(/&#?\w+;/g,r),i.add(s,c,t),a.warning('attribute "'+s+'" missed start quot('+l+")!!"),t=o+1,u=Na}break;case"/":switch(u){case La:i.setTagName(e.slice(t,o));case Na:case Ua:case Fa:u=Fa,i.closed=!0;case Ra:case Oa:case Da:break;default:throw new Error("attribute invalid close char('/')")}break;case"":return a.error("unexpected end of input"),u==La&&i.setTagName(e.slice(t,o)),o;case">":switch(u){case La:i.setTagName(e.slice(t,o));case Na:case Ua:case Fa:break;case Ra:case Oa:"/"===(c=e.slice(t,o)).slice(-1)&&(i.closed=!0,c=c.slice(0,-1));case Da:u===Da&&(c=s),u==Ra?(a.warning('attribute "'+c+'" missed quot(")!!'),i.add(s,c.replace(/&#?\w+;/g,r),t)):("http://www.w3.org/1999/xhtml"===n[""]&&c.match(/^(?:disabled|checked|selected)$/i)||a.warning('attribute "'+c+'" missed value!! "'+c+'" instead!!'),i.add(c,c,t));break;case Ma:throw new Error("attribute value missed!!")}return o;case"":l=" ";default:if(l<=" ")switch(u){case La:i.setTagName(e.slice(t,o)),u=Ua;break;case Oa:s=e.slice(t,o),u=Da;break;case Ra:var c=e.slice(t,o).replace(/&#?\w+;/g,r);a.warning('attribute "'+c+'" missed quot(")!!'),i.add(s,c,t);case Na:u=Ua}else switch(u){case Da:i.tagName;"http://www.w3.org/1999/xhtml"===n[""]&&s.match(/^(?:disabled|checked|selected)$/i)||a.warning('attribute "'+s+'" missed value!! "'+s+'" instead2!!'),i.add(s,s,t),t=o,u=Oa;break;case Na:a.warning('attribute space is required"'+s+'"!!');case Ua:u=Oa,t=o;break;case Ma:u=Ra,t=o;break;case Fa:throw new Error("elements closed character '/' and '>' must be connected to")}}o++}}function qa(e,t,i){for(var n=e.tagName,r=null,a=e.length;a--;){var s=e[a],o=s.qName,u=s.value;if(0<(h=o.indexOf(":")))var l=s.prefix=o.slice(0,h),c=o.slice(h+1),d="xmlns"===l&&c;else l=null,d="xmlns"===(c=o)&&"";s.localName=c,!1!==d&&(null==r&&(r={},za(i,i={})),i[d]=r[d]=u,s.uri="http://www.w3.org/2000/xmlns/",t.startPrefixMapping(d,u))}for(a=e.length;a--;){(l=(s=e[a]).prefix)&&("xml"===l&&(s.uri="http://www.w3.org/XML/1998/namespace"),"xmlns"!==l&&(s.uri=i[l||""]))}var h;c=0<(h=n.indexOf(":"))?(l=e.prefix=n.slice(0,h),e.localName=n.slice(h+1)):(l=null,e.localName=n);var p=e.uri=i[l||""];if(t.startElement(p,c,n,e),!e.closed)return e.currentNSMap=i,e.localNSMap=r,!0;if(t.endElement(p,c,n),r)for(l in r)t.endPrefixMapping(l)}function Ha(e,t,i,n,r){if(/^(?:script|textarea)$/i.test(i)){var a=e.indexOf("</"+i+">",t),s=e.substring(t+1,a);if(/[&<]/.test(s))return/^script$/i.test(i)||(s=s.replace(/&#?\w+;/g,n)),r.characters(s,0,s.length),a}return t+1}function Wa(e,t,i,n){var r=n[i];return null==r&&((r=e.lastIndexOf("</"+i+">"))<t&&(r=e.lastIndexOf("</"+i)),n[i]=r),r<t}function za(e,t){for(var i in e)t[i]=e[i]}function Ga(e,t,i,n){switch(e.charAt(t+2)){case"-":return"-"!==e.charAt(t+3)?-1:t<(r=e.indexOf("--\x3e",t+4))?(i.comment(e,t+4,r-t-4),r+3):(n.error("Unclosed comment"),-1);default:if("CDATA["==e.substr(t+3,6)){var r=e.indexOf("]]>",t+9);return i.startCDATA(),i.characters(e,t+9,r-t-9),i.endCDATA(),r+3}var a=function(e,t){var i,n=[],r=/'[^']+'|"[^"]+"|[^\s<>\/=]+=?|(\/?\s*>|<)/g;r.lastIndex=t,r.exec(e);for(;i=r.exec(e);)if(n.push(i),i[1])return n}(e,t),s=a.length;if(1<s&&/!doctype/i.test(a[0][0])){var o=a[1][0],u=3<s&&/^public$/i.test(a[2][0])&&a[3][0],l=4<s&&a[4][0],c=a[s-1];return i.startDTD(o,u&&u.replace(/^(['"])(.*?)\1$/,"$2"),l&&l.replace(/^(['"])(.*?)\1$/,"$2")),i.endDTD(),c.index+c[0].length}}return-1}function Xa(e,t,i){var n=e.indexOf("?>",t);if(n){var r=e.substring(t,n).match(/^<\?(\S*)\s*([\s\S]*?)\s*$/);if(r){r[0].length;return i.processingInstruction(r[1],r[2]),n+2}return-1}return-1}function Ka(e){}function Ya(e,t){return e.__proto__=t,e}Ba.prototype={parse:function(e,t,i){var n=this.domBuilder;n.startDocument(),za(t,t={}),function(i,e,n,r,a){function s(e){var t=e.slice(1,-1);return t in n?n[t]:"#"===t.charAt(0)?function(e){if(65535<e){var t=55296+((e-=65536)>>10),i=56320+(1023&e);return String.fromCharCode(t,i)}return String.fromCharCode(e)}(parseInt(t.substr(1).replace("x","0x"))):(a.error("entity not found:"+e),e)}function t(e){if(f<e){var t=i.substring(f,e).replace(/&#?\w+;/g,s);d&&o(f),r.characters(t,0,e-f),f=e}}function o(e,t){for(;l<=e&&(t=c.exec(i));)u=t.index,l=u+t[0].length,d.lineNumber++;d.columnNumber=e-u+1}var u=0,l=0,c=/.*(?:\r\n?|\n)|.*$/g,d=r.locator,h=[{currentNSMap:e}],p={},f=0;for(;;){try{var m=i.indexOf("<",f);if(m<0){if(!i.substr(f).match(/^\s*$/)){var g=r.doc,v=g.createTextNode(i.substr(f));g.appendChild(v),r.currentElement=v}return}switch(f<m&&t(m),i.charAt(m+1)){case"/":var y=i.indexOf(">",m+3),_=i.substring(m+2,y),b=h.pop();y<0?(_=i.substring(m+2).replace(/[\s<].*/,""),a.error("end tag name: "+_+" is not complete:"+b.tagName),y=m+1+_.length):_.match(/\s</)&&(_=_.replace(/[\s<].*/,""),a.error("end tag name: "+_+" maybe not complete"),y=m+1+_.length);var T=b.localNSMap,S=b.tagName==_;if(S||b.tagName&&b.tagName.toLowerCase()==_.toLowerCase()){if(r.endElement(b.uri,b.localName,_),T)for(var k in T)r.endPrefixMapping(k);S||a.fatalError("end tag name: "+_+" is not match the current start tagName:"+b.tagName)}else h.push(b);y++;break;case"?":d&&o(m),y=Xa(i,m,r);break;case"!":d&&o(m),y=Ga(i,m,r,a);break;default:d&&o(m);var C=new Ka,E=h[h.length-1].currentNSMap,w=(y=Va(i,m,C,E,s,a),C.length);if(!C.closed&&Wa(i,y,C.tagName,p)&&(C.closed=!0,n.nbsp||a.warning("unclosed xml attribute")),d&&w){for(var I=ja(d,{}),A=0;A<w;A++){var x=C[A];o(x.offset),x.locator=ja(d,{})}r.locator=I,qa(C,r,E)&&h.push(C),r.locator=d}else qa(C,r,E)&&h.push(C);"http://www.w3.org/1999/xhtml"!==C.uri||C.closed?y++:y=Ha(i,y,C.tagName,s,r)}}catch(e){a.error("element parse error: "+e),y=-1}f<y?f=y:t(Math.max(m,f)+1)}}(e,t,i,n,this.errorHandler),n.endDocument()}},Ka.prototype={setTagName:function(e){if(!Pa.test(e))throw new Error("invalid tagName:"+e);this.tagName=e},add:function(e,t,i){if(!Pa.test(e))throw new Error("invalid attribute:"+e);this[this.length++]={qName:e,value:t,offset:i}},length:0,getLocalName:function(e){return this[e].localName},getLocator:function(e){return this[e].locator},getQName:function(e){return this[e].qName},getURI:function(e){return this[e].uri},getValue:function(e){return this[e].value}},Ya({},Ya.prototype)instanceof Ya||(Ya=function(e,t){function i(){}for(t in i.prototype=t,i=new i,e)i[t]=e[t];return i});var $a={XMLReader:Ba};function Qa(e,t){for(var i in e)t[i]=e[i]}function Ja(e,t){var i=e.prototype;if(Object.create){var n=Object.create(t.prototype);i.__proto__=n}if(!(i instanceof t)){var r=function(){};r.prototype=t.prototype,Qa(i,r=new r),e.prototype=i=r}i.constructor!=e&&(i.constructor=e)}var Za="http://www.w3.org/1999/xhtml",es={},ts=es.ELEMENT_NODE=1,is=es.ATTRIBUTE_NODE=2,ns=es.TEXT_NODE=3,rs=es.CDATA_SECTION_NODE=4,as=es.ENTITY_REFERENCE_NODE=5,ss=es.ENTITY_NODE=6,os=es.PROCESSING_INSTRUCTION_NODE=7,us=es.COMMENT_NODE=8,ls=es.DOCUMENT_NODE=9,cs=es.DOCUMENT_TYPE_NODE=10,ds=es.DOCUMENT_FRAGMENT_NODE=11,hs=es.NOTATION_NODE=12,ps={},fs={},ms=(ps.INDEX_SIZE_ERR=(fs[1]="Index size error",1),ps.DOMSTRING_SIZE_ERR=(fs[2]="DOMString size error",2),ps.HIERARCHY_REQUEST_ERR=(fs[3]="Hierarchy request error",3)),gs=(ps.WRONG_DOCUMENT_ERR=(fs[4]="Wrong document",4),ps.INVALID_CHARACTER_ERR=(fs[5]="Invalid character",5),ps.NO_DATA_ALLOWED_ERR=(fs[6]="No data allowed",6),ps.NO_MODIFICATION_ALLOWED_ERR=(fs[7]="No modification allowed",7),ps.NOT_FOUND_ERR=(fs[8]="Not found",8)),vs=(ps.NOT_SUPPORTED_ERR=(fs[9]="Not supported",9),ps.INUSE_ATTRIBUTE_ERR=(fs[10]="Attribute in use",10));ps.INVALID_STATE_ERR=(fs[11]="Invalid state",11),ps.SYNTAX_ERR=(fs[12]="Syntax error",12),ps.INVALID_MODIFICATION_ERR=(fs[13]="Invalid modification",13),ps.NAMESPACE_ERR=(fs[14]="Invalid namespace",14),ps.INVALID_ACCESS_ERR=(fs[15]="Invalid access",15);function ys(e,t){if(t instanceof Error)var i=t;else i=this,Error.call(this,fs[e]),this.message=fs[e],Error.captureStackTrace&&Error.captureStackTrace(this,ys);return i.code=e,t&&(this.message=this.message+": "+t),i}function _s(){}function bs(e,t){this._node=e,this._refresh=t,Ts(this)}function Ts(e){var t=e._node._inc||e._node.ownerDocument._inc;if(e._inc!=t){var i=e._refresh(e._node);Qs(e,"length",i.length),Qa(i,e),e._inc=t}}function Ss(){}function ks(e,t){for(var i=e.length;i--;)if(e[i]===t)return i}function Cs(e,t,i,n){if(n?t[ks(t,n)]=i:t[t.length++]=i,e){var r=(i.ownerElement=e).ownerDocument;r&&(n&&Ls(r,e,n),function(e,t,i){e&&e._inc++,"http://www.w3.org/2000/xmlns/"==i.namespaceURI&&(t._nsMap[i.prefix?i.localName:""]=i.value)}(r,e,i))}}function Es(e,t,i){var n=ks(t,i);if(!(0<=n))throw ys(gs,new Error(e.tagName+"@"+i));for(var r=t.length-1;n<r;)t[n]=t[++n];if(t.length=r,e){var a=e.ownerDocument;a&&(Ls(a,e,i),i.ownerElement=null)}}function ws(e){if(this._features={},e)for(var t in e)this._features=e[t]}function Is(){}function As(e){return("<"==e?"<":">"==e&&">")||"&"==e&&"&"||'"'==e&&"""||"&#"+e.charCodeAt()+";"}function xs(e,t){if(t(e))return!0;if(e=e.firstChild)do{if(xs(e,t))return!0}while(e=e.nextSibling)}function Ps(){}function Ls(e,t,i){e&&e._inc++,"http://www.w3.org/2000/xmlns/"==i.namespaceURI&&delete t._nsMap[i.prefix?i.localName:""]}function Os(e,t,i){if(e&&e._inc){e._inc++;var n=t.childNodes;if(i)n[n.length++]=i;else{for(var r=t.firstChild,a=0;r;)r=(n[a++]=r).nextSibling;n.length=a}}}function Ds(e,t){var i=t.previousSibling,n=t.nextSibling;return i?i.nextSibling=n:e.firstChild=n,n?n.previousSibling=i:e.lastChild=i,Os(e.ownerDocument,e),t}function Ms(e,t,i){var n=t.parentNode;if(n&&n.removeChild(t),t.nodeType===ds){var r=t.firstChild;if(null==r)return t;var a=t.lastChild}else r=a=t;var s=i?i.previousSibling:e.lastChild;for(r.previousSibling=s,a.nextSibling=i,s?s.nextSibling=r:e.firstChild=r,null==i?e.lastChild=a:i.previousSibling=a;r.parentNode=e,r!==a&&(r=r.nextSibling););return Os(e.ownerDocument||e,e),t.nodeType==ds&&(t.firstChild=t.lastChild=null),t}function Rs(){this._nsMap={}}function Ns(){}function Us(){}function Fs(){}function Bs(){}function js(){}function Vs(){}function qs(){}function Hs(){}function Ws(){}function zs(){}function Gs(){}function Xs(){}function Ks(e,t){var i=[],n=9==this.nodeType?this.documentElement:this,r=n.prefix,a=n.namespaceURI;if(a&&null==r&&null==(r=n.lookupPrefix(a)))var s=[{namespace:a,prefix:null}];return $s(this,i,e,t,s),i.join("")}function Ys(e,t,i){var n=e.prefix||"",r=e.namespaceURI;if(!n&&!r)return!1;if("xml"===n&&"http://www.w3.org/XML/1998/namespace"===r||"http://www.w3.org/2000/xmlns/"==r)return!1;for(var a=i.length;a--;){var s=i[a];if(s.prefix==n)return s.namespace!=r}return!0}function $s(e,t,i,n,r){if(n){if(!(e=n(e)))return;if("string"==typeof e)return void t.push(e)}switch(e.nodeType){case ts:r=r||[];var a=e.attributes,s=a.length,o=e.firstChild,u=e.tagName;i=Za===e.namespaceURI||i,t.push("<",u);for(var l=0;l<s;l++){"xmlns"==(c=a.item(l)).prefix?r.push({prefix:c.localName,namespace:c.value}):"xmlns"==c.nodeName&&r.push({prefix:"",namespace:c.value})}for(l=0;l<s;l++){var c;if(Ys(c=a.item(l),0,r)){var d=c.prefix||"",h=c.namespaceURI,p=d?" xmlns:"+d:" xmlns";t.push(p,'="',h,'"'),r.push({prefix:d,namespace:h})}$s(c,t,i,n,r)}if(Ys(e,0,r)){d=e.prefix||"",h=e.namespaceURI,p=d?" xmlns:"+d:" xmlns";t.push(p,'="',h,'"'),r.push({prefix:d,namespace:h})}if(o||i&&!/^(?:meta|link|img|br|hr|input)$/i.test(u)){if(t.push(">"),i&&/^script$/i.test(u))for(;o;)o.data?t.push(o.data):$s(o,t,i,n,r),o=o.nextSibling;else for(;o;)$s(o,t,i,n,r),o=o.nextSibling;t.push("</",u,">")}else t.push("/>");return;case ls:case ds:for(o=e.firstChild;o;)$s(o,t,i,n,r),o=o.nextSibling;return;case is:return t.push(" ",e.name,'="',e.value.replace(/[<&"]/g,As),'"');case ns:return t.push(e.data.replace(/[<&]/g,As));case rs:return t.push("<![CDATA[",e.data,"]]>");case us:return t.push("\x3c!--",e.data,"--\x3e");case cs:var f=e.publicId,m=e.systemId;if(t.push("<!DOCTYPE ",e.name),f)t.push(' PUBLIC "',f),m&&"."!=m&&t.push('" "',m),t.push('">');else if(m&&"."!=m)t.push(' SYSTEM "',m,'">');else{var g=e.internalSubset;g&&t.push(" [",g,"]"),t.push(">")}return;case os:return t.push("<?",e.target," ",e.data,"?>");case as:return t.push("&",e.nodeName,";");default:t.push("??",e.nodeName)}}function Qs(e,t,i){e[t]=i}ys.prototype=Error.prototype,Qa(ps,ys),_s.prototype={length:0,item:function(e){return this[e]||null},toString:function(e,t){for(var i=[],n=0;n<this.length;n++)$s(this[n],i,e,t);return i.join("")}},bs.prototype.item=function(e){return Ts(this),this[e]},Ja(bs,_s),Ss.prototype={length:0,item:_s.prototype.item,getNamedItem:function(e){for(var t=this.length;t--;){var i=this[t];if(i.nodeName==e)return i}},setNamedItem:function(e){var t=e.ownerElement;if(t&&t!=this._ownerElement)throw new ys(vs);var i=this.getNamedItem(e.nodeName);return Cs(this._ownerElement,this,e,i),i},setNamedItemNS:function(e){var t,i=e.ownerElement;if(i&&i!=this._ownerElement)throw new ys(vs);return t=this.getNamedItemNS(e.namespaceURI,e.localName),Cs(this._ownerElement,this,e,t),t},removeNamedItem:function(e){var t=this.getNamedItem(e);return Es(this._ownerElement,this,t),t},removeNamedItemNS:function(e,t){var i=this.getNamedItemNS(e,t);return Es(this._ownerElement,this,i),i},getNamedItemNS:function(e,t){for(var i=this.length;i--;){var n=this[i];if(n.localName==t&&n.namespaceURI==e)return n}return null}},ws.prototype={hasFeature:function(e,t){var i=this._features[e.toLowerCase()];return!(!i||t&&!(t in i))},createDocument:function(e,t,i){var n=new Ps;if(n.implementation=this,n.childNodes=new _s,(n.doctype=i)&&n.appendChild(i),t){var r=n.createElementNS(e,t);n.appendChild(r)}return n},createDocumentType:function(e,t,i){var n=new Vs;return n.name=e,n.nodeName=e,n.publicId=t,n.systemId=i,n}},Is.prototype={firstChild:null,lastChild:null,previousSibling:null,nextSibling:null,attributes:null,parentNode:null,childNodes:null,ownerDocument:null,nodeValue:null,namespaceURI:null,prefix:null,localName:null,insertBefore:function(e,t){return Ms(this,e,t)},replaceChild:function(e,t){this.insertBefore(e,t),t&&this.removeChild(t)},removeChild:function(e){return Ds(this,e)},appendChild:function(e){return this.insertBefore(e,null)},hasChildNodes:function(){return null!=this.firstChild},cloneNode:function(e){return function e(t,i,n){var r=new i.constructor;for(var a in i){var s=i[a];"object"!=typeof s&&s!=r[a]&&(r[a]=s)}i.childNodes&&(r.childNodes=new _s);r.ownerDocument=t;switch(r.nodeType){case ts:var o=i.attributes,u=r.attributes=new Ss,l=o.length;u._ownerElement=r;for(var c=0;c<l;c++)r.setAttributeNode(e(t,o.item(c),!0));break;case is:n=!0}if(n)for(var d=i.firstChild;d;)r.appendChild(e(t,d,n)),d=d.nextSibling;return r}(this.ownerDocument||this,this,e)},normalize:function(){for(var e=this.firstChild;e;){var t=e.nextSibling;t&&t.nodeType==ns&&e.nodeType==ns?(this.removeChild(t),e.appendData(t.data)):(e.normalize(),e=t)}},isSupported:function(e,t){return this.ownerDocument.implementation.hasFeature(e,t)},hasAttributes:function(){return 0<this.attributes.length},lookupPrefix:function(e){for(var t=this;t;){var i=t._nsMap;if(i)for(var n in i)if(i[n]==e)return n;t=t.nodeType==is?t.ownerDocument:t.parentNode}return null},lookupNamespaceURI:function(e){for(var t=this;t;){var i=t._nsMap;if(i&&e in i)return i[e];t=t.nodeType==is?t.ownerDocument:t.parentNode}return null},isDefaultNamespace:function(e){return null==this.lookupPrefix(e)}},Qa(es,Is),Qa(es,Is.prototype),Ps.prototype={nodeName:"#document",nodeType:ls,doctype:null,documentElement:null,_inc:1,insertBefore:function(e,t){if(e.nodeType!=ds)return null==this.documentElement&&e.nodeType==ts&&(this.documentElement=e),Ms(this,e,t),e.ownerDocument=this,e;for(var i=e.firstChild;i;){var n=i.nextSibling;this.insertBefore(i,t),i=n}return e},removeChild:function(e){return this.documentElement==e&&(this.documentElement=null),Ds(this,e)},importNode:function(e,t){return function e(t,i,n){var r;switch(i.nodeType){case ts:(r=i.cloneNode(!1)).ownerDocument=t;case ds:break;case is:n=!0}r=r||i.cloneNode(!1);r.ownerDocument=t;r.parentNode=null;if(n)for(var a=i.firstChild;a;)r.appendChild(e(t,a,n)),a=a.nextSibling;return r}(this,e,t)},getElementById:function(t){var i=null;return xs(this.documentElement,function(e){if(e.nodeType==ts&&e.getAttribute("id")==t)return i=e,!0}),i},createElement:function(e){var t=new Rs;return t.ownerDocument=this,t.nodeName=e,t.tagName=e,t.childNodes=new _s,(t.attributes=new Ss)._ownerElement=t},createDocumentFragment:function(){var e=new zs;return e.ownerDocument=this,e.childNodes=new _s,e},createTextNode:function(e){var t=new Fs;return t.ownerDocument=this,t.appendData(e),t},createComment:function(e){var t=new Bs;return t.ownerDocument=this,t.appendData(e),t},createCDATASection:function(e){var t=new js;return t.ownerDocument=this,t.appendData(e),t},createProcessingInstruction:function(e,t){var i=new Gs;return i.ownerDocument=this,i.tagName=i.target=e,i.nodeValue=i.data=t,i},createAttribute:function(e){var t=new Ns;return t.ownerDocument=this,t.name=e,t.nodeName=e,t.localName=e,t.specified=!0,t},createEntityReference:function(e){var t=new Ws;return t.ownerDocument=this,t.nodeName=e,t},createElementNS:function(e,t){var i=new Rs,n=t.split(":"),r=i.attributes=new Ss;return i.childNodes=new _s,i.ownerDocument=this,i.nodeName=t,i.tagName=t,i.namespaceURI=e,2==n.length?(i.prefix=n[0],i.localName=n[1]):i.localName=t,r._ownerElement=i},createAttributeNS:function(e,t){var i=new Ns,n=t.split(":");return i.ownerDocument=this,i.nodeName=t,i.name=t,i.namespaceURI=e,i.specified=!0,2==n.length?(i.prefix=n[0],i.localName=n[1]):i.localName=t,i}},Ja(Ps,Is),Ps.prototype.getElementsByTagName=(Rs.prototype={nodeType:ts,hasAttribute:function(e){return null!=this.getAttributeNode(e)},getAttribute:function(e){var t=this.getAttributeNode(e);return t&&t.value||""},getAttributeNode:function(e){return this.attributes.getNamedItem(e)},setAttribute:function(e,t){var i=this.ownerDocument.createAttribute(e);i.value=i.nodeValue=""+t,this.setAttributeNode(i)},removeAttribute:function(e){var t=this.getAttributeNode(e);t&&this.removeAttributeNode(t)},appendChild:function(e){return e.nodeType===ds?this.insertBefore(e,null):function(e,t){var i=t.parentNode;if(i){var n=e.lastChild;i.removeChild(t);n=e.lastChild}return n=e.lastChild,t.parentNode=e,t.previousSibling=n,t.nextSibling=null,n?n.nextSibling=t:e.firstChild=t,e.lastChild=t,Os(e.ownerDocument,e,t),t}(this,e)},setAttributeNode:function(e){return this.attributes.setNamedItem(e)},setAttributeNodeNS:function(e){return this.attributes.setNamedItemNS(e)},removeAttributeNode:function(e){return this.attributes.removeNamedItem(e.nodeName)},removeAttributeNS:function(e,t){var i=this.getAttributeNodeNS(e,t);i&&this.removeAttributeNode(i)},hasAttributeNS:function(e,t){return null!=this.getAttributeNodeNS(e,t)},getAttributeNS:function(e,t){var i=this.getAttributeNodeNS(e,t);return i&&i.value||""},setAttributeNS:function(e,t,i){var n=this.ownerDocument.createAttributeNS(e,t);n.value=n.nodeValue=""+i,this.setAttributeNode(n)},getAttributeNodeNS:function(e,t){return this.attributes.getNamedItemNS(e,t)},getElementsByTagName:function(n){return new bs(this,function(t){var i=[];return xs(t,function(e){e===t||e.nodeType!=ts||"*"!==n&&e.tagName!=n||i.push(e)}),i})},getElementsByTagNameNS:function(n,r){return new bs(this,function(t){var i=[];return xs(t,function(e){e===t||e.nodeType!==ts||"*"!==n&&e.namespaceURI!==n||"*"!==r&&e.localName!=r||i.push(e)}),i})}}).getElementsByTagName,Ps.prototype.getElementsByTagNameNS=Rs.prototype.getElementsByTagNameNS,Ja(Rs,Is),Ns.prototype.nodeType=is,Ja(Ns,Is),Us.prototype={data:"",substringData:function(e,t){return this.data.substring(e,e+t)},appendData:function(e){e=this.data+e,this.nodeValue=this.data=e,this.length=e.length},insertData:function(e,t){this.replaceData(e,0,t)},appendChild:function(e){throw new Error(fs[ms])},deleteData:function(e,t){this.replaceData(e,t,"")},replaceData:function(e,t,i){i=this.data.substring(0,e)+i+this.data.substring(e+t),this.nodeValue=this.data=i,this.length=i.length}},Ja(Us,Is),Fs.prototype={nodeName:"#text",nodeType:ns,splitText:function(e){var t=this.data,i=t.substring(e);t=t.substring(0,e),this.data=this.nodeValue=t,this.length=t.length;var n=this.ownerDocument.createTextNode(i);return this.parentNode&&this.parentNode.insertBefore(n,this.nextSibling),n}},Ja(Fs,Us),Bs.prototype={nodeName:"#comment",nodeType:us},Ja(Bs,Us),js.prototype={nodeName:"#cdata-section",nodeType:rs},Ja(js,Us),Vs.prototype.nodeType=cs,Ja(Vs,Is),qs.prototype.nodeType=hs,Ja(qs,Is),Hs.prototype.nodeType=ss,Ja(Hs,Is),Ws.prototype.nodeType=as,Ja(Ws,Is),zs.prototype.nodeName="#document-fragment",zs.prototype.nodeType=ds,Ja(zs,Is),Gs.prototype.nodeType=os,Ja(Gs,Is),Xs.prototype.serializeToString=function(e,t,i){return Ks.call(e,t,i)},Is.prototype.toString=Ks;try{if(Object.defineProperty){Object.defineProperty(bs.prototype,"length",{get:function(){return Ts(this),this.$$length}}),Object.defineProperty(Is.prototype,"textContent",{get:function(){return function e(t){switch(t.nodeType){case ts:case ds:var i=[];for(t=t.firstChild;t;)7!==t.nodeType&&8!==t.nodeType&&i.push(e(t)),t=t.nextSibling;return i.join("");default:return t.nodeValue}}(this)},set:function(e){switch(this.nodeType){case ts:case ds:for(;this.firstChild;)this.removeChild(this.firstChild);(e||String(e))&&this.appendChild(this.ownerDocument.createTextNode(e));break;default:this.data=e,this.value=e,this.nodeValue=e}}}),Qs=function(e,t,i){e["$$"+t]=i}}}catch(e){}function Js(e){return!!e&&"object"==typeof e}function Zs(){for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];return t.reduce(function(t,i){return Object.keys(i).forEach(function(e){Array.isArray(t[e])&&Array.isArray(i[e])?t[e]=t[e].concat(i[e]):Js(t[e])&&Js(i[e])?t[e]=Zs(t[e],i[e]):t[e]=i[e]}),t},{})}function eo(e){return e.reduce(function(e,t){return e.concat(t)},[])}function to(e){if(!e.length)return[];for(var t=[],i=0;i<e.length;i++)t.push(e[i]);return t}function io(e){var t=e.baseUrl,i=void 0===t?"":t,n=e.source,r=void 0===n?"":n,a=e.range,s=void 0===a?"":a,o=e.indexRange,u=void 0===o?"":o,l={uri:r,resolvedUri:va(i||"",r)};if(s||u){var c=(s||u).split("-"),d=parseInt(c[0],10),h=parseInt(c[1],10);l.byterange={length:h-d+1,offset:d}}return l}function no(e){var t=e.type,i=void 0===t?"static":t,n=e.duration,r=e.timescale,a=void 0===r?1:r,s=e.sourceDuration,o=No[i](e),u=function(e,t){for(var i=[],n=e;n<t;n++)i.push(n);return i}(o.start,o.end).map(function(o){return function(e,t){var i=o.duration,n=o.timescale,r=void 0===n?1:n,a=o.periodIndex,s=o.startNumber;return{number:(void 0===s?1:s)+e,duration:i/r,timeline:a,time:t*i}}}(e));if("static"===i){var l=u.length-1;u[l].duration=s-n/a*l}return u}function ro(e){var t=e.baseUrl,i=e.initialization,n=void 0===i?{}:i,r=e.sourceDuration,a=e.indexRange,s=void 0===a?"":a,o=e.duration;if(!t)throw new Error(Do);var u=io({baseUrl:t,source:n.sourceURL,range:n.range}),l=io({baseUrl:t,source:t,indexRange:s});if(l.map=u,o){var c=no(e);c.length&&(l.duration=c[0].duration,l.timeline=c[0].timeline)}else r&&(l.duration=r,l.timeline=0);return l.number=0,[l]}function ao(e,t,i){for(var n=e.sidx.map?e.sidx.map:null,r=e.sidx.duration,a=e.timeline||0,s=e.sidx.byterange,o=s.offset+s.length,u=t.timescale,l=t.references.filter(function(e){return 1!==e.referenceType}),c=[],d=o+t.firstOffset,h=0;h<l.length;h++){var p=t.references[h],f=p.referencedSize,m=p.subsegmentDuration,g=ro({baseUrl:i,timescale:u,timeline:a,periodIndex:a,duration:m,sourceDuration:r,indexRange:d+"-"+(d+f-1)})[0];n&&(g.map=n),c.push(g),d+=f}return e.segments=c,e}function so(e){return function(t){return Object.keys(t).map(function(e){return t[e]})}(e.reduce(function(e,t){var i,n=t.attributes.id+(t.attributes.lang||"");e[n]?(t.segments[0]&&(t.segments[0].discontinuity=!0),(i=e[n].segments).push.apply(i,t.segments),t.attributes.contentProtection&&(e[n].attributes.contentProtection=t.attributes.contentProtection)):e[n]=t;return e},{})).map(function(e){return e.discontinuityStarts=function(e,n){return e.reduce(function(e,t,i){return t[n]&&e.push(i),e},[])}(e.segments,"discontinuity"),e})}function oo(e,t){if(void 0===t&&(t={}),!Object.keys(t).length)return e;for(var i in e){var n=e[i];if(n.sidx){var r=n.sidx.uri+"-"+(s=n.sidx.byterange,void 0,o=s.offset+s.length-1,s.offset+"-"+o),a=t[r]&&t[r].sidx;n.sidx&&a&&ao(n,a,n.sidx.resolvedUri)}}var s,o;return e}function uo(e){var t,i=e.attributes,n=e.segments,r=e.sidx,a={attributes:(t={NAME:i.id,AUDIO:"audio",SUBTITLES:"subs",RESOLUTION:{width:i.width,height:i.height},CODECS:i.codecs,BANDWIDTH:i.bandwidth},t["PROGRAM-ID"]=1,t),uri:"",endList:"static"===(i.type||"static"),timeline:i.periodIndex,resolvedUri:"",targetDuration:i.duration,segments:n,mediaSequence:n.length?n[0].number:1};return i.contentProtection&&(a.contentProtection=i.contentProtection),r&&(a.sidx=r),a}function lo(e,t,i){var n;if(void 0===i&&(i={}),!e.length)return{};var r=e[0].attributes,a=r.sourceDuration,s=r.type,o=void 0===s?"static":s,u=r.suggestedPresentationDelay,l=r.minimumUpdatePeriod,c=so(e.filter(function(e){var t=e.attributes;return"video/mp4"===t.mimeType||"video"===t.contentType})).map(uo),d=so(e.filter(function(e){var t=e.attributes;return"audio/mp4"===t.mimeType||"audio"===t.contentType})),h=e.filter(function(e){var t=e.attributes;return"text/vtt"===t.mimeType||"text"===t.contentType}),p={allowCache:!0,discontinuityStarts:[],segments:[],endList:!0,mediaGroups:(n={AUDIO:{},VIDEO:{}},n["CLOSED-CAPTIONS"]={},n.SUBTITLES={},n),uri:"",duration:a,playlists:oo(c,i)};return 0<=l&&(p.minimumUpdatePeriod=1e3*l),t&&(p.locations=t),"dynamic"===o&&(p.suggestedPresentationDelay=u),d.length&&(p.mediaGroups.AUDIO.audio=function(e,s){var o;void 0===s&&(s={});var t=e.reduce(function(e,t){var i=t.attributes.role&&t.attributes.role.value||"",n=t.attributes.lang||"",r="main";if(n){var a=i?" ("+i+")":"";r=t.attributes.lang+a}return e[r]&&e[r].playlists[0].attributes.BANDWIDTH>t.attributes.bandwidth||(e[r]={language:n,autoselect:!0,default:"main"===i,playlists:oo([function(e){var t,i=e.attributes,n=e.segments,r=e.sidx,a={attributes:(t={NAME:i.id,BANDWIDTH:i.bandwidth,CODECS:i.codecs},t["PROGRAM-ID"]=1,t),uri:"",endList:"static"===(i.type||"static"),timeline:i.periodIndex,resolvedUri:"",targetDuration:i.duration,segments:n,mediaSequence:n.length?n[0].number:1};return i.contentProtection&&(a.contentProtection=i.contentProtection),r&&(a.sidx=r),a}(t)],s),uri:""},"undefined"==typeof o&&"main"===i&&((o=t).default=!0)),e},{});o||(t[Object.keys(t)[0]].default=!0);return t}(d,i)),h.length&&(p.mediaGroups.SUBTITLES.subs=function(e,n){return void 0===n&&(n={}),e.reduce(function(e,t){var i=t.attributes.lang||"text";return e[i]||(e[i]={language:i,default:!1,autoselect:!1,playlists:oo([function(e){var t,i=e.attributes,n=e.segments;"undefined"==typeof n&&(n=[{uri:i.baseUrl,timeline:i.periodIndex,resolvedUri:i.baseUrl||"",duration:i.sourceDuration,number:0}],i.duration=i.sourceDuration);var r=((t={NAME:i.id,BANDWIDTH:i.bandwidth})["PROGRAM-ID"]=1,t);return i.codecs&&(r.CODECS=i.codecs),{attributes:r,uri:"",endList:"static"===(i.type||"static"),timeline:i.periodIndex,resolvedUri:i.baseUrl||"",targetDuration:i.duration,segments:n,mediaSequence:n.length?n[0].number:1}}(t)],n),uri:""}),e},{})}(h,i)),p}function co(e,t){for(var i,n,r,a,s,o,u,l,c,d,h,p,f=e.type,m=void 0===f?"static":f,g=e.minimumUpdatePeriod,v=void 0===g?0:g,y=e.media,_=void 0===y?"":y,b=e.sourceDuration,T=e.timescale,S=void 0===T?1:T,k=e.startNumber,C=void 0===k?1:k,E=e.periodIndex,w=[],I=-1,A=0;A<t.length;A++){var x=t[A],P=x.d,L=x.r||0,O=x.t||0;I<0&&(I=O),O&&I<O&&(I=O);var D=void 0;if(L<0){var M=A+1;D=M===t.length?"dynamic"===m&&0<v&&0<_.indexOf("$Number$")?(n=I,r=P,void 0,a=(i=e).NOW,s=i.clientOffset,o=i.availabilityStartTime,u=i.timescale,l=void 0===u?1:u,c=i.start,d=void 0===c?0:c,h=i.minimumUpdatePeriod,p=(a+s)/1e3+(void 0===h?0:h)-(o+d),Math.ceil((p*l-n)/r)):(b*S-I)/P:(t[M].t-I)/P}else D=L+1;for(var R=C+w.length+D,N=C+w.length;N<R;)w.push({number:N,duration:P/S,time:I,timeline:E}),I+=P,N++}return w}function ho(e,t){return e.replace(Uo,function(a){return function(e,t,i,n){if("$$"===e)return"$";if("undefined"==typeof a[t])return e;var r=""+a[t];return"RepresentationID"===t?r:(n=i?parseInt(n,10):1)<=r.length?r:new Array(n-r.length+1).join("0")+r}}(t))}function po(i,e){var n={RepresentationID:i.id,Bandwidth:i.bandwidth||0},t=i.initialization,r=void 0===t?{sourceURL:"",range:""}:t,a=io({baseUrl:i.baseUrl,source:ho(r.sourceURL,n),range:r.range});return function(e,t){return e.duration||t?e.duration?no(e):co(e,t):[{number:e.startNumber||1,duration:e.sourceDuration,time:0,timeline:e.periodIndex}]}(i,e).map(function(e){n.Number=e.number,n.Time=e.time;var t=ho(i.media||"",n);return{uri:t,timeline:e.timeline,duration:e.duration,resolvedUri:va(i.baseUrl||"",t),map:a,number:e.number}})}function fo(t,e){var i=t.duration,n=t.segmentUrls,r=void 0===n?[]:n;if(!i&&!e||i&&e)throw new Error(Mo);var a,s=r.map(function(e){return function(e,t){var i=e.baseUrl,n=e.initialization,r=void 0===n?{}:n,a=io({baseUrl:i,source:r.sourceURL,range:r.range}),s=io({baseUrl:i,source:t.media,range:t.mediaRange});return s.map=a,s}(t,e)});return i&&(a=no(t)),e&&(a=co(t,e)),a.map(function(e,t){if(s[t]){var i=s[t];return i.timeline=e.timeline,i.duration=e.duration,i.number=e.number,i}}).filter(function(e){return e})}function mo(e){var t,i,n=e.attributes,r=e.segmentInfo;r.template?(i=po,t=Zs(n,r.template)):r.base?(i=ro,t=Zs(n,r.base)):r.list&&(i=fo,t=Zs(n,r.list));var a={attributes:n};if(!i)return a;var s=i(t,r.timeline);if(t.duration){var o=t,u=o.duration,l=o.timescale,c=void 0===l?1:l;t.duration=u/c}else s.length?t.duration=s.reduce(function(e,t){return Math.max(e,Math.ceil(t.duration))},0):t.duration=0;return a.attributes=t,a.segments=s,r.base&&t.indexRange&&(a.sidx=s[0],a.segments=[]),a}function go(e,t){return to(e.childNodes).filter(function(e){return e.tagName===t})}function vo(e){return e.textContent.trim()}function yo(e){var t=/P(?:(\d*)Y)?(?:(\d*)M)?(?:(\d*)D)?(?:T(?:(\d*)H)?(?:(\d*)M)?(?:([\d.]*)S)?)?/.exec(e);if(!t)return 0;var i=t.slice(1),n=i[0],r=i[1],a=i[2],s=i[3],o=i[4],u=i[5];return 31536e3*parseFloat(n||0)+2592e3*parseFloat(r||0)+86400*parseFloat(a||0)+3600*parseFloat(s||0)+60*parseFloat(o||0)+parseFloat(u||0)}function _o(e){return e&&e.attributes?to(e.attributes).reduce(function(e,t){var i=Fo[t.name]||Fo.DEFAULT;return e[t.name]=i(t.value),e},{}):{}}function bo(e,i){return i.length?eo(e.map(function(t){return i.map(function(e){return va(t,vo(e))})})):e}function To(e){var t=go(e,"SegmentTemplate")[0],i=go(e,"SegmentList")[0],n=i&&go(i,"SegmentURL").map(function(e){return Zs({tag:"SegmentURL"},_o(e))}),r=go(e,"SegmentBase")[0],a=i||t,s=a&&go(a,"SegmentTimeline")[0],o=i||r||t,u=o&&go(o,"Initialization")[0],l=t&&_o(t);l&&u?l.initialization=u&&_o(u):l&&l.initialization&&(l.initialization={sourceURL:l.initialization});var c={template:l,timeline:s&&go(s,"S").map(function(e){return _o(e)}),list:i&&Zs(_o(i),{segmentUrls:n,initialization:_o(u)}),base:r&&Zs(_o(r),{initialization:_o(u)})};return Object.keys(c).forEach(function(e){c[e]||delete c[e]}),c}function So(c,d,h){return function(e){var t=_o(e),i=bo(d,go(e,"BaseURL")),n=go(e,"Role")[0],r={role:_o(n)},a=Zs(c,t,r),s=function(e){return e.reduce(function(e,t){var i=_o(t),n=Bo[i.schemeIdUri];if(n){e[n]={attributes:i};var r=go(t,"cenc:pssh")[0];if(r){var a=vo(r),s=a&&Sa(a);e[n].pssh=s}}return e},{})}(go(e,"ContentProtection"));Object.keys(s).length&&(a=Zs(a,{contentProtection:s}));var o=To(e),u=go(e,"Representation"),l=Zs(h,o);return eo(u.map(function(a,s,o){return function(e){var t=go(e,"BaseURL"),i=bo(s,t),n=Zs(a,_o(e)),r=To(e);return i.map(function(e){return{segmentInfo:Zs(o,r),attributes:Zs(n,{baseUrl:e})}})}}(a,i,l)))}}function ko(e,t){void 0===t&&(t={});var i=t,n=i.manifestUri,r=void 0===n?"":n,a=i.NOW,s=void 0===a?Date.now():a,o=i.clientOffset,u=void 0===o?0:o,l=go(e,"Period");if(!l.length)throw new Error(Po);var c=go(e,"Location"),d=_o(e),h=bo([r],go(e,"BaseURL"));return d.sourceDuration=d.mediaPresentationDuration||0,d.NOW=s,d.clientOffset=u,c.length&&(d.locations=c.map(vo)),{locations:d.locations,representationInfo:eo(l.map(function(l,c){return function(e,t){var i=bo(c,go(e,"BaseURL")),n=_o(e),r=parseInt(n.id,10),a=T.isNaN(r)?t:r,s=Zs(l,{periodIndex:a}),o=go(e,"AdaptationSet"),u=To(e);return eo(o.map(So(s,i,u)))}}(d,h)))}}function Co(e){if(""===e)throw new Error(Lo);var t=(new xo).parseFromString(e,"application/xml"),i=t&&"MPD"===t.documentElement.tagName?t.documentElement:null;if(!i||i&&0<i.getElementsByTagName("parsererror").length)throw new Error(Oo);return i}function Eo(e,t){void 0===t&&(t={});var i=ko(Co(e),t),n=function(e){return e.map(mo)}(i.representationInfo);return lo(n,i.locations,t.sidxMapping)}function wo(e){return function(e){var t=go(e,"UTCTiming")[0];if(!t)return null;var i=_o(t);switch(i.schemeIdUri){case"urn:mpeg:dash:utc:http-head:2014":case"urn:mpeg:dash:utc:http-head:2012":i.method="HEAD";break;case"urn:mpeg:dash:utc:http-xsdate:2014":case"urn:mpeg:dash:utc:http-iso:2014":case"urn:mpeg:dash:utc:http-xsdate:2012":case"urn:mpeg:dash:utc:http-iso:2012":i.method="GET";break;case"urn:mpeg:dash:utc:direct:2014":case"urn:mpeg:dash:utc:direct:2012":i.method="DIRECT",i.value=Date.parse(i.value);break;case"urn:mpeg:dash:utc:http-ntp:2014":case"urn:mpeg:dash:utc:ntp:2014":case"urn:mpeg:dash:utc:sntp:2014":default:throw new Error(Ro)}return i}(Co(e))}var Io={DOMImplementation:ws,XMLSerializer:Xs},Ao=i(function(e,t){function i(e){this.options=e||{locator:{}}}function l(){this.cdata=!1}function c(e,t){t.lineNumber=e.lineNumber,t.columnNumber=e.columnNumber}function d(e){if(e)return"\n@"+(e.systemId||"")+"#[line:"+e.lineNumber+",col:"+e.columnNumber+"]"}function r(e,t,i){return"string"==typeof e?e.substr(t,i):e.length>=t+i||t?new java.lang.String(e,t,i)+"":e}function h(e,t){e.currentElement?e.currentElement.appendChild(t):e.doc.appendChild(t)}i.prototype.parseFromString=function(e,t){var i=this.options,n=new p,r=i.domBuilder||new l,a=i.errorHandler,s=i.locator,o=i.xmlns||{},u={lt:"<",gt:">",amp:"&",quot:'"',apos:"'"};return s&&r.setDocumentLocator(s),n.errorHandler=function(n,e,r){if(!n){if(e instanceof l)return e;n=e}var a={},s=n instanceof Function;function t(t){var i=n[t];!i&&s&&(i=2==n.length?function(e){n(t,e)}:n),a[t]=i&&function(e){i("[xmldom "+t+"]\t"+e+d(r))}||function(){}}return r=r||{},t("warning"),t("error"),t("fatalError"),a}(a,r,s),n.domBuilder=i.domBuilder||r,/\/x?html?$/.test(t)&&(u.nbsp=" ",u.copy="©",o[""]="http://www.w3.org/1999/xhtml"),o.xml=o.xml||"http://www.w3.org/XML/1998/namespace",e?n.parse(e,o,u):n.errorHandler.error("invalid doc source"),r.doc},l.prototype={startDocument:function(){this.doc=(new n).createDocument(null,null,null),this.locator&&(this.doc.documentURI=this.locator.systemId)},startElement:function(e,t,i,n){var r=this.doc,a=r.createElementNS(e,i||t),s=n.length;h(this,a),this.currentElement=a,this.locator&&c(this.locator,a);for(var o=0;o<s;o++){e=n.getURI(o);var u=n.getValue(o),l=(i=n.getQName(o),r.createAttributeNS(e,i));this.locator&&c(n.getLocator(o),l),l.value=l.nodeValue=u,a.setAttributeNode(l)}},endElement:function(e,t,i){var n=this.currentElement;n.tagName;this.currentElement=n.parentNode},startPrefixMapping:function(e,t){},endPrefixMapping:function(e){},processingInstruction:function(e,t){var i=this.doc.createProcessingInstruction(e,t);this.locator&&c(this.locator,i),h(this,i)},ignorableWhitespace:function(e,t,i){},characters:function(e,t,i){if(e=r.apply(this,arguments)){if(this.cdata)var n=this.doc.createCDATASection(e);else n=this.doc.createTextNode(e);this.currentElement?this.currentElement.appendChild(n):/^\s*$/.test(e)&&this.doc.appendChild(n),this.locator&&c(this.locator,n)}},skippedEntity:function(e){},endDocument:function(){this.doc.normalize()},setDocumentLocator:function(e){(this.locator=e)&&(e.lineNumber=0)},comment:function(e,t,i){e=r.apply(this,arguments);var n=this.doc.createComment(e);this.locator&&c(this.locator,n),h(this,n)},startCDATA:function(){this.cdata=!0},endCDATA:function(){this.cdata=!1},startDTD:function(e,t,i){var n=this.doc.implementation;if(n&&n.createDocumentType){var r=n.createDocumentType(e,t,i);this.locator&&c(this.locator,r),h(this,r)}},warning:function(e){},error:function(e){},fatalError:function(e){throw e}},"endDTD,startEntity,endEntity,attributeDecl,elementDecl,externalEntityDecl,internalEntityDecl,resolveEntity,getExternalSubset,notationDecl,unparsedEntityDecl".replace(/\w+/g,function(e){l.prototype[e]=function(){return null}});var p=$a.XMLReader,n=t.DOMImplementation=Io.DOMImplementation;t.XMLSerializer=Io.XMLSerializer,t.DOMParser=i}),xo=(Ao.DOMImplementation,Ao.XMLSerializer,Ao.DOMParser),Po="INVALID_NUMBER_OF_PERIOD",Lo="DASH_EMPTY_MANIFEST",Oo="DASH_INVALID_XML",Do="NO_BASE_URL",Mo="SEGMENT_TIME_UNSPECIFIED",Ro="UNSUPPORTED_UTC_TIMING_SCHEME",No={static:function(e){var t=e.duration,i=e.timescale,n=void 0===i?1:i,r=e.sourceDuration;return{start:0,end:Math.ceil(r/(t/n))}},dynamic:function(e){var t=e.NOW,i=e.clientOffset,n=e.availabilityStartTime,r=e.timescale,a=void 0===r?1:r,s=e.duration,o=e.start,u=void 0===o?0:o,l=e.minimumUpdatePeriod,c=void 0===l?0:l,d=e.timeShiftBufferDepth,h=void 0===d?1/0:d,p=(t+i)/1e3,f=n+u,m=p+c-f,g=Math.ceil(m*a/s),v=Math.floor((p-f-h)*a/s),y=Math.floor((p-f)*a/s);return{start:Math.max(0,v),end:Math.min(g,y)}}},Uo=/\$([A-z]*)(?:(%0)([0-9]+)d)?\$/g,Fo={mediaPresentationDuration:function(e){return yo(e)},availabilityStartTime:function(e){return function(e){return/^\d+-\d+-\d+T\d+:\d+:\d+(\.\d+)?$/.test(e)&&(e+="Z"),Date.parse(e)}(e)/1e3},minimumUpdatePeriod:function(e){return yo(e)},suggestedPresentationDelay:function(e){return yo(e)},type:function(e){return e},timeShiftBufferDepth:function(e){return yo(e)},start:function(e){return yo(e)},width:function(e){return parseInt(e,10)},height:function(e){return parseInt(e,10)},bandwidth:function(e){return parseInt(e,10)},startNumber:function(e){return parseInt(e,10)},timescale:function(e){return parseInt(e,10)},duration:function(e){var t=parseInt(e,10);return isNaN(t)?yo(e):t},d:function(e){return parseInt(e,10)},t:function(e){return parseInt(e,10)},r:function(e){return parseInt(e,10)},DEFAULT:function(e){return e}},Bo={"urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b":"org.w3.clearkey","urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed":"com.widevine.alpha","urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95":"com.microsoft.playready","urn:uuid:f239e769-efa3-4850-9c16-a903c6932efb":"com.adobe.primetime"},jo=function(e){var t,i=new DataView(e.buffer,e.byteOffset,e.byteLength),n={version:e[0],flags:new Uint8Array(e.subarray(1,4)),references:[],referenceId:i.getUint32(4),timescale:i.getUint32(8),earliestPresentationTime:i.getUint32(12),firstOffset:i.getUint32(16)},r=i.getUint16(22);for(t=24;r;t+=12,r--)n.references.push({referenceType:(128&e[t])>>>7,referencedSize:2147483647&i.getUint32(t),subsegmentDuration:i.getUint32(t+4),startsWithSap:!!(128&e[t+8]),sapType:(112&e[t+8])>>>4,sapDeltaTime:268435455&i.getUint32(t+8)});return n},Vo=i(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});function n(e){return e instanceof Uint8Array?e:new Uint8Array(e&&e.buffer||e,e&&e.byteOffset||0,e&&e.byteLength||0)}function r(e){if(!e)return"";e=Array.prototype.slice.call(e);var t=String.fromCharCode.apply(null,n(e));try{return decodeURIComponent(escape(t))}catch(e){}return t}function i(e,t){void 0===t&&(t=0);var i=(e=n(e))[t+6]<<21|e[t+7]<<14|e[t+8]<<7|e[t+9];return(16&e[t+5])>>4?20+i:10+i}function a(e,t){return void 0===t&&(t=0),(e=n(e)).length-t<10||"ID3"!==r(e.subarray(t,t+3))?t:a(e,t+=i(e,t))}var s={aac:function(e){var t=a(e);return e.length>=t+2&&255==(255&e[t])&&224==(224&e[t+1])&&16==(22&e[t+1])},mp3:function(e){var t=a(e);return e.length>=t+2&&255==(255&e[t])&&224==(224&e[t+1])&&2==(6&e[t+1])},webm:function(e){return 4<=e.length&&26==(255&e[0])&&69==(255&e[1])&&223==(255&e[2])&&163==(255&e[3])},mp4:function(e){return 8<=e.length&&/^(f|s)typ$/.test(r(e.subarray(4,8)))&&!/^ftyp3g$/.test(r(e.subarray(4,10)))},"3gp":function(e){return 10<=e.length&&/^ftyp3g$/.test(r(e.subarray(4,10)))},ts:function(e){if(e.length<189&&1<=e.length)return 71===e[0];for(var t=0;t+188<e.length&&t<188;){if(71===e[t]&&71===e[t+188])return!0;t+=1}return!1},flac:function(e){return 4<=e.length&&/^fLaC$/.test(r(e.subarray(0,4)))},ogg:function(e){return 4<=e.length&&/^OggS$/.test(r(e.subarray(0,4)))}},o=Object.keys(s).filter(function(e){return"ts"!==e}).concat("ts");o.forEach(function(e){var t=s[e];s[e]=function(e){return t(n(e))}});t.detectContainerForBytes=function(e){e=n(e);for(var t=0;t<o.length;t++){var i=o[t];if(s[i](e))return i}return""},t.getId3Offset=a,t.id3Size=i,t.isLikely=s,t.isLikelyFmp4MediaSegment=function(e){e=n(e);for(var t=0;t<e.length;){var i=(e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3])>>>0;if("moof"===r(e.subarray(t+4,t+8)))return!0;0==i||i+t>e.length?t=e.length:t+=i}return!1}});t(Vo);var qo=Vo.detectContainerForBytes,Ho=Vo.getId3Offset,Wo=(Vo.id3Size,Vo.isLikely,Vo.isLikelyFmp4MediaSegment),zo=i(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});function s(e){return e instanceof Uint8Array?e:new Uint8Array(e&&e.buffer||e,e&&e.byteOffset||0,e&&e.byteLength||0)}t.bytesToString=function(e){if(!e)return"";e=Array.prototype.slice.call(e);var t=String.fromCharCode.apply(null,s(e));try{return decodeURIComponent(escape(t))}catch(e){}return t},t.concatTypedArrays=function(){for(var e=arguments.length,t=new Array(e),i=0;i<e;i++)t[i]=arguments[i];var n=t.reduce(function(e,t){return e+=t&&(t.byteLength||t.length)||0},0),r=new Uint8Array(n),a=0;return t.forEach(function(e){e=s(e),r.set(e,a),a+=e.byteLength}),r},t.isTypedArray=function(e){return ArrayBuffer.isView(e)},t.stringToBytes=function(e,t){void 0===t&&(t=!1);return"string"!=typeof e&&e&&"function"==typeof e.toString&&(e=e.toString()),"string"!=typeof e?[]:(t||(e=unescape(encodeURIComponent(e))),e.split("").map(function(e){return 255&e.charCodeAt(0)}))},t.toUint8=s});t(zo);zo.bytesToString;function Go(){this.init=function(){var a={};this.on=function(e,t){a[e]||(a[e]=[]),a[e]=a[e].concat(t)},this.off=function(e,t){var i;return!!a[e]&&(i=a[e].indexOf(t),a[e]=a[e].slice(),a[e].splice(i,1),-1<i)},this.trigger=function(e){var t,i,n,r;if(t=a[e])if(2===arguments.length)for(n=t.length,i=0;i<n;++i)t[i].call(this,arguments[1]);else{for(r=[],i=arguments.length,i=1;i<arguments.length;++i)r.push(arguments[i]);for(n=t.length,i=0;i<n;++i)t[i].apply(this,r)}},this.dispose=function(){a={}}}}var Xo=zo.concatTypedArrays,Ko=(zo.isTypedArray,zo.stringToBytes),Yo=zo.toUint8,$o=27,Qo=15,Jo=21;Go.prototype.pipe=function(t){return this.on("data",function(e){t.push(e)}),this.on("done",function(e){t.flush(e)}),this.on("partialdone",function(e){t.partialFlush(e)}),this.on("endedtimeline",function(e){t.endTimeline(e)}),this.on("reset",function(e){t.reset(e)}),t},Go.prototype.push=function(e){this.trigger("data",e)},Go.prototype.flush=function(e){this.trigger("done",e)},Go.prototype.partialFlush=function(e){this.trigger("partialdone",e)},Go.prototype.endTimeline=function(e){this.trigger("endedtimeline",e)},Go.prototype.reset=function(e){this.trigger("reset",e)};function Zo(e,t){var i=1;for(t<e&&(i=-1);4294967296<Math.abs(t-e);)e+=8589934592*i;return e}new Go;function eu(e){var t=31&e[1];return t<<=8,t|=e[2]}function tu(e){return!!(64&e[1])}function iu(e){var t=0;return 1<(48&e[3])>>>4&&(t+=e[4]+1),t}function nu(e){switch(e){case 5:return"slice_layer_without_partitioning_rbsp_idr";case 6:return"sei_rbsp";case 7:return"seq_parameter_set_rbsp";case 8:return"pic_parameter_set_rbsp";case 9:return"access_unit_delimiter_rbsp";default:return null}}function ru(e,t){var i=e[t+6]<<21|e[t+7]<<14|e[t+8]<<7|e[t+9];return i=0<=i?i:0,(16&e[t+5])>>4?i+20:i+10}function au(e){return e[0]<<21|e[1]<<14|e[2]<<7|e[3]}var su,ou,uu,lu,cu={parseType:function(e,t){var i=eu(e);return 0===i?"pat":i===t?"pmt":t?"pes":null},parsePat:function(e){var t=tu(e),i=4+iu(e);return t&&(i+=e[i]+1),(31&e[i+10])<<8|e[i+11]},parsePmt:function(e){var t={},i=tu(e),n=4+iu(e);if(i&&(n+=e[n]+1),1&e[n+5]){var r;r=3+((15&e[n+1])<<8|e[n+2])-4;for(var a=12+((15&e[n+10])<<8|e[n+11]);a<r;){var s=n+a;t[(31&e[s+1])<<8|e[s+2]]=e[s],a+=5+((15&e[s+3])<<8|e[s+4])}return t}},parsePayloadUnitStartIndicator:tu,parsePesType:function(e,t){switch(t[eu(e)]){case $o:return"video";case Qo:return"audio";case Jo:return"timed-metadata";default:return null}},parsePesTime:function(e){if(!tu(e))return null;var t=4+iu(e);if(t>=e.byteLength)return null;var i,n=null;return 192&(i=e[t+7])&&((n={}).pts=(14&e[t+9])<<27|(255&e[t+10])<<20|(254&e[t+11])<<12|(255&e[t+12])<<5|(254&e[t+13])>>>3,n.pts*=4,n.pts+=(6&e[t+13])>>>1,n.dts=n.pts,64&i&&(n.dts=(14&e[t+14])<<27|(255&e[t+15])<<20|(254&e[t+16])<<12|(255&e[t+17])<<5|(254&e[t+18])>>>3,n.dts*=4,n.dts+=(6&e[t+18])>>>1)),n},videoPacketContainsKeyFrame:function(e){for(var t=4+iu(e),i=e.subarray(t),n=0,r=0,a=!1;r<i.byteLength-3;r++)if(1===i[r+2]){n=r+5;break}for(;n<i.byteLength;)switch(i[n]){case 0:if(0!==i[n-1]){n+=2;break}if(0!==i[n-2]){n++;break}for(r+3!==n-2&&"slice_layer_without_partitioning_rbsp_idr"===nu(31&i[r+3])&&(a=!0);1!==i[++n]&&n<i.length;);r=n-2,n+=3;break;case 1:if(0!==i[n-1]||0!==i[n-2]){n+=3;break}"slice_layer_without_partitioning_rbsp_idr"===nu(31&i[r+3])&&(a=!0),r=n-2,n+=3;break;default:n+=3}return i=i.subarray(r),n-=r,r=0,i&&3<i.byteLength&&"slice_layer_without_partitioning_rbsp_idr"===nu(31&i[r+3])&&(a=!0),a}},du=[96e3,88200,64e3,48e3,44100,32e3,24e3,22050,16e3,12e3,11025,8e3,7350],hu={isLikelyAacData:function(e){var t=function e(t,i){return t.length-i<10||t[i]!=="I".charCodeAt(0)||t[i+1]!=="D".charCodeAt(0)||t[i+2]!=="3".charCodeAt(0)?i:e(t,i+=ru(t,i))}(e,0);return e.length>=t+2&&255==(255&e[t])&&240==(240&e[t+1])&&16==(22&e[t+1])},parseId3TagSize:ru,parseAdtsSize:function(e,t){var i=(224&e[t+5])>>5,n=e[t+4]<<3;return 6144&e[t+3]|n|i},parseType:function(e,t){return e[t]==="I".charCodeAt(0)&&e[t+1]==="D".charCodeAt(0)&&e[t+2]==="3".charCodeAt(0)?"timed-metadata":!0&e[t]&&240==(240&e[t+1])?"audio":null},parseSampleRate:function(e){for(var t=0;t+5<e.length;){if(255===e[t]&&240==(246&e[t+1]))return du[(60&e[t+2])>>>2];t++}return null},parseAacTimestamp:function(e){var t,i,n;t=10,64&e[5]&&(t+=4,t+=au(e.subarray(10,14)));do{if((i=au(e.subarray(t+4,t+8)))<1)return null;if("PRIV"===String.fromCharCode(e[t],e[t+1],e[t+2],e[t+3])){n=e.subarray(t+10,t+i+10);for(var r=0;r<n.byteLength;r++)if(0===n[r]){if("com.apple.streaming.transportStreamTimestamp"!==unescape(function(e,t,i){var n,r="";for(n=t;n<i;n++)r+="%"+("00"+e[n].toString(16)).slice(-2);return r}(n,0,r)))break;var a=n.subarray(r+1),s=(1&a[3])<<30|a[4]<<22|a[5]<<14|a[6]<<6|a[7]>>>2;return s*=4,s+=3&a[7]}}t+=10,t+=i}while(t<e.byteLength);return null}},pu=9e4,fu=(su=function(e){return 9e4*e},ou=function(e,t){return e*t},uu=function(e){return e/9e4},lu=function(e,t){return e/t},pu),mu=Zo,gu={};gu.ts=cu,gu.aac=hu;function vu(e,t,i){for(var n,r,a,s,o=0,u=Cu,l=!1;u<=e.byteLength;)if(71!==e[o]||71!==e[u]&&u!==e.byteLength)o++,u++;else{switch(n=e.subarray(o,u),gu.ts.parseType(n,t.pid)){case"pes":r=gu.ts.parsePesType(n,t.table),a=gu.ts.parsePayloadUnitStartIndicator(n),"audio"===r&&a&&(s=gu.ts.parsePesTime(n))&&(s.type="audio",i.audio.push(s),l=!0)}if(l)break;o+=Cu,u+=Cu}for(o=(u=e.byteLength)-Cu,l=!1;0<=o;)if(71!==e[o]||71!==e[u]&&u!==e.byteLength)o--,u--;else{switch(n=e.subarray(o,u),gu.ts.parseType(n,t.pid)){case"pes":r=gu.ts.parsePesType(n,t.table),a=gu.ts.parsePayloadUnitStartIndicator(n),"audio"===r&&a&&(s=gu.ts.parsePesTime(n))&&(s.type="audio",i.audio.push(s),l=!0)}if(l)break;o-=Cu,u-=Cu}}function yu(e,t,i){for(var n,r,a,s,o,u,l,c=0,d=Cu,h=!1,p={data:[],size:0};d<e.byteLength;)if(71!==e[c]||71!==e[d])c++,d++;else{switch(n=e.subarray(c,d),gu.ts.parseType(n,t.pid)){case"pes":if(r=gu.ts.parsePesType(n,t.table),a=gu.ts.parsePayloadUnitStartIndicator(n),"video"===r&&(a&&!h&&(s=gu.ts.parsePesTime(n))&&(s.type="video",i.video.push(s),h=!0),!i.firstKeyFrame)){if(a&&0!==p.size){for(o=new Uint8Array(p.size),u=0;p.data.length;)l=p.data.shift(),o.set(l,u),u+=l.byteLength;if(gu.ts.videoPacketContainsKeyFrame(o)){var f=gu.ts.parsePesTime(o);f&&(i.firstKeyFrame=f,i.firstKeyFrame.type="video")}p.size=0}p.data.push(n),p.size+=n.byteLength}}if(h&&i.firstKeyFrame)break;c+=Cu,d+=Cu}for(c=(d=e.byteLength)-Cu,h=!1;0<=c;)if(71!==e[c]||71!==e[d])c--,d--;else{switch(n=e.subarray(c,d),gu.ts.parseType(n,t.pid)){case"pes":r=gu.ts.parsePesType(n,t.table),a=gu.ts.parsePayloadUnitStartIndicator(n),"video"===r&&a&&(s=gu.ts.parsePesTime(n))&&(s.type="video",i.video.push(s),h=!0)}if(h)break;c-=Cu,d-=Cu}}function _u(e){var t={pid:null,table:null},i={};for(var n in function(e,t){for(var i,n=0,r=Cu;r<e.byteLength;)if(71!==e[n]||71!==e[r])n++,r++;else{switch(i=e.subarray(n,r),gu.ts.parseType(i,t.pid)){case"pat":t.pid||(t.pid=gu.ts.parsePat(i));break;case"pmt":t.table||(t.table=gu.ts.parsePmt(i))}if(t.pid&&t.table)return;n+=Cu,r+=Cu}}(e,t),t.table){if(t.table.hasOwnProperty(n))switch(t.table[n]){case $o:i.video=[],yu(e,t,i),0===i.video.length&&delete i.video;break;case Qo:i.audio=[],vu(e,t,i),0===i.audio.length&&delete i.audio}}return i}var bu,Tu,Su,ku=pu,Cu=188,Eu=function(e,t){var i;return(i=gu.aac.isLikelyAacData(e)?function(e){for(var t,i=!1,n=0,r=null,a=null,s=0,o=0;3<=e.length-o;){switch(gu.aac.parseType(e,o)){case"timed-metadata":if(e.length-o<10){i=!0;break}if((s=gu.aac.parseId3TagSize(e,o))>e.length){i=!0;break}null===a&&(t=e.subarray(o,o+s),a=gu.aac.parseAacTimestamp(t)),o+=s;break;case"audio":if(e.length-o<7){i=!0;break}if((s=gu.aac.parseAdtsSize(e,o))>e.length){i=!0;break}null===r&&(t=e.subarray(o,o+s),r=gu.aac.parseSampleRate(t)),n++,o+=s;break;default:o++}if(i)return null}if(null===r||null===a)return null;var u=ku/r;return{audio:[{type:"audio",dts:a,pts:a},{type:"audio",dts:a+1024*n*u,pts:a+1024*n*u}]}}(e):_u(e))&&(i.audio||i.video)?(function(e,t){if(e.audio&&e.audio.length){var i=t;"undefined"==typeof i&&(i=e.audio[0].dts),e.audio.forEach(function(e){e.dts=mu(e.dts,i),e.pts=mu(e.pts,i),e.dtsTime=e.dts/ku,e.ptsTime=e.pts/ku})}if(e.video&&e.video.length){var n=t;if("undefined"==typeof n&&(n=e.video[0].dts),e.video.forEach(function(e){e.dts=mu(e.dts,n),e.pts=mu(e.pts,n),e.dtsTime=e.dts/ku,e.ptsTime=e.pts/ku}),e.firstKeyFrame){var r=e.firstKeyFrame;r.dts=mu(r.dts,n),r.pts=mu(r.pts,n),r.dtsTime=r.dts/ku,r.ptsTime=r.dts/ku}}}(i,t),i):null},wu=function(e){return e>>>0},Iu=function(e){var t="";return t+=String.fromCharCode(e[0]),t+=String.fromCharCode(e[1]),t+=String.fromCharCode(e[2]),t+=String.fromCharCode(e[3])},Au=wu,xu=function e(t,i){var n,r,a,s,o,u=[];if(!i.length)return null;for(n=0;n<t.byteLength;)r=Au(t[n]<<24|t[n+1]<<16|t[n+2]<<8|t[n+3]),a=Iu(t.subarray(n+4,n+8)),s=1<r?n+r:t.byteLength,a===i[0]&&(1===i.length?u.push(t.subarray(n+8,s)):(o=e(t.subarray(n+8,s),i.slice(1))).length&&(u=u.concat(o))),n=s;return u},Pu=function(e){var t,i=new DataView(e.buffer,e.byteOffset,e.byteLength),n={version:e[0],flags:new Uint8Array(e.subarray(1,4)),trackId:i.getUint32(4)},r=1&n.flags[2],a=2&n.flags[2],s=8&n.flags[2],o=16&n.flags[2],u=32&n.flags[2],l=65536&n.flags[0],c=131072&n.flags[0];return t=8,r&&(t+=4,n.baseDataOffset=i.getUint32(12),t+=4),a&&(n.sampleDescriptionIndex=i.getUint32(t),t+=4),s&&(n.defaultSampleDuration=i.getUint32(t),t+=4),o&&(n.defaultSampleSize=i.getUint32(t),t+=4),u&&(n.defaultSampleFlags=i.getUint32(t)),l&&(n.durationIsEmpty=!0),!r&&c&&(n.baseDataOffsetIsMoof=!0),n},Lu=function(e){return{isLeading:(12&e[0])>>>2,dependsOn:3&e[0],isDependedOn:(192&e[1])>>>6,hasRedundancy:(48&e[1])>>>4,paddingValue:(14&e[1])>>>1,isNonSyncSample:1&e[1],degradationPriority:e[2]<<8|e[3]}},Ou=function(e){var t,i={version:e[0],flags:new Uint8Array(e.subarray(1,4)),samples:[]},n=new DataView(e.buffer,e.byteOffset,e.byteLength),r=1&i.flags[2],a=4&i.flags[2],s=1&i.flags[1],o=2&i.flags[1],u=4&i.flags[1],l=8&i.flags[1],c=n.getUint32(4),d=8;for(r&&(i.dataOffset=n.getInt32(d),d+=4),a&&c&&(t={flags:Lu(e.subarray(d,d+4))},d+=4,s&&(t.duration=n.getUint32(d),d+=4),o&&(t.size=n.getUint32(d),d+=4),l&&(1===i.version?t.compositionTimeOffset=n.getInt32(d):t.compositionTimeOffset=n.getUint32(d),d+=4),i.samples.push(t),c--);c--;)t={},s&&(t.duration=n.getUint32(d),d+=4),o&&(t.size=n.getUint32(d),d+=4),u&&(t.flags=Lu(e.subarray(d,d+4)),d+=4),l&&(1===i.version?t.compositionTimeOffset=n.getInt32(d):t.compositionTimeOffset=n.getUint32(d),d+=4),i.samples.push(t);return i},Du=wu,Mu=function(e){var t={version:e[0],flags:new Uint8Array(e.subarray(1,4)),baseMediaDecodeTime:Du(e[4]<<24|e[5]<<16|e[6]<<8|e[7])};return 1===t.version&&(t.baseMediaDecodeTime*=Math.pow(2,32),t.baseMediaDecodeTime+=Du(e[8]<<24|e[9]<<16|e[10]<<8|e[11])),t},Ru=wu,Nu=function(e){return("00"+e.toString(16)).slice(-2)};bu=function(r,e){var t,i,n;return t=xu(e,["moof","traf"]),i=[].concat.apply([],t.map(function(n){return xu(n,["tfhd"]).map(function(e){var t,i;return t=Ru(e[4]<<24|e[5]<<16|e[6]<<8|e[7]),i=r[t]||9e4,(xu(n,["tfdt"]).map(function(e){var t,i;return t=e[0],i=Ru(e[4]<<24|e[5]<<16|e[6]<<8|e[7]),1===t&&(i*=Math.pow(2,32),i+=Ru(e[8]<<24|e[9]<<16|e[10]<<8|e[11])),i})[0]||1/0)/i})})),n=Math.min.apply(null,i),isFinite(n)?n:0},Tu=function(e){var t=xu(e,["moov","trak"]),h=[];return t.forEach(function(e){var t,i,n={},r=xu(e,["tkhd"])[0];r&&(i=(t=new DataView(r.buffer,r.byteOffset,r.byteLength)).getUint8(0),n.id=0===i?t.getUint32(12):t.getUint32(20));var a=xu(e,["mdia","hdlr"])[0];if(a){var s=Iu(a.subarray(8,12));n.type="vide"===s?"video":"soun"===s?"audio":s}var o=xu(e,["mdia","minf","stbl","stsd"])[0];if(o){var u=o.subarray(8);n.codec=Iu(u.subarray(4,8));var l,c=xu(u,[n.codec])[0];c&&(/^[a-z]vc[1-9]$/i.test(n.codec)?(l=c.subarray(78),"avcC"===Iu(l.subarray(4,8))&&11<l.length?(n.codec+=".",n.codec+=Nu(l[9]),n.codec+=Nu(l[10]),n.codec+=Nu(l[11])):n.codec="avc1.4d400d"):/^mp4[a,v]$/i.test(n.codec)&&(l=c.subarray(28),"esds"===Iu(l.subarray(4,8))&&20<l.length&&0!==l[19]?(n.codec+="."+Nu(l[19]),n.codec+="."+Nu(l[20]>>>2&63).replace(/^0/,"")):n.codec="mp4a.40.2"))}var d=xu(e,["mdia","mdhd"])[0];d&&(n.timescale=Su(d)),h.push(n)}),h};var Uu=bu,Fu=Tu,Bu=(Su=function(e){var t=0===e[0]?12:20;return Ru(e[t]<<24|e[1+t]<<16|e[2+t]<<8|e[3+t])},i(function(e,t){function i(e){return e&&"object"==typeof e&&"default"in e?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});function r(e){return e?e.replace(/avc1\.(\d+)\.(\d+)/i,function(e,t,i){return"avc1."+("00"+Number(t).toString(16)).slice(-2)+"00"+("00"+Number(i).toString(16)).slice(-2)}):e}function n(e){return e.map(r)}function a(e){void 0===e&&(e="");var t=e.split(","),a={};return t.forEach(function(r){r=r.trim(),["video","audio"].forEach(function(e){var t=l[e].exec(r.toLowerCase());if(t&&!(t.length<=1)){var i=r.substring(0,t[1].length),n=r.replace(i,"");a[e]={type:i,details:n}}})}),a}function s(e){return void 0===e&&(e=""),l.audio.test(e.trim().toLowerCase())}function o(e){if(e&&"string"==typeof e){var t=e.toLowerCase().split(",").map(function(e){return r(e.trim())}),i="video";1===t.length&&s(t[0])&&(i="audio");var n="mp4";return t.every(function(e){return l.mp4.test(e)})?n="mp4":t.every(function(e){return l.webm.test(e)})?n="webm":t.every(function(e){return l.ogg.test(e)})&&(n="ogg"),i+"/"+n+';codecs="'+e+'"'}}var u=i(T),l={mp4:/^(av0?1|avc0?[1234]|vp0?9|flac|opus|mp3|mp4a|mp4v)/,webm:/^(vp0?[89]|av0?1|opus|vorbis)/,ogg:/^(vp0?[89]|theora|flac|opus|vorbis)/,video:/^(av0?1|avc0?[1234]|vp0?[89]|hvc1|hev1|theora|mp4v)/,audio:/^(mp4a|flac|vorbis|opus|ac-[34]|ec-3|alac|mp3)/,muxerVideo:/^(avc0?1)/,muxerAudio:/^(mp4a)/};t.DEFAULT_AUDIO_CODEC="mp4a.40.2",t.DEFAULT_VIDEO_CODEC="avc1.4d400d",t.browserSupportsCodec=function(e){return void 0===e&&(e=""),u.default.MediaSource&&u.default.MediaSource.isTypeSupported&&u.default.MediaSource.isTypeSupported(o(e))||!1},t.codecsFromDefault=function(e,t){if(!e.mediaGroups.AUDIO||!t)return null;var i=e.mediaGroups.AUDIO[t];if(!i)return null;for(var n in i){var r=i[n];if(r.default&&r.playlists)return a(r.playlists[0].attributes.CODECS)}return null},t.getMimeForCodec=o,t.isAudioCodec=s,t.isVideoCodec=function(e){return void 0===e&&(e=""),l.video.test(e.trim().toLowerCase())},t.mapLegacyAvcCodecs=function(e){return e.replace(/avc1\.(\d+)\.(\d+)/i,function(e){return n([e])[0]})},t.muxerSupportsCodec=function(e){return void 0===e&&(e=""),e.toLowerCase().split(",").every(function(e){return e=e.trim(),l.muxerVideo.test(e)||l.muxerAudio.test(e)})},t.parseCodecs=a,t.translateLegacyCodec=r,t.translateLegacyCodecs=n}));t(Bu);function ju(e,t,i){return e&&i&&i.responseURL&&t!==i.responseURL?i.responseURL:t}function Vu(e,t){return e+"-"+t}function qu(e){var t=e.manifestString,i=e.customTagParsers,n=void 0===i?[]:i,r=e.customTagMappers,a=void 0===r?[]:r,s=new Ea;return n.forEach(function(e){return s.addParser(e)}),a.forEach(function(e){return s.addTagMapper(e)}),s.push(t),s.end(),s.manifest}function Hu(r,a){["AUDIO","SUBTITLES"].forEach(function(e){for(var t in r.mediaGroups[e])for(var i in r.mediaGroups[e][t]){var n=r.mediaGroups[e][t][i];a(n,e,t,i)}})}function Wu(e){var t=e.playlist,i=e.uri,n=e.id;t.id=n,i&&(t.uri=i),t.attributes=t.attributes||{}}function zu(s,e){s.uri=e;for(var t=0;t<s.playlists.length;t++)if(!s.playlists[t].uri){var i="placeholder-uri-"+t;s.playlists[t].uri=i}Hu(s,function(e,t,i,n){if(e.playlists&&e.playlists.length&&!e.playlists[0].uri){var r="placeholder-uri-"+t+"-"+i+"-"+n,a=Vu(0,r);e.playlists[0].uri=r,e.playlists[0].id=a,s.playlists[a]=e.playlists[0],s.playlists[r]=e.playlists[0]}}),function(e){for(var t=e.playlists.length;t--;){var i=e.playlists[t];Wu({playlist:i,id:Vu(t,i.uri)}),i.resolvedUri=$l(e.uri,i.uri),e.playlists[i.id]=i,(e.playlists[i.uri]=i).attributes.BANDWIDTH||Ql.warn("Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.")}}(s),function(t){Hu(t,function(e){e.uri&&(e.resolvedUri=$l(t.uri,e.uri))})}(s)}function Gu(e,t){e.resolvedUri||(e.resolvedUri=$l(t,e.uri)),e.key&&!e.key.resolvedUri&&(e.key.resolvedUri=$l(t,e.key.uri)),e.map&&!e.map.resolvedUri&&(e.map.resolvedUri=$l(t,e.map.uri))}function Xu(e,t){var i=Jl(e,{}),n=i.playlists[t.id];if(!n)return null;if(n.segments&&t.segments&&n.segments.length===t.segments.length&&n.endList===t.endList&&n.mediaSequence===t.mediaSequence)return null;var r=Jl(n,t);n.segments&&(r.segments=function(e,t,i){var n=t.slice();i=i||0;for(var r=Math.min(e.length,t.length+i),a=i;a<r;a++)n[a-i]=Jl(e[a],n[a-i]);return n}(n.segments,t.segments,t.mediaSequence-n.mediaSequence)),r.segments.forEach(function(e){Gu(e,r.resolvedUri)});for(var a=0;a<i.playlists.length;a++)i.playlists[a].id===t.id&&(i.playlists[a]=r);return i.playlists[t.id]=r,i.playlists[t.uri]=r,i}function Ku(e,t){var i=e.segments[e.segments.length-1];return t&&i&&i.duration?1e3*i.duration:500*(e.targetDuration||10)}function Yu(e,t){var i,n=[];if(e&&e.length)for(i=0;i<e.length;i++)t(e.start(i),e.end(i))&&n.push([e.start(i),e.end(i)]);return da.createTimeRanges(n)}function $u(e,i){return Yu(e,function(e,t){return e-.1<=i&&i<=t+.1})}function Qu(e,t){return Yu(e,function(e){return t<=e-ec})}function Ju(e){var t=[];if(!e||!e.length)return"";for(var i=0;i<e.length;i++)t.push(e.start(i)+" => "+e.end(i));return t.join(", ")}function Zu(e){for(var t=[],i=0;i<e.length;i++)t.push({start:e.start(i),end:e.end(i)});return t}function el(e,t,i){if("undefined"==typeof t&&(t=e.mediaSequence+e.segments.length),t<e.mediaSequence)return 0;var n=function(e,t){var i=0,n=t-e.mediaSequence,r=e.segments[n];if(r){if("undefined"!=typeof r.start)return{result:r.start,precise:!0};if("undefined"!=typeof r.end)return{result:r.end-r.duration,precise:!0}}for(;n--;){if("undefined"!=typeof(r=e.segments[n]).end)return{result:i+r.end,precise:!0};if(i+=r.duration,"undefined"!=typeof r.start)return{result:i+r.start,precise:!0}}return{result:i,precise:!1}}(e,t);if(n.precise)return n.result;var r=function(e,t){for(var i,n=0,r=t-e.mediaSequence;r<e.segments.length;r++){if("undefined"!=typeof(i=e.segments[r]).start)return{result:i.start-n,precise:!0};if(n+=i.duration,"undefined"!=typeof i.end)return{result:i.end-n,precise:!0}}return{result:-1,precise:!1}}(e,t);return r.precise?r.result:n.result+i}function tl(e,t,i){if(!e)return 0;if("number"!=typeof i&&(i=0),"undefined"==typeof t){if(e.totalDuration)return e.totalDuration;if(!e.endList)return T.Infinity}return el(e,t,i)}function il(e,t,i){var n=0;if(i<t){var r=[i,t];t=r[0],i=r[1]}if(t<0){for(var a=t;a<Math.min(0,i);a++)n+=e.targetDuration;t=0}for(var s=t;s<i;s++)n+=e.segments[s].duration;return n}function nl(e,t){if(!e.segments.length)return 0;var i=e.segments.length,n=e.segments[i-1].duration||e.targetDuration,r="number"==typeof t?t:n+2*e.targetDuration;if(0===r)return i;for(var a=0;i--&&!(r<=(a+=e.segments[i].duration)););return Math.max(0,i)}function rl(e,t,i,n){if(!e||!e.segments)return null;if(e.endList)return tl(e);if(null===t)return null;t=t||0;var r=i?nl(e,n):e.segments.length;return el(e,e.mediaSequence+r,t)}function al(e){return e.excludeUntil&&e.excludeUntil>Date.now()}function sl(e){return e.excludeUntil&&e.excludeUntil===1/0}function ol(e){var t=al(e);return!e.disabled&&!t}function ul(e,t){return t.attributes&&t.attributes[e]}function ll(e,t){if(1===e.playlists.length)return!0;var i=t.attributes.BANDWIDTH||Number.MAX_VALUE;return 0===e.playlists.filter(function(e){return!!ol(e)&&(e.attributes.BANDWIDTH||0)<i}).length}function cl(e,t,i,n){var r="arraybuffer"===e.responseType?e.response:e.responseText;!t&&r&&(e.responseTime=Date.now(),e.roundTripTime=e.responseTime-e.requestTime,e.bytesReceived=r.byteLength||r.length,e.bandwidth||(e.bandwidth=Math.floor(e.bytesReceived/e.roundTripTime*8*1e3))),i.headers&&(e.responseHeaders=i.headers),t&&"ETIMEDOUT"===t.code&&(e.timedout=!0),t||e.aborted||200===i.statusCode||206===i.statusCode||0===i.statusCode||(t=new Error("XHR Failed with a response of: "+(e&&(r||e.responseText)))),n(t,e)}function dl(){return function e(t,i){t=rc({timeout:45e3},t);var n=e.beforeRequest||da.Vhs.xhr.beforeRequest;if(n&&"function"==typeof n){var r=n(t);r&&(t=r)}var a=nc(t,function(e,t){return cl(a,e,t,i)}),s=a.abort;return a.abort=function(){return a.aborted=!0,s.apply(a,arguments)},a.uri=t.uri,a.requestTime=Date.now(),a}}function hl(e){var t={};return e.byterange&&(t.Range=function(e){var t=e.offset+e.length-1;return"bytes="+e.offset+"-"+t}(e.byterange)),t}function pl(e,t){var i=e.toString(16);return"00".substring(0,2-i.length)+i+(t%2?" ":"")}function fl(e){return 32<=e&&e<126?String.fromCharCode(e):"."}function ml(i){var n={};return Object.keys(i).forEach(function(e){var t=i[e];ArrayBuffer.isView(t)?n[e]={bytes:t.buffer,byteOffset:t.byteOffset,byteLength:t.byteLength}:n[e]=t}),n}function gl(e){var t=e.byterange||{length:1/0,offset:0};return[t.length,t.offset,e.resolvedUri].join(",")}function vl(e){return e.resolvedUri}function yl(e){for(var t=Array.prototype.slice.call(e),i="",n=0;n<t.length/16;n++)i+=t.slice(16*n,16*n+16).map(pl).join("")+" "+t.slice(16*n,16*n+16).map(fl).join("")+"\n";return i}function _l(e){var t=e.playlist,i=e.time,n=void 0===i?void 0:i,r=e.callback;if(!r)throw new Error("getProgramTime: callback must be provided");if(!t||void 0===n)return r({message:"getProgramTime: playlist and time must be provided"});var a=function(e,t){if(!t||!t.segments||0===t.segments.length)return null;for(var i,n=0,r=0;r<t.segments.length&&!(e<=(n=(i=t.segments[r]).videoTimingInfo?i.videoTimingInfo.transmuxedPresentationEnd:n+i.duration));r++);var a=t.segments[t.segments.length-1];if(a.videoTimingInfo&&a.videoTimingInfo.transmuxedPresentationEnd<e)return null;if(n<e){if(e>n+.25*a.duration)return null;i=a}return{segment:i,estimatedStart:i.videoTimingInfo?i.videoTimingInfo.transmuxedPresentationStart:n-i.duration,type:i.videoTimingInfo?"accurate":"estimate"}}(n,t);if(!a)return r({message:"valid programTime was not found"});if("estimate"===a.type)return r({message:"Accurate programTime could not be determined. Please seek to e.seekTime and try again",seekTime:a.estimatedStart});var s={mediaSeconds:n},o=function(e,t){if(!t.dateTimeObject)return null;var i=t.videoTimingInfo.transmuxerPrependedSeconds,n=e-(t.videoTimingInfo.transmuxedPresentationStart+i);return new Date(t.dateTimeObject.getTime()+1e3*n)}(n,a.segment);return o&&(s.programDateTime=o.toISOString()),r(null,s)}function bl(e){var t=e.programTime,i=e.playlist,n=e.retryCount,r=void 0===n?2:n,a=e.seekTo,s=e.pauseAfterSeek,o=void 0===s||s,u=e.tech,l=e.callback;if(!l)throw new Error("seekToProgramTime: callback must be provided");if("undefined"==typeof t||!i||!a)return l({message:"seekToProgramTime: programTime, seekTo and playlist must be provided"});if(!i.endList&&!u.hasStarted_)return l({message:"player must be playing a live stream to start buffering"});if(!function(e){if(!e.segments||0===e.segments.length)return!1;for(var t=0;t<e.segments.length;t++){if(!e.segments[t].dateTimeObject)return!1}return!0}(i))return l({message:"programDateTime tags must be provided in the manifest "+i.resolvedUri});var c=function(e,t){var i;try{i=new Date(e)}catch(e){return null}if(!t||!t.segments||0===t.segments.length)return null;var n=t.segments[0];if(i<n.dateTimeObject)return null;for(var r=0;r<t.segments.length-1;r++){if(n=t.segments[r],i<t.segments[r+1].dateTimeObject)break}var a=t.segments[t.segments.length-1],s=a.dateTimeObject,o=a.videoTimingInfo?function(e){return e.transmuxedPresentationEnd-e.transmuxedPresentationStart-e.transmuxerPrependedSeconds}(a.videoTimingInfo):a.duration+.25*a.duration;return new Date(s.getTime()+1e3*o)<i?null:(s<i&&(n=a),{segment:n,estimatedStart:n.videoTimingInfo?n.videoTimingInfo.transmuxedPresentationStart:ic.duration(t,t.mediaSequence+t.segments.indexOf(n)),type:n.videoTimingInfo?"accurate":"estimate"})}(t,i);if(!c)return l({message:t+" was not found in the stream"});var d=c.segment,h=function(e,t){var i,n;try{i=new Date(e),n=new Date(t)}catch(e){}var r=i.getTime();return(n.getTime()-r)/1e3}(d.dateTimeObject,t);if("estimate"===c.type)return 0===r?l({message:t+" is not buffered yet. Try again"}):(a(c.estimatedStart+h),void u.one("seeked",function(){bl({programTime:t,playlist:i,retryCount:r-1,seekTo:a,pauseAfterSeek:o,tech:u,callback:l})}));var p=d.start+h;u.one("seeked",function(){return l(null,u.currentTime())}),o&&u.pause(),a(p)}function Tl(e,t){if(4===e.readyState)return t()}function Sl(e){var t=e.masterXml,i=e.srcUrl,n=e.clientOffset,r=e.sidxMapping,a=Eo(t,{manifestUri:i,clientOffset:n,sidxMapping:r});return zu(a,i),a}function kl(e,t){for(var s=!0,o=sc(e,{duration:t.duration,minimumUpdatePeriod:t.minimumUpdatePeriod}),i=0;i<t.playlists.length;i++){var n=Xu(o,t.playlists[i]);n&&(o=n,s=!1)}return Hu(t,function(e,t,i,n){if(e.playlists&&e.playlists.length){var r=e.playlists[0].id,a=Xu(o,e.playlists[0]);a&&((o=a).mediaGroups[t][i][n].playlists[0]=o.playlists[r],s=!1)}}),t.minimumUpdatePeriod!==e.minimumUpdatePeriod&&(s=!1),s?null:o}function Cl(e){var t=e.byterange.offset+e.byterange.length-1;return e.uri+"-"+e.byterange.offset+"-"+t}function El(e,t){var i,n,r={};for(var a in e){var s=e[a].sidx;if(s){var o=Cl(s);if(!t[o])break;var u=t[o].sidxInfo;i=u,n=s,(Boolean(!i.map&&!n.map)||Boolean(i.map&&n.map&&i.map.byterange.offset===n.map.byterange.offset&&i.map.byterange.length===n.map.byterange.length))&&i.uri===n.uri&&i.byterange.offset===n.byterange.offset&&i.byterange.length===n.byterange.length&&(r[o]=t[o])}}return r}function wl(o,e,u,l,t,c){var d={uri:ju(t.handleManifestRedirects,e.resolvedUri),byterange:e.byterange,playlist:u},h=da.mergeOptions(d,{responseType:"arraybuffer",headers:hl(d)});return function(e,t,r){function a(e,t,i,n){return t.abort(),u=!0,r(e,t,i,n)}function i(e,t){if(!u){if(e)return a(e,t,"",o);var i=t.responseText.substring(o&&o.byteLength||0,t.responseText.length);if(o=Xo(o,Ko(i,!0)),s=s||Ho(o),o.length<10||s&&o.length<s+2)return Tl(t,function(){return a(e,t,"",o)});var n=qo(o);return"ts"===n&&o.length<188?Tl(t,function(){return a(e,t,"",o)}):!n&&o.length<376?Tl(t,function(){return a(e,t,"",o)}):a(null,t,n,o)}}var s,o=[],u=!1,n=t({uri:e,beforeSend:function(t){t.overrideMimeType("text/plain; charset=x-user-defined"),t.addEventListener("progress",function(e){e.total,e.loaded;return cl(t,null,{statusCode:t.status},i)})}},function(e,t){return cl(n,e,t,i)});return n}(d.uri,l,function(e,t,i,n){if(e)return c(e,t);if(!i||"mp4"!==i)return c({status:t.status,message:"Unsupported "+(i||"unknown")+" container type for sidx segment at URL: "+d.uri,response:"",playlist:u,internal:!0,blacklistDuration:1/0,code:2},t);var r=d.byterange,a=r.offset,s=r.length;if(n.length>=s+a)return c(e,{response:n.subarray(a,a+s),status:t.status,uri:t.uri});o.request=l(h,c)})}function Il(e){for(var t=new Uint8Array(new ArrayBuffer(e.length)),i=0;i<e.length;i++)t[i]=e.charCodeAt(i);return t.buffer}function Al(e){var t=e.transmuxer,i=e.bytes,n=e.audioAppendStart,r=e.gopsToAlignWith,a=e.isPartial,s=e.remux,o=e.onData,u=e.onTrackInfo,l=e.onAudioTimingInfo,c=e.onVideoTimingInfo,d=e.onVideoSegmentTimingInfo,h=e.onId3,p=e.onCaptions,f=e.onDone,m={isPartial:a,buffer:[]};if(t.onmessage=function(e){Bl&&("data"===e.data.action&&function(e,t,i){var n=e.data.segment,r=n.type,a=n.initSegment,s=n.captions,o=n.captionStreams,u=n.metadata,l=n.videoFrameDtsTime,c=n.videoFramePtsTime;t.buffer.push({captions:s,captionStreams:o,metadata:u});var d=e.data.segment.boxes||{data:e.data.segment.data},h={type:r,data:new Uint8Array(d.data,d.data.byteOffset,d.data.byteLength),initSegment:new Uint8Array(a.data,a.byteOffset,a.byteLength)};"undefined"!=typeof l&&(h.videoFrameDtsTime=l),"undefined"!=typeof c&&(h.videoFramePtsTime=c),i(h)}(e,m,o),"trackinfo"===e.data.action&&u(e.data.trackInfo),"gopInfo"===e.data.action&&function(e,t){t.gopInfo=e.data.gopInfo}(e,m),"audioTimingInfo"===e.data.action&&l(e.data.audioTimingInfo),"videoTimingInfo"===e.data.action&&c(e.data.videoTimingInfo),"videoSegmentTimingInfo"===e.data.action&&d(e.data.videoSegmentTimingInfo),"id3Frame"===e.data.action&&h([e.data.id3Frame],e.data.id3Frame.dispatchType),"caption"===e.data.action&&p(e.data.caption),"transmuxed"===e.data.type&&(t.onmessage=null,function(e){var t=e.transmuxedData,i=e.callback;t.buffer=[],i(t)}({transmuxedData:m,callback:f}),cc()))},n&&t.postMessage({action:"setAudioAppendStart",appendStart:n}),Array.isArray(r)&&t.postMessage({action:"alignGopsWith",gopsToAlignWith:r}),"undefined"!=typeof s&&t.postMessage({action:"setRemux",remux:s}),i.byteLength){var g=i instanceof ArrayBuffer?i:i.buffer,v=i instanceof ArrayBuffer?0:i.byteOffset;t.postMessage({action:"push",data:g,byteOffset:v,byteLength:i.byteLength},[g])}t.postMessage({action:a?"partialFlush":"flush"})}function xl(e,t){e.postMessage({action:t}),cc()}function Pl(e,t){Bl?lc.push(xl.bind(null,t,e)):xl(t,Bl=e)}function Ll(e){Bl?lc.push(e):Al(Bl=e)}function Ol(e){e.forEach(function(e){e.abort()})}function Dl(e,t){return t.timedout?{status:t.status,message:"HLS request timed-out at URL: "+t.uri,code:mc,xhr:t}:t.aborted?{status:t.status,message:"HLS request aborted at URL: "+t.uri,code:gc,xhr:t}:e?{status:t.status,message:"HLS request errored at URL: "+t.uri,code:fc,xhr:t}:null}function Ml(e){var i=e.segment,t=e.bytes,n=e.isPartial,r=e.trackInfoFn,a=e.timingInfoFn,s=e.videoSegmentTimingInfoFn,o=e.id3Fn,u=e.captionsFn,l=e.dataFn,c=e.doneFn,d=i.map&&i.map.tracks||{},h=Boolean(d.audio&&d.video),p=a.bind(null,i,"audio","start"),f=a.bind(null,i,"audio","end"),m=a.bind(null,i,"video","start"),g=a.bind(null,i,"video","end");if(!n&&!i.lastReachedChar){var v=function(e,t){var i=Eu(e,t*fu);if(!i)return null;var n={hasVideo:i.video&&2===i.video.length||!1,hasAudio:i.audio&&2===i.audio.length||!1};return n.hasVideo&&(n.videoStart=i.video[0].ptsTime),n.hasAudio&&(n.audioStart=i.audio[0].ptsTime),n}(t,i.baseStartTime);v&&(r(i,{hasAudio:v.hasAudio,hasVideo:v.hasVideo,isMuxed:h}),r=null,v.hasAudio&&!h&&p(v.audioStart),v.hasVideo&&m(v.videoStart),m=p=null)}Ll({bytes:t,transmuxer:i.transmuxer,audioAppendStart:i.audioAppendStart,gopsToAlignWith:i.gopsToAlignWith,isPartial:n,remux:h,onData:function(e){e.type="combined"===e.type?"video":e.type,l(i,e)},onTrackInfo:function(e){r&&(h&&(e.isMuxed=!0),r(i,e))},onAudioTimingInfo:function(e){p&&"undefined"!=typeof e.start&&(p(e.start),p=null),f&&"undefined"!=typeof e.end&&f(e.end)},onVideoTimingInfo:function(e){m&&"undefined"!=typeof e.start&&(m(e.start),m=null),g&&"undefined"!=typeof e.end&&g(e.end)},onVideoSegmentTimingInfo:function(e){s(e)},onId3:function(e,t){o(i,e,t)},onCaptions:function(e){u(i,[e])},onDone:function(e){c&&!n&&(e.type="combined"===e.type?"video":e.type,c(null,i,e))}})}function Rl(e){var n=e.segment,r=e.bytes,t=e.isPartial,i=e.trackInfoFn,a=e.timingInfoFn,s=e.videoSegmentTimingInfoFn,o=e.id3Fn,u=e.captionsFn,l=e.dataFn,c=e.doneFn,d=new Uint8Array(r);if(Wo(d)){n.isFmp4=!0;var h=n.map.tracks,p={isFmp4:!0,hasVideo:!!h.video,hasAudio:!!h.audio};h.audio&&h.audio.codec&&"enca"!==h.audio.codec&&(p.audioCodec=h.audio.codec),h.video&&h.video.codec&&"encv"!==h.video.codec&&(p.videoCodec=h.video.codec),h.video&&h.audio&&(p.isMuxed=!0),i(n,p);var f=Uu(n.map.timescales,d);p.hasAudio&&!p.isMuxed&&a(n,"audio","start",f),p.hasVideo&&a(n,"video","start",f);var m=function(e){l(n,{data:r,type:p.hasAudio&&!p.isMuxed?"audio":"video"}),e&&e.length&&u(n,e),c(null,n,{})};if(!h.video||!r.byteLength||!n.transmuxer)return void m();var g=r instanceof ArrayBuffer?r:r.buffer,v=r instanceof ArrayBuffer?0:r.byteOffset;return n.transmuxer.addEventListener("message",function e(t){if("mp4Captions"===t.data.action){n.transmuxer.removeEventListener("message",e);var i=t.data.data;n.bytes=r=new Uint8Array(i,i.byteOffset||0,i.byteLength),m(t.data.captions)}}),void n.transmuxer.postMessage({action:"pushMp4Captions",timescales:n.map.timescales,trackIds:[h.video.id],data:g,byteOffset:v,byteLength:r.byteLength},[g])}if(n.transmuxer){if("undefined"==typeof n.container&&(n.container=qo(d)),"ts"!==n.container&&"aac"!==n.container)return i(n,{hasAudio:!1,hasVideo:!1}),void c(null,n,{});Ml({segment:n,bytes:r,isPartial:t,trackInfoFn:i,timingInfoFn:a,videoSegmentTimingInfoFn:s,id3Fn:o,captionsFn:u,dataFn:l,doneFn:c})}else c(null,n,{})}function Nl(e){var i=e.activeXhrs,n=e.decryptionWorker,r=e.trackInfoFn,a=e.timingInfoFn,s=e.videoSegmentTimingInfoFn,o=e.id3Fn,u=e.captionsFn,l=e.dataFn,c=e.doneFn,d=0,h=!1;return function(e,t){if(!h){if(e)return h=!0,Ol(i),c(e,t);if((d+=1)===i.length){if(t.endOfAllRequests=Date.now(),t.encryptedBytes)return function(e){var t,n=e.decryptionWorker,r=e.segment,a=e.trackInfoFn,s=e.timingInfoFn,o=e.videoSegmentTimingInfoFn,u=e.id3Fn,l=e.captionsFn,c=e.dataFn,d=e.doneFn;n.addEventListener("message",function e(t){if(t.data.source===r.requestId){n.removeEventListener("message",e);var i=t.data.decrypted;r.bytes=new Uint8Array(i.bytes,i.byteOffset,i.byteLength),Rl({segment:r,bytes:r.bytes,isPartial:!1,trackInfoFn:a,timingInfoFn:s,videoSegmentTimingInfoFn:o,id3Fn:u,captionsFn:l,dataFn:c,doneFn:d})}}),t=r.key.bytes.slice?r.key.bytes.slice():new Uint32Array(Array.prototype.slice.call(r.key.bytes)),n.postMessage(ml({source:r.requestId,encrypted:r.encryptedBytes,key:t,iv:r.key.iv}),[r.encryptedBytes.buffer,t.buffer])}({decryptionWorker:n,segment:t,trackInfoFn:r,timingInfoFn:a,videoSegmentTimingInfoFn:s,id3Fn:o,captionsFn:u,dataFn:l,doneFn:c});Rl({segment:t,bytes:t.bytes,isPartial:!1,trackInfoFn:r,timingInfoFn:a,videoSegmentTimingInfoFn:s,id3Fn:o,captionsFn:u,dataFn:l,doneFn:c})}}}}function Ul(e){var n=e.segment,r=e.progressFn,a=e.trackInfoFn,s=e.timingInfoFn,o=e.videoSegmentTimingInfoFn,u=e.id3Fn,l=e.captionsFn,c=e.dataFn,d=e.handlePartialData;return function(e){var t=e.target;if(!t.aborted){if(d&&!n.key&&t.responseText&&8<=t.responseText.length){var i=Il(t.responseText.substring(n.lastReachedChar||0));!n.lastReachedChar&&Wo(new Uint8Array(i))||(n.lastReachedChar=t.responseText.length,Rl({segment:n,bytes:i,isPartial:!0,trackInfoFn:a,timingInfoFn:s,videoSegmentTimingInfoFn:o,id3Fn:u,captionsFn:l,dataFn:c}))}return n.stats=da.mergeOptions(n.stats,function(e){var t=e.target,i={bandwidth:1/0,bytesReceived:0,roundTripTime:Date.now()-t.requestTime||0};return i.bytesReceived=e.loaded,i.bandwidth=Math.floor(i.bytesReceived/i.roundTripTime*8*1e3),i}(e)),!n.stats.firstBytesReceivedAt&&n.stats.bytesReceived&&(n.stats.firstBytesReceivedAt=Date.now()),r(e,n)}}}function Fl(e){var t=e.xhr,i=e.xhrOptions,n=e.decryptionWorker,r=e.segment,a=e.abortFn,s=e.progressFn,o=e.trackInfoFn,u=e.timingInfoFn,l=e.videoSegmentTimingInfoFn,c=e.id3Fn,d=e.captionsFn,h=e.dataFn,p=e.doneFn,f=e.handlePartialData,m=[],g=Nl({activeXhrs:m,decryptionWorker:n,trackInfoFn:o,timingInfoFn:u,videoSegmentTimingInfoFn:l,id3Fn:c,captionsFn:d,dataFn:h,doneFn:p});if(r.key&&!r.key.bytes){var v=t(da.mergeOptions(i,{uri:r.key.resolvedUri,responseType:"arraybuffer"}),function(a,s){return function(e,t){var i=t.response,n=Dl(e,t);if(n)return s(n,a);if(16!==i.byteLength)return s({status:t.status,message:"Invalid HLS key at URL: "+t.uri,code:fc,xhr:t},a);var r=new DataView(i);return a.key.bytes=new Uint32Array([r.getUint32(0),r.getUint32(4),r.getUint32(8),r.getUint32(12)]),s(null,a)}}(r,g));m.push(v)}if(r.map&&!r.map.bytes){var y=t(da.mergeOptions(i,{uri:r.map.resolvedUri,responseType:"arraybuffer",headers:hl(r.map)}),function(e){var a=e.segment,s=e.finishProcessingFn;return function(e,t){var i=t.response,n=Dl(e,t);if(n)return s(n,a);if(0===i.byteLength)return s({status:t.status,message:"Empty HLS segment content at URL: "+t.uri,code:fc,xhr:t},a);a.map.bytes=new Uint8Array(t.response);var r=qo(a.map.bytes);return"mp4"!==r?s({status:t.status,message:"Found unsupported "+(r||"unknown")+" container for initialization segment at URL: "+t.uri,code:fc,internal:!0,xhr:t},a):(Fu(a.map.bytes).forEach(function(e){a.map.tracks=a.map.tracks||{},a.map.tracks[e.type]||(a.map.tracks[e.type]=e).id&&e.timescale&&(a.map.timescales=a.map.timescales||{},a.map.timescales[e.id]=e.timescale)}),s(null,a))}}({segment:r,finishProcessingFn:g}));m.push(y)}var _=da.mergeOptions(i,{uri:r.resolvedUri,responseType:"arraybuffer",headers:hl(r)});f&&(_.responseType="text",_.beforeSend=function(e){e.overrideMimeType("text/plain; charset=x-user-defined")});var b=t(_,function(e){var a=e.segment,s=e.finishProcessingFn,o=e.responseType;return function(e,t){var i=t.response,n=Dl(e,t);if(n)return s(n,a);var r="arraybuffer"!==o&&t.responseText?Il(t.responseText.substring(a.lastReachedChar||0)):t.response;return 0===i.byteLength?s({status:t.status,message:"Empty HLS segment content at URL: "+t.uri,code:fc,xhr:t},a):(a.stats=function(e){return{bandwidth:e.bandwidth,bytesReceived:e.bytesReceived||0,roundTripTime:e.roundTripTime||0}}(t),a.key?a.encryptedBytes=new Uint8Array(r):a.bytes=new Uint8Array(r),s(null,a))}}({segment:r,finishProcessingFn:g,responseType:_.responseType}));b.addEventListener("progress",Ul({segment:r,progressFn:s,trackInfoFn:o,timingInfoFn:u,videoSegmentTimingInfoFn:l,id3Fn:c,captionsFn:d,dataFn:h,handlePartialData:f})),m.push(b);var T={};return m.forEach(function(e){e.addEventListener("loadend",function(e){var t=e.loadendState,i=e.abortFn;return function(e){e.target.aborted&&i&&!t.calledAbortFn&&(i(),t.calledAbortFn=!0)}}({loadendState:T,abortFn:a}))}),function(){return Ol(m)}}var Bl,jl=Bu.DEFAULT_AUDIO_CODEC,Vl=Bu.DEFAULT_VIDEO_CODEC,ql=Bu.browserSupportsCodec,Hl=Bu.codecsFromDefault,Wl=Bu.getMimeForCodec,zl=Bu.isAudioCodec,Gl=Bu.isVideoCodec,Xl=(Bu.mapLegacyAvcCodecs,Bu.muxerSupportsCodec),Kl=Bu.parseCodecs,Yl=Bu.translateLegacyCodec,$l=(Bu.translateLegacyCodecs,va),Ql=da.log,Jl=da.mergeOptions,Zl=function(c){function e(e,t,i){var n;if(void 0===i&&(i={}),n=c.call(this)||this,!e)throw new Error("A non-empty playlist URL or object is required");var r=i,a=r.withCredentials,s=void 0!==a&&a,o=r.handleManifestRedirects,u=void 0!==o&&o;n.src=e,n.vhs_=t,n.withCredentials=s,n.handleManifestRedirects=u;var l=t.options_;return n.customTagParsers=l&&l.customTagParsers||[],n.customTagMappers=l&&l.customTagMappers||[],n.state="HAVE_NOTHING",n.on("mediaupdatetimeout",function(){"HAVE_METADATA"===n.state&&(n.state="HAVE_CURRENT_METADATA",n.request=n.vhs_.xhr({uri:$l(n.master.uri,n.media().uri),withCredentials:n.withCredentials},function(e,t){if(n.request)return e?n.playlistRequestError(n.request,n.media(),"HAVE_METADATA"):void n.haveMetadata({playlistString:n.request.responseText,url:n.media().uri,id:n.media().id})}))}),n}Ge(e,c);var t=e.prototype;return t.playlistRequestError=function(e,t,i){var n=t.uri,r=t.id;this.request=null,i&&(this.state=i),this.error={playlist:this.master.playlists[r],status:e.status,message:"HLS playlist request error at URL: "+n+".",responseText:e.responseText,code:500<=e.status?4:2},this.trigger("error")},t.haveMetadata=function(e){var t=this,i=e.playlistString,n=e.playlistObject,r=e.url,a=e.id;this.request=null,this.state="HAVE_METADATA";var s=n||qu({manifestString:i,customTagParsers:this.customTagParsers,customTagMappers:this.customTagMappers});Wu({playlist:s,uri:r,id:a});var o=Xu(this.master,s);this.targetDuration=s.targetDuration,o?(this.master=o,this.media_=this.master.playlists[a]):this.trigger("playlistunchanged"),this.media().endList||(T.clearTimeout(this.mediaUpdateTimeout),this.mediaUpdateTimeout=T.setTimeout(function(){t.trigger("mediaupdatetimeout")},Ku(this.media(),!!o))),this.trigger("loadedplaylist")},t.dispose=function(){this.trigger("dispose"),this.stopRequest(),T.clearTimeout(this.mediaUpdateTimeout),T.clearTimeout(this.finalRenditionTimeout),this.off()},t.stopRequest=function(){if(this.request){var e=this.request;this.request=null,e.onreadystatechange=null,e.abort()}},t.media=function(i,e){var n=this;if(!i)return this.media_;if("HAVE_NOTHING"===this.state)throw new Error("Cannot switch media playlist from "+this.state);if("string"==typeof i){if(!this.master.playlists[i])throw new Error("Unknown playlist URI: "+i);i=this.master.playlists[i]}if(T.clearTimeout(this.finalRenditionTimeout),e){var t=i.targetDuration/2*1e3||5e3;this.finalRenditionTimeout=T.setTimeout(this.media.bind(this,i,!1),t)}else{var r=this.state,a=!this.media_||i.id!==this.media_.id;if(this.master.playlists[i.id].endList||i.endList&&i.segments.length)return this.request&&(this.request.onreadystatechange=null,this.request.abort(),this.request=null),this.state="HAVE_METADATA",this.media_=i,void(a&&(this.trigger("mediachanging"),"HAVE_MASTER"===r?this.trigger("loadedmetadata"):this.trigger("mediachange")));if(a){if(this.state="SWITCHING_MEDIA",this.request){if(i.resolvedUri===this.request.url)return;this.request.onreadystatechange=null,this.request.abort(),this.request=null}this.media_&&this.trigger("mediachanging"),this.request=this.vhs_.xhr({uri:i.resolvedUri,withCredentials:this.withCredentials},function(e,t){if(n.request){if(i.resolvedUri=ju(n.handleManifestRedirects,i.resolvedUri,t),e)return n.playlistRequestError(n.request,i,r);n.haveMetadata({playlistString:t.responseText,url:i.uri,id:i.id}),"HAVE_MASTER"===r?n.trigger("loadedmetadata"):n.trigger("mediachange")}})}}},t.pause=function(){this.stopRequest(),T.clearTimeout(this.mediaUpdateTimeout),"HAVE_NOTHING"===this.state&&(this.started=!1),"SWITCHING_MEDIA"===this.state?this.media_?this.state="HAVE_METADATA":this.state="HAVE_MASTER":"HAVE_CURRENT_METADATA"===this.state&&(this.state="HAVE_METADATA")},t.load=function(e){var t=this;T.clearTimeout(this.mediaUpdateTimeout);var i=this.media();if(e){var n=i?i.targetDuration/2*1e3:5e3;this.mediaUpdateTimeout=T.setTimeout(function(){return t.load()},n)}else this.started?i&&!i.endList?this.trigger("mediaupdatetimeout"):this.trigger("loadedplaylist"):this.start()},t.start=function(){var n=this;if(this.started=!0,"object"==typeof this.src)return this.src.uri||(this.src.uri=T.location.href),this.src.resolvedUri=this.src.uri,void setTimeout(function(){n.setupInitialPlaylist(n.src)},0);this.request=this.vhs_.xhr({uri:this.src,withCredentials:this.withCredentials},function(e,t){if(n.request){if(n.request=null,e)return n.error={status:t.status,message:"HLS playlist request error at URL: "+n.src+".",responseText:t.responseText,code:2},"HAVE_NOTHING"===n.state&&(n.started=!1),n.trigger("error");n.src=ju(n.handleManifestRedirects,n.src,t);var i=qu({manifestString:t.responseText,customTagParsers:n.customTagParsers,customTagMappers:n.customTagMappers});n.setupInitialPlaylist(i)}})},t.srcUri=function(){return"string"==typeof this.src?this.src:this.src.uri},t.setupInitialPlaylist=function(e){if(this.state="HAVE_MASTER",e.playlists)return this.master=e,zu(this.master,this.srcUri()),e.playlists.forEach(function(t){t.segments&&t.segments.forEach(function(e){Gu(e,t.resolvedUri)})}),this.trigger("loadedplaylist"),void(this.request||this.media(this.master.playlists[0]));var t=this.srcUri()||T.location.href;this.master=function(e,t){var i=Vu(0,t),n={mediaGroups:{AUDIO:{},VIDEO:{},"CLOSED-CAPTIONS":{},SUBTITLES:{}},uri:T.location.href,resolvedUri:T.location.href,playlists:[{uri:t,id:i,resolvedUri:t,attributes:{}}]};return n.playlists[i]=n.playlists[0],n.playlists[t]=n.playlists[0],n}(0,t),this.haveMetadata({playlistObject:e,url:t,id:this.master.playlists[0].id}),this.trigger("loadedmetadata")},e}(da.EventTarget),ec=1/30,tc=da.createTimeRange,ic={duration:tl,seekable:function(e,t,i){var n=t||0,r=rl(e,t,!0,i);return null===r?tc():tc(n,r)},safeLiveIndex:nl,getMediaInfoForTime:function(e,t,i,n){var r,a=e.segments.length,s=t-n;if(s<0){if(0<i)for(r=i-1;0<=r;r--)if(0<(s+=e.segments[r].duration+ec))return{mediaIndex:r,startTime:n-il(e,i,r)};return{mediaIndex:0,startTime:t}}if(i<0){for(r=i;r<0;r++)if((s-=e.targetDuration)<0)return{mediaIndex:0,startTime:t};i=0}for(r=i;r<a;r++)if((s-=e.segments[r].duration+ec)<0)return{mediaIndex:r,startTime:n+il(e,i,r)};return{mediaIndex:a-1,startTime:t}},isEnabled:ol,isDisabled:function(e){return e.disabled},isBlacklisted:al,isIncompatible:sl,playlistEnd:rl,isAes:function(e){for(var t=0;t<e.segments.length;t++)if(e.segments[t].key)return!0;return!1},hasAttribute:ul,estimateSegmentRequestTime:function(e,t,i,n){return void 0===n&&(n=0),ul("BANDWIDTH",i)?(e*i.attributes.BANDWIDTH-8*n)/t:NaN},isLowestEnabledRendition:ll},nc=da.xhr,rc=da.mergeOptions,ac=Object.freeze({__proto__:null,createTransferableMessage:ml,initSegmentId:gl,segmentKeyId:vl,hexDump:yl,tagDump:function(e){var t=e.bytes;return yl(t)},textRanges:function(e){var t,i,n,r="";for(t=0;t<e.length;t++)r+=(n=t,(i=e).start(n)+"-"+i.end(n)+" ");return r}}),sc=da.mergeOptions,oc=function(c){function e(e,t,i,n){var r;void 0===i&&(i={}),r=c.call(this)||this;var a=i,s=a.withCredentials,o=void 0!==s&&s,u=a.handleManifestRedirects,l=void 0!==u&&u;if(r.vhs_=t,r.withCredentials=o,r.handleManifestRedirects=l,!e)throw new Error("A non-empty playlist URL or object is required");return r.on("minimumUpdatePeriod",function(){r.refreshXml_()}),r.on("mediaupdatetimeout",function(){r.refreshMedia_(r.media().id)}),r.state="HAVE_NOTHING",r.loadedPlaylists_={},"string"==typeof e?(r.srcUrl=e,r.sidxMapping_={},Ve(r)):(r.setupChildLoader(n,e),r)}Ge(e,c);var t=e.prototype;return t.setupChildLoader=function(e,t){this.masterPlaylistLoader_=e,this.childPlaylist_=t},t.dispose=function(){this.trigger("dispose"),this.stopRequest(),this.loadedPlaylists_={},T.clearTimeout(this.minimumUpdatePeriodTimeout_),T.clearTimeout(this.mediaRequest_),T.clearTimeout(this.mediaUpdateTimeout),this.off()},t.hasPendingRequest=function(){return this.request||this.mediaRequest_},t.stopRequest=function(){if(this.request){var e=this.request;this.request=null,e.onreadystatechange=null,e.abort()}},t.sidxRequestFinished_=function(r,a,s,o){var u=this;return function(e,t){if(u.request){if(u.request=null,e)return u.error="object"==typeof e?e:{status:t.status,message:"DASH playlist request error at URL: "+r.uri,response:t.response,code:2},s&&(u.state=s),void u.trigger("error");var i=Yo(t.response),n=jo(i.subarray(8));return o(a,n)}}},t.media=function(i){var n=this;if(!i)return this.media_;if("HAVE_NOTHING"===this.state)throw new Error("Cannot switch media playlist from "+this.state);var r=this.state;if("string"==typeof i){if(!this.master.playlists[i])throw new Error("Unknown playlist URI: "+i);i=this.master.playlists[i]}var e=!this.media_||i.id!==this.media_.id;if(e&&this.loadedPlaylists_[i.id]&&this.loadedPlaylists_[i.id].endList)return this.state="HAVE_METADATA",this.media_=i,void(e&&(this.trigger("mediachanging"),this.trigger("mediachange")));if(e)if(this.media_&&this.trigger("mediachanging"),i.sidx){var t,a;a=this.masterPlaylistLoader_?(t=this.masterPlaylistLoader_.master,this.masterPlaylistLoader_.sidxMapping_):(t=this.master,this.sidxMapping_);var s=Cl(i.sidx);a[s]={sidxInfo:i.sidx},this.request=wl(this,i.sidx,i,this.vhs_.xhr,{handleManifestRedirects:this.handleManifestRedirects},this.sidxRequestFinished_(i,t,r,function(e,t){if(!e||!t)throw new Error("failed to request sidx");a[s].sidx=t,n.haveMetadata({startingState:r,playlist:e.playlists[i.id]})}))}else this.mediaRequest_=T.setTimeout(this.haveMetadata.bind(this,{startingState:r,playlist:i}),0)},t.haveMetadata=function(e){var t=e.startingState,i=e.playlist;this.state="HAVE_METADATA",this.loadedPlaylists_[i.id]=i,this.mediaRequest_=null,this.refreshMedia_(i.id),"HAVE_MASTER"===t?this.trigger("loadedmetadata"):this.trigger("mediachange")},t.pause=function(){this.stopRequest(),T.clearTimeout(this.mediaUpdateTimeout),T.clearTimeout(this.minimumUpdatePeriodTimeout_),"HAVE_NOTHING"===this.state&&(this.started=!1)},t.load=function(e){var t=this;T.clearTimeout(this.mediaUpdateTimeout),T.clearTimeout(this.minimumUpdatePeriodTimeout_);var i=this.media();if(e){var n=i?i.targetDuration/2*1e3:5e3;this.mediaUpdateTimeout=T.setTimeout(function(){return t.load()},n)}else this.started?i&&!i.endList?this.trigger("mediaupdatetimeout"):this.trigger("loadedplaylist"):this.start()},t.start=function(){var i=this;this.started=!0,this.masterPlaylistLoader_?this.mediaRequest_=T.setTimeout(this.haveMaster_.bind(this),0):this.request=this.vhs_.xhr({uri:this.srcUrl,withCredentials:this.withCredentials},function(e,t){if(i.request){if(i.request=null,e)return i.error={status:t.status,message:"DASH playlist request error at URL: "+i.srcUrl,responseText:t.responseText,code:2},"HAVE_NOTHING"===i.state&&(i.started=!1),i.trigger("error");i.masterXml_=t.responseText,t.responseHeaders&&t.responseHeaders.date?i.masterLoaded_=Date.parse(t.responseHeaders.date):i.masterLoaded_=Date.now(),i.srcUrl=ju(i.handleManifestRedirects,i.srcUrl,t),i.syncClientServerClock_(i.onClientServerClockSync_.bind(i))}})},t.syncClientServerClock_=function(n){var r=this,a=wo(this.masterXml_);return null===a?(this.clientOffset_=this.masterLoaded_-Date.now(),n()):"DIRECT"===a.method?(this.clientOffset_=a.value-Date.now(),n()):void(this.request=this.vhs_.xhr({uri:$l(this.srcUrl,a.value),method:a.method,withCredentials:this.withCredentials},function(e,t){if(r.request){if(e)return r.clientOffset_=r.masterLoaded_-Date.now(),n();var i;i="HEAD"===a.method?t.responseHeaders&&t.responseHeaders.date?Date.parse(t.responseHeaders.date):r.masterLoaded_:Date.parse(t.responseText),r.clientOffset_=i-Date.now(),n()}}))},t.haveMaster_=function(){this.state="HAVE_MASTER",this.mediaRequest_=null,this.masterPlaylistLoader_?this.media_||this.media(this.childPlaylist_):(this.updateMainManifest_(Sl({masterXml:this.masterXml_,srcUrl:this.srcUrl,clientOffset:this.clientOffset_,sidxMapping:this.sidxMapping_})),this.trigger("loadedplaylist"))},t.updateMinimumUpdatePeriodTimeout_=function(){var t=this;T.clearTimeout(this.minimumUpdatePeriodTimeout_);function e(e){t.minimumUpdatePeriodTimeout_=T.setTimeout(function(){t.trigger("minimumUpdatePeriod")},e)}var i=this.master&&this.master.minimumUpdatePeriod;0<i?e(i):0===i&&(this.media()?e(1e3*this.media().targetDuration):this.one("loadedplaylist",function(){e(1e3*t.media().targetDuration)}))},t.onClientServerClockSync_=function(){this.haveMaster_(),this.hasPendingRequest()||this.media_||this.media(this.master.playlists[0]),this.updateMinimumUpdatePeriodTimeout_()},t.updateMainManifest_=function(e){if(this.master=e,this.master.locations&&this.master.locations.length){var t=this.master.locations[0];t!==this.srcUrl&&(this.srcUrl=t)}},t.refreshXml_=function(){var o=this;this.request=this.vhs_.xhr({uri:this.srcUrl,withCredentials:this.withCredentials},function(e,t){if(o.request){if(o.request=null,e)return o.error={status:t.status,message:"DASH playlist request error at URL: "+o.srcUrl,responseText:t.responseText,code:2},"HAVE_NOTHING"===o.state&&(o.started=!1),o.trigger("error");o.masterXml_=t.responseText,o.sidxMapping_=function(e,t,i,a){var n=Eo(e,{manifestUri:t,clientOffset:i}),s=El(n.playlists,a);return Hu(n,function(e,t,i,n){if(e.playlists&&e.playlists.length){var r=e.playlists;s=sc(s,El(r,a))}}),s}(o.masterXml_,o.srcUrl,o.clientOffset_,o.sidxMapping_);var i=Sl({masterXml:o.masterXml_,srcUrl:o.srcUrl,clientOffset:o.clientOffset_,sidxMapping:o.sidxMapping_}),n=kl(o.master,i),r=o.media().sidx;if(n)if(r){var a=Cl(r);if(!o.sidxMapping_[a]){var s=o.media();o.request=wl(o,s.sidx,s,o.vhs_.xhr,{handleManifestRedirects:o.handleManifestRedirects},o.sidxRequestFinished_(s,i,o.state,function(e,t){if(!e||!t)throw new Error("failed to request sidx on minimumUpdatePeriod");o.sidxMapping_[a].sidx=t,o.updateMinimumUpdatePeriodTimeout_(),o.refreshMedia_(o.media().id)}))}}else o.updateMainManifest_(n),o.media_&&(o.media_=o.master.playlists[o.media_.id]);o.updateMinimumUpdatePeriodTimeout_()}})},t.refreshMedia_=function(e){var t,i,n=this;if(!e)throw new Error("refreshMedia_ must take a media id");i=this.masterPlaylistLoader_?(t=this.masterPlaylistLoader_.master,Sl({masterXml:this.masterPlaylistLoader_.masterXml_,srcUrl:this.masterPlaylistLoader_.srcUrl,clientOffset:this.masterPlaylistLoader_.clientOffset_,sidxMapping:this.masterPlaylistLoader_.sidxMapping_})):(t=this.master,Sl({masterXml:this.masterXml_,srcUrl:this.srcUrl,clientOffset:this.clientOffset_,sidxMapping:this.sidxMapping_}));var r=kl(t,i);r?(this.masterPlaylistLoader_?this.masterPlaylistLoader_.master=r:this.master=r,this.media_=r.playlists[e]):(this.media_=t.playlists[e],this.trigger("playlistunchanged")),this.media().endList||(this.mediaUpdateTimeout=T.setTimeout(function(){n.trigger("mediaupdatetimeout")},Ku(this.media(),!!r))),this.trigger("loadedplaylist")},e}(da.EventTarget),uc={GOAL_BUFFER_LENGTH:30,MAX_GOAL_BUFFER_LENGTH:60,BACK_BUFFER_LENGTH:30,GOAL_BUFFER_LENGTH_RATE:1,INITIAL_BANDWIDTH:4194304,BANDWIDTH_VARIANCE:1.2,BUFFER_LOW_WATER_LINE:0,MAX_BUFFER_LOW_WATER_LINE:30,BUFFER_LOW_WATER_LINE_RATE:1},lc=[],cc=function(){Bl=null,lc.length&&("function"==typeof(Bl=lc.shift())?Bl():Al(Bl))},dc=function(e){Pl("reset",e)},hc=function(){Bl=null,lc.length=0},pc=function(e){Pl("endTimeline",e)},fc=2,mc=-101,gc=-102,vc="undefined"!=typeof window?window:{},yc="undefined"==typeof Symbol?"__target":Symbol(),_c="application/javascript",bc=vc.BlobBuilder||vc.WebKitBlobBuilder||vc.MozBlobBuilder||vc.MSBlobBuilder,Tc=vc.URL||vc.webkitURL||Tc&&Tc.msURL,Sc=vc.Worker;function kc(r,a){return function(e){var t=this;if(!a)return new Sc(r);if(Sc&&!e){var i=Ic(a.toString().replace(/^function.+?{/,"").slice(0,-1));return this[yc]=new Sc(i),function(e,t){if(!e||!t)return;var i=e.terminate;e.objURL=t,e.terminate=function(){e.objURL&&Tc.revokeObjectURL(e.objURL),i.call(e)}}(this[yc],i),this[yc]}var n={postMessage:function(e){t.onmessage&&setTimeout(function(){t.onmessage({data:e,target:n})})}};a.call(n),this.postMessage=function(e){setTimeout(function(){n.onmessage({data:e,target:t})})},this.isThisThread=!0}}if(Sc){var Cc,Ec=Ic("self.onmessage = function () {}"),wc=new Uint8Array(1);try{(Cc=new Sc(Ec)).postMessage(wc,[wc.buffer])}catch(e){Sc=null}finally{Tc.revokeObjectURL(Ec),Cc&&Cc.terminate()}}function Ic(t){try{return Tc.createObjectURL(new Blob([t],{type:_c}))}catch(e){var i=new bc;return i.append(t),Tc.createObjectURL(i.getBlob(type))}}function Ac(e,t){var i=t.attributes||{};return e&&e.mediaGroups&&e.mediaGroups.AUDIO&&i.AUDIO&&e.mediaGroups.AUDIO[i.AUDIO]}function xc(e,t){var i=t.attributes||{},n=function(e){var t=e.attributes||{};if(t.CODECS)return Kl(t.CODECS)}(t)||{};if(Ac(e,t)&&!n.audio&&!function(e,t){if(!Ac(e,t))return!0;var i=t.attributes||{},n=e.mediaGroups.AUDIO[i.AUDIO];for(var r in n)if(!n[r].uri&&!n[r].playlists)return!0;return!1}(e,t)){var r=Hl(e,i.AUDIO);r&&(n.audio=r.audio)}var a={};return n.video&&(a.video=Yl(""+n.video.type+n.video.details)),n.audio&&(a.audio=Yl(""+n.audio.type+n.audio.details)),a}function Pc(e){return da.log.debug?da.log.debug.bind(da,"VHS:",e+" >"):function(){}}function Lc(e){if(e&&e.playlist){var t=e.playlist;return JSON.stringify({id:t.id,bandwidth:e.bandwidth,width:e.width,height:e.height,codecs:t.attributes&&t.attributes.CODECS||""})}}function Oc(e,t){if(!e)return"";var i=T.getComputedStyle(e);return i?i[t]:""}function Dc(e,n){var r=e.slice();e.sort(function(e,t){var i=n(e,t);return 0===i?r.indexOf(e)-r.indexOf(t):i})}function Mc(e,t){var i,n;return e.attributes.BANDWIDTH&&(i=e.attributes.BANDWIDTH),i=i||T.Number.MAX_VALUE,t.attributes.BANDWIDTH&&(n=t.attributes.BANDWIDTH),i-(n=n||T.Number.MAX_VALUE)}function Rc(e){var t=e.inbandTextTracks,i=e.metadataArray,n=e.timestampOffset,r=e.videoDuration;if(i){var a=T.WebKitDataCue||T.VTTCue,s=t.metadataTrack_;if(s&&(i.forEach(function(e){var i=e.cueTime+n;!("number"!=typeof i||T.isNaN(i)||i<0)&&i<1/0&&e.frames.forEach(function(e){var t=new a(i,i,e.value||e.url||e.data||"");t.frame=e,t.value=e,function(e){Object.defineProperties(e.frame,{id:{get:function(){return da.log.warn("cue.frame.id is deprecated. Use cue.value.key instead."),e.value.key}},value:{get:function(){return da.log.warn("cue.frame.value is deprecated. Use cue.value.data instead."),e.value.data}},privateData:{get:function(){return da.log.warn("cue.frame.privateData is deprecated. Use cue.value.data instead."),e.value.data}}})}(t),s.addCue(t)})}),s.cues&&s.cues.length)){for(var o=s.cues,u=[],l=0;l<o.length;l++)o[l]&&u.push(o[l]);var c=u.reduce(function(e,t){var i=e[t.startTime]||[];return i.push(t),e[t.startTime]=i,e},{}),d=Object.keys(c).sort(function(e,t){return Number(e)-Number(t)});d.forEach(function(e,t){var i=c[e],n=Number(d[t+1])||r;i.forEach(function(e){e.endTime=n})})}}}function Nc(e,t,i){var n,r;if(i&&i.cues)for(n=i.cues.length;n--;)(r=i.cues[n]).startTime>=e&&r.endTime<=t&&i.removeCue(r)}function Uc(e){return"number"==typeof e&&isFinite(e)}function Fc(e){return e+"TimingInfo"}function Bc(e){var t=e.segmentTimeline,i=e.currentTimeline,n=e.startOfSegment,r=e.buffered;return e.overrideCheck||t!==i?r.length?r.end(r.length-1):n:null}function jc(e){var t=e.timelineChangeController,i=e.currentTimeline,n=e.segmentTimeline,r=e.loaderType,a=e.audioDisabled;if(i===n)return!1;if("audio"===r){var s=t.lastTimelineChange({type:"main"});return!s||s.to!==n}if("main"===r&&a){var o=t.pendingTimelineChange({type:"audio"});return!o||o.to!==n}return!1}var Vc=new kc("./transmuxer-worker.worker.js",function(e,t){var Zt=this; -/*! @name @videojs/http-streaming @version 2.2.4 @license Apache-2.0 */!function(){function e(){this.init=function(){var a={};this.on=function(e,t){a[e]||(a[e]=[]),a[e]=a[e].concat(t)},this.off=function(e,t){var i;return!!a[e]&&(i=a[e].indexOf(t),a[e]=a[e].slice(),a[e].splice(i,1),-1<i)},this.trigger=function(e){var t,i,n,r;if(t=a[e])if(2===arguments.length)for(n=t.length,i=0;i<n;++i)t[i].call(this,arguments[1]);else{for(r=[],i=arguments.length,i=1;i<arguments.length;++i)r.push(arguments[i]);for(n=t.length,i=0;i<n;++i)t[i].apply(this,r)}},this.dispose=function(){a={}}}}e.prototype.pipe=function(t){return this.on("data",function(e){t.push(e)}),this.on("done",function(e){t.flush(e)}),this.on("partialdone",function(e){t.partialFlush(e)}),this.on("endedtimeline",function(e){t.endTimeline(e)}),this.on("reset",function(e){t.reset(e)}),t},e.prototype.push=function(e){this.trigger("data",e)},e.prototype.flush=function(e){this.trigger("done",e)},e.prototype.partialFlush=function(e){this.trigger("partialdone",e)},e.prototype.endTimeline=function(e){this.trigger("endedtimeline",e)},e.prototype.reset=function(e){this.trigger("reset",e)};var l,t,i,r,a,n,s,o,u,c,d,h,p,f,m,g,v,y,_,b,T,S,k,C,E,w,I,A,x,P,L,O,D,M,R,N,U,F,B,j,V=e,q=Math.pow(2,32)-1;!function(){var e;if(S={avc1:[],avcC:[],btrt:[],dinf:[],dref:[],esds:[],ftyp:[],hdlr:[],mdat:[],mdhd:[],mdia:[],mfhd:[],minf:[],moof:[],moov:[],mp4a:[],mvex:[],mvhd:[],pasp:[],sdtp:[],smhd:[],stbl:[],stco:[],stsc:[],stsd:[],stsz:[],stts:[],styp:[],tfdt:[],tfhd:[],traf:[],trak:[],trun:[],trex:[],tkhd:[],vmhd:[]},"undefined"!=typeof Uint8Array){for(e in S)S.hasOwnProperty(e)&&(S[e]=[e.charCodeAt(0),e.charCodeAt(1),e.charCodeAt(2),e.charCodeAt(3)]);k=new Uint8Array(["i".charCodeAt(0),"s".charCodeAt(0),"o".charCodeAt(0),"m".charCodeAt(0)]),E=new Uint8Array(["a".charCodeAt(0),"v".charCodeAt(0),"c".charCodeAt(0),"1".charCodeAt(0)]),C=new Uint8Array([0,0,0,1]),w=new Uint8Array([0,0,0,0,0,0,0,0,118,105,100,101,0,0,0,0,0,0,0,0,0,0,0,0,86,105,100,101,111,72,97,110,100,108,101,114,0]),I=new Uint8Array([0,0,0,0,0,0,0,0,115,111,117,110,0,0,0,0,0,0,0,0,0,0,0,0,83,111,117,110,100,72,97,110,100,108,101,114,0]),A={video:w,audio:I},L=new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,12,117,114,108,32,0,0,0,1]),P=new Uint8Array([0,0,0,0,0,0,0,0]),O=new Uint8Array([0,0,0,0,0,0,0,0]),D=O,M=new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0]),R=O,x=new Uint8Array([0,0,0,1,0,0,0,0,0,0,0,0])}}(),l=function(e){var t,i,n=[],r=0;for(t=1;t<arguments.length;t++)n.push(arguments[t]);for(t=n.length;t--;)r+=n[t].byteLength;for(i=new Uint8Array(r+8),new DataView(i.buffer,i.byteOffset,i.byteLength).setUint32(0,i.byteLength),i.set(e,4),t=0,r=8;t<n.length;t++)i.set(n[t],r),r+=n[t].byteLength;return i},t=function(){return l(S.dinf,l(S.dref,L))},i=function(e){return l(S.esds,new Uint8Array([0,0,0,0,3,25,0,0,0,4,17,64,21,0,6,0,0,0,218,192,0,0,218,192,5,2,e.audioobjecttype<<3|e.samplingfrequencyindex>>>1,e.samplingfrequencyindex<<7|e.channelcount<<3,6,1,2]))},m=function(e){return l(S.hdlr,A[e])},f=function(e){var t=new Uint8Array([0,0,0,0,0,0,0,2,0,0,0,3,0,1,95,144,e.duration>>>24&255,e.duration>>>16&255,e.duration>>>8&255,255&e.duration,85,196,0,0]);return e.samplerate&&(t[12]=e.samplerate>>>24&255,t[13]=e.samplerate>>>16&255,t[14]=e.samplerate>>>8&255,t[15]=255&e.samplerate),l(S.mdhd,t)},p=function(e){return l(S.mdia,f(e),m(e.type),n(e))},a=function(e){return l(S.mfhd,new Uint8Array([0,0,0,0,(4278190080&e)>>24,(16711680&e)>>16,(65280&e)>>8,255&e]))},n=function(e){return l(S.minf,"video"===e.type?l(S.vmhd,x):l(S.smhd,P),t(),v(e))},s=function(e,t){for(var i=[],n=t.length;n--;)i[n]=_(t[n]);return l.apply(null,[S.moof,a(e)].concat(i))},o=function(e){for(var t=e.length,i=[];t--;)i[t]=d(e[t]);return l.apply(null,[S.moov,c(4294967295)].concat(i).concat(u(e)))},u=function(e){for(var t=e.length,i=[];t--;)i[t]=b(e[t]);return l.apply(null,[S.mvex].concat(i))},c=function(e){var t=new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,2,0,1,95,144,(4278190080&e)>>24,(16711680&e)>>16,(65280&e)>>8,255&e,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255]);return l(S.mvhd,t)},g=function(e){var t,i,n=e.samples||[],r=new Uint8Array(4+n.length);for(i=0;i<n.length;i++)t=n[i].flags,r[i+4]=t.dependsOn<<4|t.isDependedOn<<2|t.hasRedundancy;return l(S.sdtp,r)},v=function(e){return l(S.stbl,y(e),l(S.stts,R),l(S.stsc,D),l(S.stsz,M),l(S.stco,O))},y=function(e){return l(S.stsd,new Uint8Array([0,0,0,0,0,0,0,1]),"video"===e.type?N(e):U(e))},N=function(e){var t,i,n=e.sps||[],r=e.pps||[],a=[],s=[];for(t=0;t<n.length;t++)a.push((65280&n[t].byteLength)>>>8),a.push(255&n[t].byteLength),a=a.concat(Array.prototype.slice.call(n[t]));for(t=0;t<r.length;t++)s.push((65280&r[t].byteLength)>>>8),s.push(255&r[t].byteLength),s=s.concat(Array.prototype.slice.call(r[t]));if(i=[S.avc1,new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,(65280&e.width)>>8,255&e.width,(65280&e.height)>>8,255&e.height,0,72,0,0,0,72,0,0,0,0,0,0,0,1,19,118,105,100,101,111,106,115,45,99,111,110,116,114,105,98,45,104,108,115,0,0,0,0,0,0,0,0,0,0,0,0,0,24,17,17]),l(S.avcC,new Uint8Array([1,e.profileIdc,e.profileCompatibility,e.levelIdc,255].concat([n.length],a,[r.length],s))),l(S.btrt,new Uint8Array([0,28,156,128,0,45,198,192,0,45,198,192]))],e.sarRatio){var o=e.sarRatio[0],u=e.sarRatio[1];i.push(l(S.pasp,new Uint8Array([(4278190080&o)>>24,(16711680&o)>>16,(65280&o)>>8,255&o,(4278190080&u)>>24,(16711680&u)>>16,(65280&u)>>8,255&u])))}return l.apply(null,i)},U=function(e){return l(S.mp4a,new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,(65280&e.channelcount)>>8,255&e.channelcount,(65280&e.samplesize)>>8,255&e.samplesize,0,0,0,0,(65280&e.samplerate)>>8,255&e.samplerate,0,0]),i(e))},h=function(e){var t=new Uint8Array([0,0,0,7,0,0,0,0,0,0,0,0,(4278190080&e.id)>>24,(16711680&e.id)>>16,(65280&e.id)>>8,255&e.id,0,0,0,0,(4278190080&e.duration)>>24,(16711680&e.duration)>>16,(65280&e.duration)>>8,255&e.duration,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,(65280&e.width)>>8,255&e.width,0,0,(65280&e.height)>>8,255&e.height,0,0]);return l(S.tkhd,t)},_=function(e){var t,i,n,r,a,s;return t=l(S.tfhd,new Uint8Array([0,0,0,58,(4278190080&e.id)>>24,(16711680&e.id)>>16,(65280&e.id)>>8,255&e.id,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0])),a=Math.floor(e.baseMediaDecodeTime/(1+q)),s=Math.floor(e.baseMediaDecodeTime%(1+q)),i=l(S.tfdt,new Uint8Array([1,0,0,0,a>>>24&255,a>>>16&255,a>>>8&255,255&a,s>>>24&255,s>>>16&255,s>>>8&255,255&s])),92,"audio"===e.type?(n=T(e,92),l(S.traf,t,i,n)):(r=g(e),n=T(e,r.length+92),l(S.traf,t,i,n,r))},d=function(e){return e.duration=e.duration||4294967295,l(S.trak,h(e),p(e))},b=function(e){var t=new Uint8Array([0,0,0,0,(4278190080&e.id)>>24,(16711680&e.id)>>16,(65280&e.id)>>8,255&e.id,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,1]);return"video"!==e.type&&(t[t.length-1]=0),l(S.trex,t)},j=function(e,t){var i=0,n=0,r=0,a=0;return e.length&&(void 0!==e[0].duration&&(i=1),void 0!==e[0].size&&(n=2),void 0!==e[0].flags&&(r=4),void 0!==e[0].compositionTimeOffset&&(a=8)),[0,0,i|n|r|a,1,(4278190080&e.length)>>>24,(16711680&e.length)>>>16,(65280&e.length)>>>8,255&e.length,(4278190080&t)>>>24,(16711680&t)>>>16,(65280&t)>>>8,255&t]},B=function(e,t){var i,n,r,a,s,o;for(t+=20+16*(a=e.samples||[]).length,r=j(a,t),(n=new Uint8Array(r.length+16*a.length)).set(r),i=r.length,o=0;o<a.length;o++)s=a[o],n[i++]=(4278190080&s.duration)>>>24,n[i++]=(16711680&s.duration)>>>16,n[i++]=(65280&s.duration)>>>8,n[i++]=255&s.duration,n[i++]=(4278190080&s.size)>>>24,n[i++]=(16711680&s.size)>>>16,n[i++]=(65280&s.size)>>>8,n[i++]=255&s.size,n[i++]=s.flags.isLeading<<2|s.flags.dependsOn,n[i++]=s.flags.isDependedOn<<6|s.flags.hasRedundancy<<4|s.flags.paddingValue<<1|s.flags.isNonSyncSample,n[i++]=61440&s.flags.degradationPriority,n[i++]=15&s.flags.degradationPriority,n[i++]=(4278190080&s.compositionTimeOffset)>>>24,n[i++]=(16711680&s.compositionTimeOffset)>>>16,n[i++]=(65280&s.compositionTimeOffset)>>>8,n[i++]=255&s.compositionTimeOffset;return l(S.trun,n)},F=function(e,t){var i,n,r,a,s,o;for(t+=20+8*(a=e.samples||[]).length,r=j(a,t),(i=new Uint8Array(r.length+8*a.length)).set(r),n=r.length,o=0;o<a.length;o++)s=a[o],i[n++]=(4278190080&s.duration)>>>24,i[n++]=(16711680&s.duration)>>>16,i[n++]=(65280&s.duration)>>>8,i[n++]=255&s.duration,i[n++]=(4278190080&s.size)>>>24,i[n++]=(16711680&s.size)>>>16,i[n++]=(65280&s.size)>>>8,i[n++]=255&s.size;return l(S.trun,i)},T=function(e,t){return"audio"===e.type?F(e,t):B(e,t)};r=function(){return l(S.ftyp,k,C,k,E)};function H(e,t){var i={size:0,flags:{isLeading:0,dependsOn:1,isDependedOn:0,hasRedundancy:0,degradationPriority:0,isNonSyncSample:1}};return i.dataOffset=t,i.compositionTimeOffset=e.pts-e.dts,i.duration=e.duration,i.size=4*e.length,i.size+=e.byteLength,e.keyFrame&&(i.flags.dependsOn=2,i.flags.isNonSyncSample=0),i}function W(e){for(var t=[];e--;)t.push(0);return t}function z(){if(!X){var e={96e3:[ue,[227,64],W(154),[56]],88200:[ue,[231],W(170),[56]],64e3:[ue,[248,192],W(240),[56]],48e3:[ue,[255,192],W(268),[55,148,128],W(54),[112]],44100:[ue,[255,192],W(268),[55,163,128],W(84),[112]],32e3:[ue,[255,192],W(268),[55,234],W(226),[112]],24e3:[ue,[255,192],W(268),[55,255,128],W(268),[111,112],W(126),[224]],16e3:[ue,[255,192],W(268),[55,255,128],W(268),[111,255],W(269),[223,108],W(195),[1,192]],12e3:[le,W(268),[3,127,248],W(268),[6,255,240],W(268),[13,255,224],W(268),[27,253,128],W(259),[56]],11025:[le,W(268),[3,127,248],W(268),[6,255,240],W(268),[13,255,224],W(268),[27,255,192],W(268),[55,175,128],W(108),[112]],8e3:[le,W(268),[3,121,16],W(47),[7]]};X=function(i){return Object.keys(i).reduce(function(e,t){return e[t]=new Uint8Array(i[t].reduce(function(e,t){return e.concat(t)},[])),e},{})}(e)}return X}function G(){G.prototype.init.call(this),this.captionPackets_=[],this.ccStreams_=[new Me(0,0),new Me(0,1),new Me(1,0),new Me(1,1)],this.reset(),this.ccStreams_.forEach(function(e){e.on("data",this.trigger.bind(this,"data")),e.on("partialdone",this.trigger.bind(this,"partialdone")),e.on("done",this.trigger.bind(this,"done"))},this)}var X,K,Y,$,Q,J=function(e){return l(S.mdat,e)},Z=s,ee=function(e){var t,i=r(),n=o(e);return(t=new Uint8Array(i.byteLength+n.byteLength)).set(i),t.set(n,i.byteLength),t},te=function(e){var t,i,n=[],r=[];for(r.byteLength=0,r.nalCount=0,r.duration=0,t=n.byteLength=0;t<e.length;t++)"access_unit_delimiter_rbsp"===(i=e[t]).nalUnitType?(n.length&&(n.duration=i.dts-n.dts,r.byteLength+=n.byteLength,r.nalCount+=n.length,r.duration+=n.duration,r.push(n)),(n=[i]).byteLength=i.data.byteLength,n.pts=i.pts,n.dts=i.dts):("slice_layer_without_partitioning_rbsp_idr"===i.nalUnitType&&(n.keyFrame=!0),n.duration=i.dts-n.dts,n.byteLength+=i.data.byteLength,n.push(i));return r.length&&(!n.duration||n.duration<=0)&&(n.duration=r[r.length-1].duration),r.byteLength+=n.byteLength,r.nalCount+=n.length,r.duration+=n.duration,r.push(n),r},ie=function(e){var t,i,n=[],r=[];for(n.byteLength=0,n.nalCount=0,n.duration=0,n.pts=e[0].pts,n.dts=e[0].dts,r.byteLength=0,r.nalCount=0,r.duration=0,r.pts=e[0].pts,r.dts=e[0].dts,t=0;t<e.length;t++)(i=e[t]).keyFrame?(n.length&&(r.push(n),r.byteLength+=n.byteLength,r.nalCount+=n.nalCount,r.duration+=n.duration),(n=[i]).nalCount=i.length,n.byteLength=i.byteLength,n.pts=i.pts,n.dts=i.dts,n.duration=i.duration):(n.duration+=i.duration,n.nalCount+=i.length,n.byteLength+=i.byteLength,n.push(i));return r.length&&n.duration<=0&&(n.duration=r[r.length-1].duration),r.byteLength+=n.byteLength,r.nalCount+=n.nalCount,r.duration+=n.duration,r.push(n),r},ne=function(e){var t;return!e[0][0].keyFrame&&1<e.length&&(t=e.shift(),e.byteLength-=t.byteLength,e.nalCount-=t.nalCount,e[0][0].dts=t.dts,e[0][0].pts=t.pts,e[0][0].duration+=t.duration),e},re=function(e,t){var i,n,r,a,s,o=t||0,u=[];for(i=0;i<e.length;i++)for(a=e[i],n=0;n<a.length;n++)s=a[n],o+=(r=H(s,o)).size,u.push(r);return u},ae=function(e){var t,i,n,r,a,s,o=0,u=e.byteLength,l=e.nalCount,c=new Uint8Array(u+4*l),d=new DataView(c.buffer);for(t=0;t<e.length;t++)for(r=e[t],i=0;i<r.length;i++)for(a=r[i],n=0;n<a.length;n++)s=a[n],d.setUint32(o,s.data.byteLength),o+=4,c.set(s.data,o),o+=s.data.byteLength;return c},se=function(e,t){var i,n=[];return i=H(e,t||0),n.push(i),n},oe=function(e){var t,i,n=0,r=e.byteLength,a=e.length,s=new Uint8Array(r+4*a),o=new DataView(s.buffer);for(t=0;t<e.length;t++)i=e[t],o.setUint32(n,i.data.byteLength),n+=4,s.set(i.data,n),n+=i.data.byteLength;return s},ue=[33,16,5,32,164,27],le=[33,65,108,84,1,2,4,8,168,2,4,8,17,191,252],ce=9e4,de=(Y=function(e,t){return e*t},$=function(e){return e/9e4}),he=(Q=function(e,t){return e/t},function(e,t){return K(Q(e,t))}),pe=function(e,t){return Y($(e),t)},fe=function(e,t,i){return $(i?e:e-t)},me=K=function(e){return 9e4*e},ge=de,ve=function(e,t,i,n){var r,a,s,o,u,l=0,c=0,d=0;if(t.length&&(r=he(e.baseMediaDecodeTime,e.samplerate),a=Math.ceil(ce/(e.samplerate/1024)),i&&n&&(l=r-Math.max(i,n),d=(c=Math.floor(l/a))*a),!(c<1||ce/2<d))){for(s=(s=z()[e.samplerate])||t[0].data,o=0;o<c;o++)u=t[0],t.splice(0,0,{data:s,dts:u.dts-a,pts:u.pts-a});e.baseMediaDecodeTime-=Math.floor(pe(d,e.samplerate))}},ye=function(e,t,i){return t.minSegmentDts>=i?e:(t.minSegmentDts=1/0,e.filter(function(e){return e.dts>=i&&(t.minSegmentDts=Math.min(t.minSegmentDts,e.dts),t.minSegmentPts=t.minSegmentDts,!0)}))},_e=function(e){var t,i,n=[];for(t=0;t<e.length;t++)i=e[t],n.push({size:i.data.byteLength,duration:1024});return n},be=function(e){var t,i,n=0,r=new Uint8Array(function(e){var t,i=0;for(t=0;t<e.length;t++)i+=e[t].data.byteLength;return i}(e));for(t=0;t<e.length;t++)i=e[t],r.set(i.data,n),n+=i.data.byteLength;return r},Te=ce,Se=function(e){delete e.minSegmentDts,delete e.maxSegmentDts,delete e.minSegmentPts,delete e.maxSegmentPts},ke=function(e,t){var i,n=e.minSegmentDts;return t||(n-=e.timelineStartInfo.dts),i=e.timelineStartInfo.baseMediaDecodeTime,i+=n,i=Math.max(0,i),"audio"===e.type&&(i*=e.samplerate/Te,i=Math.floor(i)),i},Ce=function(e,t){"number"==typeof t.pts&&(void 0===e.timelineStartInfo.pts&&(e.timelineStartInfo.pts=t.pts),void 0===e.minSegmentPts?e.minSegmentPts=t.pts:e.minSegmentPts=Math.min(e.minSegmentPts,t.pts),void 0===e.maxSegmentPts?e.maxSegmentPts=t.pts:e.maxSegmentPts=Math.max(e.maxSegmentPts,t.pts)),"number"==typeof t.dts&&(void 0===e.timelineStartInfo.dts&&(e.timelineStartInfo.dts=t.dts),void 0===e.minSegmentDts?e.minSegmentDts=t.dts:e.minSegmentDts=Math.min(e.minSegmentDts,t.dts),void 0===e.maxSegmentDts?e.maxSegmentDts=t.dts:e.maxSegmentDts=Math.max(e.maxSegmentDts,t.dts))},Ee=function(e){for(var t=0,i={payloadType:-1,payloadSize:0},n=0,r=0;t<e.byteLength&&128!==e[t];){for(;255===e[t];)n+=255,t++;for(n+=e[t++];255===e[t];)r+=255,t++;if(r+=e[t++],!i.payload&&4===n){if("GA94"===String.fromCharCode(e[t+3],e[t+4],e[t+5],e[t+6])){i.payloadType=n,i.payloadSize=r,i.payload=e.subarray(t,t+r);break}i.payload=void 0}t+=r,r=n=0}return i},we=function(e){return 181!==e.payload[0]?null:49!=(e.payload[1]<<8|e.payload[2])?null:"GA94"!==String.fromCharCode(e.payload[3],e.payload[4],e.payload[5],e.payload[6])?null:3!==e.payload[7]?null:e.payload.subarray(8,e.payload.length-1)},Ie=function(e,t){var i,n,r,a,s=[];if(!(64&t[0]))return s;for(n=31&t[0],i=0;i<n;i++)a={type:3&t[2+(r=3*i)],pts:e},4&t[2+r]&&(a.ccData=t[3+r]<<8|t[4+r],s.push(a));return s},Ae=function(e){for(var t,i,n=e.byteLength,r=[],a=1;a<n-2;)0===e[a]&&0===e[a+1]&&3===e[a+2]?(r.push(a+2),a+=2):a++;if(0===r.length)return e;t=n-r.length,i=new Uint8Array(t);var s=0;for(a=0;a<t;s++,a++)s===r[0]&&(s++,r.shift()),i[a]=e[s];return i},xe=4;(G.prototype=new V).push=function(e){var t,i,n;if("sei_rbsp"===e.nalUnitType&&(t=Ee(e.escapedRBSP)).payloadType===xe&&(i=we(t)))if(e.dts<this.latestDts_)this.ignoreNextEqualDts_=!0;else{if(e.dts===this.latestDts_&&this.ignoreNextEqualDts_)return this.numSameDts_--,void(this.numSameDts_||(this.ignoreNextEqualDts_=!1));n=Ie(e.pts,i),this.captionPackets_=this.captionPackets_.concat(n),this.latestDts_!==e.dts&&(this.numSameDts_=0),this.numSameDts_++,this.latestDts_=e.dts}},G.prototype.flushCCStreams=function(t){this.ccStreams_.forEach(function(e){return"flush"===t?e.flush():e.partialFlush()},this)},G.prototype.flushStream=function(e){this.captionPackets_.length&&(this.captionPackets_.forEach(function(e,t){e.presortIndex=t}),this.captionPackets_.sort(function(e,t){return e.pts===t.pts?e.presortIndex-t.presortIndex:e.pts-t.pts}),this.captionPackets_.forEach(function(e){e.type<2&&this.dispatchCea608Packet(e)},this),this.captionPackets_.length=0),this.flushCCStreams(e)},G.prototype.flush=function(){return this.flushStream("flush")},G.prototype.partialFlush=function(){return this.flushStream("partialFlush")},G.prototype.reset=function(){this.latestDts_=null,this.ignoreNextEqualDts_=!1,this.numSameDts_=0,this.activeCea608Channel_=[null,null],this.ccStreams_.forEach(function(e){e.reset()})},G.prototype.dispatchCea608Packet=function(e){this.setsTextOrXDSActive(e)?this.activeCea608Channel_[e.type]=null:this.setsChannel1Active(e)?this.activeCea608Channel_[e.type]=0:this.setsChannel2Active(e)&&(this.activeCea608Channel_[e.type]=1),null!==this.activeCea608Channel_[e.type]&&this.ccStreams_[(e.type<<1)+this.activeCea608Channel_[e.type]].push(e)},G.prototype.setsChannel1Active=function(e){return 4096==(30720&e.ccData)},G.prototype.setsChannel2Active=function(e){return 6144==(30720&e.ccData)},G.prototype.setsTextOrXDSActive=function(e){return 256==(28928&e.ccData)||4138==(30974&e.ccData)||6186==(30974&e.ccData)};function Pe(e){return null===e?"":(e=Oe[e]||e,String.fromCharCode(e))}function Le(){for(var e=[],t=15;t--;)e.push("");return e}var Oe={42:225,92:233,94:237,95:243,96:250,123:231,124:247,125:209,126:241,127:9608,304:174,305:176,306:189,307:191,308:8482,309:162,310:163,311:9834,312:224,313:160,314:232,315:226,316:234,317:238,318:244,319:251,544:193,545:201,546:211,547:218,548:220,549:252,550:8216,551:161,552:42,553:39,554:8212,555:169,556:8480,557:8226,558:8220,559:8221,560:192,561:194,562:199,563:200,564:202,565:203,566:235,567:206,568:207,569:239,570:212,571:217,572:249,573:219,574:171,575:187,800:195,801:227,802:205,803:204,804:236,805:210,806:242,807:213,808:245,809:123,810:125,811:92,812:94,813:95,814:124,815:126,816:196,817:228,818:214,819:246,820:223,821:165,822:164,823:9474,824:197,825:229,826:216,827:248,828:9484,829:9488,830:9492,831:9496},De=[4352,4384,4608,4640,5376,5408,5632,5664,5888,5920,4096,4864,4896,5120,5152],Me=function e(t,i){e.prototype.init.call(this),this.field_=t||0,this.dataChannel_=i||0,this.name_="CC"+(1+(this.field_<<1|this.dataChannel_)),this.setConstants(),this.reset(),this.push=function(e){var t,i,n,r,a;if((t=32639&e.ccData)!==this.lastControlCode_){if(4096==(61440&t)?this.lastControlCode_=t:t!==this.PADDING_&&(this.lastControlCode_=null),n=t>>>8,r=255&t,t!==this.PADDING_)if(t===this.RESUME_CAPTION_LOADING_)this.mode_="popOn";else if(t===this.END_OF_CAPTION_)this.mode_="popOn",this.clearFormatting(e.pts),this.flushDisplayed(e.pts),i=this.displayed_,this.displayed_=this.nonDisplayed_,this.nonDisplayed_=i,this.startPts_=e.pts;else if(t===this.ROLL_UP_2_ROWS_)this.rollUpRows_=2,this.setRollUp(e.pts);else if(t===this.ROLL_UP_3_ROWS_)this.rollUpRows_=3,this.setRollUp(e.pts);else if(t===this.ROLL_UP_4_ROWS_)this.rollUpRows_=4,this.setRollUp(e.pts);else if(t===this.CARRIAGE_RETURN_)this.clearFormatting(e.pts),this.flushDisplayed(e.pts),this.shiftRowsUp_(),this.startPts_=e.pts;else if(t===this.BACKSPACE_)"popOn"===this.mode_?this.nonDisplayed_[this.row_]=this.nonDisplayed_[this.row_].slice(0,-1):this.displayed_[this.row_]=this.displayed_[this.row_].slice(0,-1);else if(t===this.ERASE_DISPLAYED_MEMORY_)this.flushDisplayed(e.pts),this.displayed_=Le();else if(t===this.ERASE_NON_DISPLAYED_MEMORY_)this.nonDisplayed_=Le();else if(t===this.RESUME_DIRECT_CAPTIONING_)"paintOn"!==this.mode_&&(this.flushDisplayed(e.pts),this.displayed_=Le()),this.mode_="paintOn",this.startPts_=e.pts;else if(this.isSpecialCharacter(n,r))a=Pe((n=(3&n)<<8)|r),this[this.mode_](e.pts,a),this.column_++;else if(this.isExtCharacter(n,r))"popOn"===this.mode_?this.nonDisplayed_[this.row_]=this.nonDisplayed_[this.row_].slice(0,-1):this.displayed_[this.row_]=this.displayed_[this.row_].slice(0,-1),a=Pe((n=(3&n)<<8)|r),this[this.mode_](e.pts,a),this.column_++;else if(this.isMidRowCode(n,r))this.clearFormatting(e.pts),this[this.mode_](e.pts," "),this.column_++,14==(14&r)&&this.addFormatting(e.pts,["i"]),1==(1&r)&&this.addFormatting(e.pts,["u"]);else if(this.isOffsetControlCode(n,r))this.column_+=3&r;else if(this.isPAC(n,r)){var s=De.indexOf(7968&t);"rollUp"===this.mode_&&(s-this.rollUpRows_+1<0&&(s=this.rollUpRows_-1),this.setRollUp(e.pts,s)),s!==this.row_&&(this.clearFormatting(e.pts),this.row_=s),1&r&&-1===this.formatting_.indexOf("u")&&this.addFormatting(e.pts,["u"]),16==(16&t)&&(this.column_=4*((14&t)>>1)),this.isColorPAC(r)&&14==(14&r)&&this.addFormatting(e.pts,["i"])}else this.isNormalChar(n)&&(0===r&&(r=null),a=Pe(n),a+=Pe(r),this[this.mode_](e.pts,a),this.column_+=a.length)}else this.lastControlCode_=null}};Me.prototype=new V,Me.prototype.flushDisplayed=function(e){var t=this.displayed_.map(function(e){try{return e.trim()}catch(e){return""}}).join("\n").replace(/^\n+|\n+$/g,"");t.length&&this.trigger("data",{startPts:this.startPts_,endPts:e,text:t,stream:this.name_})},Me.prototype.reset=function(){this.mode_="popOn",this.topRow_=0,this.startPts_=0,this.displayed_=Le(),this.nonDisplayed_=Le(),this.lastControlCode_=null,this.column_=0,this.row_=14,this.rollUpRows_=2,this.formatting_=[]},Me.prototype.setConstants=function(){0===this.dataChannel_?(this.BASE_=16,this.EXT_=17,this.CONTROL_=(20|this.field_)<<8,this.OFFSET_=23):1===this.dataChannel_&&(this.BASE_=24,this.EXT_=25,this.CONTROL_=(28|this.field_)<<8,this.OFFSET_=31),this.PADDING_=0,this.RESUME_CAPTION_LOADING_=32|this.CONTROL_,this.END_OF_CAPTION_=47|this.CONTROL_,this.ROLL_UP_2_ROWS_=37|this.CONTROL_,this.ROLL_UP_3_ROWS_=38|this.CONTROL_,this.ROLL_UP_4_ROWS_=39|this.CONTROL_,this.CARRIAGE_RETURN_=45|this.CONTROL_,this.RESUME_DIRECT_CAPTIONING_=41|this.CONTROL_,this.BACKSPACE_=33|this.CONTROL_,this.ERASE_DISPLAYED_MEMORY_=44|this.CONTROL_,this.ERASE_NON_DISPLAYED_MEMORY_=46|this.CONTROL_},Me.prototype.isSpecialCharacter=function(e,t){return e===this.EXT_&&48<=t&&t<=63},Me.prototype.isExtCharacter=function(e,t){return(e===this.EXT_+1||e===this.EXT_+2)&&32<=t&&t<=63},Me.prototype.isMidRowCode=function(e,t){return e===this.EXT_&&32<=t&&t<=47},Me.prototype.isOffsetControlCode=function(e,t){return e===this.OFFSET_&&33<=t&&t<=35},Me.prototype.isPAC=function(e,t){return e>=this.BASE_&&e<this.BASE_+8&&64<=t&&t<=127},Me.prototype.isColorPAC=function(e){return 64<=e&&e<=79||96<=e&&e<=127},Me.prototype.isNormalChar=function(e){return 32<=e&&e<=127},Me.prototype.setRollUp=function(e,t){if("rollUp"!==this.mode_&&(this.row_=14,this.mode_="rollUp",this.flushDisplayed(e),this.nonDisplayed_=Le(),this.displayed_=Le()),void 0!==t&&t!==this.row_)for(var i=0;i<this.rollUpRows_;i++)this.displayed_[t-i]=this.displayed_[this.row_-i],this.displayed_[this.row_-i]="";void 0===t&&(t=this.row_),this.topRow_=t-this.rollUpRows_+1},Me.prototype.addFormatting=function(e,t){this.formatting_=this.formatting_.concat(t);var i=t.reduce(function(e,t){return e+"<"+t+">"},"");this[this.mode_](e,i)},Me.prototype.clearFormatting=function(e){if(this.formatting_.length){var t=this.formatting_.reverse().reduce(function(e,t){return e+"</"+t+">"},"");this.formatting_=[],this[this.mode_](e,t)}},Me.prototype.popOn=function(e,t){var i=this.nonDisplayed_[this.row_];i+=t,this.nonDisplayed_[this.row_]=i},Me.prototype.rollUp=function(e,t){var i=this.displayed_[this.row_];i+=t,this.displayed_[this.row_]=i},Me.prototype.shiftRowsUp_=function(){var e;for(e=0;e<this.topRow_;e++)this.displayed_[e]="";for(e=this.row_+1;e<15;e++)this.displayed_[e]="";for(e=this.topRow_;e<this.row_;e++)this.displayed_[e]=this.displayed_[e+1];this.displayed_[this.row_]=""},Me.prototype.paintOn=function(e,t){var i=this.displayed_[this.row_];i+=t,this.displayed_[this.row_]=i};function Re(e,t){var i=1;for(t<e&&(i=-1);4294967296<Math.abs(t-e);)e+=8589934592*i;return e}function Ne(e){var t,i;Ne.prototype.init.call(this),this.type_=e||"shared",this.push=function(e){"shared"!==this.type_&&e.type!==this.type_||(void 0===i&&(i=e.dts),e.dts=Re(e.dts,i),e.pts=Re(e.pts,i),t=e.dts,this.trigger("data",e))},this.flush=function(){i=t,this.trigger("done")},this.endTimeline=function(){this.flush(),this.trigger("endedtimeline")},this.discontinuity=function(){t=i=void 0},this.reset=function(){this.discontinuity(),this.trigger("reset")}}var Ue={CaptionStream:G,Cea608Stream:Me},Fe={H264_STREAM_TYPE:27,ADTS_STREAM_TYPE:15,METADATA_STREAM_TYPE:21};Ne.prototype=new V;function Be(e,t,i){var n,r="";for(n=t;n<i;n++)r+="%"+("00"+e[n].toString(16)).slice(-2);return r}function je(e,t,i){return decodeURIComponent(Be(e,t,i))}function Ve(e){return e[0]<<21|e[1]<<14|e[2]<<7|e[3]}var qe,He=Ne,We={TXXX:function(e){var t;if(3===e.data[0]){for(t=1;t<e.data.length;t++)if(0===e.data[t]){e.description=je(e.data,1,t),e.value=je(e.data,t+1,e.data.length).replace(/\0*$/,"");break}e.data=e.value}},WXXX:function(e){var t;if(3===e.data[0])for(t=1;t<e.data.length;t++)if(0===e.data[t]){e.description=je(e.data,1,t),e.url=je(e.data,t+1,e.data.length);break}},PRIV:function(e){var t,i;for(t=0;t<e.data.length;t++)if(0===e.data[t]){e.owner=(i=e.data,unescape(Be(i,0,t)));break}e.privateData=e.data.subarray(t+1),e.data=e.privateData}};(qe=function(e){var t,u={debug:!(!e||!e.debug),descriptor:e&&e.descriptor},l=0,c=[],d=0;if(qe.prototype.init.call(this),this.dispatchType=Fe.METADATA_STREAM_TYPE.toString(16),u.descriptor)for(t=0;t<u.descriptor.length;t++)this.dispatchType+=("00"+u.descriptor[t].toString(16)).slice(-2);this.push=function(e){var t,i,n,r,a;if("timed-metadata"===e.type)if(e.dataAlignmentIndicator&&(d=0,c.length=0),0===c.length&&(e.data.length<10||e.data[0]!=="I".charCodeAt(0)||e.data[1]!=="D".charCodeAt(0)||e.data[2]!=="3".charCodeAt(0)))u.debug;else if(c.push(e),d+=e.data.byteLength,1===c.length&&(l=Ve(e.data.subarray(6,10)),l+=10),!(d<l)){for(t={data:new Uint8Array(l),frames:[],pts:c[0].pts,dts:c[0].dts},a=0;a<l;)t.data.set(c[0].data.subarray(0,l-a),a),a+=c[0].data.byteLength,d-=c[0].data.byteLength,c.shift();i=10,64&t.data[5]&&(i+=4,i+=Ve(t.data.subarray(10,14)),l-=Ve(t.data.subarray(16,20)));do{if((n=Ve(t.data.subarray(i+4,i+8)))<1)return;if((r={id:String.fromCharCode(t.data[i],t.data[i+1],t.data[i+2],t.data[i+3]),data:t.data.subarray(i+10,i+n+10)}).key=r.id,We[r.id]&&(We[r.id](r),"com.apple.streaming.transportStreamTimestamp"===r.owner)){var s=r.data,o=(1&s[3])<<30|s[4]<<22|s[5]<<14|s[6]<<6|s[7]>>>2;o*=4,o+=3&s[7],r.timeStamp=o,void 0===t.pts&&void 0===t.dts&&(t.pts=r.timeStamp,t.dts=r.timeStamp),this.trigger("timestamp",r)}t.frames.push(r),i+=10,i+=n}while(i<l);this.trigger("data",t)}}}).prototype=new V;var ze,Ge,Xe,Ke=qe,Ye=He;(ze=function(){var r=new Uint8Array(188),a=0;ze.prototype.init.call(this),this.push=function(e){var t,i=0,n=188;for(a?((t=new Uint8Array(e.byteLength+a)).set(r.subarray(0,a)),t.set(e,a),a=0):t=e;n<t.byteLength;)71!==t[i]||71!==t[n]?(i++,n++):(this.trigger("data",t.subarray(i,n)),i+=188,n+=188);i<t.byteLength&&(r.set(t.subarray(i),0),a=t.byteLength-i)},this.flush=function(){188===a&&71===r[0]&&(this.trigger("data",r),a=0),this.trigger("done")},this.endTimeline=function(){this.flush(),this.trigger("endedtimeline")},this.reset=function(){a=0,this.trigger("reset")}}).prototype=new V,(Ge=function(){var n,r,a,s;Ge.prototype.init.call(this),(s=this).packetsWaitingForPmt=[],this.programMapTable=void 0,n=function(e,t){var i=0;t.payloadUnitStartIndicator&&(i+=e[i]+1),"pat"===t.type?r(e.subarray(i),t):a(e.subarray(i),t)},r=function(e,t){t.section_number=e[7],t.last_section_number=e[8],s.pmtPid=(31&e[10])<<8|e[11],t.pmtPid=s.pmtPid},a=function(e,t){var i,n;if(1&e[5]){for(s.programMapTable={video:null,audio:null,"timed-metadata":{}},i=3+((15&e[1])<<8|e[2])-4,n=12+((15&e[10])<<8|e[11]);n<i;){var r=e[n],a=(31&e[n+1])<<8|e[n+2];r===Fe.H264_STREAM_TYPE&&null===s.programMapTable.video?s.programMapTable.video=a:r===Fe.ADTS_STREAM_TYPE&&null===s.programMapTable.audio?s.programMapTable.audio=a:r===Fe.METADATA_STREAM_TYPE&&(s.programMapTable["timed-metadata"][a]=r),n+=5+((15&e[n+3])<<8|e[n+4])}t.programMapTable=s.programMapTable}},this.push=function(e){var t={},i=4;if(t.payloadUnitStartIndicator=!!(64&e[1]),t.pid=31&e[1],t.pid<<=8,t.pid|=e[2],1<(48&e[3])>>>4&&(i+=e[i]+1),0===t.pid)t.type="pat",n(e.subarray(i),t),this.trigger("data",t);else if(t.pid===this.pmtPid)for(t.type="pmt",n(e.subarray(i),t),this.trigger("data",t);this.packetsWaitingForPmt.length;)this.processPes_.apply(this,this.packetsWaitingForPmt.shift());else void 0===this.programMapTable?this.packetsWaitingForPmt.push([e,i,t]):this.processPes_(e,i,t)},this.processPes_=function(e,t,i){i.pid===this.programMapTable.video?i.streamType=Fe.H264_STREAM_TYPE:i.pid===this.programMapTable.audio?i.streamType=Fe.ADTS_STREAM_TYPE:i.streamType=this.programMapTable["timed-metadata"][i.pid],i.type="pes",i.data=e.subarray(t),this.trigger("data",i)}}).prototype=new V,Ge.STREAM_TYPES={h264:27,adts:15},(Xe=function(){function n(e,t,i){var n,r,a=new Uint8Array(e.size),s={type:t},o=0,u=0;if(e.data.length&&!(e.size<9)){for(s.trackId=e.data[0].pid,o=0;o<e.data.length;o++)r=e.data[o],a.set(r.data,u),u+=r.data.byteLength;!function(e,t){var i;t.packetLength=6+(e[4]<<8|e[5]),t.dataAlignmentIndicator=0!=(4&e[6]),192&(i=e[7])&&(t.pts=(14&e[9])<<27|(255&e[10])<<20|(254&e[11])<<12|(255&e[12])<<5|(254&e[13])>>>3,t.pts*=4,t.pts+=(6&e[13])>>>1,t.dts=t.pts,64&i&&(t.dts=(14&e[14])<<27|(255&e[15])<<20|(254&e[16])<<12|(255&e[17])<<5|(254&e[18])>>>3,t.dts*=4,t.dts+=(6&e[18])>>>1)),t.data=e.subarray(9+e[8])}(a,s),n="video"===t||s.packetLength<=e.size,(i||n)&&(e.size=0,e.data.length=0),n&&l.trigger("data",s)}}var t,l=this,r={data:[],size:0},a={data:[],size:0},s={data:[],size:0};Xe.prototype.init.call(this),this.push=function(i){({pat:function(){},pes:function(){var e,t;switch(i.streamType){case Fe.H264_STREAM_TYPE:e=r,t="video";break;case Fe.ADTS_STREAM_TYPE:e=a,t="audio";break;case Fe.METADATA_STREAM_TYPE:e=s,t="timed-metadata";break;default:return}i.payloadUnitStartIndicator&&n(e,t,!0),e.data.push(i),e.size+=i.data.byteLength},pmt:function(){var e={type:"metadata",tracks:[]};null!==(t=i.programMapTable).video&&e.tracks.push({timelineStartInfo:{baseMediaDecodeTime:0},id:+t.video,codec:"avc",type:"video"}),null!==t.audio&&e.tracks.push({timelineStartInfo:{baseMediaDecodeTime:0},id:+t.audio,codec:"adts",type:"audio"}),l.trigger("data",e)}})[i.type]()},this.reset=function(){r.size=0,r.data.length=0,a.size=0,a.data.length=0,this.trigger("reset")},this.flushStreams_=function(){n(r,"video"),n(a,"audio"),n(s,"timed-metadata")},this.flush=function(){this.flushStreams_(),this.trigger("done")}}).prototype=new V;var $e={PAT_PID:0,MP2T_PACKET_LENGTH:188,TransportPacketStream:ze,TransportParseStream:Ge,ElementaryStream:Xe,TimestampRolloverStream:Ye,CaptionStream:Ue.CaptionStream,Cea608Stream:Ue.Cea608Stream,MetadataStream:Ke};for(var Qe in Fe)Fe.hasOwnProperty(Qe)&&($e[Qe]=Fe[Qe]);var Je,Ze=$e,et=ce,tt=[96e3,88200,64e3,48e3,44100,32e3,24e3,22050,16e3,12e3,11025,8e3,7350];(Je=function(u){var l,c=0;Je.prototype.init.call(this),this.push=function(e){var t,i,n,r,a,s,o=0;if(u||(c=0),"audio"===e.type)for(l?(r=l,(l=new Uint8Array(r.byteLength+e.data.byteLength)).set(r),l.set(e.data,r.byteLength)):l=e.data;o+5<l.length;)if(255===l[o]&&240==(246&l[o+1])){if(i=2*(1&~l[o+1]),t=(3&l[o+3])<<11|l[o+4]<<3|(224&l[o+5])>>5,s=(a=1024*(1+(3&l[o+6])))*et/tt[(60&l[o+2])>>>2],n=o+t,l.byteLength<n)return;if(this.trigger("data",{pts:e.pts+c*s,dts:e.dts+c*s,sampleCount:a,audioobjecttype:1+(l[o+2]>>>6&3),channelcount:(1&l[o+2])<<2|(192&l[o+3])>>>6,samplerate:tt[(60&l[o+2])>>>2],samplingfrequencyindex:(60&l[o+2])>>>2,samplesize:16,data:l.subarray(o+7+i,n)}),c++,l.byteLength===n)return void(l=void 0);l=l.subarray(n)}else o++},this.flush=function(){c=0,this.trigger("done")},this.reset=function(){l=void 0,this.trigger("reset")},this.endTimeline=function(){l=void 0,this.trigger("endedtimeline")}}).prototype=new V;var it,nt,rt,at=Je,st=function(n){var r=n.byteLength,a=0,s=0;this.length=function(){return 8*r},this.bitsAvailable=function(){return 8*r+s},this.loadWord=function(){var e=n.byteLength-r,t=new Uint8Array(4),i=Math.min(4,r);if(0===i)throw new Error("no bytes available");t.set(n.subarray(e,e+i)),a=new DataView(t.buffer).getUint32(0),s=8*i,r-=i},this.skipBits=function(e){var t;e<s||(e-=s,e-=8*(t=Math.floor(e/8)),r-=t,this.loadWord()),a<<=e,s-=e},this.readBits=function(e){var t=Math.min(s,e),i=a>>>32-t;return 0<(s-=t)?a<<=t:0<r&&this.loadWord(),0<(t=e-t)?i<<t|this.readBits(t):i},this.skipLeadingZeros=function(){var e;for(e=0;e<s;++e)if(0!=(a&2147483648>>>e))return a<<=e,s-=e,e;return this.loadWord(),e+this.skipLeadingZeros()},this.skipUnsignedExpGolomb=function(){this.skipBits(1+this.skipLeadingZeros())},this.skipExpGolomb=function(){this.skipBits(1+this.skipLeadingZeros())},this.readUnsignedExpGolomb=function(){var e=this.skipLeadingZeros();return this.readBits(e+1)-1},this.readExpGolomb=function(){var e=this.readUnsignedExpGolomb();return 1&e?1+e>>>1:-1*(e>>>1)},this.readBoolean=function(){return 1===this.readBits(1)},this.readUnsignedByte=function(){return this.readBits(8)},this.loadWord()};(nt=function(){var n,r,a=0;nt.prototype.init.call(this),this.push=function(e){for(var t,i=(r=r?((t=new Uint8Array(r.byteLength+e.data.byteLength)).set(r),t.set(e.data,r.byteLength),t):e.data).byteLength;a<i-3;a++)if(1===r[a+2]){n=a+5;break}for(;n<i;)switch(r[n]){case 0:if(0!==r[n-1]){n+=2;break}if(0!==r[n-2]){n++;break}for(a+3!==n-2&&this.trigger("data",r.subarray(a+3,n-2));1!==r[++n]&&n<i;);a=n-2,n+=3;break;case 1:if(0!==r[n-1]||0!==r[n-2]){n+=3;break}this.trigger("data",r.subarray(a+3,n-2)),a=n-2,n+=3;break;default:n+=3}r=r.subarray(a),n-=a,a=0},this.reset=function(){r=null,a=0,this.trigger("reset")},this.flush=function(){r&&3<r.byteLength&&this.trigger("data",r.subarray(a+3)),r=null,a=0,this.trigger("done")},this.endTimeline=function(){this.flush(),this.trigger("endedtimeline")}}).prototype=new V,rt={100:!0,110:!0,122:!0,244:!0,44:!0,83:!0,86:!0,118:!0,128:!0,138:!0,139:!0,134:!0},(it=function(){var i,n,r,a,s,o,_,t=new nt;it.prototype.init.call(this),(i=this).push=function(e){"video"===e.type&&(n=e.trackId,r=e.pts,a=e.dts,t.push(e))},t.on("data",function(e){var t={trackId:n,pts:r,dts:a,data:e};switch(31&e[0]){case 5:t.nalUnitType="slice_layer_without_partitioning_rbsp_idr";break;case 6:t.nalUnitType="sei_rbsp",t.escapedRBSP=s(e.subarray(1));break;case 7:t.nalUnitType="seq_parameter_set_rbsp",t.escapedRBSP=s(e.subarray(1)),t.config=o(t.escapedRBSP);break;case 8:t.nalUnitType="pic_parameter_set_rbsp";break;case 9:t.nalUnitType="access_unit_delimiter_rbsp"}i.trigger("data",t)}),t.on("done",function(){i.trigger("done")}),t.on("partialdone",function(){i.trigger("partialdone")}),t.on("reset",function(){i.trigger("reset")}),t.on("endedtimeline",function(){i.trigger("endedtimeline")}),this.flush=function(){t.flush()},this.partialFlush=function(){t.partialFlush()},this.reset=function(){t.reset()},this.endTimeline=function(){t.endTimeline()},_=function(e,t){var i,n=8,r=8;for(i=0;i<e;i++)0!==r&&(r=(n+t.readExpGolomb()+256)%256),n=0===r?n:r},s=function(e){for(var t,i,n=e.byteLength,r=[],a=1;a<n-2;)0===e[a]&&0===e[a+1]&&3===e[a+2]?(r.push(a+2),a+=2):a++;if(0===r.length)return e;t=n-r.length,i=new Uint8Array(t);var s=0;for(a=0;a<t;s++,a++)s===r[0]&&(s++,r.shift()),i[a]=e[s];return i},o=function(e){var t,i,n,r,a,s,o,u,l,c,d,h,p,f=0,m=0,g=0,v=0,y=1;if(i=(t=new st(e)).readUnsignedByte(),r=t.readUnsignedByte(),n=t.readUnsignedByte(),t.skipUnsignedExpGolomb(),rt[i]&&(3===(a=t.readUnsignedExpGolomb())&&t.skipBits(1),t.skipUnsignedExpGolomb(),t.skipUnsignedExpGolomb(),t.skipBits(1),t.readBoolean()))for(d=3!==a?8:12,p=0;p<d;p++)t.readBoolean()&&_(p<6?16:64,t);if(t.skipUnsignedExpGolomb(),0===(s=t.readUnsignedExpGolomb()))t.readUnsignedExpGolomb();else if(1===s)for(t.skipBits(1),t.skipExpGolomb(),t.skipExpGolomb(),o=t.readUnsignedExpGolomb(),p=0;p<o;p++)t.skipExpGolomb();if(t.skipUnsignedExpGolomb(),t.skipBits(1),u=t.readUnsignedExpGolomb(),l=t.readUnsignedExpGolomb(),0===(c=t.readBits(1))&&t.skipBits(1),t.skipBits(1),t.readBoolean()&&(f=t.readUnsignedExpGolomb(),m=t.readUnsignedExpGolomb(),g=t.readUnsignedExpGolomb(),v=t.readUnsignedExpGolomb()),t.readBoolean()&&t.readBoolean()){switch(t.readUnsignedByte()){case 1:h=[1,1];break;case 2:h=[12,11];break;case 3:h=[10,11];break;case 4:h=[16,11];break;case 5:h=[40,33];break;case 6:h=[24,11];break;case 7:h=[20,11];break;case 8:h=[32,11];break;case 9:h=[80,33];break;case 10:h=[18,11];break;case 11:h=[15,11];break;case 12:h=[64,33];break;case 13:h=[160,99];break;case 14:h=[4,3];break;case 15:h=[3,2];break;case 16:h=[2,1];break;case 255:h=[t.readUnsignedByte()<<8|t.readUnsignedByte(),t.readUnsignedByte()<<8|t.readUnsignedByte()]}h&&(y=h[0]/h[1])}return{profileIdc:i,levelIdc:n,profileCompatibility:r,width:Math.ceil((16*(u+1)-2*f-2*m)*y),height:(2-c)*(l+1)*16-2*g-2*v,sarRatio:h}}}).prototype=new V;function ot(e,t){var i=e[t+6]<<21|e[t+7]<<14|e[t+8]<<7|e[t+9];return i=0<=i?i:0,(16&e[t+5])>>4?i+20:i+10}var ut,lt={H264Stream:it,NalByteStream:nt},ct=function(e){var t=function e(t,i){return t.length-i<10||t[i]!=="I".charCodeAt(0)||t[i+1]!=="D".charCodeAt(0)||t[i+2]!=="3".charCodeAt(0)?i:e(t,i+=ot(t,i))}(e,0);return e.length>=t+2&&255==(255&e[t])&&240==(240&e[t+1])&&16==(22&e[t+1])},dt=ot,ht=function(e,t){var i=(224&e[t+5])>>5,n=e[t+4]<<3;return 6144&e[t+3]|n|i};(ut=function(){var o=new Uint8Array,u=0;ut.prototype.init.call(this),this.setTimestamp=function(e){u=e},this.push=function(e){var t,i,n,r,a=0,s=0;for(o.length?(r=o.length,(o=new Uint8Array(e.byteLength+r)).set(o.subarray(0,r)),o.set(e,r)):o=e;3<=o.length-s;)if(o[s]!=="I".charCodeAt(0)||o[s+1]!=="D".charCodeAt(0)||o[s+2]!=="3".charCodeAt(0))if(255!=(255&o[s])||240!=(240&o[s+1]))s++;else{if(o.length-s<7)break;if(s+(a=ht(o,s))>o.length)break;n={type:"audio",data:o.subarray(s,s+a),pts:u,dts:u},this.trigger("data",n),s+=a}else{if(o.length-s<10)break;if(s+(a=dt(o,s))>o.length)break;i={type:"timed-metadata",data:o.subarray(s,s+a)},this.trigger("data",i),s+=a}t=o.length-s,o=0<t?o.subarray(s):new Uint8Array},this.reset=function(){o=new Uint8Array,this.trigger("reset")},this.endTimeline=function(){o=new Uint8Array,this.trigger("endedtimeline")}}).prototype=new V;function pt(e,t){var i;if(e.length!==t.length)return!1;for(i=0;i<e.length;i++)if(e[i]!==t[i])return!1;return!0}function ft(e,t,i,n,r,a){return{start:{dts:e,pts:e+(i-t)},end:{dts:e+(n-t),pts:e+(r-i)},prependedContentDuration:a,baseMediaDecodeTime:e}}var mt,gt,vt,yt,_t=ut,bt=["audioobjecttype","channelcount","samplerate","samplingfrequencyindex","samplesize"],Tt=["width","height","profileIdc","levelIdc","profileCompatibility","sarRatio"],St=lt.H264Stream,kt=ct,Ct=ce;(gt=function(a,s){var o=[],u=0,l=0,c=0,d=1/0;s=s||{},gt.prototype.init.call(this),this.push=function(t){Ce(a,t),a&&bt.forEach(function(e){a[e]=t[e]}),o.push(t)},this.setEarliestDts=function(e){l=e},this.setVideoBaseMediaDecodeTime=function(e){d=e},this.setAudioAppendStart=function(e){c=e},this.flush=function(){var e,t,i,n,r;0!==o.length&&(e=ye(o,a,l),a.baseMediaDecodeTime=ke(a,s.keepOriginalTimestamps),ve(a,e,c,d),a.samples=_e(e),i=J(be(e)),o=[],t=Z(u,[a]),n=new Uint8Array(t.byteLength+i.byteLength),u++,n.set(t),n.set(i,t.byteLength),Se(a),r=Math.ceil(1024*Ct/a.samplerate),e.length&&this.trigger("timingInfo",{start:e[0].pts,end:e[0].pts+e.length*r}),this.trigger("data",{track:a,boxes:n})),this.trigger("done","AudioSegmentStream")},this.reset=function(){Se(a),o=[],this.trigger("reset")}}).prototype=new V,(mt=function(c,d){var t,i,h=0,p=[],f=[];d=d||{},mt.prototype.init.call(this),delete c.minPTS,this.gopCache_=[],this.push=function(e){Ce(c,e),"seq_parameter_set_rbsp"!==e.nalUnitType||t||(t=e.config,c.sps=[e.data],Tt.forEach(function(e){c[e]=t[e]},this)),"pic_parameter_set_rbsp"!==e.nalUnitType||i||(i=e.data,c.pps=[e.data]),p.push(e)},this.flush=function(){for(var e,t,i,n,r,a,s,o,u=0;p.length&&"access_unit_delimiter_rbsp"!==p[0].nalUnitType;)p.shift();if(0===p.length)return this.resetStream_(),void this.trigger("done","VideoSegmentStream");if(e=te(p),(i=ie(e))[0][0].keyFrame||((t=this.getGopForFusion_(p[0],c))?(u=t.duration,i.unshift(t),i.byteLength+=t.byteLength,i.nalCount+=t.nalCount,i.pts=t.pts,i.dts=t.dts,i.duration+=t.duration):i=ne(i)),f.length){var l;if(!(l=d.alignGopsAtEnd?this.alignGopsAtEnd_(i):this.alignGopsAtStart_(i)))return this.gopCache_.unshift({gop:i.pop(),pps:c.pps,sps:c.sps}),this.gopCache_.length=Math.min(6,this.gopCache_.length),p=[],this.resetStream_(),void this.trigger("done","VideoSegmentStream");Se(c),i=l}Ce(c,i),c.samples=re(i),r=J(ae(i)),c.baseMediaDecodeTime=ke(c,d.keepOriginalTimestamps),this.trigger("processedGopsInfo",i.map(function(e){return{pts:e.pts,dts:e.dts,byteLength:e.byteLength}})),s=i[0],o=i[i.length-1],this.trigger("segmentTimingInfo",ft(c.baseMediaDecodeTime,s.dts,s.pts,o.dts+o.duration,o.pts+o.duration,u)),this.trigger("timingInfo",{start:i[0].pts,end:i[i.length-1].pts+i[i.length-1].duration}),this.gopCache_.unshift({gop:i.pop(),pps:c.pps,sps:c.sps}),this.gopCache_.length=Math.min(6,this.gopCache_.length),p=[],this.trigger("baseMediaDecodeTime",c.baseMediaDecodeTime),this.trigger("timelineStartInfo",c.timelineStartInfo),n=Z(h,[c]),a=new Uint8Array(n.byteLength+r.byteLength),h++,a.set(n),a.set(r,n.byteLength),this.trigger("data",{track:c,boxes:a}),this.resetStream_(),this.trigger("done","VideoSegmentStream")},this.reset=function(){this.resetStream_(),p=[],this.gopCache_.length=0,f.length=0,this.trigger("reset")},this.resetStream_=function(){Se(c),i=t=void 0},this.getGopForFusion_=function(e){var t,i,n,r,a,s=1/0;for(a=0;a<this.gopCache_.length;a++)n=(r=this.gopCache_[a]).gop,c.pps&&pt(c.pps[0],r.pps[0])&&c.sps&&pt(c.sps[0],r.sps[0])&&(n.dts<c.timelineStartInfo.dts||-1e4<=(t=e.dts-n.dts-n.duration)&&t<=45e3&&(!i||t<s)&&(i=r,s=t));return i?i.gop:null},this.alignGopsAtStart_=function(e){var t,i,n,r,a,s,o,u;for(a=e.byteLength,s=e.nalCount,o=e.duration,t=i=0;t<f.length&&i<e.length&&(n=f[t],r=e[i],n.pts!==r.pts);)r.pts>n.pts?t++:(i++,a-=r.byteLength,s-=r.nalCount,o-=r.duration);return 0===i?e:i===e.length?null:((u=e.slice(i)).byteLength=a,u.duration=o,u.nalCount=s,u.pts=u[0].pts,u.dts=u[0].dts,u)},this.alignGopsAtEnd_=function(e){var t,i,n,r,a,s,o;for(t=f.length-1,i=e.length-1,a=null,s=!1;0<=t&&0<=i;){if(n=f[t],r=e[i],n.pts===r.pts){s=!0;break}n.pts>r.pts?t--:(t===f.length-1&&(a=i),i--)}if(!s&&null===a)return null;if(0===(o=s?i:a))return e;var u=e.slice(o),l=u.reduce(function(e,t){return e.byteLength+=t.byteLength,e.duration+=t.duration,e.nalCount+=t.nalCount,e},{byteLength:0,duration:0,nalCount:0});return u.byteLength=l.byteLength,u.duration=l.duration,u.nalCount=l.nalCount,u.pts=u[0].pts,u.dts=u[0].dts,u},this.alignGopsWith=function(e){f=e}}).prototype=new V,(yt=function(e,t){this.numberOfTracks=0,this.metadataStream=t,"undefined"!=typeof(e=e||{}).remux?this.remuxTracks=!!e.remux:this.remuxTracks=!0,"boolean"==typeof e.keepOriginalTimestamps?this.keepOriginalTimestamps=e.keepOriginalTimestamps:this.keepOriginalTimestamps=!1,this.pendingTracks=[],this.videoTrack=null,this.pendingBoxes=[],this.pendingCaptions=[],this.pendingMetadata=[],this.pendingBytes=0,this.emittedTracks=0,yt.prototype.init.call(this),this.push=function(e){return e.text?this.pendingCaptions.push(e):e.frames?this.pendingMetadata.push(e):(this.pendingTracks.push(e.track),this.pendingBytes+=e.boxes.byteLength,"video"===e.track.type&&(this.videoTrack=e.track,this.pendingBoxes.push(e.boxes)),void("audio"===e.track.type&&(this.audioTrack=e.track,this.pendingBoxes.unshift(e.boxes))))}}).prototype=new V,yt.prototype.flush=function(e){var t,i,n,r,a=0,s={captions:[],captionStreams:{},metadata:[],info:{}},o=0;if(this.pendingTracks.length<this.numberOfTracks){if("VideoSegmentStream"!==e&&"AudioSegmentStream"!==e)return;if(this.remuxTracks)return;if(0===this.pendingTracks.length)return this.emittedTracks++,void(this.emittedTracks>=this.numberOfTracks&&(this.trigger("done"),this.emittedTracks=0))}if(this.videoTrack?(o=this.videoTrack.timelineStartInfo.pts,Tt.forEach(function(e){s.info[e]=this.videoTrack[e]},this)):this.audioTrack&&(o=this.audioTrack.timelineStartInfo.pts,bt.forEach(function(e){s.info[e]=this.audioTrack[e]},this)),this.videoTrack||this.audioTrack){for(1===this.pendingTracks.length?s.type=this.pendingTracks[0].type:s.type="combined",this.emittedTracks+=this.pendingTracks.length,n=ee(this.pendingTracks),s.initSegment=new Uint8Array(n.byteLength),s.initSegment.set(n),s.data=new Uint8Array(this.pendingBytes),r=0;r<this.pendingBoxes.length;r++)s.data.set(this.pendingBoxes[r],a),a+=this.pendingBoxes[r].byteLength;for(r=0;r<this.pendingCaptions.length;r++)(t=this.pendingCaptions[r]).startTime=fe(t.startPts,o,this.keepOriginalTimestamps),t.endTime=fe(t.endPts,o,this.keepOriginalTimestamps),s.captionStreams[t.stream]=!0,s.captions.push(t);for(r=0;r<this.pendingMetadata.length;r++)(i=this.pendingMetadata[r]).cueTime=fe(i.pts,o,this.keepOriginalTimestamps),s.metadata.push(i);for(s.metadata.dispatchType=this.metadataStream.dispatchType,this.pendingTracks.length=0,this.videoTrack=null,this.pendingBoxes.length=0,this.pendingCaptions.length=0,this.pendingBytes=0,this.pendingMetadata.length=0,this.trigger("data",s),r=0;r<s.captions.length;r++)t=s.captions[r],this.trigger("caption",t);for(r=0;r<s.metadata.length;r++)i=s.metadata[r],this.trigger("id3Frame",i)}this.emittedTracks>=this.numberOfTracks&&(this.trigger("done"),this.emittedTracks=0)},yt.prototype.setRemux=function(e){this.remuxTracks=e},(vt=function(n){var r,a,s=this,i=!0;vt.prototype.init.call(this),n=n||{},this.baseMediaDecodeTime=n.baseMediaDecodeTime||0,this.transmuxPipeline_={},this.setupAacPipeline=function(){var t={};(this.transmuxPipeline_=t).type="aac",t.metadataStream=new Ze.MetadataStream,t.aacStream=new _t,t.audioTimestampRolloverStream=new Ze.TimestampRolloverStream("audio"),t.timedMetadataTimestampRolloverStream=new Ze.TimestampRolloverStream("timed-metadata"),t.adtsStream=new at,t.coalesceStream=new yt(n,t.metadataStream),t.headOfPipeline=t.aacStream,t.aacStream.pipe(t.audioTimestampRolloverStream).pipe(t.adtsStream),t.aacStream.pipe(t.timedMetadataTimestampRolloverStream).pipe(t.metadataStream).pipe(t.coalesceStream),t.metadataStream.on("timestamp",function(e){t.aacStream.setTimestamp(e.timeStamp)}),t.aacStream.on("data",function(e){"timed-metadata"!==e.type&&"audio"!==e.type||t.audioSegmentStream||(a=a||{timelineStartInfo:{baseMediaDecodeTime:s.baseMediaDecodeTime},codec:"adts",type:"audio"},t.coalesceStream.numberOfTracks++,t.audioSegmentStream=new gt(a,n),t.audioSegmentStream.on("timingInfo",s.trigger.bind(s,"audioTimingInfo")),t.adtsStream.pipe(t.audioSegmentStream).pipe(t.coalesceStream),s.trigger("trackinfo",{hasAudio:!!a,hasVideo:!!r}))}),t.coalesceStream.on("data",this.trigger.bind(this,"data")),t.coalesceStream.on("done",this.trigger.bind(this,"done"))},this.setupTsPipeline=function(){var i={};(this.transmuxPipeline_=i).type="ts",i.metadataStream=new Ze.MetadataStream,i.packetStream=new Ze.TransportPacketStream,i.parseStream=new Ze.TransportParseStream,i.elementaryStream=new Ze.ElementaryStream,i.timestampRolloverStream=new Ze.TimestampRolloverStream,i.adtsStream=new at,i.h264Stream=new St,i.captionStream=new Ze.CaptionStream,i.coalesceStream=new yt(n,i.metadataStream),i.headOfPipeline=i.packetStream,i.packetStream.pipe(i.parseStream).pipe(i.elementaryStream).pipe(i.timestampRolloverStream),i.timestampRolloverStream.pipe(i.h264Stream),i.timestampRolloverStream.pipe(i.adtsStream),i.timestampRolloverStream.pipe(i.metadataStream).pipe(i.coalesceStream),i.h264Stream.pipe(i.captionStream).pipe(i.coalesceStream),i.elementaryStream.on("data",function(e){var t;if("metadata"===e.type){for(t=e.tracks.length;t--;)r||"video"!==e.tracks[t].type?a||"audio"!==e.tracks[t].type||((a=e.tracks[t]).timelineStartInfo.baseMediaDecodeTime=s.baseMediaDecodeTime):(r=e.tracks[t]).timelineStartInfo.baseMediaDecodeTime=s.baseMediaDecodeTime;r&&!i.videoSegmentStream&&(i.coalesceStream.numberOfTracks++,i.videoSegmentStream=new mt(r,n),i.videoSegmentStream.on("timelineStartInfo",function(e){a&&!n.keepOriginalTimestamps&&(a.timelineStartInfo=e,i.audioSegmentStream.setEarliestDts(e.dts-s.baseMediaDecodeTime))}),i.videoSegmentStream.on("processedGopsInfo",s.trigger.bind(s,"gopInfo")),i.videoSegmentStream.on("segmentTimingInfo",s.trigger.bind(s,"videoSegmentTimingInfo")),i.videoSegmentStream.on("baseMediaDecodeTime",function(e){a&&i.audioSegmentStream.setVideoBaseMediaDecodeTime(e)}),i.videoSegmentStream.on("timingInfo",s.trigger.bind(s,"videoTimingInfo")),i.h264Stream.pipe(i.videoSegmentStream).pipe(i.coalesceStream)),a&&!i.audioSegmentStream&&(i.coalesceStream.numberOfTracks++,i.audioSegmentStream=new gt(a,n),i.audioSegmentStream.on("timingInfo",s.trigger.bind(s,"audioTimingInfo")),i.adtsStream.pipe(i.audioSegmentStream).pipe(i.coalesceStream)),s.trigger("trackinfo",{hasAudio:!!a,hasVideo:!!r})}}),i.coalesceStream.on("data",this.trigger.bind(this,"data")),i.coalesceStream.on("id3Frame",function(e){e.dispatchType=i.metadataStream.dispatchType,s.trigger("id3Frame",e)}),i.coalesceStream.on("caption",this.trigger.bind(this,"caption")),i.coalesceStream.on("done",this.trigger.bind(this,"done"))},this.setBaseMediaDecodeTime=function(e){var t=this.transmuxPipeline_;n.keepOriginalTimestamps||(this.baseMediaDecodeTime=e),a&&(a.timelineStartInfo.dts=void 0,a.timelineStartInfo.pts=void 0,Se(a),t.audioTimestampRolloverStream&&t.audioTimestampRolloverStream.discontinuity()),r&&(t.videoSegmentStream&&(t.videoSegmentStream.gopCache_=[]),r.timelineStartInfo.dts=void 0,r.timelineStartInfo.pts=void 0,Se(r),t.captionStream.reset()),t.timestampRolloverStream&&t.timestampRolloverStream.discontinuity()},this.setAudioAppendStart=function(e){a&&this.transmuxPipeline_.audioSegmentStream.setAudioAppendStart(e)},this.setRemux=function(e){var t=this.transmuxPipeline_;n.remux=e,t&&t.coalesceStream&&t.coalesceStream.setRemux(e)},this.alignGopsWith=function(e){r&&this.transmuxPipeline_.videoSegmentStream&&this.transmuxPipeline_.videoSegmentStream.alignGopsWith(e)},this.push=function(e){if(i){var t=kt(e);t&&"aac"!==this.transmuxPipeline_.type?this.setupAacPipeline():t||"ts"===this.transmuxPipeline_.type||this.setupTsPipeline(),i=!1}this.transmuxPipeline_.headOfPipeline.push(e)},this.flush=function(){i=!0,this.transmuxPipeline_.headOfPipeline.flush()},this.endTimeline=function(){this.transmuxPipeline_.headOfPipeline.endTimeline()},this.reset=function(){this.transmuxPipeline_.headOfPipeline&&this.transmuxPipeline_.headOfPipeline.reset()},this.resetCaptions=function(){this.transmuxPipeline_.captionStream&&this.transmuxPipeline_.captionStream.reset()}}).prototype=new V;function Et(a,s){var o=[],u=0,l=0,c=0,d=1/0,h=null,p=null;s=s||{},Et.prototype.init.call(this),this.push=function(t){Ce(a,t),a&&bt.forEach(function(e){a[e]=t[e]}),o.push(t)},this.setEarliestDts=function(e){l=e},this.setVideoBaseMediaDecodeTime=function(e){d=e},this.setAudioAppendStart=function(e){c=e},this.processFrames_=function(){var e,t,i,n,r;0!==o.length&&0!==(e=ye(o,a,l)).length&&(a.baseMediaDecodeTime=ke(a,s.keepOriginalTimestamps),ve(a,e,c,d),a.samples=_e(e),i=J(be(e)),o=[],t=Z(u,[a]),u++,a.initSegment=ee([a]),(n=new Uint8Array(t.byteLength+i.byteLength)).set(t),n.set(i,t.byteLength),Se(a),null===h&&(p=h=e[0].pts),p+=e.length*(1024*At/a.samplerate),r={start:h},this.trigger("timingInfo",r),this.trigger("data",{track:a,boxes:n}))},this.flush=function(){this.processFrames_(),this.trigger("timingInfo",{start:h,end:p}),this.resetTiming_(),this.trigger("done","AudioSegmentStream")},this.partialFlush=function(){this.processFrames_(),this.trigger("partialdone","AudioSegmentStream")},this.endTimeline=function(){this.flush(),this.trigger("endedtimeline","AudioSegmentStream")},this.resetTiming_=function(){Se(a),p=h=null},this.reset=function(){this.resetTiming_(),o=[],this.trigger("reset")}}var wt={Transmuxer:vt,VideoSegmentStream:mt,AudioSegmentStream:gt,AUDIO_PROPERTIES:bt,VIDEO_PROPERTIES:Tt,generateVideoSegmentTimingInfo:ft}.Transmuxer,It={Adts:at,h264:lt},At=ce;Et.prototype=new V;function xt(o,u){var t,i,l,c=0,d=[],h=[],p=null,f=null,m=!0;u=u||{},xt.prototype.init.call(this),this.push=function(e){Ce(o,e),"undefined"==typeof o.timelineStartInfo.dts&&(o.timelineStartInfo.dts=e.dts),"seq_parameter_set_rbsp"!==e.nalUnitType||t||(t=e.config,o.sps=[e.data],Tt.forEach(function(e){o[e]=t[e]},this)),"pic_parameter_set_rbsp"!==e.nalUnitType||i||(i=e.data,o.pps=[e.data]),d.push(e)},this.processNals_=function(e){var t;for(d=h.concat(d);d.length&&"access_unit_delimiter_rbsp"!==d[0].nalUnitType;)d.shift();if(0!==d.length){var i=te(d);if(i.length)if(h=i[i.length-1],e&&(i.pop(),i.duration-=h.duration,i.nalCount-=h.length,i.byteLength-=h.byteLength),i.length){if(this.trigger("timelineStartInfo",o.timelineStartInfo),m){if(!(l=ie(i))[0][0].keyFrame){if(!(l=ne(l))[0][0].keyFrame)return d=[].concat.apply([],i).concat(h),void(h=[]);(i=[].concat.apply([],l)).duration=l.duration}m=!1}for(null===p&&(p=i[0].pts,f=p),f+=i.duration,this.trigger("timingInfo",{start:p,end:f}),t=0;t<i.length;t++){var n=i[t];o.samples=se(n);var r=J(oe(n));Se(o),Ce(o,n),o.baseMediaDecodeTime=ke(o,u.keepOriginalTimestamps);var a=Z(c,[o]);c++,o.initSegment=ee([o]);var s=new Uint8Array(a.byteLength+r.byteLength);s.set(a),s.set(r,a.byteLength),this.trigger("data",{track:o,boxes:s,sequence:c,videoFrameDts:n.dts,videoFramePts:n.pts})}d=[]}else d=[]}},this.resetTimingAndConfig_=function(){i=t=void 0,f=p=null},this.partialFlush=function(){this.processNals_(!0),this.trigger("partialdone","VideoSegmentStream")},this.flush=function(){this.processNals_(!1),this.resetTimingAndConfig_(),this.trigger("done","VideoSegmentStream")},this.endTimeline=function(){this.flush(),this.trigger("endedtimeline","VideoSegmentStream")},this.reset=function(){this.resetTimingAndConfig_(),h=[],d=[],m=!0,this.trigger("reset")}}var Pt=Et;xt.prototype=new V;function Lt(e){return e.prototype=new V,e.prototype.init.call(e),e}function Ot(t,i){t.on("data",i.trigger.bind(i,"data")),t.on("done",i.trigger.bind(i,"done")),t.on("partialdone",i.trigger.bind(i,"partialdone")),t.on("endedtimeline",i.trigger.bind(i,"endedtimeline")),t.on("audioTimingInfo",i.trigger.bind(i,"audioTimingInfo")),t.on("videoTimingInfo",i.trigger.bind(i,"videoTimingInfo")),t.on("trackinfo",i.trigger.bind(i,"trackinfo")),t.on("id3Frame",function(e){e.dispatchType=t.metadataStream.dispatchType,e.cueTime=de(e.pts),i.trigger("id3Frame",e)}),t.on("caption",function(e){i.trigger("caption",e)})}function Dt(i){var n=null,r=!0;i=i||{},Dt.prototype.init.call(this),i.baseMediaDecodeTime=i.baseMediaDecodeTime||0,this.push=function(e){if(r){var t=Rt(e);!t||n&&"aac"===n.type?t||n&&"ts"===n.type||(n=function(i){var n={type:"ts",tracks:{audio:null,video:null},packet:new Ze.TransportPacketStream,parse:new Ze.TransportParseStream,elementary:new Ze.ElementaryStream,timestampRollover:new Ze.TimestampRolloverStream,adts:new It.Adts,h264:new It.h264.H264Stream,captionStream:new Ze.CaptionStream,metadataStream:new Ze.MetadataStream};return n.headOfPipeline=n.packet,n.packet.pipe(n.parse).pipe(n.elementary).pipe(n.timestampRollover),n.timestampRollover.pipe(n.h264),n.h264.pipe(n.captionStream),n.timestampRollover.pipe(n.metadataStream),n.timestampRollover.pipe(n.adts),n.elementary.on("data",function(e){if("metadata"===e.type){for(var t=0;t<e.tracks.length;t++)n.tracks[e.tracks[t].type]||(n.tracks[e.tracks[t].type]=e.tracks[t],n.tracks[e.tracks[t].type].timelineStartInfo.baseMediaDecodeTime=i.baseMediaDecodeTime);n.tracks.video&&!n.videoSegmentStream&&(n.videoSegmentStream=new Mt(n.tracks.video,i),n.videoSegmentStream.on("timelineStartInfo",function(e){n.tracks.audio&&!i.keepOriginalTimestamps&&n.audioSegmentStream.setEarliestDts(e.dts-i.baseMediaDecodeTime)}),n.videoSegmentStream.on("timingInfo",n.trigger.bind(n,"videoTimingInfo")),n.videoSegmentStream.on("data",function(e){n.trigger("data",{type:"video",data:e})}),n.videoSegmentStream.on("done",n.trigger.bind(n,"done")),n.videoSegmentStream.on("partialdone",n.trigger.bind(n,"partialdone")),n.videoSegmentStream.on("endedtimeline",n.trigger.bind(n,"endedtimeline")),n.h264.pipe(n.videoSegmentStream)),n.tracks.audio&&!n.audioSegmentStream&&(n.audioSegmentStream=new Pt(n.tracks.audio,i),n.audioSegmentStream.on("data",function(e){n.trigger("data",{type:"audio",data:e})}),n.audioSegmentStream.on("done",n.trigger.bind(n,"done")),n.audioSegmentStream.on("partialdone",n.trigger.bind(n,"partialdone")),n.audioSegmentStream.on("endedtimeline",n.trigger.bind(n,"endedtimeline")),n.audioSegmentStream.on("timingInfo",n.trigger.bind(n,"audioTimingInfo")),n.adts.pipe(n.audioSegmentStream)),n.trigger("trackinfo",{hasAudio:!!n.tracks.audio,hasVideo:!!n.tracks.video})}}),n.captionStream.on("data",function(e){var t;t=n.tracks.video&&n.tracks.video.timelineStartInfo.pts||0,e.startTime=fe(e.startPts,t,i.keepOriginalTimestamps),e.endTime=fe(e.endPts,t,i.keepOriginalTimestamps),n.trigger("caption",e)}),(n=Lt(n)).metadataStream.on("data",n.trigger.bind(n,"id3Frame")),n}(i),Ot(n,this)):(n=function(t){var i={type:"aac",tracks:{audio:null},metadataStream:new Ze.MetadataStream,aacStream:new _t,audioRollover:new Ze.TimestampRolloverStream("audio"),timedMetadataRollover:new Ze.TimestampRolloverStream("timed-metadata"),adtsStream:new at(!0)};return i.headOfPipeline=i.aacStream,i.aacStream.pipe(i.audioRollover).pipe(i.adtsStream),i.aacStream.pipe(i.timedMetadataRollover).pipe(i.metadataStream),i.metadataStream.on("timestamp",function(e){i.aacStream.setTimestamp(e.timeStamp)}),i.aacStream.on("data",function(e){"timed-metadata"!==e.type&&"audio"!==e.type||i.audioSegmentStream||(i.tracks.audio=i.tracks.audio||{timelineStartInfo:{baseMediaDecodeTime:t.baseMediaDecodeTime},codec:"adts",type:"audio"},i.audioSegmentStream=new Pt(i.tracks.audio,t),i.audioSegmentStream.on("data",function(e){i.trigger("data",{type:"audio",data:e})}),i.audioSegmentStream.on("partialdone",i.trigger.bind(i,"partialdone")),i.audioSegmentStream.on("done",i.trigger.bind(i,"done")),i.audioSegmentStream.on("endedtimeline",i.trigger.bind(i,"endedtimeline")),i.audioSegmentStream.on("timingInfo",i.trigger.bind(i,"audioTimingInfo")),i.adtsStream.pipe(i.audioSegmentStream),i.trigger("trackinfo",{hasAudio:!!i.tracks.audio,hasVideo:!!i.tracks.video}))}),(i=Lt(i)).metadataStream.on("data",i.trigger.bind(i,"id3Frame")),i}(i),Ot(n,this)),r=!1}n.headOfPipeline.push(e)},this.flush=function(){n&&(r=!0,n.headOfPipeline.flush())},this.partialFlush=function(){n&&n.headOfPipeline.partialFlush()},this.endTimeline=function(){n&&n.headOfPipeline.endTimeline()},this.reset=function(){n&&n.headOfPipeline.reset()},this.setBaseMediaDecodeTime=function(e){i.keepOriginalTimestamps||(i.baseMediaDecodeTime=e),n&&(n.tracks.audio&&(n.tracks.audio.timelineStartInfo.dts=void 0,n.tracks.audio.timelineStartInfo.pts=void 0,Se(n.tracks.audio),n.audioRollover&&n.audioRollover.discontinuity()),n.tracks.video&&(n.videoSegmentStream&&(n.videoSegmentStream.gopCache_=[]),n.tracks.video.timelineStartInfo.dts=void 0,n.tracks.video.timelineStartInfo.pts=void 0,Se(n.tracks.video)),n.timestampRollover&&n.timestampRollover.discontinuity())},this.setRemux=function(e){i.remux=e,n&&n.coalesceStream&&n.coalesceStream.setRemux(e)},this.setAudioAppendStart=function(e){n&&n.tracks.audio&&n.audioSegmentStream&&n.audioSegmentStream.setAudioAppendStart(e)},this.alignGopsWith=function(e){}}var Mt=xt,Rt=ct;Dt.prototype=new V;function Nt(e,t){for(var i=e,n=0;n<t.length;n++){var r=t[n];if(i<r.size)return r;i-=r.size}return null}function Ut(e,c){var n=Ht(e,["moof","traf"]),t=Ht(e,["mdat"]),d={},r=[];return t.forEach(function(e,t){var i=n[t];r.push({mdat:e,traf:i})}),r.forEach(function(e){var t,i=e.mdat,n=e.traf,r=Ht(n,["tfhd"]),a=Kt(r[0]),s=a.trackId,o=Ht(n,["tfdt"]),u=0<o.length?zt(o[0]).baseMediaDecodeTime:0,l=Ht(n,["trun"]);c===s&&0<l.length&&(t=function(e,t,i){var n,r,a,s,o=new DataView(e.buffer,e.byteOffset,e.byteLength),u=[];for(r=0;r+4<e.length;r+=a)if(a=o.getUint32(r),r+=4,!(a<=0))switch(31&e[r]){case 6:var l=e.subarray(r+1,r+1+a),c=Nt(r,t);if(n={nalUnitType:"sei_rbsp",size:a,data:l,escapedRBSP:Yt(l),trackId:i},c)n.pts=c.pts,n.dts=c.dts,s=c;else{if(!s)break;n.pts=s.pts,n.dts=s.dts}u.push(n)}return u}(i,function(e,t,i){var n=t,r=i.defaultSampleDuration||0,a=i.defaultSampleSize||0,s=i.trackId,o=[];return e.forEach(function(e){var t=Xt(e).samples;t.forEach(function(e){void 0===e.duration&&(e.duration=r),void 0===e.size&&(e.size=a),e.trackId=s,e.dts=n,void 0===e.compositionTimeOffset&&(e.compositionTimeOffset=0),e.pts=n+e.compositionTimeOffset,n+=e.duration}),o=o.concat(t)}),o}(l,u,a),s),d[s]||(d[s]=[]),d[s]=d[s].concat(t))}),d}function Ft(e){return"AudioSegmentStream"===e?"audio":"VideoSegmentStream"===e?"video":""}var Bt=Dt,jt=function(e){return e>>>0},Vt=function(e){var t="";return t+=String.fromCharCode(e[0]),t+=String.fromCharCode(e[1]),t+=String.fromCharCode(e[2]),t+=String.fromCharCode(e[3])},qt=jt,Ht=function e(t,i){var n,r,a,s,o,u=[];if(!i.length)return null;for(n=0;n<t.byteLength;)r=qt(t[n]<<24|t[n+1]<<16|t[n+2]<<8|t[n+3]),a=Vt(t.subarray(n+4,n+8)),s=1<r?n+r:t.byteLength,a===i[0]&&(1===i.length?u.push(t.subarray(n+8,s)):(o=e(t.subarray(n+8,s),i.slice(1))).length&&(u=u.concat(o))),n=s;return u},Wt=jt,zt=function(e){var t={version:e[0],flags:new Uint8Array(e.subarray(1,4)),baseMediaDecodeTime:Wt(e[4]<<24|e[5]<<16|e[6]<<8|e[7])};return 1===t.version&&(t.baseMediaDecodeTime*=Math.pow(2,32),t.baseMediaDecodeTime+=Wt(e[8]<<24|e[9]<<16|e[10]<<8|e[11])),t},Gt=function(e){return{isLeading:(12&e[0])>>>2,dependsOn:3&e[0],isDependedOn:(192&e[1])>>>6,hasRedundancy:(48&e[1])>>>4,paddingValue:(14&e[1])>>>1,isNonSyncSample:1&e[1],degradationPriority:e[2]<<8|e[3]}},Xt=function(e){var t,i={version:e[0],flags:new Uint8Array(e.subarray(1,4)),samples:[]},n=new DataView(e.buffer,e.byteOffset,e.byteLength),r=1&i.flags[2],a=4&i.flags[2],s=1&i.flags[1],o=2&i.flags[1],u=4&i.flags[1],l=8&i.flags[1],c=n.getUint32(4),d=8;for(r&&(i.dataOffset=n.getInt32(d),d+=4),a&&c&&(t={flags:Gt(e.subarray(d,d+4))},d+=4,s&&(t.duration=n.getUint32(d),d+=4),o&&(t.size=n.getUint32(d),d+=4),l&&(1===i.version?t.compositionTimeOffset=n.getInt32(d):t.compositionTimeOffset=n.getUint32(d),d+=4),i.samples.push(t),c--);c--;)t={},s&&(t.duration=n.getUint32(d),d+=4),o&&(t.size=n.getUint32(d),d+=4),u&&(t.flags=Gt(e.subarray(d,d+4)),d+=4),l&&(1===i.version?t.compositionTimeOffset=n.getInt32(d):t.compositionTimeOffset=n.getUint32(d),d+=4),i.samples.push(t);return i},Kt=function(e){var t,i=new DataView(e.buffer,e.byteOffset,e.byteLength),n={version:e[0],flags:new Uint8Array(e.subarray(1,4)),trackId:i.getUint32(4)},r=1&n.flags[2],a=2&n.flags[2],s=8&n.flags[2],o=16&n.flags[2],u=32&n.flags[2],l=65536&n.flags[0],c=131072&n.flags[0];return t=8,r&&(t+=4,n.baseDataOffset=i.getUint32(12),t+=4),a&&(n.sampleDescriptionIndex=i.getUint32(t),t+=4),s&&(n.defaultSampleDuration=i.getUint32(t),t+=4),o&&(n.defaultSampleSize=i.getUint32(t),t+=4),u&&(n.defaultSampleFlags=i.getUint32(t)),l&&(n.durationIsEmpty=!0),!r&&c&&(n.baseDataOffsetIsMoof=!0),n},Yt=Ae,$t=Ue.CaptionStream,Qt=function(){var t,a,s,o,u,i,n=!1;this.isInitialized=function(){return n},this.init=function(e){t=new $t,n=!0,i=!!e&&e.isPartial,t.on("data",function(e){e.startTime=e.startPts/o,e.endTime=e.endPts/o,u.captions.push(e),u.captionStreams[e.stream]=!0})},this.isNewInit=function(e,t){return!(e&&0===e.length||t&&"object"==typeof t&&0===Object.keys(t).length)&&(s!==e[0]||o!==t[s])},this.parse=function(e,t,i){var n;if(!this.isInitialized())return null;if(!t||!i)return null;if(this.isNewInit(t,i))s=t[0],o=i[s];else if(null===s||!o)return a.push(e),null;for(;0<a.length;){var r=a.shift();this.parse(r,t,i)}return null!==(n=function(e,t,i){return null===t?null:{seiNals:Ut(e,t)[t],timescale:i}}(e,s,o))&&n.seiNals?(this.pushNals(n.seiNals),this.flushStream(),u):null},this.pushNals=function(e){if(!this.isInitialized()||!e||0===e.length)return null;e.forEach(function(e){t.push(e)})},this.flushStream=function(){if(!this.isInitialized())return null;i?t.partialFlush():t.flush()},this.clearParsedCaptions=function(){u.captions=[],u.captionStreams={}},this.resetCaptionStream=function(){if(!this.isInitialized())return null;t.reset()},this.clearAllCaptions=function(){this.clearParsedCaptions(),this.resetCaptionStream()},this.reset=function(){a=[],o=s=null,u?this.clearParsedCaptions():u={captions:[],captionStreams:{}},this.resetCaptionStream()},this.reset()},Jt=function(){function e(e,t){this.options=t||{},this.self=e,this.init()}var t=e.prototype;return t.init=function(){this.transmuxer&&this.transmuxer.dispose(),this.transmuxer=this.options.handlePartialData?new Bt(this.options):new wt(this.options),this.options.handlePartialData?function(n,e){e.on("data",function(e){var t={data:e.data.track.initSegment.buffer,byteOffset:e.data.track.initSegment.byteOffset,byteLength:e.data.track.initSegment.byteLength},i={boxes:{data:e.data.boxes.buffer,byteOffset:e.data.boxes.byteOffset,byteLength:e.data.boxes.byteLength},initSegment:t,type:e.type,sequence:e.data.sequence};"undefined"!=typeof e.data.videoFrameDts&&(i.videoFrameDtsTime=ge(e.data.videoFrameDts)),"undefined"!=typeof e.data.videoFramePts&&(i.videoFramePtsTime=ge(e.data.videoFramePts)),n.postMessage({action:"data",segment:i},[i.boxes.data,i.initSegment.data])}),e.on("id3Frame",function(e){n.postMessage({action:"id3Frame",id3Frame:e})}),e.on("caption",function(e){n.postMessage({action:"caption",caption:e})}),e.on("done",function(e){n.postMessage({action:"done",type:Ft(e)})}),e.on("partialdone",function(e){n.postMessage({action:"partialdone",type:Ft(e)})}),e.on("endedsegment",function(e){n.postMessage({action:"endedSegment",type:Ft(e)})}),e.on("trackinfo",function(e){n.postMessage({action:"trackinfo",trackInfo:e})}),e.on("audioTimingInfo",function(e){if(null!==e.start){var t={start:ge(e.start)};e.end&&(t.end=ge(e.end)),n.postMessage({action:"audioTimingInfo",audioTimingInfo:t})}else n.postMessage({action:"audioTimingInfo",audioTimingInfo:e})}),e.on("videoTimingInfo",function(e){var t={start:ge(e.start)};e.end&&(t.end=ge(e.end)),n.postMessage({action:"videoTimingInfo",videoTimingInfo:t})})}(this.self,this.transmuxer):function(n,e){e.on("data",function(e){var t=e.initSegment;e.initSegment={data:t.buffer,byteOffset:t.byteOffset,byteLength:t.byteLength};var i=e.data;e.data=i.buffer,n.postMessage({action:"data",segment:e,byteOffset:i.byteOffset,byteLength:i.byteLength},[e.data])}),e.on("done",function(e){n.postMessage({action:"done"})}),e.on("gopInfo",function(e){n.postMessage({action:"gopInfo",gopInfo:e})}),e.on("videoSegmentTimingInfo",function(e){var t={start:{decode:ge(e.start.dts),presentation:ge(e.start.pts)},end:{decode:ge(e.end.dts),presentation:ge(e.end.pts)},baseMediaDecodeTime:ge(e.baseMediaDecodeTime)};e.prependedContentDuration&&(t.prependedContentDuration=ge(e.prependedContentDuration)),n.postMessage({action:"videoSegmentTimingInfo",videoSegmentTimingInfo:t})}),e.on("id3Frame",function(e){n.postMessage({action:"id3Frame",id3Frame:e})}),e.on("caption",function(e){n.postMessage({action:"caption",caption:e})}),e.on("trackinfo",function(e){n.postMessage({action:"trackinfo",trackInfo:e})}),e.on("audioTimingInfo",function(e){n.postMessage({action:"audioTimingInfo",audioTimingInfo:{start:ge(e.start),end:ge(e.end)}})}),e.on("videoTimingInfo",function(e){n.postMessage({action:"videoTimingInfo",videoTimingInfo:{start:ge(e.start),end:ge(e.end)}})})}(this.self,this.transmuxer)},t.pushMp4Captions=function(e){this.captionParser||(this.captionParser=new Qt,this.captionParser.init());var t=new Uint8Array(e.data,e.byteOffset,e.byteLength),i=this.captionParser.parse(t,e.trackIds,e.timescales);this.self.postMessage({action:"mp4Captions",captions:i&&i.captions||[],data:t.buffer},[t.buffer])},t.clearAllMp4Captions=function(){this.captionParser&&this.captionParser.clearAllCaptions()},t.clearParsedMp4Captions=function(){this.captionParser&&this.captionParser.clearParsedCaptions()},t.push=function(e){var t=new Uint8Array(e.data,e.byteOffset,e.byteLength);this.transmuxer.push(t)},t.reset=function(){this.transmuxer.reset()},t.setTimestampOffset=function(e){var t=e.timestampOffset||0;this.transmuxer.setBaseMediaDecodeTime(Math.round(me(t)))},t.setAudioAppendStart=function(e){this.transmuxer.setAudioAppendStart(Math.ceil(me(e.appendStart)))},t.setRemux=function(e){this.transmuxer.setRemux(e.remux)},t.flush=function(e){this.transmuxer.flush(),Zt.postMessage({action:"done",type:"transmuxed"})},t.partialFlush=function(e){this.transmuxer.partialFlush(),Zt.postMessage({action:"partialdone",type:"transmuxed"})},t.endTimeline=function(){this.transmuxer.endTimeline(),Zt.postMessage({action:"endedtimeline",type:"transmuxed"})},t.alignGopsWith=function(e){this.transmuxer.alignGopsWith(e.gopsToAlignWith.slice())},e}();new function(t){t.onmessage=function(e){"init"===e.data.action&&e.data.options?this.messageHandlers=new Jt(t,e.data.options):(this.messageHandlers||(this.messageHandlers=new Jt(t)),e.data&&e.data.action&&"init"!==e.data.action&&this.messageHandlers[e.data.action]&&this.messageHandlers[e.data.action](e.data))}}(Zt)}()}),qc=Pc("PlaylistSelector"),Hc=function(n){function e(e,t){var i;if(i=n.call(this)||this,!e)throw new TypeError("Initialization settings are required");if("function"!=typeof e.currentTime)throw new TypeError("No currentTime getter specified");if(!e.mediaSource)throw new TypeError("No MediaSource specified");return i.bandwidth=e.bandwidth,i.throughput={rate:0,count:0},i.roundTrip=NaN,i.resetStats_(),i.mediaIndex=null,i.hasPlayed_=e.hasPlayed,i.currentTime_=e.currentTime,i.seekable_=e.seekable,i.seeking_=e.seeking,i.duration_=e.duration,i.mediaSource_=e.mediaSource,i.vhs_=e.vhs,i.loaderType_=e.loaderType,i.currentMediaInfo_=void 0,i.startingMediaInfo_=void 0,i.segmentMetadataTrack_=e.segmentMetadataTrack,i.goalBufferLength_=e.goalBufferLength,i.sourceType_=e.sourceType,i.sourceUpdater_=e.sourceUpdater,i.inbandTextTracks_=e.inbandTextTracks,i.state_="INIT",i.handlePartialData_=e.handlePartialData,i.timelineChangeController_=e.timelineChangeController,i.shouldSaveSegmentTimingInfo_=!0,i.checkBufferTimeout_=null,i.error_=void 0,i.currentTimeline_=-1,i.pendingSegment_=null,i.xhrOptions_=null,i.pendingSegments_=[],i.audioDisabled_=!1,i.isPendingTimestampOffset_=!1,i.gopBuffer_=[],i.timeMapping_=0,i.safeAppend_=11<=da.browser.IE_VERSION,i.appendInitSegment_={audio:!0,video:!0},i.playlistOfLastInitSegment_={audio:null,video:null},i.callQueue_=[],i.loadQueue_=[],i.metadataQueue_={id3:[],caption:[]},i.activeInitSegmentId_=null,i.initSegments_={},i.cacheEncryptionKeys_=e.cacheEncryptionKeys,i.keyCache_={},i.decrypter_=e.decrypter,i.syncController_=e.syncController,i.syncPoint_={segmentIndex:0,time:0},i.transmuxer_=i.createTransmuxer_(),i.triggerSyncInfoUpdate_=function(){return i.trigger("syncinfoupdate")},i.syncController_.on("syncinfoupdate",i.triggerSyncInfoUpdate_),i.mediaSource_.addEventListener("sourceopen",function(){i.isEndOfStream_()||(i.ended_=!1)}),i.fetchAtBuffer_=!1,i.logger_=Pc("SegmentLoader["+i.loaderType_+"]"),Object.defineProperty(Ve(i),"state",{get:function(){return this.state_},set:function(e){e!==this.state_&&(this.logger_(this.state_+" -> "+e),this.state_=e,this.trigger("statechange"))}}),i.sourceUpdater_.on("ready",function(){i.hasEnoughInfoToAppend_()&&i.processCallQueue_()}),"main"===i.loaderType_&&i.timelineChangeController_.on("pendingtimelinechange",function(){i.hasEnoughInfoToAppend_()&&i.processCallQueue_()}),"audio"===i.loaderType_&&i.timelineChangeController_.on("timelinechange",function(){i.hasEnoughInfoToLoad_()&&i.processLoadQueue_(),i.hasEnoughInfoToAppend_()&&i.processCallQueue_()}),i}Ge(e,n);var t=e.prototype;return t.createTransmuxer_=function(){var e=new Vc;return e.postMessage({action:"init",options:{remux:!1,alignGopsAtEnd:this.safeAppend_,keepOriginalTimestamps:!0,handlePartialData:this.handlePartialData_}}),e},t.resetStats_=function(){this.mediaBytesTransferred=0,this.mediaRequests=0,this.mediaRequestsAborted=0,this.mediaRequestsTimedout=0,this.mediaRequestsErrored=0,this.mediaTransferDuration=0,this.mediaSecondsLoaded=0},t.dispose=function(){this.trigger("dispose"),this.state="DISPOSED",this.pause(),this.abort_(),this.transmuxer_&&(this.transmuxer_.terminate(),hc()),this.resetStats_(),this.checkBufferTimeout_&&T.clearTimeout(this.checkBufferTimeout_),this.syncController_&&this.triggerSyncInfoUpdate_&&this.syncController_.off("syncinfoupdate",this.triggerSyncInfoUpdate_),this.off()},t.setAudio=function(e){this.audioDisabled_=!e,e?this.appendInitSegment_.audio=!0:this.sourceUpdater_.removeAudio(0,this.duration_())},t.abort=function(){"WAITING"===this.state?(this.abort_(),this.state="READY",this.paused()||this.monitorBuffer_()):this.pendingSegment_&&(this.pendingSegment_=null)},t.abort_=function(){this.pendingSegment_&&this.pendingSegment_.abortRequests&&this.pendingSegment_.abortRequests(),this.pendingSegment_=null,this.callQueue_=[],this.loadQueue_=[],this.metadataQueue_.id3=[],this.metadataQueue_.caption=[],this.timelineChangeController_.clearPendingTimelineChange(this.loaderType_)},t.checkForAbort_=function(e){return"APPENDING"!==this.state||this.pendingSegment_?!this.pendingSegment_||this.pendingSegment_.requestId!==e:(this.state="READY",!0)},t.error=function(e){return"undefined"!=typeof e&&(this.logger_("error occurred:",e),this.error_=e),this.pendingSegment_=null,this.error_},t.endOfStream=function(){this.ended_=!0,this.transmuxer_&&dc(this.transmuxer_),this.gopBuffer_.length=0,this.pause(),this.trigger("ended")},t.buffered_=function(){if(!this.sourceUpdater_||!this.startingMediaInfo_)return da.createTimeRanges();if("main"===this.loaderType_){var e=this.startingMediaInfo_,t=e.hasAudio,i=e.hasVideo,n=e.isMuxed;if(i&&t&&!this.audioDisabled_&&!n)return this.sourceUpdater_.buffered();if(i)return this.sourceUpdater_.videoBuffered()}return this.sourceUpdater_.audioBuffered()},t.initSegmentForMap=function(e,t){if(void 0===t&&(t=!1),!e)return null;var i=gl(e),n=this.initSegments_[i];return t&&!n&&e.bytes&&(this.initSegments_[i]=n={resolvedUri:e.resolvedUri,byterange:e.byterange,bytes:e.bytes,tracks:e.tracks,timescales:e.timescales}),n||e},t.segmentKey=function(e,t){if(void 0===t&&(t=!1),!e)return null;var i=vl(e),n=this.keyCache_[i];this.cacheEncryptionKeys_&&t&&!n&&e.bytes&&(this.keyCache_[i]=n={resolvedUri:e.resolvedUri,bytes:e.bytes});var r={resolvedUri:(n||e).resolvedUri};return n&&(r.bytes=n.bytes),r},t.couldBeginLoading_=function(){return this.playlist_&&!this.paused()},t.load=function(){if(this.monitorBuffer_(),this.playlist_){if(this.syncController_.setDateTimeMapping(this.playlist_),"INIT"===this.state&&this.couldBeginLoading_())return this.init_();!this.couldBeginLoading_()||"READY"!==this.state&&"INIT"!==this.state||(this.state="READY")}},t.init_=function(){return this.state="READY",this.resetEverything(),this.monitorBuffer_()},t.playlist=function(e,t){if(void 0===t&&(t={}),e){var i=this.playlist_,n=this.pendingSegment_;this.playlist_=e,this.xhrOptions_=t,"INIT"===this.state&&(e.syncInfo={mediaSequence:e.mediaSequence,time:0});var r=null;if(i&&(i.id?r=i.id:i.uri&&(r=i.uri)),this.logger_("playlist update ["+r+" => "+(e.id||e.uri)+"]"),this.trigger("syncinfoupdate"),"INIT"===this.state&&this.couldBeginLoading_())return this.init_();if(!i||i.uri!==e.uri)return null===this.mediaIndex&&!this.handlePartialData_||this.resyncLoader(),this.currentMediaInfo_=void 0,void this.trigger("playlistupdate");var a=e.mediaSequence-i.mediaSequence;this.logger_("live window shift ["+a+"]"),null!==this.mediaIndex&&(this.mediaIndex-=a),n&&(n.mediaIndex-=a,0<=n.mediaIndex&&(n.segment=e.segments[n.mediaIndex])),this.syncController_.saveExpiredSegmentInfo(i,e)}},t.pause=function(){this.checkBufferTimeout_&&(T.clearTimeout(this.checkBufferTimeout_),this.checkBufferTimeout_=null)},t.paused=function(){return null===this.checkBufferTimeout_},t.resetEverything=function(e){this.ended_=!1,this.appendInitSegment_={audio:!0,video:!0},this.resetLoader(),this.remove(0,1/0,e),this.transmuxer_&&this.transmuxer_.postMessage({action:"clearAllMp4Captions"})},t.resetLoader=function(){this.fetchAtBuffer_=!1,this.resyncLoader()},t.resyncLoader=function(){this.transmuxer_&&dc(this.transmuxer_),this.mediaIndex=null,this.syncPoint_=null,this.isPendingTimestampOffset_=!1,this.callQueue_=[],this.loadQueue_=[],this.metadataQueue_.id3=[],this.metadataQueue_.caption=[],this.abort(),this.transmuxer_&&this.transmuxer_.postMessage({action:"clearParsedMp4Captions"})},t.remove=function(e,t,i){if(void 0===i&&(i=function(){}),t===1/0&&(t=this.duration_()),this.sourceUpdater_&&this.currentMediaInfo_){var n=1,r=function(){0===--n&&i()};for(var a in this.audioDisabled_||(n++,this.sourceUpdater_.removeAudio(e,t,r)),"main"===this.loaderType_&&this.currentMediaInfo_&&this.currentMediaInfo_.hasVideo&&(this.gopBuffer_=function(e,t,i,n){for(var r=Math.ceil((t-n)*fu),a=Math.ceil((i-n)*fu),s=e.slice(),o=e.length;o--&&!(e[o].pts<=a););if(-1===o)return s;for(var u=o+1;u--&&!(e[u].pts<=r););return u=Math.max(u,0),s.splice(u,o-u+1),s}(this.gopBuffer_,e,t,this.timeMapping_),n++,this.sourceUpdater_.removeVideo(e,t,r)),this.inbandTextTracks_)Nc(e,t,this.inbandTextTracks_[a]);Nc(e,t,this.segmentMetadataTrack_),r()}},t.monitorBuffer_=function(){this.checkBufferTimeout_&&T.clearTimeout(this.checkBufferTimeout_),this.checkBufferTimeout_=T.setTimeout(this.monitorBufferTick_.bind(this),1)},t.monitorBufferTick_=function(){"READY"===this.state&&this.fillBuffer_(),this.checkBufferTimeout_&&T.clearTimeout(this.checkBufferTimeout_),this.checkBufferTimeout_=T.setTimeout(this.monitorBufferTick_.bind(this),500)},t.fillBuffer_=function(){if(!this.sourceUpdater_.updating()){this.syncPoint_||(this.syncPoint_=this.syncController_.getSyncPoint(this.playlist_,this.duration_(),this.currentTimeline_,this.currentTime_()));var e=this.buffered_(),t=this.checkBuffer_(e,this.playlist_,this.mediaIndex,this.hasPlayed_(),this.currentTime_(),this.syncPoint_);t&&(t.timestampOffset=Bc({segmentTimeline:t.timeline,currentTimeline:this.currentTimeline_,startOfSegment:t.startOfSegment,buffered:e,overrideCheck:this.isPendingTimestampOffset_}),this.isPendingTimestampOffset_=!1,"number"==typeof t.timestampOffset&&this.timelineChangeController_.pendingTimelineChange({type:this.loaderType_,from:this.currentTimeline_,to:t.timeline}),this.loadSegment_(t))}},t.isEndOfStream_=function(e,t){if(void 0===e&&(e=this.mediaIndex),void 0===t&&(t=this.playlist_),!t||!this.mediaSource_)return!1;var i=e+1===t.segments.length;return t.endList&&"open"===this.mediaSource_.readyState&&i},t.checkBuffer_=function(e,t,i,n,r,a){var s=0;e.length&&(s=e.end(e.length-1));var o=Math.max(0,s-r);if(!t.segments.length)return null;if(o>=this.goalBufferLength_())return null;if(!n&&1<=o)return null;var u,l=null,c=!1;if(null===a)l=this.getSyncSegmentCandidate_(t),c=!0;else if(null!==i){var d=t.segments[i];u=d&&d.end?d.end:s,l=i+1}else if(this.fetchAtBuffer_){var h=ic.getMediaInfoForTime(t,s,a.segmentIndex,a.time);l=h.mediaIndex,u=h.startTime}else{var p=ic.getMediaInfoForTime(t,r,a.segmentIndex,a.time);l=p.mediaIndex,u=p.startTime}var f=this.generateSegmentInfo_(t,l,u,c);return!f||this.mediaSource_&&this.playlist_&&f.mediaIndex===this.playlist_.segments.length-1&&"ended"===this.mediaSource_.readyState&&!this.seeking_()?void 0:(this.logger_("checkBuffer_ returning "+f.uri,{segmentInfo:f,playlist:t,currentMediaIndex:i,nextMediaIndex:l,startOfSegment:u,isSyncRequest:c}),f)},t.getSyncSegmentCandidate_=function(e){var t=this;if(-1===this.currentTimeline_)return 0;var i=e.segments.map(function(e,t){return{timeline:e.timeline,segmentIndex:t}}).filter(function(e){return e.timeline===t.currentTimeline_});return i.length?i[Math.min(i.length-1,1)].segmentIndex:Math.max(e.segments.length-1,0)},t.generateSegmentInfo_=function(e,t,i,n){if(t<0||t>=e.segments.length)return null;var r,a,s=e.segments[t],o=this.sourceUpdater_.audioBuffered(),u=this.sourceUpdater_.videoBuffered();return o.length&&(r=o.end(o.length-1)-this.sourceUpdater_.audioTimestampOffset()),u.length&&(a=function(e,t,i){if("undefined"==typeof t||null===t||!e.length)return[];var n,r=Math.ceil((t-i+3)*fu);for(n=0;n<e.length&&!(e[n].pts>r);n++);return e.slice(n)}(this.gopBuffer_,this.currentTime_()-this.sourceUpdater_.videoTimestampOffset(),this.timeMapping_)),{requestId:"segment-loader-"+Math.random(),uri:s.resolvedUri,mediaIndex:t,isSyncRequest:n,startOfSegment:i,playlist:e,bytes:null,encryptedBytes:null,timestampOffset:null,timeline:s.timeline,duration:s.duration,segment:s,byteLength:0,transmuxer:this.transmuxer_,audioAppendStart:r,gopsToAlignWith:a}},t.abortRequestEarly_=function(e){if(this.vhs_.tech_.paused()||!this.xhrOptions_.timeout||!this.playlist_.attributes.BANDWIDTH)return!1;if(Date.now()-(e.firstBytesReceivedAt||Date.now())<1e3)return!1;var t=this.currentTime_(),i=e.bandwidth,n=this.pendingSegment_.duration,r=ic.estimateSegmentRequestTime(n,i,this.playlist_,e.bytesReceived),a=function(e,t,i){return void 0===i&&(i=1),((e.length?e.end(e.length-1):0)-t)/i}(this.buffered_(),t,this.vhs_.tech_.playbackRate())-1;if(r<=a)return!1;var s=function(e){var t=e.master,i=e.currentTime,n=e.bandwidth,r=e.duration,a=e.segmentDuration,s=e.timeUntilRebuffer,o=e.currentTimeline,u=e.syncController,l=t.playlists.filter(function(e){return!ic.isIncompatible(e)}),c=l.filter(ic.isEnabled);c.length||(c=l.filter(function(e){return!ic.isDisabled(e)}));var d=c.filter(ic.hasAttribute.bind(null,"BANDWIDTH")).map(function(e){var t=u.getSyncPoint(e,r,o,i)?1:2;return{playlist:e,rebufferingImpact:ic.estimateSegmentRequestTime(a,n,e)*t-s}}),h=d.filter(function(e){return e.rebufferingImpact<=0});return Dc(h,function(e,t){return Mc(t.playlist,e.playlist)}),h.length?h[0]:(Dc(d,function(e,t){return e.rebufferingImpact-t.rebufferingImpact}),d[0]||null)}({master:this.vhs_.playlists.master,currentTime:t,bandwidth:i,duration:this.duration_(),segmentDuration:n,timeUntilRebuffer:a,currentTimeline:this.currentTimeline_,syncController:this.syncController_});if(s){var o=r-a-s.rebufferingImpact,u=.5;return a<=ec&&(u=1),!s.playlist||s.playlist.uri===this.playlist_.uri||o<u?!1:(this.bandwidth=s.playlist.attributes.BANDWIDTH*uc.BANDWIDTH_VARIANCE+1,this.abort(),this.trigger("earlyabort"),!0)}},t.handleAbort_=function(){this.mediaRequestsAborted+=1},t.handleProgress_=function(e,t){this.checkForAbort_(t.requestId)||this.abortRequestEarly_(t.stats)||this.trigger("progress")},t.handleTrackInfo_=function(e,t){this.checkForAbort_(e.requestId)||this.abortRequestEarly_(e.stats)||this.checkForIllegalMediaSwitch(t)||(t=t||{},function(e,t){if(!e&&!t||!e&&t||e&&!t)return!1;if(e===t)return!0;var i=Object.keys(e).sort(),n=Object.keys(t).sort();if(i.length!==n.length)return!1;for(var r=0;r<i.length;r++){var a=i[r];if(a!==n[r])return!1;if(e[a]!==t[a])return!1}return!0}(this.currentMediaInfo_,t)||(this.appendInitSegment_={audio:!0,video:!0},this.startingMediaInfo_=t,this.currentMediaInfo_=t,this.logger_("trackinfo update",t),this.trigger("trackinfo")),this.checkForAbort_(e.requestId)||this.abortRequestEarly_(e.stats)||(this.pendingSegment_.trackInfo=t,this.hasEnoughInfoToAppend_()&&this.processCallQueue_()))},t.handleTimingInfo_=function(e,t,i,n){if(!this.checkForAbort_(e.requestId)&&!this.abortRequestEarly_(e.stats)){var r=this.pendingSegment_,a=Fc(t);r[a]=r[a]||{},r[a][i]=n,this.logger_("timinginfo: "+t+" - "+i+" - "+n),this.hasEnoughInfoToAppend_()&&this.processCallQueue_()}},t.handleCaptions_=function(e,t){var s=this;if(!this.checkForAbort_(e.requestId)&&!this.abortRequestEarly_(e.stats))if(0!==t.length)if(this.pendingSegment_.hasAppendedData_){var o=null===this.sourceUpdater_.videoTimestampOffset()?this.sourceUpdater_.audioTimestampOffset():this.sourceUpdater_.videoTimestampOffset(),u={};t.forEach(function(e){u[e.stream]=u[e.stream]||{startTime:1/0,captions:[],endTime:0};var t=u[e.stream];t.startTime=Math.min(t.startTime,e.startTime+o),t.endTime=Math.max(t.endTime,e.endTime+o),t.captions.push(e)}),Object.keys(u).forEach(function(e){var t=u[e],i=t.startTime,n=t.endTime,r=t.captions,a=s.inbandTextTracks_;s.logger_("adding cues from "+i+" -> "+n+" for "+e),function(e,t,i){if(!e[i]){t.trigger({type:"usage",name:"vhs-608"}),t.trigger({type:"usage",name:"hls-608"});var n=t.textTracks().getTrackById(i);e[i]=n||t.addRemoteTextTrack({kind:"captions",id:i,label:i},!1).track}}(a,s.vhs_.tech_,e),Nc(i,n,a[e]),function(e){var i=e.inbandTextTracks,t=e.captionArray,n=e.timestampOffset;if(t){var r=T.WebKitDataCue||T.VTTCue;t.forEach(function(e){var t=e.stream;i[t].addCue(new r(e.startTime+n,e.endTime+n,e.text))})}}({captionArray:r,inbandTextTracks:a,timestampOffset:o})}),this.transmuxer_&&this.transmuxer_.postMessage({action:"clearParsedMp4Captions"})}else this.metadataQueue_.caption.push(this.handleCaptions_.bind(this,e,t));else this.logger_("SegmentLoader received no captions from a caption event")},t.handleId3_=function(e,t,i){if(!this.checkForAbort_(e.requestId)&&!this.abortRequestEarly_(e.stats))if(this.pendingSegment_.hasAppendedData_){var n=null===this.sourceUpdater_.videoTimestampOffset()?this.sourceUpdater_.audioTimestampOffset():this.sourceUpdater_.videoTimestampOffset();!function(e,t,i){e.metadataTrack_||(e.metadataTrack_=i.addRemoteTextTrack({kind:"metadata",label:"Timed Metadata"},!1).track,e.metadataTrack_.inBandMetadataTrackDispatchType=t)}(this.inbandTextTracks_,i,this.vhs_.tech_),Rc({inbandTextTracks:this.inbandTextTracks_,metadataArray:t,timestampOffset:n,videoDuration:this.duration_()})}else this.metadataQueue_.id3.push(this.handleId3_.bind(this,e,t,i))},t.processMetadataQueue_=function(){this.metadataQueue_.id3.forEach(function(e){return e()}),this.metadataQueue_.caption.forEach(function(e){return e()}),this.metadataQueue_.id3=[],this.metadataQueue_.caption=[]},t.processCallQueue_=function(){var e=this.callQueue_;this.callQueue_=[],e.forEach(function(e){return e()})},t.processLoadQueue_=function(){var e=this.loadQueue_;this.loadQueue_=[],e.forEach(function(e){return e()})},t.hasEnoughInfoToLoad_=function(){if("audio"!==this.loaderType_)return!0;var e=this.pendingSegment_;return!!e&&(!this.currentMediaInfo_||!jc({timelineChangeController:this.timelineChangeController_,currentTimeline:this.currentTimeline_,segmentTimeline:e.timeline,loaderType:this.loaderType_,audioDisabled:this.audioDisabled_}))},t.hasEnoughInfoToAppend_=function(){if(!this.sourceUpdater_.ready())return!1;var e=this.pendingSegment_;if(!e||!e.trackInfo)return!1;if(!this.handlePartialData_){var t=this.currentMediaInfo_,i=t.hasAudio,n=t.hasVideo,r=t.isMuxed;if(n&&!e.videoTimingInfo)return!1;if(i&&!this.audioDisabled_&&!r&&!e.audioTimingInfo)return!1}return!jc({timelineChangeController:this.timelineChangeController_,currentTimeline:this.currentTimeline_,segmentTimeline:e.timeline,loaderType:this.loaderType_,audioDisabled:this.audioDisabled_})},t.handleData_=function(e,t){if(!this.checkForAbort_(e.requestId)&&!this.abortRequestEarly_(e.stats))if(!this.callQueue_.length&&this.hasEnoughInfoToAppend_()){var i=this.pendingSegment_;if(this.setTimeMapping_(i.timeline),this.updateMediaSecondsLoaded_(i.segment),"closed"!==this.mediaSource_.readyState){if(e.map&&(e.map=this.initSegmentForMap(e.map,!0),i.segment.map=e.map),e.key&&this.segmentKey(e.key,!0),i.isFmp4=e.isFmp4,i.timingInfo=i.timingInfo||{},i.isFmp4)this.trigger("fmp4"),i.timingInfo.start=i[Fc(t.type)].start;else{var n,r="main"===this.loaderType_&&this.currentMediaInfo_.hasVideo;r&&(n=this.handlePartialData_?t.videoFramePtsTime:i.videoTimingInfo.start),i.timingInfo.start=this.trueSegmentStart_({currentStart:i.timingInfo.start,playlist:i.playlist,mediaIndex:i.mediaIndex,currentVideoTimestampOffset:this.sourceUpdater_.videoTimestampOffset(),useVideoTimingInfo:r,firstVideoFrameTimeForData:n,videoTimingInfo:i.videoTimingInfo,audioTimingInfo:i.audioTimingInfo})}this.updateAppendInitSegmentStatus(i,t.type),this.updateSourceBufferTimestampOffset_(i),i.hasAppendedData_=!0,this.processMetadataQueue_(),this.appendData_(i,t)}}else this.callQueue_.push(this.handleData_.bind(this,e,t))},t.updateAppendInitSegmentStatus=function(e,t){"main"!==this.loaderType_||"number"!=typeof e.timestampOffset||e.changedTimestampOffset||(this.appendInitSegment_={audio:!0,video:!0}),this.playlistOfLastInitSegment_[t]!==e.playlist&&(this.appendInitSegment_[t]=!0)},t.getInitSegmentAndUpdateState_=function(e){var t=e.type,i=e.initSegment,n=e.map,r=e.playlist;if(n){var a=gl(n);if(this.activeInitSegmentId_===a)return null;i=this.initSegmentForMap(n,!0).bytes,this.activeInitSegmentId_=a}return i&&this.appendInitSegment_[t]?(this.playlistOfLastInitSegment_[t]=r,this.appendInitSegment_[t]=!!n,this.activeInitSegmentId_=null,i):null},t.appendToSourceBuffer_=function(e){var t=this,i=e.segmentInfo,n=e.type,r=e.initSegment,a=e.data,s=[a],o=a.byteLength;r&&(s.unshift(r),o+=r.byteLength);var u=function(e){var t,i=0;return e.bytes&&(t=new Uint8Array(e.bytes),e.segments.forEach(function(e){t.set(e,i),i+=e.byteLength})),t}({bytes:o,segments:s});this.sourceUpdater_.appendBuffer({segmentInfo:i,type:n,bytes:u},function(e){e&&(t.error(n+" append of "+u.length+"b failed for segment #"+i.mediaIndex+" in playlist "+i.playlist.id),t.trigger("appenderror"))})},t.handleVideoSegmentTimingInfo_=function(e,t){if(this.pendingSegment_&&e===this.pendingSegment_.requestId){var i=this.pendingSegment_.segment;i.videoTimingInfo||(i.videoTimingInfo={}),i.videoTimingInfo.transmuxerPrependedSeconds=t.prependedContentDuration||0,i.videoTimingInfo.transmuxedPresentationStart=t.start.presentation,i.videoTimingInfo.transmuxedPresentationEnd=t.end.presentation,i.videoTimingInfo.baseMediaDecodeTime=t.baseMediaDecodeTime}},t.appendData_=function(e,t){var i=t.type,n=t.data;if(n&&n.byteLength&&("audio"!==i||!this.audioDisabled_)){var r=this.getInitSegmentAndUpdateState_({type:i,initSegment:t.initSegment,playlist:e.playlist,map:e.isFmp4?e.segment.map:null});this.appendToSourceBuffer_({segmentInfo:e,type:i,initSegment:r,data:n})}},t.loadSegment_=function(i){var n=this;this.state="WAITING",this.pendingSegment_=i,this.trimBackBuffer_(i),"number"==typeof i.timestampOffset&&this.transmuxer_&&this.transmuxer_.postMessage({action:"clearAllMp4Captions"}),this.hasEnoughInfoToLoad_()?this.updateTransmuxerAndRequestSegment_(i):this.loadQueue_.push(function(){var e=n.buffered_();"number"==typeof i.timestampOffset&&(i.timestampOffset=Bc({segmentTimeline:i.timeline,currentTimeline:n.currentTimeline_,startOfSegment:i.startOfSegment,buffered:e,overrideCheck:!0})),delete i.audioAppendStart;var t=n.sourceUpdater_.audioBuffered();t.length&&(i.audioAppendStart=t.end(t.length-1)-n.sourceUpdater_.audioTimestampOffset()),n.updateTransmuxerAndRequestSegment_(i)})},t.updateTransmuxerAndRequestSegment_=function(e){this.shouldUpdateTransmuxerTimestampOffset_(e.timestampOffset)&&(this.gopBuffer_.length=0,e.gopsToAlignWith=[],this.timeMapping_=0,this.transmuxer_.postMessage({action:"reset"}),this.transmuxer_.postMessage({action:"setTimestampOffset",timestampOffset:e.timestampOffset}));var t=this.createSimplifiedSegmentObj_(e);e.abortRequests=Fl({xhr:this.vhs_.xhr,xhrOptions:this.xhrOptions_,decryptionWorker:this.decrypter_,segment:t,handlePartialData:this.handlePartialData_,abortFn:this.handleAbort_.bind(this),progressFn:this.handleProgress_.bind(this),trackInfoFn:this.handleTrackInfo_.bind(this),timingInfoFn:this.handleTimingInfo_.bind(this),videoSegmentTimingInfoFn:this.handleVideoSegmentTimingInfo_.bind(this,e.requestId),captionsFn:this.handleCaptions_.bind(this),id3Fn:this.handleId3_.bind(this),dataFn:this.handleData_.bind(this),doneFn:this.segmentRequestFinished_.bind(this)})},t.trimBackBuffer_=function(e){var t=function(e,t,i){var n=t-uc.BACK_BUFFER_LENGTH;e.length&&(n=Math.max(n,e.start(0)));var r=t-i;return Math.min(r,n)}(this.seekable_(),this.currentTime_(),this.playlist_.targetDuration||10);0<t&&this.remove(0,t)},t.createSimplifiedSegmentObj_=function(e){var t=e.segment,i={resolvedUri:t.resolvedUri,byterange:t.byterange,requestId:e.requestId,transmuxer:e.transmuxer,audioAppendStart:e.audioAppendStart,gopsToAlignWith:e.gopsToAlignWith},n=e.playlist.segments[e.mediaIndex];if(n&&n.end&&n.timeline===t.timeline&&(i.baseStartTime=n.end+e.timestampOffset),t.key){var r=t.key.iv||new Uint32Array([0,0,0,e.mediaIndex+e.playlist.mediaSequence]);i.key=this.segmentKey(t.key),i.key.iv=r}return t.map&&(i.map=this.initSegmentForMap(t.map)),i},t.saveTransferStats_=function(e){this.mediaRequests+=1,e&&(this.mediaBytesTransferred+=e.bytesReceived,this.mediaTransferDuration+=e.roundTripTime)},t.saveBandwidthRelatedStats_=function(e){this.bandwidth=e.bandwidth,this.roundTrip=e.roundTripTime,this.pendingSegment_.byteLength=e.bytesReceived},t.handleTimeout_=function(){this.mediaRequestsTimedout+=1,this.bandwidth=1,this.roundTrip=NaN,this.trigger("bandwidthupdate")},t.segmentRequestFinished_=function(e,t,i){if(this.callQueue_.length)this.callQueue_.push(this.segmentRequestFinished_.bind(this,e,t,i));else if(this.saveTransferStats_(t.stats),this.pendingSegment_&&t.requestId===this.pendingSegment_.requestId){if(e){if(this.pendingSegment_=null,this.state="READY",e.code===gc)return;return this.pause(),e.code===mc?void this.handleTimeout_():(this.mediaRequestsErrored+=1,this.error(e),void this.trigger("error"))}this.saveBandwidthRelatedStats_(t.stats);var n=this.pendingSegment_;n.endOfAllRequests=t.endOfAllRequests,i.gopInfo&&(this.gopBuffer_=function(e,t,i){if(!t.length)return e;if(i)return t.slice();for(var n=t[0].pts,r=0;r<e.length&&!(e[r].pts>=n);r++);return e.slice(0,r).concat(t)}(this.gopBuffer_,i.gopInfo,this.safeAppend_)),this.state="APPENDING";var r=this.isEndOfStream_(n.mediaIndex,n.playlist),a=null!==this.mediaIndex,s=n.timeline!==this.currentTimeline_&&0<n.timeline;!n.isFmp4&&(r||a&&s)&&pc(this.transmuxer_),this.trigger("appending"),this.waitForAppendsToComplete_(n)}},t.setTimeMapping_=function(e){var t=this.syncController_.mappingForTimeline(e);null!==t&&(this.timeMapping_=t)},t.updateMediaSecondsLoaded_=function(e){"number"==typeof e.start&&"number"==typeof e.end?this.mediaSecondsLoaded+=e.end-e.start:this.mediaSecondsLoaded+=e.duration},t.shouldUpdateTransmuxerTimestampOffset_=function(e){return null!==e&&("main"===this.loaderType_&&e!==this.sourceUpdater_.videoTimestampOffset()||!this.audioDisabled_&&e!==this.sourceUpdater_.audioTimestampOffset())},t.trueSegmentStart_=function(e){var t=e.currentStart,i=e.playlist,n=e.mediaIndex,r=e.firstVideoFrameTimeForData,a=e.currentVideoTimestampOffset,s=e.useVideoTimingInfo,o=e.videoTimingInfo,u=e.audioTimingInfo;if("undefined"!=typeof t)return t;if(!s)return u.start;var l=i.segments[n-1];return 0!==n&&l&&"undefined"!=typeof l.start&&l.end===r+a?o.start:r},t.waitForAppendsToComplete_=function(e){if(!this.currentMediaInfo_)return this.error({message:"No starting media returned, likely due to an unsupported media format.",blacklistDuration:1/0}),void this.trigger("error");var t=this.currentMediaInfo_,i=t.hasAudio,n=t.hasVideo,r=t.isMuxed,a="main"===this.loaderType_&&n,s=!this.audioDisabled_&&i&&!r;if(e.waitingOnAppends=0,!e.hasAppendedData_)return e.timingInfo||"number"!=typeof e.timestampOffset||(this.isPendingTimestampOffset_=!0),e.timingInfo={start:0},e.waitingOnAppends++,this.isPendingTimestampOffset_||(this.updateSourceBufferTimestampOffset_(e),this.processMetadataQueue_()),void this.checkAppendsDone_(e);a&&e.waitingOnAppends++,s&&e.waitingOnAppends++,a&&this.sourceUpdater_.videoQueueCallback(this.checkAppendsDone_.bind(this,e)),s&&this.sourceUpdater_.audioQueueCallback(this.checkAppendsDone_.bind(this,e))},t.checkAppendsDone_=function(e){this.checkForAbort_(e.requestId)||(e.waitingOnAppends--,0===e.waitingOnAppends&&this.handleAppendsDone_())},t.checkForIllegalMediaSwitch=function(e){var t=function(e,t,i){return"main"===e&&t&&i?i.hasAudio||i.hasVideo?t.hasVideo&&!i.hasVideo?"Only audio found in segment when we expected video. We can't switch to audio only from a stream that had video. To get rid of this message, please add codec information to the manifest.":!t.hasVideo&&i.hasVideo?"Video found in segment when we expected only audio. We can't switch to a stream with video from an audio only stream. To get rid of this message, please add codec information to the manifest.":null:"Neither audio nor video found in segment.":null}(this.loaderType_,this.currentMediaInfo_,e);return!!t&&(this.error({message:t,blacklistDuration:1/0}),this.trigger("error"),!0)},t.updateSourceBufferTimestampOffset_=function(e){if(null!==e.timestampOffset&&"number"==typeof e.timingInfo.start&&!e.changedTimestampOffset&&"main"===this.loaderType_){var t=!1;e.timestampOffset-=e.timingInfo.start,e.changedTimestampOffset=!0,e.timestampOffset!==this.sourceUpdater_.videoTimestampOffset()&&(this.sourceUpdater_.videoTimestampOffset(e.timestampOffset),t=!0),e.timestampOffset!==this.sourceUpdater_.audioTimestampOffset()&&(this.sourceUpdater_.audioTimestampOffset(e.timestampOffset),t=!0),t&&this.trigger("timestampoffset")}},t.updateTimingInfoEnd_=function(e){e.timingInfo=e.timingInfo||{};var t="main"===this.loaderType_&&this.currentMediaInfo_.hasVideo&&e.videoTimingInfo?e.videoTimingInfo:e.audioTimingInfo;t&&(e.timingInfo.end="number"==typeof t.end?t.end:t.start+e.duration)},t.handleAppendsDone_=function(){if(this.pendingSegment_&&this.trigger("appendsdone"),!this.pendingSegment_)return this.state="READY",void(this.paused()||this.monitorBuffer_());var e=this.pendingSegment_;if(this.updateTimingInfoEnd_(e),this.shouldSaveSegmentTimingInfo_&&this.syncController_.saveSegmentTimingInfo({segmentInfo:e,shouldSaveTimelineMapping:"main"===this.loaderType_}),this.logger_(function(e){var t=e.segment,i=t.start,n=t.end,r=e.playlist,a=r.mediaSequence,s=r.id,o=r.segments,u=void 0===o?[]:o,l=e.mediaIndex,c=e.timeline;return["appending ["+l+"] of ["+a+", "+(a+u.length)+"] from playlist ["+s+"]","["+i+" => "+n+"] in timeline ["+c+"]"].join(" ")}(e)),this.recordThroughput_(e),this.pendingSegment_=null,this.state="READY",e.isSyncRequest)this.trigger("syncinfoupdate");else{this.addSegmentMetadataCue_(e),this.fetchAtBuffer_=!0,this.currentTimeline_!==e.timeline&&(this.timelineChangeController_.lastTimelineChange({type:this.loaderType_,from:this.currentTimeline_,to:e.timeline}),"main"!==this.loaderType_||this.audioDisabled_||this.timelineChangeController_.lastTimelineChange({type:"audio",from:this.currentTimeline_,to:e.timeline})),this.currentTimeline_=e.timeline,this.trigger("syncinfoupdate");var t=e.segment;if(t.end&&this.currentTime_()-t.end>3*e.playlist.targetDuration)this.resetEverything();else null!==this.mediaIndex&&this.trigger("bandwidthupdate"),this.trigger("progress"),this.mediaIndex=e.mediaIndex,this.isEndOfStream_(e.mediaIndex,e.playlist)&&this.endOfStream(),this.trigger("appended"),this.paused()||this.monitorBuffer_()}},t.recordThroughput_=function(e){var t=this.throughput.rate,i=Date.now()-e.endOfAllRequests+1,n=Math.floor(e.byteLength/i*8*1e3);this.throughput.rate+=(n-t)/++this.throughput.count},t.addSegmentMetadataCue_=function(e){if(this.segmentMetadataTrack_){var t=e.segment,i=t.start,n=t.end;if(Uc(i)&&Uc(n)){Nc(i,n,this.segmentMetadataTrack_);var r=T.WebKitDataCue||T.VTTCue,a={custom:t.custom,dateTimeObject:t.dateTimeObject,dateTimeString:t.dateTimeString,bandwidth:e.playlist.attributes.BANDWIDTH,resolution:e.playlist.attributes.RESOLUTION,codecs:e.playlist.attributes.CODECS,byteLength:e.byteLength,uri:e.uri,timeline:e.timeline,playlist:e.playlist.id,start:i,end:n},s=new r(i,n,JSON.stringify(a));s.value=a,this.segmentMetadataTrack_.addCue(s)}}},e}(da.EventTarget);function Wc(){}function zc(e){return"string"!=typeof e?e:e.replace(/./,function(e){return e.toUpperCase()})}function Gc(e,t){var i=t[e+"Buffer"];return i&&i.updating||t.queuePending[e]}function Xc(e,t){if(0!==t.queue.length){var i=0,n=t.queue[i];if("mediaSource"!==n.type){if("mediaSource"!==e&&t.started_&&"closed"!==t.mediaSource.readyState&&!Gc(e,t)){if(n.type!==e){if(null===(i=function(e,t){for(var i=0;i<t.length;i++){var n=t[i];if("mediaSource"===n.type)return null;if(n.type===e)return i}return null}(e,t.queue)))return;n=t.queue[i]}t.queue.splice(i,1),n.action(e,t),n.doneFn?t.queuePending[e]=n:Xc(e,t)}}else t.updating()||"closed"===t.mediaSource.readyState||(t.queue.shift(),n.action(t),n.doneFn&&n.doneFn(),Xc("audio",t),Xc("video",t))}}function Kc(e,t){var i=t[e+"Buffer"],n=zc(e);i&&(i.removeEventListener("updateend",t["on"+n+"UpdateEnd_"]),i.removeEventListener("error",t["on"+n+"Error_"]),t.codecs[e]=null,t[e+"Buffer"]=null)}function Yc(e,t){return e&&t&&-1!==Array.prototype.indexOf.call(e.sourceBuffers,t)}function $c(e){var t=e.type,i=e.sourceUpdater,n=e.action,r=e.doneFn,a=e.name;i.queue.push({type:t,action:n,doneFn:r,name:a}),Xc(t,i)}function Qc(i,n){return function(e){if(n.queuePending[i]){var t=n.queuePending[i].doneFn;n.queuePending[i]=null,t&&t(n[i+"Error_"])}Xc(i,n)}}function Jc(e){return decodeURIComponent(escape(String.fromCharCode.apply(null,e)))}function Zc(e,t){for(var i=e.cues,n=0;n<i.length;n++){var r=i[n];if(t>=r.adStartTime&&t<=r.adEndTime)return r}return null}function ed(e,t){e.abort(),e.pause(),t&&t.activePlaylistLoader&&(t.activePlaylistLoader.pause(),t.activePlaylistLoader=null)}function td(e,t){(t.activePlaylistLoader=e).load()}function id(t){["AUDIO","SUBTITLES","CLOSED-CAPTIONS"].forEach(function(e){wd[e](e,t)});var i=t.mediaTypes,e=t.masterPlaylistLoader,n=t.tech,r=t.vhs;["AUDIO","SUBTITLES"].forEach(function(e){i[e].activeGroup=function(a,s){return function(t){var e=s.masterPlaylistLoader,i=s.mediaTypes[a].groups,n=e.media();if(!n)return null;var r=null;return n.attributes[a]&&(r=i[n.attributes[a]]),r=r||i.main,"undefined"==typeof t?r:null===t?null:r.filter(function(e){return e.id===t.id})[0]||null}}(e,t),i[e].activeTrack=Id[e](e,t),i[e].onGroupChanged=function(o,u){return function(){var e=u.segmentLoaders,t=e[o],i=e.main,n=u.mediaTypes[o],r=n.activeTrack(),a=n.activeGroup(r),s=n.activePlaylistLoader;ed(t,n),a&&(a.playlistLoader?(t.resyncLoader(),td(a.playlistLoader,n)):s&&i.resetEverything())}}(e,t),i[e].onGroupChanging=function(t,i){return function(){var e=i.segmentLoaders[t];e.abort(),e.pause()}}(e,t),i[e].onTrackChanged=function(o,u){return function(){var e=u.segmentLoaders,t=e[o],i=e.main,n=u.mediaTypes[o],r=n.activeTrack(),a=n.activeGroup(r),s=n.activePlaylistLoader;if(ed(t,n),a){if("AUDIO"===o){if(!a.playlistLoader)return i.setAudio(!0),void i.resetEverything();t.setAudio(!0),i.setAudio(!1)}s!==a.playlistLoader&&(t.track&&t.track(r),t.resetEverything()),td(a.playlistLoader,n)}}}(e,t)});var a=i.AUDIO.activeGroup();if(a){var s=(a.filter(function(e){return e.default})[0]||a[0]).id;i.AUDIO.tracks[s].enabled=!0,i.AUDIO.onTrackChanged()}function o(){i.AUDIO.onTrackChanged(),n.trigger({type:"usage",name:"vhs-audio-change"}),n.trigger({type:"usage",name:"hls-audio-change"})}for(var u in e.on("mediachange",function(){["AUDIO","SUBTITLES"].forEach(function(e){return i[e].onGroupChanged()})}),e.on("mediachanging",function(){["AUDIO","SUBTITLES"].forEach(function(e){return i[e].onGroupChanging()})}),n.audioTracks().addEventListener("change",o),n.remoteTextTracks().addEventListener("change",i.SUBTITLES.onTrackChanged),r.on("dispose",function(){n.audioTracks().removeEventListener("change",o),n.remoteTextTracks().removeEventListener("change",i.SUBTITLES.onTrackChanged)}),n.clearTracks("audio"),i.AUDIO.tracks)n.audioTracks().addTrack(i.AUDIO.tracks[u])}function nd(e,t,i){var n=e.masterPlaylistController_,r=n[(e.options_.smoothQualityChange?"smooth":"fast")+"QualityChange_"].bind(n);if(t.attributes.RESOLUTION){var a=t.attributes.RESOLUTION;this.width=a.width,this.height=a.height}this.bandwidth=t.attributes.BANDWIDTH,this.codecs=xc(n.master(),t),this.playlist=t,this.id=i,this.enabled=function(r,a,s){return function(e){var t=r.master.playlists[a],i=sl(t),n=ol(t);return"undefined"==typeof e?n:(e?delete t.disabled:t.disabled=!0,e===n||i||(s(),e?r.trigger("renditionenabled"):r.trigger("renditiondisabled")),e)}}(e.playlists,t.id,r)}function rd(e){!function t(i,e){var n=0,r=0,a=da.mergeOptions(Od,e);i.ready(function(){i.trigger({type:"usage",name:"vhs-error-reload-initialized"}),i.trigger({type:"usage",name:"hls-error-reload-initialized"})});function s(){r&&i.currentTime(r)}function o(e){null!=e&&(r=i.duration()!==1/0&&i.currentTime()||0,i.one("loadedmetadata",s),i.src(e),i.trigger({type:"usage",name:"vhs-error-reload"}),i.trigger({type:"usage",name:"hls-error-reload"}),i.play())}function u(){return Date.now()-n<1e3*a.errorInterval?(i.trigger({type:"usage",name:"vhs-error-reload-canceled"}),void i.trigger({type:"usage",name:"hls-error-reload-canceled"})):a.getSource&&"function"==typeof a.getSource?(n=Date.now(),a.getSource.call(i,o)):void da.log.error("ERROR: reloadSourceOnError - The option getSource must be a function!")}function l(){i.off("loadedmetadata",s),i.off("error",u),i.off("dispose",l)}i.on("error",u),i.on("dispose",l),i.reloadSourceOnError=function(e){l(),t(i,e)}}(this,e)}var ad,sd=["video","audio"],od=function(n,r){return function(e,t){var i=t[e+"Buffer"];Yc(t.mediaSource,i)&&(t.logger_("Appending segment "+r.mediaIndex+"'s "+n.length+" bytes to "+e+"Buffer"),i.appendBuffer(n))}},ud=function(n,r){return function(e,t){var i=t[e+"Buffer"];Yc(t.mediaSource,i)&&(t.logger_("Removing "+n+" to "+r+" from "+e+"Buffer"),i.remove(n,r))}},ld=function(n){return function(e,t){var i=t[e+"Buffer"];Yc(t.mediaSource,i)&&(t.logger_("Setting "+e+"timestampOffset to "+n),i.timestampOffset=n)}},cd=function(i){return function(e,t){i()}},dd=function(t){return function(e){if("open"===e.mediaSource.readyState){e.logger_("Calling mediaSource endOfStream("+(t||"")+")");try{e.mediaSource.endOfStream(t)}catch(e){da.log.warn("Failed to call media source endOfStream",e)}}}},hd=function(t){return function(e){e.logger_("Setting mediaSource duration to "+t);try{e.mediaSource.duration=t}catch(e){da.log.warn("Failed to set media source duration",e)}}},pd=function(){return function(t,e){if("open"===e.mediaSource.readyState){var i=e[t+"Buffer"];if(Yc(e.mediaSource,i)){e.logger_("calling abort on "+t+"Buffer");try{i.abort()}catch(e){da.log.warn("Failed to abort on "+t+"Buffer",e)}}}}},fd=function(r,a){return function(e){var t=zc(r),i=Wl(a);e.logger_("Adding "+r+"Buffer with codec "+a+" to mediaSource");var n=e.mediaSource.addSourceBuffer(i);n.addEventListener("updateend",e["on"+t+"UpdateEnd_"]),n.addEventListener("error",e["on"+t+"Error_"]),e.codecs[r]=a,e[r+"Buffer"]=n}},md=function(i){return function(e){var t=e[i+"Buffer"];if(Kc(i,e),Yc(e.mediaSource,t)){e.logger_("Removing "+i+"Buffer with codec "+e.codecs[i]+" from mediaSource");try{e.mediaSource.removeSourceBuffer(t)}catch(e){da.log.warn("Failed to removeSourceBuffer "+i+"Buffer",e)}}}},gd=function(r){return function(e,t){var i=t[e+"Buffer"],n=Wl(r);Yc(t.mediaSource,i)&&t.codecs[e]!==r&&(t.logger_("changing "+e+"Buffer codec from "+t.codecs[e]+" to "+r),i.changeType(n),t.codecs[e]=r)}},vd=function(i){function e(e){var t;return(t=i.call(this)||this).mediaSource=e,t.sourceopenListener_=function(){return Xc("mediaSource",Ve(t))},t.mediaSource.addEventListener("sourceopen",t.sourceopenListener_),t.logger_=Pc("SourceUpdater"),t.audioTimestampOffset_=0,t.videoTimestampOffset_=0,t.queue=[],t.queuePending={audio:null,video:null},t.delayedAudioAppendQueue_=[],t.videoAppendQueued_=!1,t.codecs={},t.onVideoUpdateEnd_=Qc("video",Ve(t)),t.onAudioUpdateEnd_=Qc("audio",Ve(t)),t.onVideoError_=function(e){t.videoError_=e},t.onAudioError_=function(e){t.audioError_=e},t.started_=!1,t}Ge(e,i);var t=e.prototype;return t.ready=function(){return this.started_},t.createSourceBuffers=function(e){this.ready()||(this.addOrChangeSourceBuffers(e),this.started_=!0,this.trigger("ready"))},t.addSourceBuffer=function(e,t){$c({type:"mediaSource",sourceUpdater:this,action:fd(e,t),name:"addSourceBuffer"})},t.abort=function(e){$c({type:e,sourceUpdater:this,action:pd(e),name:"abort"})},t.removeSourceBuffer=function(e){this.canRemoveSourceBuffer()?$c({type:"mediaSource",sourceUpdater:this,action:md(e),name:"removeSourceBuffer"}):da.log.error("removeSourceBuffer is not supported!")},t.canRemoveSourceBuffer=function(){return!da.browser.IE_VERSION&&T.MediaSource&&T.MediaSource.prototype&&"function"==typeof T.MediaSource.prototype.removeSourceBuffer},e.canChangeType=function(){return T.SourceBuffer&&T.SourceBuffer.prototype&&"function"==typeof T.SourceBuffer.prototype.changeType},t.canChangeType=function(){return this.constructor.canChangeType()},t.changeType=function(e,t){this.canChangeType()?$c({type:e,sourceUpdater:this,action:gd(t),name:"changeType"}):da.log.error("changeType is not supported!")},t.addOrChangeSourceBuffers=function(i){var n=this;if(!i||"object"!=typeof i||0===Object.keys(i).length)throw new Error("Cannot addOrChangeSourceBuffers to undefined codecs");Object.keys(i).forEach(function(e){var t=i[e];if(!n.ready())return n.addSourceBuffer(e,t);n.canChangeType()&&n.changeType(e,t)})},t.appendBuffer=function(e,t){var i=this,n=e.segmentInfo,r=e.type,a=e.bytes;if(this.processedAppend_=!0,"audio"===r&&this.videoBuffer&&!this.videoAppendQueued_)return this.delayedAudioAppendQueue_.push([e,t]),void this.logger_("delayed audio append of "+a.length+" until video append");if($c({type:r,sourceUpdater:this,action:od(a,n||{mediaIndex:-1}),doneFn:t,name:"appendBuffer"}),"video"===r){if(this.videoAppendQueued_=!0,!this.delayedAudioAppendQueue_.length)return;var s=this.delayedAudioAppendQueue_.slice();this.logger_("queuing delayed audio "+s.length+" appendBuffers"),this.delayedAudioAppendQueue_.length=0,s.forEach(function(e){i.appendBuffer.apply(i,e)})}},t.audioBuffered=function(){return Yc(this.mediaSource,this.audioBuffer)&&this.audioBuffer.buffered?this.audioBuffer.buffered:da.createTimeRange()},t.videoBuffered=function(){return Yc(this.mediaSource,this.videoBuffer)&&this.videoBuffer.buffered?this.videoBuffer.buffered:da.createTimeRange()},t.buffered=function(){var e=Yc(this.mediaSource,this.videoBuffer)?this.videoBuffer:null,t=Yc(this.mediaSource,this.audioBuffer)?this.audioBuffer:null;return t&&!e?this.audioBuffered():e&&!t?this.videoBuffered():function(e,t){var i=null,n=null,r=0,a=[],s=[];if(!(e&&e.length&&t&&t.length))return da.createTimeRange();for(var o=e.length;o--;)a.push({time:e.start(o),type:"start"}),a.push({time:e.end(o),type:"end"});for(o=t.length;o--;)a.push({time:t.start(o),type:"start"}),a.push({time:t.end(o),type:"end"});for(a.sort(function(e,t){return e.time-t.time}),o=0;o<a.length;o++)"start"===a[o].type?2===++r&&(i=a[o].time):"end"===a[o].type&&1===--r&&(n=a[o].time),null!==i&&null!==n&&(s.push([i,n]),n=i=null);return da.createTimeRanges(s)}(this.audioBuffered(),this.videoBuffered())},t.setDuration=function(e,t){void 0===t&&(t=Wc),$c({type:"mediaSource",sourceUpdater:this,action:hd(e),name:"duration",doneFn:t})},t.endOfStream=function(e,t){void 0===e&&(e=null),void 0===t&&(t=Wc),"string"!=typeof e&&(e=void 0),$c({type:"mediaSource",sourceUpdater:this,action:dd(e),name:"endOfStream",doneFn:t})},t.removeAudio=function(e,t,i){void 0===i&&(i=Wc),this.audioBuffered().length&&0!==this.audioBuffered().end(0)?$c({type:"audio",sourceUpdater:this,action:ud(e,t),doneFn:i,name:"remove"}):i()},t.removeVideo=function(e,t,i){void 0===i&&(i=Wc),this.videoBuffered().length&&0!==this.videoBuffered().end(0)?$c({type:"video",sourceUpdater:this,action:ud(e,t),doneFn:i,name:"remove"}):i()},t.updating=function(){return!(!Gc("audio",this)&&!Gc("video",this))},t.audioTimestampOffset=function(e){return"undefined"!=typeof e&&this.audioBuffer&&this.audioTimestampOffset_!==e&&($c({type:"audio",sourceUpdater:this,action:ld(e),name:"timestampOffset"}),this.audioTimestampOffset_=e),this.audioTimestampOffset_},t.videoTimestampOffset=function(e){return"undefined"!=typeof e&&this.videoBuffer&&this.videoTimestampOffset!==e&&($c({type:"video",sourceUpdater:this,action:ld(e),name:"timestampOffset"}),this.videoTimestampOffset_=e),this.videoTimestampOffset_},t.audioQueueCallback=function(e){this.audioBuffer&&$c({type:"audio",sourceUpdater:this,action:cd(e),name:"callback"})},t.videoQueueCallback=function(e){this.videoBuffer&&$c({type:"video",sourceUpdater:this,action:cd(e),name:"callback"})},t.dispose=function(){var t=this;this.trigger("dispose"),sd.forEach(function(e){t.abort(e),t.canRemoveSourceBuffer()?t.removeSourceBuffer(e):t[e+"QueueCallback"](function(){return Kc(e,t)})}),this.videoAppendQueued_=!1,this.delayedAudioAppendQueue_.length=0,this.sourceopenListener_&&this.mediaSource.removeEventListener("sourceopen",this.sourceopenListener_),this.off()},e}(da.EventTarget),yd=new Uint8Array("\n\n".split("").map(function(e){return e.charCodeAt(0)})),_d=function(n){function e(e,t){var i;return void 0===t&&(t={}),(i=n.call(this,e,t)||this).handlePartialData_=!1,i.mediaSource_=null,i.subtitlesTrack_=null,i.loaderType_="subtitle",i.featuresNativeTextTracks_=e.featuresNativeTextTracks,i.shouldSaveSegmentTimingInfo_=!1,i}Ge(e,n);var t=e.prototype;return t.createTransmuxer_=function(){return null},t.buffered_=function(){if(!this.subtitlesTrack_||!this.subtitlesTrack_.cues.length)return da.createTimeRanges();var e=this.subtitlesTrack_.cues,t=e[0].startTime,i=e[e.length-1].startTime;return da.createTimeRanges([[t,i]])},t.initSegmentForMap=function(e,t){if(void 0===t&&(t=!1),!e)return null;var i=gl(e),n=this.initSegments_[i];if(t&&!n&&e.bytes){var r=yd.byteLength+e.bytes.byteLength,a=new Uint8Array(r);a.set(e.bytes),a.set(yd,e.bytes.byteLength),this.initSegments_[i]=n={resolvedUri:e.resolvedUri,byterange:e.byterange,bytes:a}}return n||e},t.couldBeginLoading_=function(){return this.playlist_&&this.subtitlesTrack_&&!this.paused()},t.init_=function(){return this.state="READY",this.resetEverything(),this.monitorBuffer_()},t.track=function(e){return"undefined"==typeof e||(this.subtitlesTrack_=e,"INIT"===this.state&&this.couldBeginLoading_()&&this.init_()),this.subtitlesTrack_},t.remove=function(e,t){Nc(e,t,this.subtitlesTrack_)},t.fillBuffer_=function(){var e=this;this.syncPoint_||(this.syncPoint_=this.syncController_.getSyncPoint(this.playlist_,this.duration_(),this.currentTimeline_,this.currentTime_()));var t=this.checkBuffer_(this.buffered_(),this.playlist_,this.mediaIndex,this.hasPlayed_(),this.currentTime_(),this.syncPoint_);if(t=this.skipEmptySegments_(t)){if(null===this.syncController_.timestampOffsetForTimeline(t.timeline)){return this.syncController_.one("timestampoffset",function(){e.state="READY",e.paused()||e.monitorBuffer_()}),void(this.state="WAITING_ON_TIMELINE")}this.loadSegment_(t)}},t.skipEmptySegments_=function(e){for(;e&&e.segment.empty;)e=this.generateSegmentInfo_(e.playlist,e.mediaIndex+1,e.startOfSegment+e.duration,e.isSyncRequest);return e},t.stopForError=function(e){this.error(e),this.state="READY",this.pause(),this.trigger("error")},t.segmentRequestFinished_=function(e,t,i){var n=this;if(this.subtitlesTrack_){if(this.saveTransferStats_(t.stats),!this.pendingSegment_)return this.state="READY",void(this.mediaRequestsAborted+=1);if(e)return e.code===mc&&this.handleTimeout_(),e.code===gc?this.mediaRequestsAborted+=1:this.mediaRequestsErrored+=1,void this.stopForError(e);this.saveBandwidthRelatedStats_(t.stats),this.state="APPENDING",this.trigger("appending");var r=this.pendingSegment_,a=r.segment;if(a.map&&(a.map.bytes=t.map.bytes),r.bytes=t.bytes,"function"!=typeof T.WebVTT&&this.subtitlesTrack_&&this.subtitlesTrack_.tech_){var s,o=function(){n.subtitlesTrack_.tech_.off("vttjsloaded",s),n.stopForError({message:"Error loading vtt.js"})};return s=function(){n.subtitlesTrack_.tech_.off("vttjserror",o),n.segmentRequestFinished_(e,t,i)},this.state="WAITING_ON_VTTJS",this.subtitlesTrack_.tech_.one("vttjsloaded",s),void this.subtitlesTrack_.tech_.one("vttjserror",o)}a.requested=!0;try{this.parseVTTCues_(r)}catch(e){return void this.stopForError({message:e.message})}if(this.updateTimeMapping_(r,this.syncController_.timelines[r.timeline],this.playlist_),r.cues.length?r.timingInfo={start:r.cues[0].startTime,end:r.cues[r.cues.length-1].endTime}:r.timingInfo={start:r.startOfSegment,end:r.startOfSegment+r.duration},r.isSyncRequest)return this.trigger("syncinfoupdate"),this.pendingSegment_=null,void(this.state="READY");r.byteLength=r.bytes.byteLength,this.mediaSecondsLoaded+=a.duration,r.cues.forEach(function(e){n.remove(e.startTime,e.endTime),n.subtitlesTrack_.addCue(n.featuresNativeTextTracks_?new T.VTTCue(e.startTime,e.endTime,e.text):e)}),this.handleAppendsDone_()}else this.state="READY"},t.handleData_=function(){},t.updateTimingInfoEnd_=function(){},t.parseVTTCues_=function(t){var e,i=!1;"function"==typeof T.TextDecoder?e=new T.TextDecoder("utf8"):(e=T.WebVTT.StringDecoder(),i=!0);var n=new T.WebVTT.Parser(T,T.vttjs,e);if(t.cues=[],t.timestampmap={MPEGTS:0,LOCAL:0},n.oncue=t.cues.push.bind(t.cues),n.ontimestampmap=function(e){t.timestampmap=e},n.onparsingerror=function(e){da.log.warn("Error encountered when parsing cues: "+e.message)},t.segment.map){var r=t.segment.map.bytes;i&&(r=Jc(r)),n.parse(r)}var a=t.bytes;i&&(a=Jc(a)),n.parse(a),n.flush()},t.updateTimeMapping_=function(e,t,i){var n=e.segment;if(t)if(e.cues.length){var r=e.timestampmap,a=r.MPEGTS/fu-r.LOCAL+t.mapping;if(e.cues.forEach(function(e){e.startTime+=a,e.endTime+=a}),!i.syncInfo){var s=e.cues[0].startTime,o=e.cues[e.cues.length-1].startTime;i.syncInfo={mediaSequence:i.mediaSequence+e.mediaIndex,time:Math.min(s,o-n.duration)}}}else n.empty=!0},e}(Hc),bd=[{name:"VOD",run:function(e,t,i,n,r){if(i===1/0)return null;return{time:0,segmentIndex:0}}},{name:"ProgramDateTime",run:function(e,t,i,n,r){if(!e.datetimeToDisplayTime)return null;var a=t.segments||[],s=null,o=null;r=r||0;for(var u=0;u<a.length;u++){var l=a[u];if(l.dateTimeObject){var c=l.dateTimeObject.getTime()/1e3+e.datetimeToDisplayTime,d=Math.abs(r-c);if(null!==o&&(0===d||o<d))break;o=d,s={time:c,segmentIndex:u}}}return s}},{name:"Segment",run:function(e,t,i,n,r){var a=t.segments||[],s=null,o=null;r=r||0;for(var u=0;u<a.length;u++){var l=a[u];if(l.timeline===n&&"undefined"!=typeof l.start){var c=Math.abs(r-l.start);if(null!==o&&o<c)break;(!s||null===o||c<=o)&&(o=c,s={time:l.start,segmentIndex:u})}}return s}},{name:"Discontinuity",run:function(e,t,i,n,r){var a=null;if(r=r||0,t.discontinuityStarts&&t.discontinuityStarts.length)for(var s=null,o=0;o<t.discontinuityStarts.length;o++){var u=t.discontinuityStarts[o],l=t.discontinuitySequence+o+1,c=e.discontinuities[l];if(c){var d=Math.abs(r-c.time);if(null!==s&&s<d)break;(!a||null===s||d<=s)&&(s=d,a={time:c.time,segmentIndex:u})}}return a}},{name:"Playlist",run:function(e,t,i,n,r){return t.syncInfo?{time:t.syncInfo.time,segmentIndex:t.syncInfo.mediaSequence-t.mediaSequence}:null}}],Td=function(i){function e(e){var t;return(t=i.call(this)||this).timelines=[],t.discontinuities=[],t.datetimeToDisplayTime=null,t.logger_=Pc("SyncController"),t}Ge(e,i);var t=e.prototype;return t.getSyncPoint=function(e,t,i,n){var r=this.runStrategies_(e,t,i,n);return r.length?this.selectSyncPoint_(r,{key:"time",value:n}):null},t.getExpiredTime=function(e,t){if(!e||!e.segments)return null;var i=this.runStrategies_(e,t,e.discontinuitySequence,0);if(!i.length)return null;var n=this.selectSyncPoint_(i,{key:"segmentIndex",value:0});return 0<n.segmentIndex&&(n.time*=-1),Math.abs(n.time+il(e,n.segmentIndex,0))},t.runStrategies_=function(e,t,i,n){for(var r=[],a=0;a<bd.length;a++){var s=bd[a],o=s.run(this,e,t,i,n);o&&(o.strategy=s.name,r.push({strategy:s.name,syncPoint:o}))}return r},t.selectSyncPoint_=function(e,t){for(var i=e[0].syncPoint,n=Math.abs(e[0].syncPoint[t.key]-t.value),r=e[0].strategy,a=1;a<e.length;a++){var s=Math.abs(e[a].syncPoint[t.key]-t.value);s<n&&(n=s,i=e[a].syncPoint,r=e[a].strategy)}return this.logger_("syncPoint for ["+t.key+": "+t.value+"] chosen with strategy ["+r+"]: [time:"+i.time+", segmentIndex:"+i.segmentIndex+"]"),i},t.saveExpiredSegmentInfo=function(e,t){for(var i=t.mediaSequence-e.mediaSequence-1;0<=i;i--){var n=e.segments[i];if(n&&"undefined"!=typeof n.start){t.syncInfo={mediaSequence:e.mediaSequence+i,time:n.start},this.logger_("playlist refresh sync: [time:"+t.syncInfo.time+", mediaSequence: "+t.syncInfo.mediaSequence+"]"),this.trigger("syncinfoupdate");break}}},t.setDateTimeMapping=function(e){if(!this.datetimeToDisplayTime&&e.segments&&e.segments.length&&e.segments[0].dateTimeObject){var t=e.segments[0].dateTimeObject.getTime()/1e3;this.datetimeToDisplayTime=-t}},t.saveSegmentTimingInfo=function(e){var t=e.segmentInfo,i=e.shouldSaveTimelineMapping;this.calculateSegmentTimeMapping_(t,t.timingInfo,i)&&(this.saveDiscontinuitySyncInfo_(t),t.playlist.syncInfo||(t.playlist.syncInfo={mediaSequence:t.playlist.mediaSequence+t.mediaIndex,time:t.segment.start}))},t.timestampOffsetForTimeline=function(e){return"undefined"==typeof this.timelines[e]?null:this.timelines[e].time},t.mappingForTimeline=function(e){return"undefined"==typeof this.timelines[e]?null:this.timelines[e].mapping},t.calculateSegmentTimeMapping_=function(e,t,i){var n=e.segment,r=this.timelines[e.timeline];if(null!==e.timestampOffset)r={time:e.startOfSegment,mapping:e.startOfSegment-t.start},i&&(this.timelines[e.timeline]=r,this.trigger("timestampoffset"),this.logger_("time mapping for timeline "+e.timeline+": [time: "+r.time+"] [mapping: "+r.mapping+"]")),n.start=e.startOfSegment,n.end=t.end+r.mapping;else{if(!r)return!1;n.start=t.start+r.mapping,n.end=t.end+r.mapping}return!0},t.saveDiscontinuitySyncInfo_=function(e){var t=e.playlist,i=e.segment;if(i.discontinuity)this.discontinuities[i.timeline]={time:i.start,accuracy:0};else if(t.discontinuityStarts&&t.discontinuityStarts.length)for(var n=0;n<t.discontinuityStarts.length;n++){var r=t.discontinuityStarts[n],a=t.discontinuitySequence+n+1,s=r-e.mediaIndex,o=Math.abs(s);if(!this.discontinuities[a]||this.discontinuities[a].accuracy>o){var u=void 0;u=s<0?i.start-il(t,e.mediaIndex,r):i.end+il(t,e.mediaIndex+1,r),this.discontinuities[a]={time:u,accuracy:o}}}},t.dispose=function(){this.trigger("dispose"),this.off()},e}(da.EventTarget),Sd=function(t){function e(){var e;return(e=t.call(this)||this).pendingTimelineChanges_={},e.lastTimelineChanges_={},e}Ge(e,t);var i=e.prototype;return i.clearPendingTimelineChange=function(e){this.pendingTimelineChanges_[e]=null,this.trigger("pendingtimelinechange")},i.pendingTimelineChange=function(e){var t=e.type,i=e.from,n=e.to;return"number"==typeof i&&"number"==typeof n&&(this.pendingTimelineChanges_[t]={type:t,from:i,to:n},this.trigger("pendingtimelinechange")),this.pendingTimelineChanges_[t]},i.lastTimelineChange=function(e){var t=e.type,i=e.from,n=e.to;return"number"==typeof i&&"number"==typeof n&&(this.lastTimelineChanges_[t]={type:t,from:i,to:n},delete this.pendingTimelineChanges_[t],this.trigger("timelinechange")),this.lastTimelineChanges_[t]},i.dispose=function(){this.trigger("dispose"),this.pendingTimelineChanges_={},this.lastTimelineChanges_={},this.off()},e}(da.EventTarget),kd=new kc("./decrypter-worker.worker.js",function(e,t){var i,n,r,c,g,l,s,a=this; -/*! @name @videojs/http-streaming @version 2.2.4 @license Apache-2.0 */i=function(e,t,i){return t&&o(e.prototype,t),i&&o(e,i),e},n=function(e,t){e.prototype=Object.create(t.prototype),(e.prototype.constructor=e).__proto__=t},r=function(){function e(){this.listeners={}}var t=e.prototype;return t.on=function(e,t){this.listeners[e]||(this.listeners[e]=[]),this.listeners[e].push(t)},t.off=function(e,t){if(!this.listeners[e])return!1;var i=this.listeners[e].indexOf(t);return this.listeners[e]=this.listeners[e].slice(0),this.listeners[e].splice(i,1),-1<i},t.trigger=function(e,t){var i=this.listeners[e];if(i)if(2===arguments.length)for(var n=i.length,r=0;r<n;++r)i[r].call(this,t);else for(var a=Array.prototype.slice.call(arguments,1),s=i.length,o=0;o<s;++o)i[o].apply(this,a)},t.dispose=function(){this.listeners={}},t.pipe=function(t){this.on("data",function(e){t.push(e)})},e}(),c=null,g=function(){function e(e){var t,i,n;c=c||function(){var e,t,i,n,r,a,s,o,u=[[[],[],[],[],[]],[[],[],[],[],[]]],l=u[0],c=u[1],d=l[4],h=c[4],p=[],f=[];for(e=0;e<256;e++)f[(p[e]=e<<1^283*(e>>7))^e]=e;for(t=i=0;!d[t];t^=n||1,i=f[i]||1)for(a=(a=i^i<<1^i<<2^i<<3^i<<4)>>8^255&a^99,o=16843009*p[r=p[n=p[h[d[t]=a]=t]]]^65537*r^257*n^16843008*t,s=257*p[a]^16843008*a,e=0;e<4;e++)l[e][t]=s=s<<24^s>>>8,c[e][a]=o=o<<24^o>>>8;for(e=0;e<5;e++)l[e]=l[e].slice(0),c[e]=c[e].slice(0);return u}(),this._tables=[[c[0][0].slice(),c[0][1].slice(),c[0][2].slice(),c[0][3].slice(),c[0][4].slice()],[c[1][0].slice(),c[1][1].slice(),c[1][2].slice(),c[1][3].slice(),c[1][4].slice()]];var r=this._tables[0][4],a=this._tables[1],s=e.length,o=1;if(4!==s&&6!==s&&8!==s)throw new Error("Invalid aes key size");var u=e.slice(0),l=[];for(this._key=[u,l],t=s;t<4*s+28;t++)n=u[t-1],(t%s==0||8===s&&t%s==4)&&(n=r[n>>>24]<<24^r[n>>16&255]<<16^r[n>>8&255]<<8^r[255&n],t%s==0&&(n=n<<8^n>>>24^o<<24,o=o<<1^283*(o>>7))),u[t]=u[t-s]^n;for(i=0;t;i++,t--)n=u[3&i?t:t-4],l[i]=t<=4||i<4?n:a[0][r[n>>>24]]^a[1][r[n>>16&255]]^a[2][r[n>>8&255]]^a[3][r[255&n]]}return e.prototype.decrypt=function(e,t,i,n,r,a){var s,o,u,l,c=this._key[1],d=e^c[0],h=n^c[1],p=i^c[2],f=t^c[3],m=c.length/4-2,g=4,v=this._tables[1],y=v[0],_=v[1],b=v[2],T=v[3],S=v[4];for(l=0;l<m;l++)s=y[d>>>24]^_[h>>16&255]^b[p>>8&255]^T[255&f]^c[g],o=y[h>>>24]^_[p>>16&255]^b[f>>8&255]^T[255&d]^c[g+1],u=y[p>>>24]^_[f>>16&255]^b[d>>8&255]^T[255&h]^c[g+2],f=y[f>>>24]^_[d>>16&255]^b[h>>8&255]^T[255&p]^c[g+3],g+=4,d=s,h=o,p=u;for(l=0;l<4;l++)r[(3&-l)+a]=S[d>>>24]<<24^S[h>>16&255]<<16^S[p>>8&255]<<8^S[255&f]^c[g++],s=d,d=h,h=p,p=f,f=s},e}(),l=function(t){function e(){var e;return(e=t.call(this,r)||this).jobs=[],e.delay=1,e.timeout_=null,e}n(e,t);var i=e.prototype;return i.processJob_=function(){this.jobs.shift()(),this.jobs.length?this.timeout_=setTimeout(this.processJob_.bind(this),this.delay):this.timeout_=null},i.push=function(e){this.jobs.push(e),this.timeout_||(this.timeout_=setTimeout(this.processJob_.bind(this),this.delay))},e}(r),s=function(){function u(e,t,i,n){var r=u.STEP,a=new Int32Array(e.buffer),s=new Uint8Array(e.byteLength),o=0;for(this.asyncStream_=new l,this.asyncStream_.push(this.decryptChunk_(a.subarray(o,o+r),t,i,s)),o=r;o<a.length;o+=r)i=new Uint32Array([v(a[o-4]),v(a[o-3]),v(a[o-2]),v(a[o-1])]),this.asyncStream_.push(this.decryptChunk_(a.subarray(o,o+r),t,i,s));this.asyncStream_.push(function(){n(null, -/*! @name pkcs7 @version 1.0.4 @license Apache-2.0 */ -function(e){return e.subarray(0,e.byteLength-e[e.byteLength-1])} -/*! @name aes-decrypter @version 3.1.0 @license Apache-2.0 */(s))})}return u.prototype.decryptChunk_=function(t,i,n,r){return function(){var e=function(e,t,i){var n,r,a,s,o,u,l,c,d,h=new Int32Array(e.buffer,e.byteOffset,e.byteLength>>2),p=new g(Array.prototype.slice.call(t)),f=new Uint8Array(e.byteLength),m=new Int32Array(f.buffer);for(n=i[0],r=i[1],a=i[2],s=i[3],d=0;d<h.length;d+=4)o=v(h[d]),u=v(h[d+1]),l=v(h[d+2]),c=v(h[d+3]),p.decrypt(o,u,l,c,m,d),m[d]=v(m[d]^n),m[d+1]=v(m[d+1]^r),m[d+2]=v(m[d+2]^a),m[d+3]=v(m[d+3]^s),n=o,r=u,a=l,s=c;return f}(t,i,n);r.set(e,t.byteOffset)}},i(u,null,[{key:"STEP",get:function(){return 32e3}}]),u}(),new function(a){a.onmessage=function(e){var i=e.data,t=new Uint8Array(i.encrypted.bytes,i.encrypted.byteOffset,i.encrypted.byteLength),n=new Uint32Array(i.key.bytes,i.key.byteOffset,i.key.byteLength/4),r=new Uint32Array(i.iv.bytes,i.iv.byteOffset,i.iv.byteLength/4);new s(t,n,r,function(e,t){a.postMessage(function(i){var n={};return Object.keys(i).forEach(function(e){var t=i[e];ArrayBuffer.isView(t)?n[e]={bytes:t.buffer,byteOffset:t.byteOffset,byteLength:t.byteLength}:n[e]=t}),n}({source:i.source,decrypted:t}),[t.buffer])})}}(a);function o(e,t){for(var i=0;i<t.length;i++){var n=t[i];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,n.key,n)}}function v(e){return e<<24|(65280&e)<<8|(16711680&e)>>8|e>>>24}}),Cd={AUDIO:function(u,l){return function(){var e=l.segmentLoaders[u],t=l.mediaTypes[u],i=l.blacklistCurrentPlaylist;ed(e,t);var n=t.activeTrack(),r=t.activeGroup(),a=(r.filter(function(e){return e.default})[0]||r[0]).id,s=t.tracks[a];if(n!==s){for(var o in da.log.warn("Problem encountered loading the alternate audio track.Switching back to default."),t.tracks)t.tracks[o].enabled=t.tracks[o]===s;t.onTrackChanged()}else i({message:"Problem encountered loading the default audio track."})}},SUBTITLES:function(n,r){return function(){var e=r.segmentLoaders[n],t=r.mediaTypes[n];da.log.warn("Problem encountered loading the subtitle track.Disabling subtitle track."),ed(e,t);var i=t.activeTrack();i&&(i.mode="disabled"),t.onTrackChanged()}}},Ed={AUDIO:function(e,t,i){if(t){var n=i.tech,r=i.requestOptions,a=i.segmentLoaders[e];t.on("loadedmetadata",function(){var e=t.media();a.playlist(e,r),(!n.paused()||e.endList&&"none"!==n.preload())&&a.load()}),t.on("loadedplaylist",function(){a.playlist(t.media(),r),n.paused()||a.load()}),t.on("error",Cd[e](e,i))}},SUBTITLES:function(e,t,i){var n=i.tech,r=i.requestOptions,a=i.segmentLoaders[e],s=i.mediaTypes[e];t.on("loadedmetadata",function(){var e=t.media();a.playlist(e,r),a.track(s.activeTrack()),(!n.paused()||e.endList&&"none"!==n.preload())&&a.load()}),t.on("loadedplaylist",function(){a.playlist(t.media(),r),n.paused()||a.load()}),t.on("error",Cd[e](e,i))}},wd={AUDIO:function(s,o){var u=o.vhs,l=o.sourceType,e=o.segmentLoaders[s],c=o.requestOptions,t=o.master,d=t.mediaGroups,i=t.playlists,n=o.mediaTypes[s],h=n.groups,p=n.tracks,f=o.masterPlaylistLoader;d[s]&&0!==Object.keys(d[s]).length||(d[s]={main:{default:{default:!0}}});function r(r){function e(e){var t=d[s][r][e];a.filter(function(e){return e.resolvedUri===t.resolvedUri}).length&&delete t.resolvedUri;var i=void 0;if(i="vhs-json"===l&&t.playlists?new Zl(t.playlists[0],u,c):t.resolvedUri?new Zl(t.resolvedUri,u,c):t.playlists&&"dash"===l?new oc(t.playlists[0],u,c,f):null,t=da.mergeOptions({id:e,playlistLoader:i},t),Ed[s](s,t.playlistLoader,o),h[r].push(t),"undefined"==typeof p[e]){var n=new da.AudioTrack({id:e,kind:function(e){var t=e.default?"main":"alternative";return e.characteristics&&0<=e.characteristics.indexOf("public.accessibility.describes-video")&&(t="main-desc"),t}(t),enabled:!1,language:t.language,default:t.default,label:e});p[e]=n}}h[r]||(h[r]=[]);var a=i.filter(function(e){return e.attributes[s]===r});for(var t in d[s][r])e(t)}for(var a in d[s])r(a);e.on("error",Cd[s](s,o))},SUBTITLES:function(e,t){var i=t.tech,n=t.vhs,r=t.sourceType,a=t.segmentLoaders[e],s=t.requestOptions,o=t.master.mediaGroups,u=t.mediaTypes[e],l=u.groups,c=u.tracks,d=t.masterPlaylistLoader;for(var h in o[e])for(var p in l[h]||(l[h]=[]),o[e][h])if(!o[e][h][p].forced){var f=o[e][h][p],m=void 0;if("hls"===r?m=new Zl(f.resolvedUri,n,s):"dash"===r?m=new oc(f.playlists[0],n,s,d):"vhs-json"===r&&(m=new Zl(f.playlists?f.playlists[0]:f.resolvedUri,n,s)),f=da.mergeOptions({id:p,playlistLoader:m},f),Ed[e](e,f.playlistLoader,t),l[h].push(f),"undefined"==typeof c[p]){var g=i.addRemoteTextTrack({id:p,kind:"subtitles",default:f.default&&f.autoselect,language:f.language,label:p},!1).track;c[p]=g}}a.on("error",Cd[e](e,t))},"CLOSED-CAPTIONS":function(e,t){var i=t.tech,n=t.master.mediaGroups,r=t.mediaTypes[e],a=r.groups,s=r.tracks;for(var o in n[e])for(var u in a[o]||(a[o]=[]),n[e][o]){var l=n[e][o][u];if(l.instreamId.match(/CC\d/)&&(a[o].push(da.mergeOptions({id:u},l)),"undefined"==typeof s[u])){var c=i.addRemoteTextTrack({id:l.instreamId,kind:"captions",default:l.default&&l.autoselect,language:l.language,label:u},!1).track;s[u]=c}}}},Id={AUDIO:function(i,n){return function(){var e=n.mediaTypes[i].tracks;for(var t in e)if(e[t].enabled)return e[t];return null}},SUBTITLES:function(i,n){return function(){var e=n.mediaTypes[i].tracks;for(var t in e)if("showing"===e[t].mode||"hidden"===e[t].mode)return e[t];return null}}},Ad=["mediaRequests","mediaRequestsAborted","mediaRequestsTimedout","mediaRequestsErrored","mediaTransferDuration","mediaBytesTransferred"],xd=function(m){function e(e){var t;t=m.call(this)||this;var i=e.src,n=e.handleManifestRedirects,r=e.withCredentials,a=e.tech,s=e.bandwidth,o=e.externVhs,u=e.useCueTags,l=e.blacklistDuration,c=e.enableLowInitialPlaylist,d=e.sourceType,h=e.cacheEncryptionKeys,p=e.handlePartialData;if(!i)throw new Error("A non-empty playlist URL or JSON manifest string is required");ad=o,t.withCredentials=r,t.tech_=a,t.vhs_=a.vhs,t.sourceType_=d,t.useCueTags_=u,t.blacklistDuration=l,t.enableLowInitialPlaylist=c,t.useCueTags_&&(t.cueTagsTrack_=t.tech_.addTextTrack("metadata","ad-cues"),t.cueTagsTrack_.inBandMetadataTrackDispatchType=""),t.requestOptions_={withCredentials:r,handleManifestRedirects:n,timeout:null},t.on("error",t.pauseLoading),t.mediaTypes_=function(){var t={};return["AUDIO","SUBTITLES","CLOSED-CAPTIONS"].forEach(function(e){t[e]={groups:{},tracks:{},activePlaylistLoader:null,activeGroup:Wc,activeTrack:Wc,onGroupChanged:Wc,onTrackChanged:Wc}}),t}(),t.mediaSource=new T.MediaSource,t.handleDurationChange_=t.handleDurationChange_.bind(Ve(t)),t.handleSourceOpen_=t.handleSourceOpen_.bind(Ve(t)),t.handleSourceEnded_=t.handleSourceEnded_.bind(Ve(t)),t.mediaSource.addEventListener("durationchange",t.handleDurationChange_),t.mediaSource.addEventListener("sourceopen",t.handleSourceOpen_),t.mediaSource.addEventListener("sourceended",t.handleSourceEnded_),t.seekable_=da.createTimeRanges(),t.hasPlayed_=!1,t.syncController_=new Td(e),t.segmentMetadataTrack_=a.addRemoteTextTrack({kind:"metadata",label:"segment-metadata"},!1).track,t.decrypter_=new kd,t.sourceUpdater_=new vd(t.mediaSource),t.inbandTextTracks_={},t.timelineChangeController_=new Sd;var f={vhs:t.vhs_,mediaSource:t.mediaSource,currentTime:t.tech_.currentTime.bind(t.tech_),seekable:function(){return t.seekable()},seeking:function(){return t.tech_.seeking()},duration:function(){return t.duration()},hasPlayed:function(){return t.hasPlayed_},goalBufferLength:function(){return t.goalBufferLength()},bandwidth:s,syncController:t.syncController_,decrypter:t.decrypter_,sourceType:t.sourceType_,inbandTextTracks:t.inbandTextTracks_,cacheEncryptionKeys:h,handlePartialData:p,sourceUpdater:t.sourceUpdater_,timelineChangeController:t.timelineChangeController_};return t.masterPlaylistLoader_="dash"===t.sourceType_?new oc(i,t.vhs_,t.requestOptions_):new Zl(i,t.vhs_,t.requestOptions_),t.setupMasterPlaylistLoaderListeners_(),t.mainSegmentLoader_=new Hc(da.mergeOptions(f,{segmentMetadataTrack:t.segmentMetadataTrack_,loaderType:"main"}),e),t.audioSegmentLoader_=new Hc(da.mergeOptions(f,{loaderType:"audio"}),e),t.subtitleSegmentLoader_=new _d(da.mergeOptions(f,{loaderType:"vtt",featuresNativeTextTracks:t.tech_.featuresNativeTextTracks}),e),t.setupSegmentLoaderListeners_(),Ad.forEach(function(e){t[e+"_"]=function(e){return this.audioSegmentLoader_[e]+this.mainSegmentLoader_[e]}.bind(Ve(t),e)}),t.logger_=Pc("MPC"),t.triggeredFmp4Usage=!1,t.masterPlaylistLoader_.load(),t}Ge(e,m);var t=e.prototype;return t.setupMasterPlaylistLoaderListeners_=function(){var i=this;this.masterPlaylistLoader_.on("loadedmetadata",function(){var e=i.masterPlaylistLoader_.media(),t=1.5*e.targetDuration*1e3;ll(i.masterPlaylistLoader_.master,i.masterPlaylistLoader_.media())?i.requestOptions_.timeout=0:i.requestOptions_.timeout=t,e.endList&&"none"!==i.tech_.preload()&&(i.mainSegmentLoader_.playlist(e,i.requestOptions_),i.mainSegmentLoader_.load()),id({sourceType:i.sourceType_,segmentLoaders:{AUDIO:i.audioSegmentLoader_,SUBTITLES:i.subtitleSegmentLoader_,main:i.mainSegmentLoader_},tech:i.tech_,requestOptions:i.requestOptions_,masterPlaylistLoader:i.masterPlaylistLoader_,vhs:i.vhs_,master:i.master(),mediaTypes:i.mediaTypes_,blacklistCurrentPlaylist:i.blacklistCurrentPlaylist.bind(i)}),i.triggerPresenceUsage_(i.master(),e),i.setupFirstPlay(),!i.mediaTypes_.AUDIO.activePlaylistLoader||i.mediaTypes_.AUDIO.activePlaylistLoader.media()?i.trigger("selectedinitialmedia"):i.mediaTypes_.AUDIO.activePlaylistLoader.one("loadedmetadata",function(){i.trigger("selectedinitialmedia")})}),this.masterPlaylistLoader_.on("loadedplaylist",function(){var e=i.masterPlaylistLoader_.media();if(!e){var t;if(i.excludeUnsupportedVariants_(),i.enableLowInitialPlaylist&&(t=i.selectInitialPlaylist()),t=t||i.selectPlaylist(),i.initialMedia_=t,i.masterPlaylistLoader_.media(i.initialMedia_),!("vhs-json"===i.sourceType_&&i.initialMedia_.segments))return;e=i.initialMedia_}i.handleUpdatedMediaPlaylist(e)}),this.masterPlaylistLoader_.on("error",function(){i.blacklistCurrentPlaylist(i.masterPlaylistLoader_.error)}),this.masterPlaylistLoader_.on("mediachanging",function(){i.mainSegmentLoader_.abort(),i.mainSegmentLoader_.pause()}),this.masterPlaylistLoader_.on("mediachange",function(){var e=i.masterPlaylistLoader_.media(),t=1.5*e.targetDuration*1e3;ll(i.masterPlaylistLoader_.master,i.masterPlaylistLoader_.media())?i.requestOptions_.timeout=0:i.requestOptions_.timeout=t,i.mainSegmentLoader_.playlist(e,i.requestOptions_),i.mainSegmentLoader_.load(),i.tech_.trigger({type:"mediachange",bubbles:!0})}),this.masterPlaylistLoader_.on("playlistunchanged",function(){var e=i.masterPlaylistLoader_.media();i.stuckAtPlaylistEnd_(e)&&(i.blacklistCurrentPlaylist({message:"Playlist no longer updating."}),i.tech_.trigger("playliststuck"))}),this.masterPlaylistLoader_.on("renditiondisabled",function(){i.tech_.trigger({type:"usage",name:"vhs-rendition-disabled"}),i.tech_.trigger({type:"usage",name:"hls-rendition-disabled"})}),this.masterPlaylistLoader_.on("renditionenabled",function(){i.tech_.trigger({type:"usage",name:"vhs-rendition-enabled"}),i.tech_.trigger({type:"usage",name:"hls-rendition-enabled"})})},t.handleUpdatedMediaPlaylist=function(e){this.useCueTags_&&this.updateAdCues_(e),this.mainSegmentLoader_.playlist(e,this.requestOptions_),this.updateDuration(!e.endList),this.tech_.paused()||(this.mainSegmentLoader_.load(),this.audioSegmentLoader_&&this.audioSegmentLoader_.load())},t.triggerPresenceUsage_=function(e,t){var i=e.mediaGroups||{},n=!0,r=Object.keys(i.AUDIO);for(var a in i.AUDIO)for(var s in i.AUDIO[a]){i.AUDIO[a][s].uri||(n=!1)}n&&(this.tech_.trigger({type:"usage",name:"vhs-demuxed"}),this.tech_.trigger({type:"usage",name:"hls-demuxed"})),Object.keys(i.SUBTITLES).length&&(this.tech_.trigger({type:"usage",name:"vhs-webvtt"}),this.tech_.trigger({type:"usage",name:"hls-webvtt"})),ad.Playlist.isAes(t)&&(this.tech_.trigger({type:"usage",name:"vhs-aes"}),this.tech_.trigger({type:"usage",name:"hls-aes"})),r.length&&1<Object.keys(i.AUDIO[r[0]]).length&&(this.tech_.trigger({type:"usage",name:"vhs-alternate-audio"}),this.tech_.trigger({type:"usage",name:"hls-alternate-audio"})),this.useCueTags_&&(this.tech_.trigger({type:"usage",name:"vhs-playlist-cue-tags"}),this.tech_.trigger({type:"usage",name:"hls-playlist-cue-tags"}))},t.setupSegmentLoaderListeners_=function(){var n=this;this.mainSegmentLoader_.on("bandwidthupdate",function(){var e=n.selectPlaylist(),t=n.masterPlaylistLoader_.media(),i=n.tech_.buffered();!function(e){var t=e.currentPlaylist,i=e.nextPlaylist,n=e.forwardBuffer,r=e.bufferLowWaterLine,a=e.duration;e.log;return i?!t.endList||(a<uc.MAX_BUFFER_LOW_WATER_LINE||(i.attributes.BANDWIDTH<t.attributes.BANDWIDTH||r<=n)):(da.log.warn("We received no playlist to switch to. Please check your stream."),!1)}({currentPlaylist:t,nextPlaylist:e,forwardBuffer:i.length?i.end(i.length-1)-n.tech_.currentTime():0,bufferLowWaterLine:n.bufferLowWaterLine(),duration:n.duration(),log:n.logger_})||n.masterPlaylistLoader_.media(e),n.tech_.trigger("bandwidthupdate")}),this.mainSegmentLoader_.on("progress",function(){n.trigger("progress")}),this.mainSegmentLoader_.on("error",function(){n.blacklistCurrentPlaylist(n.mainSegmentLoader_.error())}),this.mainSegmentLoader_.on("appenderror",function(){n.error=n.mainSegmentLoader_.error_,n.trigger("error")}),this.mainSegmentLoader_.on("syncinfoupdate",function(){n.onSyncInfoUpdate_()}),this.mainSegmentLoader_.on("timestampoffset",function(){n.tech_.trigger({type:"usage",name:"vhs-timestamp-offset"}),n.tech_.trigger({type:"usage",name:"hls-timestamp-offset"})}),this.audioSegmentLoader_.on("syncinfoupdate",function(){n.onSyncInfoUpdate_()}),this.audioSegmentLoader_.on("appenderror",function(){n.error=n.audioSegmentLoader_.error_,n.trigger("error")}),this.mainSegmentLoader_.on("ended",function(){n.logger_("main segment loader ended"),n.onEndOfStream()}),this.mainSegmentLoader_.on("earlyabort",function(){n.blacklistCurrentPlaylist({message:"Aborted early because there isn't enough bandwidth to complete the request without rebuffering."},120)});function e(){if(!n.sourceUpdater_.ready())return n.tryToCreateSourceBuffers_();var e=n.getCodecsOrExclude_();e&&n.sourceUpdater_.addOrChangeSourceBuffers(e)}this.mainSegmentLoader_.on("trackinfo",e),this.audioSegmentLoader_.on("trackinfo",e),this.mainSegmentLoader_.on("fmp4",function(){n.triggeredFmp4Usage||(n.tech_.trigger({type:"usage",name:"vhs-fmp4"}),n.tech_.trigger({type:"usage",name:"hls-fmp4"}),n.triggeredFmp4Usage=!0)}),this.audioSegmentLoader_.on("fmp4",function(){n.triggeredFmp4Usage||(n.tech_.trigger({type:"usage",name:"vhs-fmp4"}),n.tech_.trigger({type:"usage",name:"hls-fmp4"}),n.triggeredFmp4Usage=!0)}),this.audioSegmentLoader_.on("ended",function(){n.logger_("audioSegmentLoader ended"),n.onEndOfStream()})},t.mediaSecondsLoaded_=function(){return Math.max(this.audioSegmentLoader_.mediaSecondsLoaded+this.mainSegmentLoader_.mediaSecondsLoaded)},t.load=function(){this.mainSegmentLoader_.load(),this.mediaTypes_.AUDIO.activePlaylistLoader&&this.audioSegmentLoader_.load(),this.mediaTypes_.SUBTITLES.activePlaylistLoader&&this.subtitleSegmentLoader_.load()},t.smoothQualityChange_=function(e){void 0===e&&(e=this.selectPlaylist()),e!==this.masterPlaylistLoader_.media()&&(this.masterPlaylistLoader_.media(e),this.mainSegmentLoader_.resetLoader())},t.fastQualityChange_=function(e){var t=this;void 0===e&&(e=this.selectPlaylist()),e!==this.masterPlaylistLoader_.media()&&(this.masterPlaylistLoader_.media(e),this.mainSegmentLoader_.resetEverything(function(){da.browser.IE_VERSION||da.browser.IS_EDGE?t.tech_.setCurrentTime(t.tech_.currentTime()+.04):t.tech_.setCurrentTime(t.tech_.currentTime())}))},t.play=function(){if(!this.setupFirstPlay()){this.tech_.ended()&&this.tech_.setCurrentTime(0),this.hasPlayed_&&this.load();var e=this.tech_.seekable();return this.tech_.duration()===1/0&&this.tech_.currentTime()<e.start(0)?this.tech_.setCurrentTime(e.end(e.length-1)):void 0}},t.setupFirstPlay=function(){var e=this,t=this.masterPlaylistLoader_.media();if(!t||this.tech_.paused()||this.hasPlayed_)return!1;if(!t.endList){var i=this.seekable();if(!i.length)return!1;if(da.browser.IE_VERSION&&0===this.tech_.readyState())return this.tech_.one("loadedmetadata",function(){e.trigger("firstplay"),e.tech_.setCurrentTime(i.end(0)),e.hasPlayed_=!0}),!1;this.trigger("firstplay"),this.tech_.setCurrentTime(i.end(0))}return this.hasPlayed_=!0,this.load(),!0},t.handleSourceOpen_=function(){if(this.tryToCreateSourceBuffers_(),this.tech_.autoplay()){var e=this.tech_.play();"undefined"!=typeof e&&"function"==typeof e.then&&e.then(null,function(e){})}this.trigger("sourceopen")},t.handleSourceEnded_=function(){if(this.inbandTextTracks_.metadataTrack_){var e=this.inbandTextTracks_.metadataTrack_.cues;if(e&&e.length){var t=this.duration();e[e.length-1].endTime=isNaN(t)||Math.abs(t)===1/0?Number.MAX_VALUE:t}}},t.handleDurationChange_=function(){this.tech_.trigger("durationchange")},t.onEndOfStream=function(){var e=this.mainSegmentLoader_.ended_;this.mediaTypes_.AUDIO.activePlaylistLoader&&(e=!this.mainSegmentLoader_.currentMediaInfo_||this.mainSegmentLoader_.currentMediaInfo_.hasVideo?e&&this.audioSegmentLoader_.ended_:this.audioSegmentLoader_.ended_),e&&this.sourceUpdater_.endOfStream()},t.stuckAtPlaylistEnd_=function(e){if(!this.seekable().length)return!1;var t=this.syncController_.getExpiredTime(e,this.duration());if(null===t)return!1;var i=ad.Playlist.playlistEnd(e,t),n=this.tech_.currentTime(),r=this.tech_.buffered();if(!r.length)return i-n<=.1;var a=r.end(r.length-1);return a-n<=.1&&i-a<=.1},t.blacklistCurrentPlaylist=function(e,t){void 0===e&&(e={});var i=e.playlist||this.masterPlaylistLoader_.media();if(t=t||e.blacklistDuration||this.blacklistDuration,!i)return this.error=e,void("open"!==this.mediaSource.readyState?this.trigger("error"):this.sourceUpdater_.endOfStream("network"));var n=this.masterPlaylistLoader_.master.playlists,r=n.filter(ol),a=1===r.length&&r[0]===i;if(1===n.length&&t!==1/0)return da.log.warn("Problem encountered with playlist "+i.id+". Trying again since it is the only playlist."),this.tech_.trigger("retryplaylist"),this.masterPlaylistLoader_.load(a);if(a){var s=!1;n.forEach(function(e){if(e!==i){var t=e.excludeUntil;"undefined"!=typeof t&&t!==1/0&&(s=!0,delete e.excludeUntil)}}),s&&(da.log.warn("Removing other playlists from the exclusion list because the last rendition is about to be excluded."),this.tech_.trigger("retryplaylist"))}i.excludeUntil=Date.now()+1e3*t,this.tech_.trigger("blacklistplaylist"),this.tech_.trigger({type:"usage",name:"vhs-rendition-blacklisted"}),this.tech_.trigger({type:"usage",name:"hls-rendition-blacklisted"});var o=this.selectPlaylist();if(!o)return this.error="Playback cannot continue. No available working or supported playlists.",void this.trigger("error");var u=e.internal?this.logger_:da.log.warn,l=e.message?" "+e.message:"";return u((e.internal?"Internal problem":"Problem")+" encountered with playlist "+i.id+"."+l+" Switching to playlist "+o.id+"."),o.attributes.AUDIO!==i.attributes.AUDIO&&this.delegateLoaders_("audio",["abort","pause"]),o.attributes.SUBTITLES!==i.attributes.SUBTITLES&&this.delegateLoaders_("subtitle",["abort","pause"]),this.delegateLoaders_("main",["abort","pause"]),this.masterPlaylistLoader_.media(o,a)},t.pauseLoading=function(){this.delegateLoaders_("all",["abort","pause"])},t.delegateLoaders_=function(i,e){var n=this,r=[],t="all"===i;!t&&"main"!==i||r.push(this.masterPlaylistLoader_);var a=[];!t&&"audio"!==i||a.push("AUDIO"),!t&&"subtitle"!==i||(a.push("CLOSED-CAPTIONS"),a.push("SUBTITLES")),a.forEach(function(e){var t=n.mediaTypes_[e]&&n.mediaTypes_[e].activePlaylistLoader;t&&r.push(t)}),["main","audio","subtitle"].forEach(function(e){var t=n[e+"SegmentLoader_"];!t||i!==e&&"all"!==i||r.push(t)}),r.forEach(function(t){return e.forEach(function(e){"function"==typeof t[e]&&t[e]()})})},t.setCurrentTime=function(e){var t=$u(this.tech_.buffered(),e);return this.masterPlaylistLoader_&&this.masterPlaylistLoader_.media()&&this.masterPlaylistLoader_.media().segments?t&&t.length?e:(this.mainSegmentLoader_.resetEverything(),this.mainSegmentLoader_.abort(),this.mediaTypes_.AUDIO.activePlaylistLoader&&(this.audioSegmentLoader_.resetEverything(),this.audioSegmentLoader_.abort()),this.mediaTypes_.SUBTITLES.activePlaylistLoader&&(this.subtitleSegmentLoader_.resetEverything(),this.subtitleSegmentLoader_.abort()),void this.load()):0},t.duration=function(){if(!this.masterPlaylistLoader_)return 0;var e=this.masterPlaylistLoader_.media();return e?e.endList?this.mediaSource?this.mediaSource.duration:ad.Playlist.duration(e):1/0:0},t.seekable=function(){return this.seekable_},t.onSyncInfoUpdate_=function(){var e;if(this.masterPlaylistLoader_){var t=this.masterPlaylistLoader_.media();if(t){var i=this.syncController_.getExpiredTime(t,this.duration());if(null!==i){var n=this.masterPlaylistLoader_.master.suggestedPresentationDelay,r=ad.Playlist.seekable(t,i,n);if(0!==r.length){if(this.mediaTypes_.AUDIO.activePlaylistLoader){if(t=this.mediaTypes_.AUDIO.activePlaylistLoader.media(),null===(i=this.syncController_.getExpiredTime(t,this.duration())))return;if(0===(e=ad.Playlist.seekable(t,i,n)).length)return}var a,s;this.seekable_&&this.seekable_.length&&(a=this.seekable_.end(0),s=this.seekable_.start(0)),e?e.start(0)>r.end(0)||r.start(0)>e.end(0)?this.seekable_=r:this.seekable_=da.createTimeRanges([[e.start(0)>r.start(0)?e.start(0):r.start(0),e.end(0)<r.end(0)?e.end(0):r.end(0)]]):this.seekable_=r,this.seekable_&&this.seekable_.length&&this.seekable_.end(0)===a&&this.seekable_.start(0)===s||(this.logger_("seekable updated ["+Ju(this.seekable_)+"]"),this.tech_.trigger("seekablechanged"))}}}}},t.updateDuration=function(e){if(this.updateDuration_&&(this.mediaSource.removeEventListener("sourceopen",this.updateDuration_),this.updateDuration_=null),"open"!==this.mediaSource.readyState)return this.updateDuration_=this.updateDuration.bind(this,e),void this.mediaSource.addEventListener("sourceopen",this.updateDuration_);if(e){var t=this.seekable();if(!t.length)return;(isNaN(this.mediaSource.duration)||this.mediaSource.duration<t.end(t.length-1))&&this.sourceUpdater_.setDuration(t.end(t.length-1))}else{var i=this.tech_.buffered(),n=ad.Playlist.duration(this.masterPlaylistLoader_.media());0<i.length&&(n=Math.max(n,i.end(i.length-1))),this.mediaSource.duration!==n&&this.sourceUpdater_.setDuration(n)}},t.dispose=function(){var n=this;this.trigger("dispose"),this.decrypter_.terminate(),this.masterPlaylistLoader_.dispose(),this.mainSegmentLoader_.dispose(),["AUDIO","SUBTITLES"].forEach(function(e){var t=n.mediaTypes_[e].groups;for(var i in t)t[i].forEach(function(e){e.playlistLoader&&e.playlistLoader.dispose()})}),this.audioSegmentLoader_.dispose(),this.subtitleSegmentLoader_.dispose(),this.sourceUpdater_.dispose(),this.timelineChangeController_.dispose(),this.updateDuration_&&this.mediaSource.removeEventListener("sourceopen",this.updateDuration_),this.mediaSource.removeEventListener("durationchange",this.handleDurationChange_),this.mediaSource.removeEventListener("sourceopen",this.handleSourceOpen_),this.mediaSource.removeEventListener("sourceended",this.handleSourceEnded_),this.off()},t.master=function(){return this.masterPlaylistLoader_.master},t.media=function(){return this.masterPlaylistLoader_.media()||this.initialMedia_},t.areMediaTypesKnown_=function(){var e=!!this.mediaTypes_.AUDIO.activePlaylistLoader;return!(!this.mainSegmentLoader_.currentMediaInfo_||e&&!this.audioSegmentLoader_.currentMediaInfo_)},t.getCodecsOrExclude_=function(){var n=this,i={main:this.mainSegmentLoader_.currentMediaInfo_||{},audio:this.audioSegmentLoader_.currentMediaInfo_||{}};i.video=i.main;var e=xc(this.master(),this.media()),r={},t=!!this.mediaTypes_.AUDIO.activePlaylistLoader;if(i.main.hasVideo&&(r.video=e.video||i.main.videoCodec||Vl),i.main.isMuxed&&(r.video+=","+(e.audio||i.main.audioCodec||jl)),(i.main.hasAudio&&!i.main.isMuxed||i.audio.hasAudio||t)&&(r.audio=e.audio||i.main.audioCodec||i.audio.audioCodec||jl,i.audio.isFmp4=i.main.hasAudio&&!i.main.isMuxed?i.main.isFmp4:i.audio.isFmp4),r.audio||r.video){var a,s={};if(["video","audio"].forEach(function(e){if(r.hasOwnProperty(e)&&!function(e,t){return e?ql(t):Xl(t)}(i[e].isFmp4,r[e])){var t=i[e].isFmp4?"browser":"muxer";s[t]=s[t]||[],s[t].push(r[e]),"audio"===e&&(a=t)}}),t&&a&&this.media().attributes.AUDIO){var o=this.media().attributes.AUDIO;this.master().playlists.forEach(function(e){(e.attributes&&e.attributes.AUDIO)===o&&e!==n.media()&&(e.excludeUntil=1/0)}),this.logger_("excluding audio group "+o+" as "+a+' does not support codec(s): "'+r.audio+'"')}if(!Object.keys(s).length){if(this.sourceUpdater_.ready()&&!this.sourceUpdater_.canChangeType()){var u=[];if(["video","audio"].forEach(function(e){var t=(Kl(n.sourceUpdater_.codecs[e]||"")[e]||{}).type,i=(Kl(r[e]||"")[e]||{}).type;t&&i&&t.toLowerCase()!==i.toLowerCase()&&u.push('"'+n.sourceUpdater_.codecs[e]+'" -> "'+r[e]+'"')}),u.length)return void this.blacklistCurrentPlaylist({playlist:this.media(),message:"Codec switching not supported: "+u.join(", ")+".",blacklistDuration:1/0,internal:!0})}return r}var l=Object.keys(s).reduce(function(e,t){return e&&(e+=", "),e+=t+' does not support codec(s): "'+s[t].join(",")+'"'},"")+".";this.blacklistCurrentPlaylist({playlist:this.media(),internal:!0,message:l,blacklistDuration:1/0})}else this.blacklistCurrentPlaylist({playlist:this.media(),message:"Could not determine codecs for playlist.",blacklistDuration:1/0})},t.tryToCreateSourceBuffers_=function(){if("open"===this.mediaSource.readyState&&!this.sourceUpdater_.ready()&&this.areMediaTypesKnown_()){var e=this.getCodecsOrExclude_();if(e){this.sourceUpdater_.createSourceBuffers(e);var t=[e.video,e.audio].filter(Boolean).join(",");this.excludeIncompatibleVariants_(t)}}},t.excludeUnsupportedVariants_=function(){var i=this;this.master().playlists.forEach(function(e){var t=xc(i.master,e);!t.audio||Xl(t.audio)||ql(t.audio)||(e.excludeUntil=1/0),!t.video||Xl(t.video)||ql(t.video)||(e.excludeUntil=1/0)})},t.excludeIncompatibleVariants_=function(e){var s=this,o=Kl(e),u=Object.keys(o).length;this.master().playlists.forEach(function(e){if(e.excludeUntil!==1/0){var t={},i=2,n=[],r=xc(s.masterPlaylistLoader_.master,e);if(r.audio||r.video){var a=[r.video,r.audio].filter(Boolean).join(",");t=Kl(a),i=Object.keys(t).length}i!==u&&(n.push('codec count "'+i+'" !== "'+u+'"'),e.excludeUntil=1/0),s.sourceUpdater_.canChangeType()||(t.video&&o.video&&t.video.type.toLowerCase()!==o.video.type.toLowerCase()&&(n.push('video codec "'+t.video.type+'" !== "'+o.video.type+'"'),e.excludeUntil=1/0),t.audio&&o.audio&&t.audio.type.toLowerCase()!==o.audio.type.toLowerCase()&&(e.excludeUntil=1/0,n.push('audio codec "'+t.audio.type+'" !== "'+o.audio.type+'"'))),n.length&&s.logger_("blacklisting "+e.id+": "+n.join(" && "))}})},t.updateAdCues_=function(e){var t=0,i=this.seekable();i.length&&(t=i.start(0)),function(e,t,i){if(void 0===i&&(i=0),e.segments)for(var n,r=i,a=0;a<e.segments.length;a++){var s=e.segments[a];if(n=n||Zc(t,r+s.duration/2)){if("cueIn"in s){n.endTime=r,n.adEndTime=r,r+=s.duration,n=null;continue}if(r<n.endTime){r+=s.duration;continue}n.endTime+=s.duration}else if("cueOut"in s&&((n=new T.VTTCue(r,r+s.duration,s.cueOut)).adStartTime=r,n.adEndTime=r+parseFloat(s.cueOut),t.addCue(n)),"cueOutCont"in s){var o=s.cueOutCont.split("/").map(parseFloat),u=o[0],l=o[1];(n=new T.VTTCue(r,r+s.duration,"")).adStartTime=r-u,n.adEndTime=n.adStartTime+l,t.addCue(n)}r+=s.duration}}(e,this.cueTagsTrack_,t)},t.goalBufferLength=function(){var e=this.tech_.currentTime(),t=uc.GOAL_BUFFER_LENGTH,i=uc.GOAL_BUFFER_LENGTH_RATE,n=Math.max(t,uc.MAX_GOAL_BUFFER_LENGTH);return Math.min(t+e*i,n)},t.bufferLowWaterLine=function(){var e=this.tech_.currentTime(),t=uc.BUFFER_LOW_WATER_LINE,i=uc.BUFFER_LOW_WATER_LINE_RATE,n=Math.max(t,uc.MAX_BUFFER_LOW_WATER_LINE);return Math.min(t+e*i,n)},e}(da.EventTarget),Pd=["seeking","seeked","pause","playing","error"],Ld=function(){function e(e){var t=this;this.masterPlaylistController_=e.masterPlaylistController,this.tech_=e.tech,this.seekable=e.seekable,this.allowSeeksWithinUnsafeLiveWindow=e.allowSeeksWithinUnsafeLiveWindow,this.media=e.media,this.consecutiveUpdates=0,this.lastRecordedTime=null,this.timer_=null,this.checkCurrentTimeTimeout_=null,this.logger_=Pc("PlaybackWatcher"),this.logger_("initialize");function i(){return t.monitorCurrentTime_()}function n(){return t.techWaiting_()}function r(){return t.cancelTimer_()}function a(){return t.fixesBadSeeks_()}var s=this.masterPlaylistController_,o=["main","subtitle","audio"],u={};o.forEach(function(e){u[e]={reset:function(){return t.resetSegmentDownloads_(e)},updateend:function(){return t.checkSegmentDownloads_(e)}},s[e+"SegmentLoader_"].on("appendsdone",u[e].updateend),s[e+"SegmentLoader_"].on("playlistupdate",u[e].reset),t.tech_.on(["seeked","seeking"],u[e].reset)}),this.tech_.on("seekablechanged",a),this.tech_.on("waiting",n),this.tech_.on(Pd,r),this.tech_.on("canplay",i),this.dispose=function(){t.logger_("dispose"),t.tech_.off("seekablechanged",a),t.tech_.off("waiting",n),t.tech_.off(Pd,r),t.tech_.off("canplay",i),o.forEach(function(e){s[e+"SegmentLoader_"].off("appendsdone",u[e].updateend),s[e+"SegmentLoader_"].off("playlistupdate",u[e].reset),t.tech_.off(["seeked","seeking"],u[e].reset)}),t.checkCurrentTimeTimeout_&&T.clearTimeout(t.checkCurrentTimeTimeout_),t.cancelTimer_()}}var t=e.prototype;return t.monitorCurrentTime_=function(){this.checkCurrentTime_(),this.checkCurrentTimeTimeout_&&T.clearTimeout(this.checkCurrentTimeTimeout_),this.checkCurrentTimeTimeout_=T.setTimeout(this.monitorCurrentTime_.bind(this),250)},t.resetSegmentDownloads_=function(e){var t=this.masterPlaylistController_[e+"SegmentLoader_"];0<this[e+"StalledDownloads_"]&&this.logger_("resetting possible stalled download count for "+e+" loader"),this[e+"StalledDownloads_"]=0,this[e+"Buffered_"]=t.buffered_()},t.checkSegmentDownloads_=function(e){var t=this.masterPlaylistController_,i=t[e+"SegmentLoader_"],n=i.buffered_(),r=function(e,t){if(e===t)return!1;if(!e&&t||!t&&e)return!0;if(e.length!==t.length)return!0;for(var i=0;i<e.length;i++)if(e.start(i)!==t.start(i)||e.end(i)!==t.end(i))return!0;return!1}(this[e+"Buffered_"],n);this[e+"Buffered_"]=n,r?this.resetSegmentDownloads_(e):(this[e+"StalledDownloads_"]++,this.logger_("found #"+this[e+"StalledDownloads_"]+" "+e+" appends that did not increase buffer (possible stalled download)",{playlistId:i.playlist_&&i.playlist_.id,buffered:Zu(n)}),this[e+"StalledDownloads_"]<10||(this.logger_(e+" loader stalled download exclusion"),this.resetSegmentDownloads_(e),this.tech_.trigger({type:"usage",name:"vhs-"+e+"-download-exclusion"}),"subtitle"!==e&&t.blacklistCurrentPlaylist({message:"Excessive "+e+" segment downloading detected."},1/0)))},t.checkCurrentTime_=function(){if(this.tech_.seeking()&&this.fixesBadSeeks_())return this.consecutiveUpdates=0,void(this.lastRecordedTime=this.tech_.currentTime());if(!this.tech_.paused()&&!this.tech_.seeking()){var e=this.tech_.currentTime(),t=this.tech_.buffered();if(this.lastRecordedTime===e&&(!t.length||e+.1>=t.end(t.length-1)))return this.techWaiting_();5<=this.consecutiveUpdates&&e===this.lastRecordedTime?(this.consecutiveUpdates++,this.waiting_()):e===this.lastRecordedTime?this.consecutiveUpdates++:(this.consecutiveUpdates=0,this.lastRecordedTime=e)}},t.cancelTimer_=function(){this.consecutiveUpdates=0,this.timer_&&(this.logger_("cancelTimer_"),clearTimeout(this.timer_)),this.timer_=null},t.fixesBadSeeks_=function(){if(!this.tech_.seeking())return!1;var e,t=this.seekable(),i=this.tech_.currentTime();this.afterSeekableWindow_(t,i,this.media(),this.allowSeeksWithinUnsafeLiveWindow)&&(e=t.end(t.length-1));if(this.beforeSeekableWindow_(t,i)){var n=t.start(0);e=n+(n===t.end(0)?0:.1)}if("undefined"!=typeof e)return this.logger_("Trying to seek outside of seekable at time "+i+" with seekable range "+Ju(t)+". Seeking to "+e+"."),this.tech_.setCurrentTime(e),!0;var r=this.tech_.buffered();return!!function(e){var t=e.buffered,i=e.targetDuration,n=e.currentTime;return!!t.length&&(!(t.end(0)-t.start(0)<2*i)&&(!(n>t.start(0))&&t.start(0)-n<i))}({buffered:r,targetDuration:this.media().targetDuration,currentTime:i})&&(e=r.start(0)+.1,this.logger_("Buffered region starts ("+r.start(0)+") just beyond seek point ("+i+"). Seeking to "+e+"."),this.tech_.setCurrentTime(e),!0)},t.waiting_=function(){if(!this.techWaiting_()){var e=this.tech_.currentTime(),t=this.tech_.buffered(),i=$u(t,e);return i.length&&e+3<=i.end(0)?(this.cancelTimer_(),this.tech_.setCurrentTime(e),this.logger_("Stopped at "+e+" while inside a buffered region ["+i.start(0)+" -> "+i.end(0)+"]. Attempting to resume playback by seeking to the current time."),this.tech_.trigger({type:"usage",name:"vhs-unknown-waiting"}),void this.tech_.trigger({type:"usage",name:"hls-unknown-waiting"})):void 0}},t.techWaiting_=function(){var e=this.seekable(),t=this.tech_.currentTime();if(this.tech_.seeking()&&this.fixesBadSeeks_())return!0;if(this.tech_.seeking()||null!==this.timer_)return!0;if(this.beforeSeekableWindow_(e,t)){var i=e.end(e.length-1);return this.logger_("Fell out of live window at time "+t+". Seeking to live point (seekable end) "+i),this.cancelTimer_(),this.tech_.setCurrentTime(i),this.tech_.trigger({type:"usage",name:"vhs-live-resync"}),this.tech_.trigger({type:"usage",name:"hls-live-resync"}),!0}var n=this.tech_.vhs.masterPlaylistController_.sourceUpdater_,r=this.tech_.buffered();if(this.videoUnderflow_({audioBuffered:n.audioBuffered(),videoBuffered:n.videoBuffered(),currentTime:t}))return this.cancelTimer_(),this.tech_.setCurrentTime(t),this.tech_.trigger({type:"usage",name:"vhs-video-underflow"}),this.tech_.trigger({type:"usage",name:"hls-video-underflow"}),!0;var a=Qu(r,t);if(0<a.length){var s=a.start(0)-t;return this.logger_("Stopped at "+t+", setting timer for "+s+", seeking to "+a.start(0)),this.cancelTimer_(),this.timer_=setTimeout(this.skipTheGap_.bind(this),1e3*s,t),!0}return!1},t.afterSeekableWindow_=function(e,t,i,n){if(void 0===n&&(n=!1),!e.length)return!1;var r=e.end(e.length-1)+.1;return!i.endList&&n&&(r=e.end(e.length-1)+3*i.targetDuration),r<t},t.beforeSeekableWindow_=function(e,t){return!!(e.length&&0<e.start(0)&&t<e.start(0)-.1)},t.videoUnderflow_=function(e){var t=e.videoBuffered,i=e.audioBuffered,n=e.currentTime;if(t){var r;if(t.length&&i.length){var a=$u(t,n-3),s=$u(t,n),o=$u(i,n);o.length&&!s.length&&a.length&&(r={start:a.end(0),end:o.end(0)})}else{Qu(t,n).length||(r=this.gapFromVideoUnderflow_(t,n))}return!!r&&(this.logger_("Encountered a gap in video from "+r.start+" to "+r.end+". Seeking to current time "+n),!0)}},t.skipTheGap_=function(e){var t=this.tech_.buffered(),i=this.tech_.currentTime(),n=Qu(t,i);this.cancelTimer_(),0!==n.length&&i===e&&(this.logger_("skipTheGap_:","currentTime:",i,"scheduled currentTime:",e,"nextRange start:",n.start(0)),this.tech_.setCurrentTime(n.start(0)+ec),this.tech_.trigger({type:"usage",name:"vhs-gap-skip"}),this.tech_.trigger({type:"usage",name:"hls-gap-skip"}))},t.gapFromVideoUnderflow_=function(e,t){for(var i=function(e){if(e.length<2)return da.createTimeRanges();for(var t=[],i=1;i<e.length;i++){var n=e.end(i-1),r=e.start(i);t.push([n,r])}return da.createTimeRanges(t)}(e),n=0;n<i.length;n++){var r=i.start(n),a=i.end(n);if(t-r<4&&2<t-r)return{start:r,end:a}}return null},e}(),Od={errorInterval:30,getSource:function(e){return e(this.tech({IWillNotUseThisInPlugins:!0}).currentSource_||this.currentSource())}},Dd={PlaylistLoader:Zl,Playlist:ic,utils:ac,STANDARD_PLAYLIST_SELECTOR:function(){var e=this.useDevicePixelRatio&&T.devicePixelRatio||1;return function(e,t,i,n,r){var a={bandwidth:t,width:i,height:n,limitRenditionByPlayerDimensions:r},s=e.playlists.map(function(e){var t=e.attributes.RESOLUTION&&e.attributes.RESOLUTION.width,i=e.attributes.RESOLUTION&&e.attributes.RESOLUTION.height;return{bandwidth:e.attributes.BANDWIDTH||T.Number.MAX_VALUE,width:t,height:i,playlist:e}});Dc(s,function(e,t){return e.bandwidth-t.bandwidth});var o=(s=s.filter(function(e){return!ic.isIncompatible(e.playlist)})).filter(function(e){return ic.isEnabled(e.playlist)});o.length||(o=s.filter(function(e){return!ic.isDisabled(e.playlist)}));var u=o.filter(function(e){return e.bandwidth*uc.BANDWIDTH_VARIANCE<t}),l=u[u.length-1],c=u.filter(function(e){return e.bandwidth===l.bandwidth})[0];if(!1===r){var d=c||o[0]||s[0];if(d&&d.playlist){var h="sortedPlaylistReps";return c&&(h="bandwidthBestRep"),o[0]&&(h="enabledPlaylistReps"),qc("choosing "+Lc(d)+" using "+h+" with options",a),d.playlist}return qc("could not choose a playlist with options",a),null}var p=u.filter(function(e){return e.width&&e.height});Dc(p,function(e,t){return e.width-t.width});var f=p.filter(function(e){return e.width===i&&e.height===n});l=f[f.length-1];var m,g,v,y=f.filter(function(e){return e.bandwidth===l.bandwidth})[0];y||(g=(m=p.filter(function(e){return e.width>i||e.height>n})).filter(function(e){return e.width===m[0].width&&e.height===m[0].height}),l=g[g.length-1],v=g.filter(function(e){return e.bandwidth===l.bandwidth})[0]);var _=v||y||c||o[0]||s[0];if(_&&_.playlist){var b="sortedPlaylistReps";return v?b="resolutionPlusOneRep":y?b="resolutionBestRep":c?b="bandwidthBestRep":o[0]&&(b="enabledPlaylistReps"),qc("choosing "+Lc(_)+" using "+b+" with options",a),_.playlist}return qc("could not choose a playlist with options",a),null}(this.playlists.master,this.systemBandwidth,parseInt(Oc(this.tech_.el(),"width"),10)*e,parseInt(Oc(this.tech_.el(),"height"),10)*e,this.limitRenditionByPlayerDimensions)},INITIAL_PLAYLIST_SELECTOR:function(){var t=this,e=this.playlists.master.playlists.filter(ic.isEnabled);return Dc(e,function(e,t){return Mc(e,t)}),e.filter(function(e){return!!xc(t.playlists.master,e).video})[0]||null},comparePlaylistBandwidth:Mc,comparePlaylistResolution:function(e,t){var i,n;return e.attributes.RESOLUTION&&e.attributes.RESOLUTION.width&&(i=e.attributes.RESOLUTION.width),i=i||T.Number.MAX_VALUE,t.attributes.RESOLUTION&&t.attributes.RESOLUTION.width&&(n=t.attributes.RESOLUTION.width),i===(n=n||T.Number.MAX_VALUE)&&e.attributes.BANDWIDTH&&t.attributes.BANDWIDTH?e.attributes.BANDWIDTH-t.attributes.BANDWIDTH:i-n},xhr:dl()};["GOAL_BUFFER_LENGTH","MAX_GOAL_BUFFER_LENGTH","BACK_BUFFER_LENGTH","GOAL_BUFFER_LENGTH_RATE","BUFFER_LOW_WATER_LINE","MAX_BUFFER_LOW_WATER_LINE","BUFFER_LOW_WATER_LINE_RATE","BANDWIDTH_VARIANCE"].forEach(function(t){Object.defineProperty(Dd,t,{get:function(){return da.log.warn("using Vhs."+t+" is UNSAFE be sure you know what you are doing"),uc[t]},set:function(e){da.log.warn("using Vhs."+t+" is UNSAFE be sure you know what you are doing"),"number"!=typeof e||e<0?da.log.warn("value of Vhs."+t+" must be greater than or equal to 0"):uc[t]=e}})});function Md(e,t){for(var i=t.media(),n=-1,r=0;r<e.length;r++)if(e[r].id===i.id){n=r;break}e.selectedIndex_=n,e.trigger({selectedIndex:n,type:"change"})}var Rd="videojs-vhs";Dd.canPlaySource=function(){return da.log.warn("HLS is no longer a tech. Please remove it from your player's techOrder.")};function Nd(e){var t=e.player,i=e.sourceKeySystems,n=e.media,r=e.audioMedia,a=e.mainPlaylists,s=function(e,t,i){if(!e)return e;var n={video:t&&t.attributes&&t.attributes.CODECS,audio:i&&i.attributes&&i.attributes.CODECS};!n.audio&&n.video&&1<n.video.split(",").length&&n.video.split(",").forEach(function(e){e=e.trim(),zl(e)?n.audio=e:Gl(e)&&(n.video=e)});var r=n.video?'video/mp4;codecs="'+n.video+'"':null,a=n.audio?'audio/mp4;codecs="'+n.audio+'"':null,s={};for(var o in e)s[o]={audioContentType:a,videoContentType:r},t.contentProtection&&t.contentProtection[o]&&t.contentProtection[o].pssh&&(s[o].pssh=t.contentProtection[o].pssh),"string"==typeof e[o]&&(s[o].url=e[o]);return da.mergeOptions(e,s)}(i,n,r);s&&(!(t.currentSource().keySystems=s)||t.eme?11!==da.browser.IE_VERSION&&t.eme.initializeMediaKeys&&function(e,i){return e.reduce(function(e,n){if(!n.contentProtection)return e;var t=i.reduce(function(e,t){var i=n.contentProtection[t];return i&&i.pssh&&(e[t]={pssh:i.pssh}),e},{});return Object.keys(t).length&&e.push(t),e},[])}(r?a.concat([r]):a,Object.keys(i)).forEach(function(e){t.eme.initializeMediaKeys({keySystems:e})}):da.log.warn("DRM encrypted source cannot be decrypted without a DRM plugin"))}function Ud(){if(!T.localStorage)return null;var e=T.localStorage.getItem(Rd);if(!e)return null;try{return JSON.parse(e)}catch(e){return null}}Dd.supportsNativeHls=function(){if(!d||!d.createElement)return!1;var t=d.createElement("video");if(!da.getTech("Html5").isSupported())return!1;return["application/vnd.apple.mpegurl","audio/mpegurl","audio/x-mpegurl","application/x-mpegurl","video/x-mpegurl","video/mpegurl","application/mpegurl"].some(function(e){return/maybe|probably/i.test(t.canPlayType(e))})}(),Dd.supportsNativeDash=!!(d&&d.createElement&&da.getTech("Html5").isSupported())&&/maybe|probably/i.test(d.createElement("video").canPlayType("application/dash+xml")),Dd.supportsTypeNatively=function(e){return"hls"===e?Dd.supportsNativeHls:"dash"===e&&Dd.supportsNativeDash},Dd.isSupported=function(){return da.log.warn("HLS is no longer a tech. Please remove it from your player's techOrder.")};var Fd=function(a){function e(e,t,i){var n;if(n=a.call(this,t,da.mergeOptions(i.hls,i.vhs))||this,i.hls&&Object.keys(i.hls).length&&da.log.warn("Using hls options is deprecated. Use vhs instead."),t.options_&&t.options_.playerId){var r=da(t.options_.playerId);r.hasOwnProperty("hls")||Object.defineProperty(r,"hls",{get:function(){return da.log.warn("player.hls is deprecated. Use player.tech().vhs instead."),t.trigger({type:"usage",name:"hls-player-access"}),Ve(n)},configurable:!0}),r.hasOwnProperty("vhs")||Object.defineProperty(r,"vhs",{get:function(){return da.log.warn("player.vhs is deprecated. Use player.tech().vhs instead."),t.trigger({type:"usage",name:"vhs-player-access"}),Ve(n)},configurable:!0}),r.hasOwnProperty("dash")||Object.defineProperty(r,"dash",{get:function(){return da.log.warn("player.dash is deprecated. Use player.tech().vhs instead."),Ve(n)},configurable:!0}),n.player_=r}if(n.tech_=t,n.source_=e,n.stats={},n.ignoreNextSeekingEvent_=!1,n.setOptions_(),n.options_.overrideNative&&t.overrideNativeAudioTracks&&t.overrideNativeVideoTracks)t.overrideNativeAudioTracks(!0),t.overrideNativeVideoTracks(!0);else if(n.options_.overrideNative&&(t.featuresNativeVideoTracks||t.featuresNativeAudioTracks))throw new Error("Overriding native HLS requires emulated tracks. See https://git.io/vMpjB");return n.on(d,["fullscreenchange","webkitfullscreenchange","mozfullscreenchange","MSFullscreenChange"],function(e){var t=d.fullscreenElement||d.webkitFullscreenElement||d.mozFullScreenElement||d.msFullscreenElement;t&&t.contains(n.tech_.el())&&n.masterPlaylistController_.smoothQualityChange_()}),n.on(n.tech_,"seeking",function(){this.ignoreNextSeekingEvent_?this.ignoreNextSeekingEvent_=!1:this.setCurrentTime(this.tech_.currentTime())}),n.on(n.tech_,"error",function(){this.tech_.error()&&this.masterPlaylistController_&&this.masterPlaylistController_.pauseLoading()}),n.on(n.tech_,"play",n.play),n}Ge(e,a);var t=e.prototype;return t.setOptions_=function(){var t=this;if(this.options_.withCredentials=this.options_.withCredentials||!1,this.options_.handleManifestRedirects=!1!==this.options_.handleManifestRedirects,this.options_.limitRenditionByPlayerDimensions=!1!==this.options_.limitRenditionByPlayerDimensions,this.options_.useDevicePixelRatio=this.options_.useDevicePixelRatio||!1,this.options_.smoothQualityChange=this.options_.smoothQualityChange||!1,this.options_.useBandwidthFromLocalStorage="undefined"!=typeof this.source_.useBandwidthFromLocalStorage?this.source_.useBandwidthFromLocalStorage:this.options_.useBandwidthFromLocalStorage||!1,this.options_.customTagParsers=this.options_.customTagParsers||[],this.options_.customTagMappers=this.options_.customTagMappers||[],this.options_.cacheEncryptionKeys=this.options_.cacheEncryptionKeys||!1,this.options_.handlePartialData=this.options_.handlePartialData||!1,"number"!=typeof this.options_.blacklistDuration&&(this.options_.blacklistDuration=300),"number"!=typeof this.options_.bandwidth&&this.options_.useBandwidthFromLocalStorage){var e=Ud();e&&e.bandwidth&&(this.options_.bandwidth=e.bandwidth,this.tech_.trigger({type:"usage",name:"vhs-bandwidth-from-local-storage"}),this.tech_.trigger({type:"usage",name:"hls-bandwidth-from-local-storage"})),e&&e.throughput&&(this.options_.throughput=e.throughput,this.tech_.trigger({type:"usage",name:"vhs-throughput-from-local-storage"}),this.tech_.trigger({type:"usage",name:"hls-throughput-from-local-storage"}))}"number"!=typeof this.options_.bandwidth&&(this.options_.bandwidth=uc.INITIAL_BANDWIDTH),this.options_.enableLowInitialPlaylist=this.options_.enableLowInitialPlaylist&&this.options_.bandwidth===uc.INITIAL_BANDWIDTH,["withCredentials","useDevicePixelRatio","limitRenditionByPlayerDimensions","bandwidth","smoothQualityChange","customTagParsers","customTagMappers","handleManifestRedirects","cacheEncryptionKeys","handlePartialData"].forEach(function(e){"undefined"!=typeof t.source_[e]&&(t.options_[e]=t.source_[e])}),this.limitRenditionByPlayerDimensions=this.options_.limitRenditionByPlayerDimensions,this.useDevicePixelRatio=this.options_.useDevicePixelRatio},t.src=function(e,t){var i=this;e&&(this.setOptions_(),this.options_.src=function(e){return 0===e.toLowerCase().indexOf("data:application/vnd.videojs.vhs+json,")?JSON.parse(e.substring(e.indexOf(",")+1)):e}(this.source_.src),this.options_.tech=this.tech_,this.options_.externVhs=Dd,this.options_.sourceType=Ia(t),this.options_.seekTo=function(e){i.tech_.setCurrentTime(e)},this.masterPlaylistController_=new xd(this.options_),this.playbackWatcher_=new Ld(da.mergeOptions(this.options_,{seekable:function(){return i.seekable()},media:function(){return i.masterPlaylistController_.media()},masterPlaylistController:this.masterPlaylistController_})),this.masterPlaylistController_.on("error",function(){var e=da.players[i.tech_.options_.playerId],t=i.masterPlaylistController_.error;"object"!=typeof t||t.code?"string"==typeof t&&(t={message:t,code:3}):t.code=3,e.error(t)}),this.masterPlaylistController_.selectPlaylist=this.selectPlaylist?this.selectPlaylist.bind(this):Dd.STANDARD_PLAYLIST_SELECTOR.bind(this),this.masterPlaylistController_.selectInitialPlaylist=Dd.INITIAL_PLAYLIST_SELECTOR.bind(this),this.playlists=this.masterPlaylistController_.masterPlaylistLoader_,this.mediaSource=this.masterPlaylistController_.mediaSource,Object.defineProperties(this,{selectPlaylist:{get:function(){return this.masterPlaylistController_.selectPlaylist},set:function(e){this.masterPlaylistController_.selectPlaylist=e.bind(this)}},throughput:{get:function(){return this.masterPlaylistController_.mainSegmentLoader_.throughput.rate},set:function(e){this.masterPlaylistController_.mainSegmentLoader_.throughput.rate=e,this.masterPlaylistController_.mainSegmentLoader_.throughput.count=1}},bandwidth:{get:function(){return this.masterPlaylistController_.mainSegmentLoader_.bandwidth},set:function(e){this.masterPlaylistController_.mainSegmentLoader_.bandwidth=e,this.masterPlaylistController_.mainSegmentLoader_.throughput={rate:0,count:0}}},systemBandwidth:{get:function(){var e,t=1/(this.bandwidth||1);return e=0<this.throughput?1/this.throughput:0,Math.floor(1/(t+e))},set:function(){da.log.error('The "systemBandwidth" property is read-only')}}}),this.options_.bandwidth&&(this.bandwidth=this.options_.bandwidth),this.options_.throughput&&(this.throughput=this.options_.throughput),Object.defineProperties(this.stats,{bandwidth:{get:function(){return i.bandwidth||0},enumerable:!0},mediaRequests:{get:function(){return i.masterPlaylistController_.mediaRequests_()||0},enumerable:!0},mediaRequestsAborted:{get:function(){return i.masterPlaylistController_.mediaRequestsAborted_()||0},enumerable:!0},mediaRequestsTimedout:{get:function(){return i.masterPlaylistController_.mediaRequestsTimedout_()||0},enumerable:!0},mediaRequestsErrored:{get:function(){return i.masterPlaylistController_.mediaRequestsErrored_()||0},enumerable:!0},mediaTransferDuration:{get:function(){return i.masterPlaylistController_.mediaTransferDuration_()||0},enumerable:!0},mediaBytesTransferred:{get:function(){return i.masterPlaylistController_.mediaBytesTransferred_()||0},enumerable:!0},mediaSecondsLoaded:{get:function(){return i.masterPlaylistController_.mediaSecondsLoaded_()||0},enumerable:!0},buffered:{get:function(){return Zu(i.tech_.buffered())},enumerable:!0},currentTime:{get:function(){return i.tech_.currentTime()},enumerable:!0},currentSource:{get:function(){return i.tech_.currentSource_},enumerable:!0},currentTech:{get:function(){return i.tech_.name_},enumerable:!0},duration:{get:function(){return i.tech_.duration()},enumerable:!0},master:{get:function(){return i.playlists.master},enumerable:!0},playerDimensions:{get:function(){return i.tech_.currentDimensions()},enumerable:!0},seekable:{get:function(){return Zu(i.tech_.seekable())},enumerable:!0},timestamp:{get:function(){return Date.now()},enumerable:!0},videoPlaybackQuality:{get:function(){return i.tech_.getVideoPlaybackQuality()},enumerable:!0}}),this.tech_.one("canplay",this.masterPlaylistController_.setupFirstPlay.bind(this.masterPlaylistController_)),this.tech_.on("bandwidthupdate",function(){i.options_.useBandwidthFromLocalStorage&&function(e){if(!T.localStorage)return;var t=Ud();t=t?da.mergeOptions(t,e):e;try{T.localStorage.setItem(Rd,JSON.stringify(t))}catch(e){return}}({bandwidth:i.bandwidth,throughput:Math.round(i.throughput)})}),this.masterPlaylistController_.on("selectedinitialmedia",function(){!function(i){var e=i.playlists;i.representations=function(){return e&&e.master&&e.master.playlists?e.master.playlists.filter(function(e){return!sl(e)}).map(function(e,t){return new nd(i,e,e.id)}):[]}}(i)}),this.masterPlaylistController_.sourceUpdater_.on("ready",function(){var e=i.masterPlaylistController_.mediaTypes_.AUDIO.activePlaylistLoader;Nd({player:i.player_,sourceKeySystems:i.source_.keySystems,media:i.playlists.media(),audioMedia:e&&e.media(),mainPlaylists:i.playlists.master.playlists})}),this.on(this.masterPlaylistController_,"progress",function(){this.tech_.trigger("progress")}),this.on(this.masterPlaylistController_,"firstplay",function(){this.ignoreNextSeekingEvent_=!0}),this.setupQualityLevels_(),this.tech_.el()&&(this.mediaSourceUrl_=T.URL.createObjectURL(this.masterPlaylistController_.mediaSource),this.tech_.src(this.mediaSourceUrl_)))},t.setupQualityLevels_=function(){var e=this,t=da.players[this.tech_.options_.playerId];t&&t.qualityLevels&&!this.qualityLevels_&&(this.qualityLevels_=t.qualityLevels(),this.masterPlaylistController_.on("selectedinitialmedia",function(){!function(t,e){e.representations().forEach(function(e){t.addQualityLevel(e)}),Md(t,e.playlists)}(e.qualityLevels_,e)}),this.playlists.on("mediachange",function(){Md(e.qualityLevels_,e.playlists)}))},e.version=function(){return{"@videojs/http-streaming":"2.2.4","mux.js":"5.6.7","mpd-parser":"0.14.0","m3u8-parser":"4.5.0","aes-decrypter":"3.1.0"}},t.version=function(){return this.constructor.version()},t.canChangeType=function(){return vd.canChangeType()},t.play=function(){this.masterPlaylistController_.play()},t.setCurrentTime=function(e){this.masterPlaylistController_.setCurrentTime(e)},t.duration=function(){return this.masterPlaylistController_.duration()},t.seekable=function(){return this.masterPlaylistController_.seekable()},t.dispose=function(){this.playbackWatcher_&&this.playbackWatcher_.dispose(),this.masterPlaylistController_&&this.masterPlaylistController_.dispose(),this.qualityLevels_&&this.qualityLevels_.dispose(),this.player_&&(delete this.player_.vhs,delete this.player_.dash,delete this.player_.hls),this.tech_&&this.tech_.vhs&&delete this.tech_.vhs,this.tech_&&delete this.tech_.hls,this.mediaSourceUrl_&&T.URL.revokeObjectURL&&(T.URL.revokeObjectURL(this.mediaSourceUrl_),this.mediaSourceUrl_=null),a.prototype.dispose.call(this)},t.convertToProgramTime=function(e,t){return _l({playlist:this.masterPlaylistController_.media(),time:e,callback:t})},t.seekToProgramTime=function(e,t,i,n){return void 0===i&&(i=!0),void 0===n&&(n=2),bl({programTime:e,playlist:this.masterPlaylistController_.media(),retryCount:n,pauseAfterSeek:i,seekTo:this.options_.seekTo,tech:this.options_.tech,callback:t})},e}(da.getComponent("Component")),Bd={name:"videojs-http-streaming",VERSION:"2.2.4",canHandleSource:function(e,t){void 0===t&&(t={});var i=da.mergeOptions(da.options,t);return Bd.canPlayType(e.type,i)},handleSource:function(e,t,i){void 0===i&&(i={});var n=da.mergeOptions(da.options,i);return t.vhs=new Fd(e,t,n),da.hasOwnProperty("hls")||Object.defineProperty(t,"hls",{get:function(){return da.log.warn("player.tech().hls is deprecated. Use player.tech().vhs instead."),t.vhs},configurable:!0}),t.vhs.xhr=dl(),t.vhs.src(e.src,e.type),t.vhs},canPlayType:function(e,t){void 0===t&&(t={});var i=da.mergeOptions(da.options,t).vhs.overrideNative,n=void 0===i?!da.browser.IS_ANY_SAFARI:i,r=Ia(e);return r&&(!Dd.supportsTypeNatively(r)||n)?"maybe":""}};return ql("avc1.4d400d,mp4a.40.2")&&da.getTech("Html5").registerSourceHandler(Bd,0),da.VhsHandler=Fd,Object.defineProperty(da,"HlsHandler",{get:function(){return da.log.warn("videojs.HlsHandler is deprecated. Use videojs.VhsHandler instead."),Fd},configurable:!0}),da.VhsSourceHandler=Bd,Object.defineProperty(da,"HlsSourceHandler",{get:function(){return da.log.warn("videojs.HlsSourceHandler is deprecated. Use videojs.VhsSourceHandler instead."),Bd},configurable:!0}),da.Vhs=Dd,Object.defineProperty(da,"Hls",{get:function(){return da.log.warn("videojs.Hls is deprecated. Use videojs.Vhs instead."),Dd},configurable:!0}),da.use||(da.registerComponent("Hls",Dd),da.registerComponent("Vhs",Dd)),da.options.vhs=da.options.vhs||{},da.options.hls=da.options.hls||{},da.registerPlugin?da.registerPlugin("reloadSourceOnError",rd):da.plugin("reloadSourceOnError",rd),da}); -!function(){!function(a){var b=a&&a.videojs;b&&(b.CDN_VERSION="7.10.2")}(window)}();
\ No newline at end of file diff --git a/assets/js/videojs-contrib-quality-levels.min.js b/assets/js/videojs-contrib-quality-levels.min.js deleted file mode 100644 index f0015b89..00000000 --- a/assets/js/videojs-contrib-quality-levels.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! @name videojs-contrib-quality-levels @version 2.0.9 @license Apache-2.0 */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("video.js"),require("global/document")):"function"==typeof define&&define.amd?define(["video.js","global/document"],t):e.videojsContribQualityLevels=t(e.videojs,e.document)}(this,function(e,t){"use strict";function n(e){if(void 0===e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return e}e=e&&e.hasOwnProperty("default")?e.default:e,t=t&&t.hasOwnProperty("default")?t.default:t;var r=function(r){var i,l;function o(){var i,l=n(n(i=r.call(this)||this));if(e.browser.IS_IE8)for(var s in l=t.createElement("custom"),o.prototype)"constructor"!==s&&(l[s]=o.prototype[s]);return l.levels_=[],l.selectedIndex_=-1,Object.defineProperty(l,"selectedIndex",{get:function(){return l.selectedIndex_}}),Object.defineProperty(l,"length",{get:function(){return l.levels_.length}}),l||n(i)}l=r,(i=o).prototype=Object.create(l.prototype),i.prototype.constructor=i,i.__proto__=l;var s=o.prototype;return s.addQualityLevel=function(n){var r=this.getQualityLevelById(n.id);if(r)return r;var i=this.levels_.length;return r=new function n(r){var i=this;if(e.browser.IS_IE8)for(var l in i=t.createElement("custom"),n.prototype)"constructor"!==l&&(i[l]=n.prototype[l]);return i.id=r.id,i.label=i.id,i.width=r.width,i.height=r.height,i.bitrate=r.bandwidth,i.enabled_=r.enabled,Object.defineProperty(i,"enabled",{get:function(){return i.enabled_()},set:function(e){i.enabled_(e)}}),i}(n),""+i in this||Object.defineProperty(this,i,{get:function(){return this.levels_[i]}}),this.levels_.push(r),this.trigger({qualityLevel:r,type:"addqualitylevel"}),r},s.removeQualityLevel=function(e){for(var t=null,n=0,r=this.length;n<r;n++)if(this[n]===e){t=this.levels_.splice(n,1)[0],this.selectedIndex_===n?this.selectedIndex_=-1:this.selectedIndex_>n&&this.selectedIndex_--;break}return t&&this.trigger({qualityLevel:e,type:"removequalitylevel"}),t},s.getQualityLevelById=function(e){for(var t=0,n=this.length;t<n;t++){var r=this[t];if(r.id===e)return r}return null},s.dispose=function(){this.selectedIndex_=-1,this.levels_.length=0},o}(e.EventTarget);for(var i in r.prototype.allowedEvents_={change:"change",addqualitylevel:"addqualitylevel",removequalitylevel:"removequalitylevel"},r.prototype.allowedEvents_)r.prototype["on"+i]=null;var l=function(t){return n=this,e.mergeOptions({},t),i=n.qualityLevels,l=new r,n.on("dispose",function e(){l.dispose(),n.qualityLevels=i,n.off("dispose",e)}),n.qualityLevels=function(){return l},n.qualityLevels.VERSION="2.0.9",l;var n,i,l};return(e.registerPlugin||e.plugin)("qualityLevels",l),l.VERSION="2.0.9",l}); diff --git a/assets/js/videojs-http-source-selector.min.js b/assets/js/videojs-http-source-selector.min.js deleted file mode 100644 index d4923c66..00000000 --- a/assets/js/videojs-http-source-selector.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * videojs-http-source-selector - * @version 1.1.6 - * @copyright 2019 Justin Fujita <Justin@pivotshare.com> - * @license MIT - */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("video.js")):"function"==typeof define&&define.amd?define(["video.js"],t):(e=e||self)["videojs-http-source-selector"]=t(e.videojs)}(this,function(r){"use strict";function o(e,t){e.prototype=Object.create(t.prototype),(e.prototype.constructor=e).__proto__=t}function s(e){if(void 0===e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return e}var e=(r=r&&r.hasOwnProperty("default")?r.default:r).getComponent("MenuItem"),t=r.getComponent("Component"),a=function(n){function e(e,t){return t.selectable=!0,t.multiSelectable=!1,n.call(this,e,t)||this}o(e,n);var t=e.prototype;return t.handleClick=function(){var e=this.options_;console.log("Changing quality to:",e.label),n.prototype.handleClick.call(this);for(var t=this.player().qualityLevels(),o=0;o<t.length;o++)e.index==t.length?t[o].enabled=!0:e.index==o?t[o].enabled=!0:t[o].enabled=!1},t.update=function(){var e=this.player().qualityLevels().selectedIndex;this.selected(this.options_.index==e)},e}(e);t.registerComponent("SourceMenuItem",a);var u=r.getComponent("MenuButton"),n=function(i){function e(e,t){var o;o=i.call(this,e,t)||this,u.apply(s(o),arguments);var n=o.player().qualityLevels();if(t&&t.default)if("low"==t.default)for(var l=0;l<n.length;l++)n[l].enabled=0==l;else if(t.default="high")for(l=0;l<n.length;l++)n[l].enabled=l==n.length-1;return o.player().qualityLevels().on(["change","addqualitylevel"],r.bind(s(o),o.update)),o}o(e,i);var t=e.prototype;return t.createEl=function(){return r.dom.createEl("div",{className:"vjs-http-source-selector vjs-menu-button vjs-menu-button-popup vjs-control vjs-button"})},t.buildCSSClass=function(){return u.prototype.buildCSSClass.call(this)+" vjs-icon-cog"},t.update=function(){return u.prototype.update.call(this)},t.createItems=function(){for(var e=[],t=this.player().qualityLevels(),o=[],n=0;n<t.length;n++){var l=t.length-(n+1),i=l===t.selectedIndex,r=""+l,s=l;t[l].height?(r=t[l].height+"p",s=parseInt(t[l].height,10)):t[l].bitrate&&(r=Math.floor(t[l].bitrate/1e3)+" kbps",s=parseInt(t[l].bitrate,10)),0<=o.indexOf(r)||(o.push(r),e.push(new a(this.player_,{label:r,index:l,selected:i,sortVal:s})))}return 1<t.length&&e.push(new a(this.player_,{label:"Auto",index:t.length,selected:!1,sortVal:99999})),e.sort(function(e,t){return e.options_.sortVal<t.options_.sortVal?1:e.options_.sortVal>t.options_.sortVal?-1:0}),e},e}(u),l={},i=r.registerPlugin||r.plugin,c=function(e){var t=this;this.ready(function(){!function(n,e){if(n.addClass("vjs-http-source-selector"),console.log("videojs-http-source-selector initialized!"),console.log("player.techName_:"+n.techName_),"Html5"!=n.techName_)return;n.on(["loadedmetadata"],function(e){if(n.qualityLevels(),r.log("loadmetadata event"),"undefined"==n.videojs_http_source_selector_initialized||1==n.videojs_http_source_selector_initialized)console.log("player.videojs_http_source_selector_initialized == true");else{console.log("player.videojs_http_source_selector_initialized == false"),n.videojs_http_source_selector_initialized=!0;var t=n.controlBar,o=t.getChild("fullscreenToggle").el();t.el().insertBefore(t.addChild("SourceMenuButton").el(),o)}})}(t,r.mergeOptions(l,e))}),r.registerComponent("SourceMenuButton",n),r.registerComponent("SourceMenuItem",a)};return i("httpSourceSelector",c),c.VERSION="1.1.6",c});
\ No newline at end of file diff --git a/assets/js/videojs-markers.min.js b/assets/js/videojs-markers.min.js deleted file mode 100644 index afc1bc0d..00000000 --- a/assets/js/videojs-markers.min.js +++ /dev/null @@ -1,4 +0,0 @@ -/*! videojs-markers - v1.0.1 - 2018-02-03 -* Copyright (c) 2018 ; Licensed */ - -!function(e,r){if("function"==typeof define&&define.amd)define(["video.js"],r);else if("undefined"!=typeof exports)r(require("video.js"));else{var t={exports:{}};r(e.videojs),e.videojsMarkers=t.exports}}(this,function(e){"use strict";function r(){var e=(new Date).getTime();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(r){var t=(e+16*Math.random())%16|0;return e=Math.floor(e/16),("x"==r?t:3&t|8).toString(16)})}function t(e){var r,t={top:0,bottom:0,left:0,width:0,height:0,right:0};try{r=e.getBoundingClientRect()}catch(e){r=t}return r}function i(e){function i(){M.sort(function(e,r){return j.markerTip.time(e)-j.markerTip.time(r)})}function u(e){e.forEach(function(e){e.key=r(),S.el().querySelector(".vjs-progress-holder").appendChild(s(e)),E[e.key]=e,M.push(e)}),i()}function c(e){return j.markerTip.time(e)/S.duration()*100}function f(e,r){r.className="vjs-marker "+(e.class||""),Object.keys(j.markerStyle).forEach(function(e){r.style[e]=j.markerStyle[e]});var i=e.time/S.duration();if((i<0||i>1)&&(r.style.display="none"),r.style.left=c(e)+"%",e.duration)r.style.width=e.duration/S.duration()*100+"%",r.style.marginLeft="0px";else{var n=t(r);r.style.marginLeft=n.width/2+"px"}}function s(e){var r=n.default.createEl("div",{},{"data-marker-key":e.key,"data-marker-time":j.markerTip.time(e)});return f(e,r),r.addEventListener("click",function(r){var t=!1;if("function"==typeof j.onMarkerClick&&(t=!1===j.onMarkerClick(e)),!t){var i=this.getAttribute("data-marker-key");S.currentTime(j.markerTip.time(E[i]))}}),j.markerTip.display&&v(r),r}function d(e){M.forEach(function(r){var t=S.el().querySelector(".vjs-marker[data-marker-key='"+r.key+"']"),i=j.markerTip.time(r);(e||t.getAttribute("data-marker-time")!==i)&&(f(r,t),t.setAttribute("data-marker-time",i))}),i()}function m(e){w&&(L=l,w.style.visibility="hidden"),O=l;var r=[];e.forEach(function(e){var t=M[e];if(t){delete E[t.key],r.push(e);var i=S.el().querySelector(".vjs-marker[data-marker-key='"+t.key+"']");i&&i.parentNode.removeChild(i)}}),r.reverse(),r.forEach(function(e){M.splice(e,1)}),i()}function v(e){e.addEventListener("mouseover",function(){var r=E[e.getAttribute("data-marker-key")];if(A){A.querySelector(".vjs-tip-inner").innerText=j.markerTip.text(r),A.style.left=c(r)+"%";var i=t(A),n=t(e);A.style.marginLeft=-parseFloat(i.width/2)+parseFloat(n.width/4)+"px",A.style.visibility="visible"}}),e.addEventListener("mouseout",function(){A&&(A.style.visibility="hidden")})}function y(){A=n.default.createEl("div",{className:"vjs-tip",innerHTML:"<div class='vjs-tip-arrow'></div><div class='vjs-tip-inner'></div>"}),S.el().querySelector(".vjs-progress-holder").appendChild(A)}function k(){if(j.breakOverlay.display&&!(O<0)){var e=S.currentTime(),r=M[O],t=j.markerTip.time(r);e>=t&&e<=t+j.breakOverlay.displayTime?(L!==O&&(L=O,w&&(w.querySelector(".vjs-break-overlay-text").innerHTML=j.breakOverlay.text(r))),w&&(w.style.visibility="visible")):(L=l,w&&(w.style.visibility="hidden"))}}function p(){w=n.default.createEl("div",{className:"vjs-break-overlay",innerHTML:"<div class='vjs-break-overlay-text'></div>"}),Object.keys(j.breakOverlay.style).forEach(function(e){w&&(w.style[e]=j.breakOverlay.style[e])}),S.el().appendChild(w),L=l}function h(){x(),k(),e.onTimeUpdateAfterMarkerUpdate&&e.onTimeUpdateAfterMarkerUpdate()}function x(){if(M.length){var r=function(e){return e<M.length-1?j.markerTip.time(M[e+1]):S.duration()},t=S.currentTime(),i=l;if(O!==l){var n=r(O);if(t>=j.markerTip.time(M[O])&&t<n)return;if(O===M.length-1&&t===S.duration())return}if(t<j.markerTip.time(M[0]))i=l;else for(var a=0;a<M.length;a++)if(n=r(a),t>=j.markerTip.time(M[a])&&t<n){i=a;break}i!==O&&(i!==l&&e.onMarkerReached&&e.onMarkerReached(M[i],i),O=i)}}function b(){j.markerTip.display&&y(),S.markers.removeAll(),u(j.markers),j.breakOverlay.display&&p(),h(),S.on("timeupdate",h),S.off("loadedmetadata")}if(!n.default.mergeOptions){var T=function(e){return!!e&&"object"===(void 0===e?"undefined":a(e))&&"[object Object]"===toString.call(e)&&e.constructor===Object},g=function e(r,t){var i={};return[r,t].forEach(function(r){r&&Object.keys(r).forEach(function(t){var n=r[t];T(n)?(T(i[t])||(i[t]={}),i[t]=e(i[t],n)):i[t]=n})}),i};n.default.mergeOptions=g}n.default.createEl||(n.default.createEl=function(e,r,t){var i=n.default.Player.prototype.createEl(e,r);return t&&Object.keys(t).forEach(function(e){i.setAttribute(e,t[e])}),i});var j=n.default.mergeOptions(o,e),E={},M=[],O=l,S=this,A=null,w=null,L=l;S.on("loadedmetadata",function(){b()}),S.markers={getMarkers:function(){return M},next:function(){for(var e=S.currentTime(),r=0;r<M.length;r++){var t=j.markerTip.time(M[r]);if(t>e){S.currentTime(t);break}}},prev:function(){for(var e=S.currentTime(),r=M.length-1;r>=0;r--){var t=j.markerTip.time(M[r]);if(t+.5<e)return void S.currentTime(t)}},add:function(e){u(e)},remove:function(e){m(e)},removeAll:function(){for(var e=[],r=0;r<M.length;r++)e.push(r);m(e)},updateTime:function(e){d(e)},reset:function(e){S.markers.removeAll(),u(e)},destroy:function(){S.markers.removeAll(),w&&w.remove(),A&&A.remove(),S.off("timeupdate",k),delete S.markers}}}var n=function(e){return e&&e.__esModule?e:{default:e}}(e),a="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},o={markerStyle:{width:"7px","border-radius":"30%","background-color":"red"},markerTip:{display:!0,text:function(e){return"Break: "+e.text},time:function(e){return e.time}},breakOverlay:{display:!1,displayTime:3,text:function(e){return"Break overlay: "+e.overlayText},style:{width:"100%",height:"20%","background-color":"rgba(0,0,0,0.7)",color:"white","font-size":"17px"}},onMarkerClick:function(e){},onMarkerReached:function(e,r){},markers:[]},l=-1;n.default.plugin("markers",i)});
\ No newline at end of file diff --git a/assets/js/videojs-mobile-ui.min.js b/assets/js/videojs-mobile-ui.min.js deleted file mode 100644 index e624fbe2..00000000 --- a/assets/js/videojs-mobile-ui.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * videojs-mobile-ui - * @version 0.5.2 - * @copyright 2021 mister-ben <git@misterben.me> - * @license MIT - */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("video.js"),require("global/window")):"function"==typeof define&&define.amd?define(["video.js","global/window"],t):e.videojsMobileUi=t(e.videojs,e.window)}(this,function(e,t){"use strict";e=e&&e.hasOwnProperty("default")?e.default:e,t=t&&t.hasOwnProperty("default")?t.default:t;var n=function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")},o=function(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t},i=e.getComponent("Component"),r=e.dom||e,a=function(e){function i(t,r){n(this,i);var a=o(this,e.call(this,t,r));return a.seekSeconds=r.seekSeconds,a.tapTimeout=r.tapTimeout,a.addChild("playToggle",{}),t.on(["playing","userinactive"],function(e){a.removeClass("show-play-toggle")}),0===a.player_.options_.inactivityTimeout&&(a.player_.options_.inactivityTimeout=5e3),a.enable(),a}return function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}(i,e),i.prototype.createEl=function(){return r.createEl("div",{className:"vjs-touch-overlay",tabIndex:-1})},i.prototype.handleTap=function(e){var n=this;e.target===this.el_&&(e.preventDefault(),this.firstTapCaptured?(this.firstTapCaptured=!1,this.timeout&&t.clearTimeout(this.timeout),this.handleDoubleTap(e)):(this.firstTapCaptured=!0,this.timeout=t.setTimeout(function(){n.firstTapCaptured=!1,n.handleSingleTap(e)},this.tapTimeout)))},i.prototype.handleSingleTap=function(e){this.removeClass("skip"),this.toggleClass("show-play-toggle")},i.prototype.handleDoubleTap=function(e){var n=this,o=this.el_.getBoundingClientRect(),i=e.changedTouches[0].clientX-o.left;if(i<.4*o.width)this.player_.currentTime(Math.max(0,this.player_.currentTime()-this.seekSeconds)),this.addClass("reverse");else{if(!(i>o.width-.4*o.width))return;this.player_.currentTime(Math.min(this.player_.duration(),this.player_.currentTime()+this.seekSeconds)),this.removeClass("reverse")}this.removeClass("show-play-toggle"),this.removeClass("skip"),t.requestAnimationFrame(function(){n.addClass("skip")})},i.prototype.enable=function(){this.firstTapCaptured=!1,this.on("touchend",this.handleTap)},i.prototype.disable=function(){this.off("touchend",this.handleTap)},i}(i);i.registerComponent("TouchOverlay",a);var s={fullscreen:{enterOnRotate:!0,exitOnRotate:!0,lockOnRotate:!0,iOS:!1},touchControls:{seekSeconds:10,tapTimeout:300,disableOnEnd:!1}},l=t.screen,u=function(n,o){n.addClass("vjs-mobile-ui"),(o.touchControls.disableOnEnd||"function"==typeof n.endscreen)&&n.addClass("vjs-mobile-ui-disable-end"),o.fullscreen.iOS&&e.browser.IS_IOS&&e.browser.IOS_VERSION>9&&!n.el_.ownerDocument.querySelector(".bc-iframe")&&(n.tech_.el_.setAttribute("playsinline","playsinline"),n.tech_.supportsFullScreen=function(){return!1});var i=void 0,r=e.VERSION.split("."),a=parseInt(r[0],10),s=parseInt(r[1],10);i=a<7||7===a&&s<7?Array.prototype.indexOf.call(n.el_.children,n.getChild("ControlBar").el_):n.children_.indexOf(n.getChild("ControlBar")),n.addChild("TouchOverlay",o.touchControls,i);var u=!1,c=function(){var i="number"==typeof t.orientation?t.orientation:l&&l.orientation&&l.orientation.angle?t.orientation:(e.log("angle unknown"),0);90!==i&&270!==i&&-90!==i||!o.enterOnRotate||!1===n.paused()&&(n.requestFullscreen(),o.fullscreen.lockOnRotate&&l.orientation&&l.orientation.lock&&l.orientation.lock("landscape").then(function(){u=!0}).catch(function(){e.log("orientation lock not allowed")})),0!==i&&180!==i||!o.exitOnRotate||n.isFullscreen()&&n.exitFullscreen()};e.browser.IS_IOS?t.addEventListener("orientationchange",c):l.orientation&&(l.orientation.onchange=c),n.on("ended",function(e){!0===u&&(l.orientation.unlock(),u=!1)})},c=function(){var t=this,n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};(n.forceForTesting||e.browser.IS_ANDROID||e.browser.IS_IOS)&&this.ready(function(){u(t,e.mergeOptions(s,n))})};return(e.registerPlugin||e.plugin)("mobileUi",c),c.VERSION="0.5.2",c});
\ No newline at end of file diff --git a/assets/js/videojs-overlay.min.js b/assets/js/videojs-overlay.min.js deleted file mode 100644 index 8182c26c..00000000 --- a/assets/js/videojs-overlay.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! @name videojs-overlay @version 2.1.4 @license Apache-2.0 */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("video.js"),require("global/window")):"function"==typeof define&&define.amd?define(["video.js","global/window"],e):t.videojsOverlay=e(t.videojs,t.window)}(this,function(t,e){"use strict";function n(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}t=t&&t.hasOwnProperty("default")?t.default:t,e=e&&e.hasOwnProperty("default")?e.default:e;var r={align:"top-left",class:"",content:"This overlay will show up while the video is playing",debug:!1,showBackground:!0,attachToControlBar:!1,overlays:[{start:"playing",end:"paused"}]},i=t.getComponent("Component"),o=t.dom||t,s=t.registerPlugin||t.plugin,a=function(t){return"number"==typeof t&&t==t},h=function(t){return"string"==typeof t&&/^\S+$/.test(t)},d=function(r){var i,s;function d(t,e){var i;return i=r.call(this,t,e)||this,["start","end"].forEach(function(t){var e=i.options_[t];if(a(e))i[t+"Event_"]="timeupdate";else if(h(e))i[t+"Event_"]=e;else if("start"===t)throw new Error('invalid "start" option; expected number or string')}),["endListener_","rewindListener_","startListener_"].forEach(function(t){i[t]=function(e){return d.prototype[t].call(n(n(i)),e)}}),"timeupdate"===i.startEvent_&&i.on(t,"timeupdate",i.rewindListener_),i.debug('created, listening to "'+i.startEvent_+'" for "start" and "'+(i.endEvent_||"nothing")+'" for "end"'),i.hide(),i}s=r,(i=d).prototype=Object.create(s.prototype),i.prototype.constructor=i,i.__proto__=s;var l=d.prototype;return l.createEl=function(){var t=this.options_,n=t.content,r=t.showBackground?"vjs-overlay-background":"vjs-overlay-no-background",i=o.createEl("div",{className:"\n vjs-overlay\n vjs-overlay-"+t.align+"\n "+t.class+"\n "+r+"\n vjs-hidden\n "});return"string"==typeof n?i.innerHTML=n:n instanceof e.DocumentFragment?i.appendChild(n):o.appendContent(i,n),i},l.debug=function(){if(this.options_.debug){for(var e=t.log,n=e,r=arguments.length,i=new Array(r),o=0;o<r;o++)i[o]=arguments[o];e.hasOwnProperty(i[0])&&"function"==typeof e[i[0]]&&(n=e[i.shift()]),n.apply(void 0,["overlay#"+this.id()+": "].concat(i))}},l.hide=function(){return r.prototype.hide.call(this),this.debug("hidden"),this.debug('bound `startListener_` to "'+this.startEvent_+'"'),this.endEvent_&&(this.debug('unbound `endListener_` from "'+this.endEvent_+'"'),this.off(this.player(),this.endEvent_,this.endListener_)),this.on(this.player(),this.startEvent_,this.startListener_),this},l.shouldHide_=function(t,e){var n=this.options_.end;return a(n)?t>=n:n===e},l.show=function(){return r.prototype.show.call(this),this.off(this.player(),this.startEvent_,this.startListener_),this.debug("shown"),this.debug('unbound `startListener_` from "'+this.startEvent_+'"'),this.endEvent_&&(this.debug('bound `endListener_` to "'+this.endEvent_+'"'),this.on(this.player(),this.endEvent_,this.endListener_)),this},l.shouldShow_=function(t,e){var n=this.options_.start,r=this.options_.end;return a(n)?a(r)?t>=n&&t<r:this.hasShownSinceSeek_?Math.floor(t)===n:(this.hasShownSinceSeek_=!0,t>=n):n===e},l.startListener_=function(t){var e=this.player().currentTime();this.shouldShow_(e,t.type)&&this.show()},l.endListener_=function(t){var e=this.player().currentTime();this.shouldHide_(e,t.type)&&this.hide()},l.rewindListener_=function(t){var e=this.player().currentTime(),n=this.previousTime_,r=this.options_.start,i=this.options_.end;e<n&&(this.debug("rewind detected"),a(i)&&!this.shouldShow_(e)?(this.debug("hiding; "+i+" is an integer and overlay should not show at this time"),this.hasShownSinceSeek_=!1,this.hide()):h(i)&&e<r&&(this.debug("hiding; show point ("+r+") is before now ("+e+") and end point ("+i+") is an event"),this.hasShownSinceSeek_=!1,this.hide())),this.previousTime_=e},d}(i);t.registerComponent("Overlay",d);var l=function(e){var n=this,i=t.mergeOptions(r,e);Array.isArray(this.overlays_)&&this.overlays_.forEach(function(t){n.removeChild(t),n.controlBar&&n.controlBar.removeChild(t),t.dispose()});var o=i.overlays;delete i.overlays,this.overlays_=o.map(function(e){var r=t.mergeOptions(i,e),o="string"==typeof r.attachToControlBar||!0===r.attachToControlBar;if(!n.controls()||!n.controlBar)return n.addChild("overlay",r);if(o&&-1!==r.align.indexOf("bottom")){var s=n.controlBar.children()[0];if(void 0!==n.controlBar.getChild(r.attachToControlBar)&&(s=n.controlBar.getChild(r.attachToControlBar)),s){var a=n.controlBar.addChild("overlay",r);return n.controlBar.el().insertBefore(a.el(),s.el()),a}}var h=n.addChild("overlay",r);return n.el().insertBefore(h.el(),n.controlBar.el()),h})};return l.VERSION="2.1.4",s("overlay",l),l}); diff --git a/assets/js/videojs-share.min.js b/assets/js/videojs-share.min.js deleted file mode 100644 index 305af7f2..00000000 --- a/assets/js/videojs-share.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * videojs-share - * @version 3.2.1 - * @copyright 2019 Mikhail Khazov <mkhazov.work@gmail.com> - * @license MIT - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("video.js")):"function"==typeof define&&define.amd?define(["video.js"],e):t.videojsShare=e(t.videojs)}(this,function(t){"use strict";function e(t,e){return e={exports:{}},t(e,e.exports),e.exports}function n(t){var e;if("SELECT"===t.nodeName)t.focus(),e=t.value;else if("INPUT"===t.nodeName||"TEXTAREA"===t.nodeName){var n=t.hasAttribute("readonly");n||t.setAttribute("readonly",""),t.select(),t.setSelectionRange(0,t.value.length),n||t.removeAttribute("readonly"),e=t.value}else{t.hasAttribute("contenteditable")&&t.focus();var i=window.getSelection(),o=document.createRange();o.selectNodeContents(t),i.removeAllRanges(),i.addRange(o),e=i.toString()}return e}function i(){}function o(t,e){for(;t&&t.nodeType!==G;){if("function"==typeof t.matches&&t.matches(e))return t;t=t.parentNode}}function r(t,e,n,i,o){var r=l.apply(this,arguments);return t.addEventListener(n,r,o),{destroy:function(){t.removeEventListener(n,r,o)}}}function a(t,e,n,i,o){return"function"==typeof t.addEventListener?r.apply(null,arguments):"function"==typeof n?r.bind(null,document).apply(null,arguments):("string"==typeof t&&(t=document.querySelectorAll(t)),Array.prototype.map.call(t,function(t){return r(t,e,n,i,o)}))}function l(t,e,n,i){return function(n){n.delegateTarget=K(n.target,e),n.delegateTarget&&i.call(t,n)}}function s(t,e,n){if(!t&&!e&&!n)throw new Error("Missing required arguments");if(!X.string(e))throw new TypeError("Second argument must be a String");if(!X.fn(n))throw new TypeError("Third argument must be a Function");if(X.node(t))return c(t,e,n);if(X.nodeList(t))return u(t,e,n);if(X.string(t))return h(t,e,n);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function c(t,e,n){return t.addEventListener(e,n),{destroy:function(){t.removeEventListener(e,n)}}}function u(t,e,n){return Array.prototype.forEach.call(t,function(t){t.addEventListener(e,n)}),{destroy:function(){Array.prototype.forEach.call(t,function(t){t.removeEventListener(e,n)})}}}function h(t,e,n){return Q(document.body,t,e,n)}function d(t){return Object.keys(t).filter(function(e){return void 0!==t[e]&&""!==t[e]}).map(function(e){return encodeURIComponent(e)+"="+encodeURIComponent(t[e])}).join("&")}function f(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.fbAppId,n=t.url,i=t.redirectUri;if(!e)throw new Error("fbAppId is not defined");var o=d({app_id:e,display:"popup",redirect_uri:i,link:n});return window.open("https://www.facebook.com/dialog/feed?"+o,"_blank",tt)}function p(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.fbAppId,n=t.url,i=t.hashtag,o=t.redirectUri;if(!e)throw new Error("fbAppId is not defined");var r=d({app_id:e,display:"popup",redirect_uri:o,href:n,hashtag:i});return window.open("https://www.facebook.com/dialog/share?"+r,"_blank",tt)}function v(){var t=(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}).url;if(!t)throw new Error("url is not defined");var e=d({kid_directed_site:"0",sdk:"joey",u:t,display:"popup",ref:"plugin",src:"share_button"});return window.open("https://www.facebook.com/sharer/sharer.php?"+e,"_blank",tt)}function g(){var t=d({url:(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}).url});return window.open("https://plus.google.com/share?"+t,"_blank",tt)}function w(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=d({share_url:t.url,title:t.title,description:t.description,imageurl:t.image});return window.open("http://connect.mail.ru/share?"+e,"_blank",tt)}function m(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.url,n=t.title,i=t.description,o=(n||"")+"\r\n"+(i||"")+"\r\n"+(e||""),r="mailto:?body="+encodeURIComponent(o);return window.location.assign(r)}function y(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=d({"st.cmd":"addShare","st._surl":t.url,title:t.title});return window.open("https://ok.ru/dk?"+e,"_blank",tt)}function b(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=d({url:t.url,text:t.title});return window.open("https://t.me/share/url?"+e,"_blank",tt)}function k(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.title,n=t.url,i=t.hashtags,o=d({text:e,url:n,hashtags:(void 0===i?[]:i).join(",")});return window.open("https://twitter.com/intent/tweet?"+o,"_blank",tt)}function _(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=d({url:t.url,title:t.title});return window.open("https://www.reddit.com/submit?"+e,"_blank",tt)}function C(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.description,n=d({url:t.url,description:e,media:t.media});return window.open("https://pinterest.com/pin/create/button/?"+n,"_blank",tt)}function x(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.url,n=t.title,i=t.caption,o=t.tags,r=void 0===o?[]:o,a=t.posttype,l=void 0===a?"link":a,s=d({canonicalUrl:e,title:n,caption:i,tags:r.join(","),posttype:l});return window.open("https://www.tumblr.com/widgets/share/tool?"+s,"_blank",tt)}function E(){return!!window.navigator.userAgent.match(/Version\/[\d.]+.*Safari/)}function S(t){return E()?window.open(t):window.location.assign(t)}function j(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.url,n=t.title;if(!e&&!n)throw new Error("url and title not specified");return S("viber://forward?"+d({text:[n,e].filter(function(t){return t}).join(" ")}))}function F(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.url,n=t.image,i=t.isVkParse,o=t.description,r=t.title;o&&o.length>et&&(o=o.substr(0,et)+"..."),r&&r.length>et&&(r=r.substr(0,et)+"...");return"https://vk.com/share.php?"+d(i?{url:e}:{url:e,title:r,description:o,image:n,noparse:!0})}function A(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return window.open(F(t),"_blank",tt)}function T(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.phone,n=d({text:[t.title,t.url].filter(function(t){return t}).join(" "),phone:e});return window.open("https://api.whatsapp.com/send?"+n,"_blank",tt)}function z(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.title,n=t.url,i=d({title:e,summary:t.description,url:n});return window.open("https://www.linkedin.com/shareArticle?mini=true&"+i,"_blank",tt)}function M(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.fbAppId,n=t.url;if(!e)throw new Error("fbAppId is not defined");var i=d({app_id:e,link:n});return window.location.assign("fb-messenger://share?"+i)}function O(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.title,n=t.url;if(!n)throw new Error("url is not defined");var i=encodeURIComponent(""+n);return e&&(i=""+encodeURIComponent(e+" ")+i),window.open("https://line.me/R/msg/text/?"+i,"_blank",tt)}function L(){return"ontouchstart"in window||navigator.MaxTouchPoints>0||navigator.msMaxTouchPoints>0}function V(){return/Android/.test(window.navigator.userAgent)||/iP(hone|ad|od)/i.test(window.navigator.userAgent)}function H(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];return!(arguments.length>1&&void 0!==arguments[1])||arguments[1]?V()?t:t.filter(function(t){return!it.includes(t)}):t}t=t&&t.hasOwnProperty("default")?t.default:t;var I=function(){return"undefined"==typeof window?"":window.location.href}(),P={mobileVerification:!0,title:"Video",url:I,socials:["fbFeed","tw","reddit","gp","messenger","linkedin","vk","ok","mail","email","telegram","whatsapp","viber"],embedCode:function(){return"<iframe src='"+I+"' width='560' height='315' frameborder='0' allowfullscreen></iframe>"}(),redirectUri:function(){return I+"#close_window"}()},R=function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")},U=function(){function t(t,e){for(var n=0;n<e.length;n++){var i=e[n];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,i.key,i)}}return function(e,n,i){return n&&t(e.prototype,n),i&&t(e,i),e}}(),N=function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function, not "+typeof e);t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,enumerable:!1,writable:!0,configurable:!0}}),e&&(Object.setPrototypeOf?Object.setPrototypeOf(t,e):t.__proto__=e)},B=function(t,e){if(!t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!e||"object"!=typeof e&&"function"!=typeof e?t:e},q=function(t){function e(n,i){R(this,e);var o=B(this,t.call(this,n,i));return o.addClass("vjs-menu-button"),o.addClass("vjs-share-control"),o.addClass("vjs-icon-share"),o.controlText(n.localize("Share")),o}return N(e,t),e.prototype.handleClick=function(){this.player().getChild("ShareOverlay").open()},e}(t.getComponent("Button")),D=("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self&&self,n),Y=e(function(t,e){!function(e,n){n(t,D)}(0,function(t,e){function n(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}var i=function(t){return t&&t.__esModule?t:{default:t}}(e),o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},r=function(){function t(t,e){for(var n=0;n<e.length;n++){var i=e[n];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,i.key,i)}}return function(e,n,i){return n&&t(e.prototype,n),i&&t(e,i),e}}(),a=function(){function t(e){n(this,t),this.resolveOptions(e),this.initSelection()}return r(t,[{key:"resolveOptions",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.action=t.action,this.container=t.container,this.emitter=t.emitter,this.target=t.target,this.text=t.text,this.trigger=t.trigger,this.selectedText=""}},{key:"initSelection",value:function(){this.text?this.selectFake():this.target&&this.selectTarget()}},{key:"selectFake",value:function(){var t=this,e="rtl"==document.documentElement.getAttribute("dir");this.removeFake(),this.fakeHandlerCallback=function(){return t.removeFake()},this.fakeHandler=this.container.addEventListener("click",this.fakeHandlerCallback)||!0,this.fakeElem=document.createElement("textarea"),this.fakeElem.style.fontSize="12pt",this.fakeElem.style.border="0",this.fakeElem.style.padding="0",this.fakeElem.style.margin="0",this.fakeElem.style.position="absolute",this.fakeElem.style[e?"right":"left"]="-9999px";var n=window.pageYOffset||document.documentElement.scrollTop;this.fakeElem.style.top=n+"px",this.fakeElem.setAttribute("readonly",""),this.fakeElem.value=this.text,this.container.appendChild(this.fakeElem),this.selectedText=(0,i.default)(this.fakeElem),this.copyText()}},{key:"removeFake",value:function(){this.fakeHandler&&(this.container.removeEventListener("click",this.fakeHandlerCallback),this.fakeHandler=null,this.fakeHandlerCallback=null),this.fakeElem&&(this.container.removeChild(this.fakeElem),this.fakeElem=null)}},{key:"selectTarget",value:function(){this.selectedText=(0,i.default)(this.target),this.copyText()}},{key:"copyText",value:function(){var t=void 0;try{t=document.execCommand(this.action)}catch(e){t=!1}this.handleResult(t)}},{key:"handleResult",value:function(t){this.emitter.emit(t?"success":"error",{action:this.action,text:this.selectedText,trigger:this.trigger,clearSelection:this.clearSelection.bind(this)})}},{key:"clearSelection",value:function(){this.trigger&&this.trigger.focus(),window.getSelection().removeAllRanges()}},{key:"destroy",value:function(){this.removeFake()}},{key:"action",set:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"copy";if(this._action=t,"copy"!==this._action&&"cut"!==this._action)throw new Error('Invalid "action" value, use either "copy" or "cut"')},get:function(){return this._action}},{key:"target",set:function(t){if(void 0!==t){if(!t||"object"!==(void 0===t?"undefined":o(t))||1!==t.nodeType)throw new Error('Invalid "target" value, use a valid Element');if("copy"===this.action&&t.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if("cut"===this.action&&(t.hasAttribute("readonly")||t.hasAttribute("disabled")))throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes');this._target=t}},get:function(){return this._target}}]),t}();t.exports=a})});i.prototype={on:function(t,e,n){var i=this.e||(this.e={});return(i[t]||(i[t]=[])).push({fn:e,ctx:n}),this},once:function(t,e,n){function i(){o.off(t,i),e.apply(n,arguments)}var o=this;return i._=e,this.on(t,i,n)},emit:function(t){var e=[].slice.call(arguments,1),n=((this.e||(this.e={}))[t]||[]).slice(),i=0,o=n.length;for(i;i<o;i++)n[i].fn.apply(n[i].ctx,e);return this},off:function(t,e){var n=this.e||(this.e={}),i=n[t],o=[];if(i&&e)for(var r=0,a=i.length;r<a;r++)i[r].fn!==e&&i[r].fn._!==e&&o.push(i[r]);return o.length?n[t]=o:delete n[t],this}};var W=i,X=e(function(t,e){e.node=function(t){return void 0!==t&&t instanceof HTMLElement&&1===t.nodeType},e.nodeList=function(t){var n=Object.prototype.toString.call(t);return void 0!==t&&("[object NodeList]"===n||"[object HTMLCollection]"===n)&&"length"in t&&(0===t.length||e.node(t[0]))},e.string=function(t){return"string"==typeof t||t instanceof String},e.fn=function(t){return"[object Function]"===Object.prototype.toString.call(t)}}),G=9;if("undefined"!=typeof Element&&!Element.prototype.matches){var J=Element.prototype;J.matches=J.matchesSelector||J.mozMatchesSelector||J.msMatchesSelector||J.oMatchesSelector||J.webkitMatchesSelector}var K=o,Q=a,Z=s,$=function(t){return t&&t.__esModule?t.default:t}(e(function(t,e){!function(e,n){n(t,Y,W,Z)}(0,function(t,e,n,i){function o(t){return t&&t.__esModule?t:{default:t}}function r(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function a(t,e){if(!t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!e||"object"!=typeof e&&"function"!=typeof e?t:e}function l(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function, not "+typeof e);t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,enumerable:!1,writable:!0,configurable:!0}}),e&&(Object.setPrototypeOf?Object.setPrototypeOf(t,e):t.__proto__=e)}function s(t,e){var n="data-clipboard-"+t;if(e.hasAttribute(n))return e.getAttribute(n)}var c=o(e),u=o(n),h=o(i),d="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},f=function(){function t(t,e){for(var n=0;n<e.length;n++){var i=e[n];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,i.key,i)}}return function(e,n,i){return n&&t(e.prototype,n),i&&t(e,i),e}}(),p=function(t){function e(t,n){r(this,e);var i=a(this,(e.__proto__||Object.getPrototypeOf(e)).call(this));return i.resolveOptions(n),i.listenClick(t),i}return l(e,t),f(e,[{key:"resolveOptions",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.action="function"==typeof t.action?t.action:this.defaultAction,this.target="function"==typeof t.target?t.target:this.defaultTarget,this.text="function"==typeof t.text?t.text:this.defaultText,this.container="object"===d(t.container)?t.container:document.body}},{key:"listenClick",value:function(t){var e=this;this.listener=(0,h.default)(t,"click",function(t){return e.onClick(t)})}},{key:"onClick",value:function(t){var e=t.delegateTarget||t.currentTarget;this.clipboardAction&&(this.clipboardAction=null),this.clipboardAction=new c.default({action:this.action(e),target:this.target(e),text:this.text(e),container:this.container,trigger:e,emitter:this})}},{key:"defaultAction",value:function(t){return s("action",t)}},{key:"defaultTarget",value:function(t){var e=s("target",t);if(e)return document.querySelector(e)}},{key:"defaultText",value:function(t){return s("text",t)}},{key:"destroy",value:function(){this.listener.destroy(),this.clipboardAction&&(this.clipboardAction.destroy(),this.clipboardAction=null)}}],[{key:"isSupported",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:["copy","cut"],e="string"==typeof t?[t]:t,n=!!document.queryCommandSupported;return e.forEach(function(t){n=n&&!!document.queryCommandSupported(t)}),n}}]),e}(u.default);t.exports=p})})),tt="scrollbars=0, resizable=1, menubar=0, left=100, top=100, width=550, height=440, toolbar=0, status=0",et=80,nt=(Object.freeze||Object)({fbFeed:f,fbShare:p,fbButton:v,gp:g,mail:w,email:m,ok:y,telegram:b,tw:k,reddit:_,pinterest:C,tumblr:x,viber:j,getVkUrl:F,vk:A,whatsapp:T,linkedin:z,messenger:M,line:O}),it=["whatsapp","viber","messenger"],ot={fbFeed:'<svg width="8" height="16" viewbox="0 0 8 16" xmlns="http://www.w3.org/2000/svg">\n <path d="M5.937 2.752h1.891V.01L5.223 0c-2.893 0-3.55 2.047-3.55 3.353v1.829H0v2.824h1.673V16H5.19V8.006h2.375l.308-2.824H5.19v-1.66c0-.624.44-.77.747-.77" fill="#FFF" fill-rule="evenodd"></path>\n</svg>\n',tw:'<svg width="18" height="15" viewbox="0 0 18 15" xmlns="http://www.w3.org/2000/svg">\n <path d="M0 12.616a10.657 10.657 0 0 0 5.661 1.615c6.793 0 10.507-5.476 10.507-10.223 0-.156-.003-.31-.01-.464A7.38 7.38 0 0 0 18 1.684a7.461 7.461 0 0 1-2.12.564A3.621 3.621 0 0 0 17.503.262c-.713.411-1.505.71-2.345.871A3.739 3.739 0 0 0 12.462 0C10.422 0 8.77 1.607 8.77 3.59c0 .283.033.556.096.82A10.578 10.578 0 0 1 1.254.656a3.506 3.506 0 0 0-.5 1.807c0 1.246.65 2.346 1.642 2.99a3.731 3.731 0 0 1-1.673-.45v.046c0 1.74 1.274 3.193 2.962 3.523a3.756 3.756 0 0 1-.972.126c-.239 0-.47-.022-.695-.064.469 1.428 1.833 2.467 3.449 2.494A7.531 7.531 0 0 1 .88 12.665c-.298 0-.591-.014-.881-.049" fill="#FFF" fill-rule="evenodd"></path>\n</svg>\n',reddit:'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewbox="0 0 24 24">\n <path d="M24 11.779a2.654 2.654 0 0 0-4.497-1.899c-1.81-1.191-4.259-1.949-6.971-2.046l1.483-4.669 4.016.941-.006.058a2.17 2.17 0 0 0 2.174 2.163c1.198 0 2.172-.97 2.172-2.163a2.171 2.171 0 0 0-4.193-.785l-4.329-1.015a.37.37 0 0 0-.44.249L11.755 7.82c-2.838.034-5.409.798-7.3 2.025a2.643 2.643 0 0 0-1.799-.712A2.654 2.654 0 0 0 0 11.779c0 .97.533 1.811 1.317 2.271a4.716 4.716 0 0 0-.086.857C1.231 18.818 6.039 22 11.95 22s10.72-3.182 10.72-7.093c0-.274-.029-.544-.075-.81A2.633 2.633 0 0 0 24 11.779zM6.776 13.595c0-.868.71-1.575 1.582-1.575.872 0 1.581.707 1.581 1.575s-.709 1.574-1.581 1.574-1.582-.706-1.582-1.574zm9.061 4.669c-.797.793-2.048 1.179-3.824 1.179L12 19.44l-.013.003c-1.777 0-3.028-.386-3.824-1.179a.369.369 0 0 1 0-.523.372.372 0 0 1 .526 0c.65.647 1.729.961 3.298.961l.013.003.013-.003c1.569 0 2.648-.315 3.298-.962a.373.373 0 0 1 .526 0 .37.37 0 0 1 0 .524zm-.189-3.095a1.58 1.58 0 0 1-1.581-1.574c0-.868.709-1.575 1.581-1.575s1.581.707 1.581 1.575-.709 1.574-1.581 1.574z" fill="#FFF" fill-rule="evenodd"/>\n</svg>\n',gp:'<svg width="21" height="14" viewbox="0 0 21 14" xmlns="http://www.w3.org/2000/svg">\n <path d="M6.816.006C8.5-.071 10.08.646 11.37 1.655a24.11 24.11 0 0 1-1.728 1.754C8.091 2.36 5.89 2.06 4.34 3.272c-2.217 1.503-2.317 5.05-.186 6.668 2.073 1.843 5.991.928 6.564-1.895-1.298-.02-2.6 0-3.899-.042-.003-.76-.006-1.518-.003-2.278 2.17-.006 4.341-.01 6.516.007.13 1.786-.11 3.688-1.23 5.164-1.696 2.34-5.1 3.022-7.756 2.02C1.681 11.921-.207 9.161.018 6.348.077 2.905 3.305-.11 6.816.006zm10.375 3.812h1.893c.004.634.007 1.27.014 1.903.632.007 1.27.007 1.902.013v1.893l-1.902.016c-.007.636-.01 1.27-.014 1.902h-1.896c-.006-.632-.006-1.266-.013-1.899l-1.902-.02V5.735c.633-.006 1.266-.01 1.902-.013.004-.636.01-1.27.016-1.903z" fill="#FFF" fill-rule="evenodd"></path>\n</svg>\n',messenger:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 223 223" width="512" height="512">\n <path d="M111.5 0C50.5 0 0.8 47 0.8 104.7c0 31.1 14.5 60.3 39.7 80.3 3.3 2.6 8 2 10.5-1.2 2.6-3.2 2-8-1.2-10.5 -21.6-17.1-34-42.1-34-68.5C15.8 55.2 58.7 15 111.5 15c52.8 0 95.7 40.2 95.7 89.7 0 49.4-42.9 89.7-95.7 89.7 -9.2 0-18.3-1.2-27.1-3.6 -1.9-0.5-4-0.3-5.7 0.7l-31.1 17.6c-3.6 2-4.9 6.6-2.8 10.2 1.4 2.4 3.9 3.8 6.5 3.8 1.3 0 2.5-0.3 3.7-1l28.4-16.1c9.1 2.2 18.5 3.4 28 3.4 61.1 0 110.7-47 110.7-104.7C222.3 47 172.6 0 111.5 0z" fill="#FFF" fill-rule="evenodd"/>\n <path d="M114.7 71.9c-2.6-1.2-5.8-0.8-8 1.1l-57.9 49.1c-3.2 2.7-3.6 7.4-0.9 10.6 2.7 3.2 7.4 3.6 10.6 0.9l45.5-38.6v35.9c0 2.9 1.7 5.6 4.3 6.8 1 0.5 2.1 0.7 3.2 0.7 1.7 0 3.5-0.6 4.9-1.8l57.9-49.1c3.2-2.7 3.6-7.4 0.9-10.6 -2.7-3.2-7.4-3.6-10.6-0.9l-45.5 38.6V78.7C119 75.7 117.3 73.1 114.7 71.9z" fill="#FFF" fill-rule="evenodd"/>\n</svg>\n',linkedin:'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">\n <path fill="#FFF" fill-rule="evenodd" d="M4.98 3.5C4.98 4.881 3.87 6 2.5 6S.02 4.881.02 3.5C.02 2.12 1.13 1 2.5 1s2.48 1.12 2.48 2.5zM5 8H0v16h5V8zm7.982 0H8.014v16h4.969v-8.399c0-4.67 6.029-5.052 6.029 0V24H24V13.869c0-7.88-8.922-7.593-11.018-3.714V8z"/>\n</svg>\n',vk:'<svg width="22" height="12" viewbox="0 0 22 12" xmlns="http://www.w3.org/2000/svg">\n <path d="M10.764 11.94h1.315s.397-.042.6-.251c.187-.192.18-.552.18-.552s-.025-1.685.794-1.934c.807-.245 1.844 1.629 2.942 2.35.832.545 1.463.425 1.463.425l2.938-.039s1.537-.09.808-1.244c-.06-.095-.425-.855-2.184-2.415-1.843-1.633-1.596-1.37.623-4.195 1.351-1.72 1.892-2.771 1.722-3.22-.16-.43-1.154-.316-1.154-.316l-3.308.02s-.246-.033-.427.071c-.178.102-.292.34-.292.34s-.524 1.33-1.222 2.463C14.09 5.833 13.5 5.96 13.26 5.81c-.56-.346-.42-1.388-.42-2.13 0-2.315.368-3.28-.716-3.531-.36-.082-.624-.137-1.544-.146C9.4-.01 8.4.006 7.835.27c-.377.176-.668.568-.49.59.218.029.713.128.976.47.339.44.327 1.43.327 1.43s.195 2.725-.455 3.064c-.446.232-1.057-.242-2.371-2.41-.673-1.11-1.18-2.338-1.18-2.338S4.542.848 4.368.725C4.157.576 3.86.529 3.86.529L.717.549S.245.562.072.757c-.155.175-.012.536-.012.536s2.46 5.5 5.247 8.271c2.556 2.542 5.457 2.375 5.457 2.375" fill="#FFF" fill-rule="evenodd"></path>\n</svg>\n',ok:'<svg width="12" height="18" viewbox="0 0 12 18" xmlns="http://www.w3.org/2000/svg">\n <path d="M6.843 8.83c2.17-.468 4.162-2.626 3.521-5.3C9.863 1.442 7.561-.599 4.742.161c-6.148 1.662-3.661 9.912 2.1 8.668zm-1.6-6.458c1.39-.375 2.504.554 2.788 1.57.363 1.305-.592 2.394-1.618 2.657-2.913.747-4.16-3.43-1.17-4.227zM9.05 9.536c.41-.23.748-.608 1.367-.577.832.044 2.514 1.404-.445 2.824-1.624.778-1.699.558-2.972.926.22.411 2.55 2.453 3.214 3.082 1.103 1.046.164 2.234-.967 2.115-.718-.077-2.971-2.352-3.38-2.82-.92.438-2.541 2.674-3.431 2.81-1.175.182-2.155-1.091-.96-2.19L4.65 12.73c-.287-.145-1.171-.261-1.59-.389C-1.57 10.93.08 8.838 1.405 8.963c.478.046.907.42 1.274.621 1.931 1.05 4.463 1.029 6.37-.048z" fill="#FFF" fill-rule="evenodd"></path>\n</svg>\n',mail:'<svg width="17" height="16" viewbox="0 0 17 16" xmlns="http://www.w3.org/2000/svg">\n <path d="M8.205 3.322c1.3 0 2.521.563 3.418 1.445v.003c0-.423.29-.742.694-.742l.101-.001c.631 0 .76.586.76.771l.004 6.584c-.045.431.454.653.73.377 1.077-1.086 2.366-5.585-.67-8.192-2.831-2.43-6.629-2.03-8.649-.664-2.146 1.453-3.52 4.668-2.185 7.688 1.455 3.294 5.617 4.276 8.091 3.296 1.253-.496 1.832 1.165.53 1.708-1.965.822-7.438.74-9.994-3.605C-.692 9.057-.6 3.896 3.98 1.222c3.505-2.046 8.125-1.48 10.91 1.374 2.913 2.985 2.743 8.572-.097 10.745-1.288.986-3.199.025-3.187-1.413l-.013-.47a4.827 4.827 0 0 1-3.388 1.381c-2.566 0-4.825-2.215-4.825-4.733 0-2.543 2.259-4.784 4.825-4.784zm3.231 4.602C11.34 6.08 9.944 4.97 8.26 4.97h-.063c-1.945 0-3.023 1.5-3.023 3.204 0 1.908 1.305 3.113 3.015 3.113 1.907 0 3.162-1.37 3.252-2.992l-.004-.372z" fill="#FFF" fill-rule="evenodd"></path>\n</svg>\n',email:'<svg width="17" height="16" viewbox="0 0 17 16" xmlns="http://www.w3.org/2000/svg">\n <path d="M8.205 3.322c1.3 0 2.521.563 3.418 1.445v.003c0-.423.29-.742.694-.742l.101-.001c.631 0 .76.586.76.771l.004 6.584c-.045.431.454.653.73.377 1.077-1.086 2.366-5.585-.67-8.192-2.831-2.43-6.629-2.03-8.649-.664-2.146 1.453-3.52 4.668-2.185 7.688 1.455 3.294 5.617 4.276 8.091 3.296 1.253-.496 1.832 1.165.53 1.708-1.965.822-7.438.74-9.994-3.605C-.692 9.057-.6 3.896 3.98 1.222c3.505-2.046 8.125-1.48 10.91 1.374 2.913 2.985 2.743 8.572-.097 10.745-1.288.986-3.199.025-3.187-1.413l-.013-.47a4.827 4.827 0 0 1-3.388 1.381c-2.566 0-4.825-2.215-4.825-4.733 0-2.543 2.259-4.784 4.825-4.784zm3.231 4.602C11.34 6.08 9.944 4.97 8.26 4.97h-.063c-1.945 0-3.023 1.5-3.023 3.204 0 1.908 1.305 3.113 3.015 3.113 1.907 0 3.162-1.37 3.252-2.992l-.004-.372z" fill="#FFF" fill-rule="evenodd"></path>\n</svg>\n',telegram:'<svg width="21" height="17" viewbox="0 0 21 17" xmlns="http://www.w3.org/2000/svg">\n <path d="M10.873 13.323c-.784.757-1.56 1.501-2.329 2.252-.268.262-.57.407-.956.387-.263-.014-.41-.13-.49-.378-.589-1.814-1.187-3.626-1.773-5.44a.425.425 0 0 0-.322-.317A417.257 417.257 0 0 1 .85 8.541a2.37 2.37 0 0 1-.59-.265c-.309-.203-.353-.527-.07-.762.26-.216.57-.397.886-.522C2.828 6.304 4.59 5.638 6.35 4.964L19.039.101c.812-.311 1.442.12 1.366.988-.05.572-.2 1.137-.32 1.702-.938 4.398-1.88 8.794-2.82 13.191l-.003.026c-.23 1.006-.966 1.28-1.806.668-1.457-1.065-2.91-2.134-4.366-3.201-.068-.05-.14-.098-.217-.152zm-3.22 1.385c.023-.103.038-.151.043-.2.092-.989.189-1.977.27-2.967a.732.732 0 0 1 .256-.534c2.208-1.968 4.41-3.943 6.613-5.917.626-.561 1.256-1.12 1.876-1.688.065-.06.08-.174.117-.263-.095-.027-.203-.095-.285-.072-.189.052-.38.127-.545.23C12.722 5.343 9.45 7.395 6.175 9.44c-.167.104-.214.19-.147.389.518 1.547 1.022 3.098 1.531 4.648.02.061.048.12.094.23z" fill="#FFF" fill-rule="evenodd"></path>\n</svg>\n',whatsapp:'<svg width="22" height="22" viewbox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">\n <path d="M7.926 5.587c-.213-.51-.375-.53-.698-.543a6.234 6.234 0 0 0-.369-.013c-.42 0-.86.123-1.125.395-.323.33-1.125 1.1-1.125 2.677 0 1.578 1.15 3.104 1.306 3.318.162.213 2.244 3.498 5.476 4.837 2.528 1.048 3.278.95 3.853.828.84-.181 1.894-.802 2.16-1.552.265-.75.265-1.39.187-1.527-.078-.135-.291-.213-.614-.375-.323-.161-1.894-.937-2.192-1.04-.29-.11-.569-.072-.788.239-.31.433-.614.873-.86 1.138-.194.207-.511.233-.776.123-.356-.149-1.351-.498-2.58-1.591-.95-.847-1.596-1.901-1.784-2.218-.187-.323-.02-.511.13-.685.161-.201.316-.343.478-.53.161-.188.252-.285.355-.505.11-.214.033-.434-.045-.595-.078-.162-.724-1.74-.99-2.38zM10.996 0C4.934 0 0 4.934 0 11c0 2.405.776 4.636 2.095 6.447L.724 21.534l4.228-1.351A10.913 10.913 0 0 0 11.003 22C17.067 22 22 17.066 22 11S17.067 0 11.003 0h-.006z" fill="#FFF" fill-rule="evenodd"></path>\n</svg>\n',viber:'<svg width="21" height="21" viewbox="0 0 21 21" xmlns="http://www.w3.org/2000/svg">\n <path d="M18.639 14.904c-.628-.506-1.3-.96-1.96-1.423-1.318-.926-2.523-.997-3.506.491-.552.836-1.325.873-2.133.506-2.228-1.01-3.949-2.567-4.956-4.831-.446-1.002-.44-1.9.603-2.609.552-.375 1.108-.818 1.064-1.637C7.693 4.334 5.1.765 4.077.39 3.653.233 3.23.243 2.8.388.4 1.195-.594 3.169.358 5.507c2.84 6.974 7.84 11.829 14.721 14.792.392.169.828.236 1.049.297 1.567.015 3.402-1.494 3.932-2.992.51-1.441-.568-2.013-1.421-2.7zm-7.716-13.8c-.417-.064-1.052.026-1.02-.525.046-.817.8-.513 1.165-.565 4.833.163 8.994 4.587 8.935 9.359-.006.468.162 1.162-.536 1.149-.668-.013-.493-.717-.553-1.185-.64-5.067-2.96-7.46-7.991-8.233zm.984 1.39c3.104.372 5.64 3.065 5.615 6.024-.047.35.157.95-.409 1.036-.764.116-.615-.583-.69-1.033-.511-3.082-1.593-4.213-4.7-4.907-.458-.102-1.17-.03-1.052-.736.113-.671.752-.443 1.236-.385zm.285 2.419c1.377-.034 2.992 1.616 2.969 3.044.014.39-.028.802-.49.857-.333.04-.552-.24-.586-.585-.128-1.272-.798-2.023-2.073-2.228-.382-.061-.757-.184-.579-.7.12-.345.436-.38.76-.388z" fill="#FFF" fill-rule="evenodd"></path>\n</svg>\n'},rt=function(){function t(e,n){R(this,t),this.player=e,this.options=n,this.socials=H(n.socials,n.mobileVerification),this.copyBtnTextClass="vjs-share__btn-text",this.socialBtnClass="vjs-share__social",this._createContent(),this._initToggle(),this._initClipboard(),this._initSharing()}return t.prototype.getContent=function(){return this.content},t.prototype._createContent=function(){var t='\n <svg xmlns="http://www.w3.org/2000/svg" width="18" height="20">\n <path fill="#FFF" fill-rule="evenodd" d="M10.07 20H1.318A1.325 1.325 0 0 1 0 18.67V6.025c0-.712.542-1.21 1.318-1.21h7.294l2.776 2.656v11.2c0 .734-.59 1.33-1.318 1.33zm6.46-15.926v9.63h-3.673v1.48h3.825c.727 0 1.318-.595 1.318-1.328v-11.2L15.225 0H7.93c-.776 0-1.318.497-1.318 1.21v2.123h1.47V1.48h5.877v2.594h2.57zm-.73-1.48l-.37-.357v.356h.37zM9.918 8.888v9.63H1.47V6.295h5.878V8.89h2.57zm-.73-1.483l-.372-.355v.355h.37z"></path>\n </svg>\n <span class="'+this.copyBtnTextClass+'">'+this.player.localize("Copy")+"</span>\n ",e=document.createElement("div"),n="";this.options.embedCode&&(n='\n <div class="vjs-share__subtitle hidden-xs">'+this.player.localize("Embed Code")+':</div>\n <div class="vjs-share__short-link-wrapper hidden-xs">\n <input class="vjs-share__short-link" type="text" readonly="true" value="'+this.options.embedCode+'">\n <div class="vjs-share__btn">\n '+t+"\n </div>\n </div>"),e.innerHTML='<div class="vjs-share">\n <div class="vjs-share__top hidden-sm">\n <div class="vjs-share__title">'+this.player.localize("Share")+'</div>\n </div>\n\n <div class="vjs-share__middle">\n <div class="vjs-share__subtitle hidden-xs">'+this.player.localize("Direct Link")+':</div>\n <div class="vjs-share__short-link-wrapper">\n <input class="vjs-share__short-link" type="text" readonly="true" value="'+this.options.url+'">\n <div class="vjs-share__btn">\n '+t+"\n </div>\n </div>\n\n "+n+'\n </div>\n\n <div class="vjs-share__bottom">\n <div class="vjs-share__socials">\n '+this._getSocialItems().join("")+"\n </div>\n </div>\n </div>",this.content=e.firstChild},t.prototype._initClipboard=function(){var t=this;new $(".vjs-share__btn",{target:function(t){return t.previousElementSibling}}).on("success",function(e){var n=e.trigger.querySelector("."+t.copyBtnTextClass),i=function(){n.innerText=t.player.localize("Copy"),e.clearSelection()};n.innerText=t.player.localize("Copied"),L()?setTimeout(i,1e3):n.parentElement.addEventListener("mouseleave",function(){setTimeout(i,300)})})},t.prototype._initSharing=function(){var t=this,e=this.content.querySelectorAll("."+this.socialBtnClass);Array.from(e).forEach(function(e){e.addEventListener("click",function(e){var n=e.currentTarget.getAttribute("data-social"),i=nt[n];"function"==typeof i&&i(t.socialOptions)})})},t.prototype._initToggle=function(){var t=this.content.querySelector(".vjs-share__socials");this.socials.length>10||window.innerWidth<=180&&this.socials.length>6?t.style.height="calc((2em + 5px) * 2)":t.classList.add("horizontal")},t.prototype._getSocialItems=function(){var t=[];return this.socials.forEach(function(e){ot[e]&&t.push('\n <button class="vjs-share__social vjs-share__social_'+e+'" data-social="'+e+'">\n '+ot[e]+"\n </button>\n ")}),t},U(t,[{key:"socialOptions",get:function(){var t=this.options;return{url:t.url,title:t.title,description:t.description,image:t.image,fbAppId:t.fbAppId,isVkParse:t.isVkParse,redirectUri:t.redirectUri}}}]),t}(),at=function(t){function e(n,i){R(this,e);var o=B(this,t.call(this,n,i));return o.playerClassName="vjs-videojs-share_open",o}return N(e,t),e.prototype.open=function(){var e=this.player();e.addClass(this.playerClassName),t.prototype.open.call(this),e.trigger("sharing:opened")},e.prototype.close=function(){var e=this.player();e.removeClass(this.playerClassName),t.prototype.close.call(this),e.trigger("sharing:closed")},e}(t.getComponent("ModalDialog")),lt=function(t){function e(n,i){R(this,e);var o=B(this,t.call(this,n,i));return o.player=n,o.options=i,o}return N(e,t),e.prototype._createModal=function(){var t=new rt(this.player,this.options).getContent();this.modal=new at(this.player,{content:t,temporary:!0}),this.el=this.modal.contentEl(),this.player.addChild(this.modal)},e.prototype.open=function(){this._createModal(),this.modal.open()},e}(t.getComponent("Component")),st=function(e){function n(i,o){R(this,n);var r=B(this,e.call(this,i));return r.options=t.mergeOptions(P,o),r.player.ready(function(){r.player.addClass("vjs-share"),i.addClass("vjs-videojs-share"),i.getChild("controlBar").addChild("ShareButton",o),i.addChild("ShareOverlay",o)}),r}return N(n,e),n}(t.getPlugin("plugin"));return st.defaultState={},st.VERSION="3.2.1",t.registerComponent("ShareButton",q),t.registerComponent("ShareOverlay",lt),t.registerPlugin("share",st),st});
\ No newline at end of file diff --git a/assets/js/videojs-vtt-thumbnails.min.js b/assets/js/videojs-vtt-thumbnails.min.js deleted file mode 100644 index be86a201..00000000 --- a/assets/js/videojs-vtt-thumbnails.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * videojs-vtt-thumbnails - * @version 0.0.13 - * @copyright 2020 Chris Boustead <chris@forgemotion.com> - * @license MIT - */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("video.js")):"function"==typeof define&&define.amd?define(["video.js"],t):(e=e||self).videojsVttThumbnails=t(e.videojs)}(this,function(i){"use strict";i=i&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i;function e(s){var r=this;this.ready(function(){var e,t;e=r,t=i.mergeOptions(n,s),e.addClass("vjs-vtt-thumbnails"),e.vttThumbnails=new a(e,t)})}var n={},o={},t=i.registerPlugin||i.plugin,a=function(){function e(e,t){return this.player=e,this.options=t,this.listenForDurationChange(),this.initializeThumbnails(),this.registeredEvents={},this}var t=e.prototype;return t.src=function(e){this.resetPlugin(),this.options.src=e,this.initializeThumbnails()},t.detach=function(){this.resetPlugin()},t.resetPlugin=function(){this.thumbnailHolder&&this.thumbnailHolder.parentNode.removeChild(this.thumbnailHolder),this.progressBar&&(this.progressBar.removeEventListener("mouseenter",this.registeredEvents.progressBarMouseEnter),this.progressBar.removeEventListener("mouseleave",this.registeredEvents.progressBarMouseLeave),this.progressBar.removeEventListener("mousemove",this.registeredEvents.progressBarMouseMove)),delete this.registeredEvents.progressBarMouseEnter,delete this.registeredEvents.progressBarMouseLeave,delete this.registeredEvents.progressBarMouseMove,delete this.progressBar,delete this.vttData,delete this.thumbnailHolder,delete this.lastStyle},t.listenForDurationChange=function(){this.player.on("durationchange",function(){})},t.initializeThumbnails=function(){var e,t,s=this;this.options.src&&(e=this.getBaseUrl(),t=this.getFullyQualifiedUrl(this.options.src,e),this.getVttFile(t).then(function(e){s.vttData=s.processVtt(e),s.setupThumbnailElement()}))},t.getBaseUrl=function(){return[window.location.protocol,"//",window.location.hostname,window.location.port?":"+window.location.port:"",window.location.pathname].join("").split(/([^\/]*)$/gi).shift()},t.getVttFile=function(r){var i=this;return new Promise(function(e,t){var s=new XMLHttpRequest;s.data={resolve:e},s.addEventListener("load",i.vttFileLoaded),s.open("GET",r),s.overrideMimeType("text/plain; charset=utf-8"),s.send()})},t.vttFileLoaded=function(){this.data.resolve(this.responseText)},t.setupThumbnailElement=function(){var e=this,t=null;this.options.showTimestamp||(t=this.player.$(".vjs-mouse-display"));var s=document.createElement("div");s.setAttribute("class","vjs-vtt-thumbnail-display"),this.progressBar=this.player.$(".vjs-progress-control"),this.progressBar.appendChild(s),this.thumbnailHolder=s,t&&!this.options.showTimestamp&&t.classList.add("vjs-hidden"),this.registeredEvents.progressBarMouseEnter=function(){return e.onBarMouseenter()},this.registeredEvents.progressBarMouseLeave=function(){return e.onBarMouseleave()},this.progressBar.addEventListener("mouseenter",this.registeredEvents.progressBarMouseEnter),this.progressBar.addEventListener("mouseleave",this.registeredEvents.progressBarMouseLeave)},t.onBarMouseenter=function(){var t=this;this.mouseMoveCallback=function(e){t.onBarMousemove(e)},this.registeredEvents.progressBarMouseMove=this.mouseMoveCallback,this.progressBar.addEventListener("mousemove",this.registeredEvents.progressBarMouseMove),this.showThumbnailHolder()},t.onBarMouseleave=function(){this.registeredEvents.progressBarMouseMove&&this.progressBar.removeEventListener("mousemove",this.registeredEvents.progressBarMouseMove),this.hideThumbnailHolder()},t.getXCoord=function(e,t){var s=e.getBoundingClientRect(),r=document.documentElement;return t-(s.left+(window.pageXOffset||r.scrollLeft||0))},t.onBarMousemove=function(e){this.updateThumbnailStyle(i.dom.getPointerPosition(this.progressBar,e).x,this.progressBar.offsetWidth)},t.getStyleForTime=function(e){for(var t=0;t<this.vttData.length;++t){var s,r=this.vttData[t];if(e>=r.start&&e<r.end)return r.css.url&&!o[r.css.url]&&((s=new Image).src=r.css.url,o[r.css.url]=s),r.css}},t.showThumbnailHolder=function(){this.thumbnailHolder.style.opacity="1"},t.hideThumbnailHolder=function(){this.thumbnailHolder.style.opacity="0"},t.updateThumbnailStyle=function(e,t){var s=e*this.player.duration(),r=this.getStyleForTime(s);if(!r)return this.hideThumbnailHolder();var i=e*t,n=parseInt(r.width,10),o=n>>1,a=t-(i+o),l=i-o;if(0<l&&0<a?this.thumbnailHolder.style.transform="translateX("+(i-o)+"px)":l<=0?this.thumbnailHolder.style.transform="translateX(0px)":a<=0&&(this.thumbnailHolder.style.transform="translateX("+(t-n)+"px)"),!this.lastStyle||this.lastStyle!==r)for(var u in this.lastStyle=r)r.hasOwnProperty(u)&&(this.thumbnailHolder.style[u]=r[u])},t.processVtt=function(e){var a=this,l=[];return e.split(/[\r\n][\r\n]/i).forEach(function(e){var t,s,r,i,n,o;e.match(/([0-9]{2}:)?([0-9]{2}:)?[0-9]{2}(.[0-9]{3})?( ?--> ?)([0-9]{2}:)?([0-9]{2}:)?[0-9]{2}(.[0-9]{3})?[\r\n]{1}.*/gi)&&(r=(s=(t=e.split(/[\r\n]/i))[0].split(/ ?--> ?/i))[0],i=s[1],n=t[1],o=a.getVttCss(n),l.push({start:a.getSecondsFromTimestamp(r),end:a.getSecondsFromTimestamp(i),css:o}))}),l},t.getFullyQualifiedUrl=function(e,t){return 0<=e.indexOf("//")?e:0===t.indexOf("//")?[t.replace(/\/$/gi,""),this.trim(e,"/")].join("/"):0<t.indexOf("//")?[this.trim(t,"/"),this.trim(e,"/")].join("/"):e},t.getPropsFromDef=function(e){var t=e.split(/#xywh=/i),s=t[0],r=t[1].match(/[0-9]+/gi);return{x:r[0],y:r[1],w:r[2],h:r[3],image:s}},t.getVttCss=function(e){var t={},s=0<=this.options.src.indexOf("//")?this.options.src.split(/([^\/]*)$/gi).shift():this.getBaseUrl()+this.options.src.split(/([^\/]*)$/gi).shift();if(!(e=this.getFullyQualifiedUrl(e,s)).match(/#xywh=/i))return t.background='url("'+e+'")',t;var r=this.getPropsFromDef(e);return t.background='url("'+r.image+'") no-repeat -'+r.x+"px -"+r.y+"px",t.width=r.w+"px",t.height=r.h+"px",t.url=r.image,t},t.deconstructTimestamp=function(e){var t=e.split("."),s=t[0].split(":");return{milliseconds:parseInt(t[1],10)||0,seconds:parseInt(s.pop(),10)||0,minutes:parseInt(s.pop(),10)||0,hours:parseInt(s.pop(),10)||0}},t.getSecondsFromTimestamp=function(e){var t=this.deconstructTimestamp(e);return parseInt(3600*t.hours+60*t.minutes+t.seconds+t.milliseconds/1e3,10)},t.trim=function(e,t){var s=[" ","\n","\r","\t","\f","\v"," "," "," "," "," "," "," "," "," "," "," "," ","","\u2028","\u2029"," "].join(""),r=0,i=0;for(e+="",t&&(s=(t+"").replace(/([[\]().?/*{}+$^:])/g,"$1")),r=e.length,i=0;i<r;i++)if(-1===s.indexOf(e.charAt(i))){e=e.substring(i);break}for(i=(r=e.length)-1;0<=i;i--)if(-1===s.indexOf(e.charAt(i))){e=e.substring(0,i+1);break}return-1===s.indexOf(e.charAt(0))?e:""},e}();return t("vttThumbnails",e),e.VERSION="0.0.13",e});
\ No newline at end of file diff --git a/assets/js/watch.js b/assets/js/watch.js index 3909edd4..26ad138f 100644 --- a/assets/js/watch.js +++ b/assets/js/watch.js @@ -1,32 +1,13 @@ -var video_data = JSON.parse(document.getElementById('video_data').innerHTML); - -String.prototype.supplant = function (o) { - return this.replace(/{([^{}]*)}/g, function (a, b) { - var r = o[b]; - return typeof r === 'string' || typeof r === 'number' ? r : a; - }); -} +'use strict'; function toggle_parent(target) { - body = target.parentNode.parentNode.children[1]; - if (body.style.display === null || body.style.display === '') { - target.innerHTML = '[ + ]'; - body.style.display = 'none'; - } else { - target.innerHTML = '[ - ]'; + var body = target.parentNode.parentNode.children[1]; + if (body.style.display === 'none') { + target.textContent = '[ − ]'; body.style.display = ''; - } -} - -function toggle_comments(event) { - var target = event.target; - body = target.parentNode.parentNode.parentNode.children[1]; - if (body.style.display === null || body.style.display === '') { - target.innerHTML = '[ + ]'; - body.style.display = 'none'; } else { - target.innerHTML = '[ - ]'; - body.style.display = ''; + target.textContent = '[ + ]'; + body.style.display = 'none'; } } @@ -40,36 +21,6 @@ function swap_comments(event) { } } -function hide_youtube_replies(event) { - var target = event.target; - - sub_text = target.getAttribute('data-inner-text'); - inner_text = target.getAttribute('data-sub-text'); - - body = target.parentNode.parentNode.children[1]; - body.style.display = 'none'; - - target.innerHTML = sub_text; - target.onclick = show_youtube_replies; - target.setAttribute('data-inner-text', inner_text); - target.setAttribute('data-sub-text', sub_text); -} - -function show_youtube_replies(event) { - var target = event.target; - - sub_text = target.getAttribute('data-inner-text'); - inner_text = target.getAttribute('data-sub-text'); - - body = target.parentNode.parentNode.children[1]; - body.style.display = ''; - - target.innerHTML = sub_text; - target.onclick = hide_youtube_replies; - target.setAttribute('data-inner-text', inner_text); - target.setAttribute('data-sub-text', sub_text); -} - var continue_button = document.getElementById('continue'); if (continue_button) { continue_button.onclick = continue_autoplay; @@ -78,377 +29,154 @@ if (continue_button) { function next_video() { var url = new URL('https://example.com/watch?v=' + video_data.next_video); - if (video_data.params.autoplay || video_data.params.continue_autoplay) { + if (video_data.params.autoplay || video_data.params.continue_autoplay) url.searchParams.set('autoplay', '1'); - } - - if (video_data.params.listen !== video_data.preferences.listen) { + if (video_data.params.listen !== video_data.preferences.listen) url.searchParams.set('listen', video_data.params.listen); - } - - if (video_data.params.speed !== video_data.preferences.speed) { + if (video_data.params.speed !== video_data.preferences.speed) url.searchParams.set('speed', video_data.params.speed); - } - - if (video_data.params.local !== video_data.preferences.local) { + if (video_data.params.local !== video_data.preferences.local) url.searchParams.set('local', video_data.params.local); - } - url.searchParams.set('continue', '1'); + location.assign(url.pathname + url.search); } function continue_autoplay(event) { if (event.target.checked) { - player.on('ended', function () { - next_video(); - }); + player.on('ended', next_video); } else { player.off('ended'); } } -function number_with_separator(val) { - while (/(\d+)(\d{3})/.test(val.toString())) { - val = val.toString().replace(/(\d+)(\d{3})/, '$1' + ',' + '$2'); - } - return val; -} - -function get_playlist(plid, retries) { - if (retries == undefined) retries = 5; - playlist = document.getElementById('playlist'); - - if (retries <= 0) { - console.log('Failed to pull playlist'); - playlist.innerHTML = ''; - return; - } +function get_playlist(plid) { + var playlist = document.getElementById('playlist'); - playlist.innerHTML = ' \ - <h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3> \ - <hr>' + playlist.innerHTML = spinnerHTMLwithHR; + var plid_url; if (plid.startsWith('RD')) { - var plid_url = '/api/v1/mixes/' + plid + + plid_url = '/api/v1/mixes/' + plid + '?continuation=' + video_data.id + '&format=html&hl=' + video_data.preferences.locale; } else { - var plid_url = '/api/v1/playlists/' + plid + + plid_url = '/api/v1/playlists/' + plid + '?index=' + video_data.index + '&continuation=' + video_data.id + '&format=html&hl=' + video_data.preferences.locale; } - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', plid_url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status == 200) { - playlist.innerHTML = xhr.response.playlistHtml; - - if (xhr.response.nextVideo) { - player.on('ended', function () { - var url = new URL('https://example.com/watch?v=' + xhr.response.nextVideo); - - url.searchParams.set('list', plid); - if (!plid.startsWith('RD')) { - url.searchParams.set('index', xhr.response.index); - } - - if (video_data.params.autoplay || video_data.params.continue_autoplay) { - url.searchParams.set('autoplay', '1'); - } - - if (video_data.params.listen !== video_data.preferences.listen) { - url.searchParams.set('listen', video_data.params.listen); - } - - if (video_data.params.speed !== video_data.preferences.speed) { - url.searchParams.set('speed', video_data.params.speed); - } - - if (video_data.params.local !== video_data.preferences.local) { - url.searchParams.set('local', video_data.params.local); - } - - location.assign(url.pathname + url.search); - }); - } - } else { - playlist.innerHTML = ''; - document.getElementById('continue').style.display = ''; - } + helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, { + on200: function (response) { + playlist.innerHTML = response.playlistHtml; + + if (!response.nextVideo) return; + + var nextVideo = document.getElementById(response.nextVideo); + nextVideo.parentNode.parentNode.scrollTop = nextVideo.offsetTop; + + player.on('ended', function () { + var url = new URL('https://example.com/watch?v=' + response.nextVideo); + + url.searchParams.set('list', plid); + if (!plid.startsWith('RD')) + url.searchParams.set('index', response.index); + if (video_data.params.autoplay || video_data.params.continue_autoplay) + url.searchParams.set('autoplay', '1'); + if (video_data.params.listen !== video_data.preferences.listen) + url.searchParams.set('listen', video_data.params.listen); + if (video_data.params.speed !== video_data.preferences.speed) + url.searchParams.set('speed', video_data.params.speed); + if (video_data.params.local !== video_data.preferences.local) + url.searchParams.set('local', video_data.params.local); + + location.assign(url.pathname + url.search); + }); + }, + onNon200: function (xhr) { + playlist.innerHTML = ''; + document.getElementById('continue').style.display = ''; + }, + onError: function (xhr) { + playlist.innerHTML = spinnerHTMLwithHR; + }, + onTimeout: function (xhr) { + playlist.innerHTML = spinnerHTMLwithHR; } - } - - xhr.onerror = function () { - playlist = document.getElementById('playlist'); - playlist.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3><hr>'; - - console.log('Pulling playlist timed out... ' + retries + '/5'); - setTimeout(function () { get_playlist(plid, retries - 1) }, 1000); - } - - xhr.ontimeout = function () { - playlist = document.getElementById('playlist'); - playlist.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3><hr>'; - - console.log('Pulling playlist timed out... ' + retries + '/5'); - get_playlist(plid, retries - 1); - } - - xhr.send(); + }); } -function get_reddit_comments(retries) { - if (retries == undefined) retries = 5; - comments = document.getElementById('comments'); - - if (retries <= 0) { - console.log('Failed to pull comments'); - comments.innerHTML = ''; - return; - } +function get_reddit_comments() { + var comments = document.getElementById('comments'); var fallback = comments.innerHTML; - comments.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>'; + comments.innerHTML = spinnerHTML; var url = '/api/v1/comments/' + video_data.id + '?source=reddit&format=html' + '&hl=' + video_data.preferences.locale; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status == 200) { - comments.innerHTML = ' \ - <div> \ - <h3> \ - <a href="javascript:void(0)">[ - ]</a> \ - {title} \ - </h3> \ - <p> \ - <b> \ - <a href="javascript:void(0)" data-comments="youtube"> \ - {youtubeCommentsText} \ - </a> \ - </b> \ - </p> \ + var onNon200 = function (xhr) { comments.innerHTML = fallback; }; + if (video_data.params.comments[1] === 'youtube') + onNon200 = function (xhr) {}; + + helpers.xhr('GET', url, {retries: 5, entity_name: ''}, { + on200: function (response) { + comments.innerHTML = ' \ + <div> \ + <h3> \ + <a href="javascript:void(0)">[ − ]</a> \ + {title} \ + </h3> \ + <p> \ <b> \ - <a rel="noopener" target="_blank" href="https://reddit.com{permalink}">{redditPermalinkText}</a> \ - </b> \ - </div> \ - <div>{contentHtml}</div> \ - <hr>'.supplant({ - title: xhr.response.title, - youtubeCommentsText: video_data.youtube_comments_text, - redditPermalinkText: video_data.reddit_permalink_text, - permalink: xhr.response.permalink, - contentHtml: xhr.response.contentHtml - }); - - comments.children[0].children[0].children[0].onclick = toggle_comments; - comments.children[0].children[1].children[0].onclick = swap_comments; - } else { - if (video_data.params.comments[1] === 'youtube') { - console.log('Pulling comments failed... ' + retries + '/5'); - setTimeout(function () { get_youtube_comments(retries - 1) }, 1000); - } else { - comments.innerHTML = fallback; - } - } - } - } - - xhr.onerror = function () { - console.log('Pulling comments failed... ' + retries + '/5'); - setTimeout(function () { get_reddit_comments(retries - 1) }, 1000); - } - - xhr.ontimeout = function () { - console.log('Pulling comments failed... ' + retries + '/5'); - get_reddit_comments(retries - 1); - } - - xhr.send(); -} - -function get_youtube_comments(retries) { - if (retries == undefined) retries = 5; - comments = document.getElementById('comments'); - - if (retries <= 0) { - console.log('Failed to pull comments'); - comments.innerHTML = ''; - return; - } - - var fallback = comments.innerHTML; - comments.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>'; - - var url = '/api/v1/comments/' + video_data.id + - '?format=html' + - '&hl=' + video_data.preferences.locale + - '&thin_mode=' + video_data.preferences.thin_mode; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status == 200) { - comments.innerHTML = ' \ - <div> \ - <h3> \ - <a href="javascript:void(0)">[ - ]</a> \ - {commentsText} \ - </h3> \ - <b> \ - <a href="javascript:void(0)" data-comments="reddit"> \ - {redditComments} \ + <a href="javascript:void(0)" data-comments="youtube"> \ + {youtubeCommentsText} \ </a> \ </b> \ - </div> \ - <div>{contentHtml}</div> \ - <hr>'.supplant({ - contentHtml: xhr.response.contentHtml, - redditComments: video_data.reddit_comments_text, - commentsText: video_data.comments_text.supplant( - { commentCount: number_with_separator(xhr.response.commentCount) } - ) - }); - - comments.children[0].children[0].children[0].onclick = toggle_comments; - comments.children[0].children[1].children[0].onclick = swap_comments; - } else { - if (video_data.params.comments[1] === 'youtube') { - setTimeout(function () { get_youtube_comments(retries - 1) }, 1000); - } else { - comments.innerHTML = ''; - } - } - } - } - - xhr.onerror = function () { - comments.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>'; - console.log('Pulling comments failed... ' + retries + '/5'); - setTimeout(function () { get_youtube_comments(retries - 1) }, 1000); - } - - xhr.ontimeout = function () { - comments.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>'; - console.log('Pulling comments failed... ' + retries + '/5'); - get_youtube_comments(retries - 1); - } - - xhr.send(); -} - -function get_youtube_replies(target, load_more, load_replies) { - var continuation = target.getAttribute('data-continuation'); - - var body = target.parentNode.parentNode; - var fallback = body.innerHTML; - body.innerHTML = - '<h3 style="text-align:center"><div class="loading"><i class="icon ion-ios-refresh"></i></div></h3>'; - - var url = '/api/v1/comments/' + video_data.id + - '?format=html' + - '&hl=' + video_data.preferences.locale + - '&thin_mode=' + video_data.preferences.thin_mode + - '&continuation=' + continuation - if (load_replies) { - url += '&action=action_get_comment_replies'; - } - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status == 200) { - if (load_more) { - body = body.parentNode.parentNode; - body.removeChild(body.lastElementChild); - body.innerHTML += xhr.response.contentHtml; - } else { - body.removeChild(body.lastElementChild); - - var p = document.createElement('p'); - var a = document.createElement('a'); - p.appendChild(a); - - a.href = 'javascript:void(0)'; - a.onclick = hide_youtube_replies; - a.setAttribute('data-sub-text', video_data.hide_replies_text); - a.setAttribute('data-inner-text', video_data.show_replies_text); - a.innerText = video_data.hide_replies_text; - - var div = document.createElement('div'); - div.innerHTML = xhr.response.contentHtml; - - body.appendChild(p); - body.appendChild(div); - } - } else { - body.innerHTML = fallback; - } - } - } - - xhr.ontimeout = function () { - console.log('Pulling comments failed.'); - body.innerHTML = fallback; - } - - xhr.send(); + </p> \ + <b> \ + <a rel="noopener" target="_blank" href="https://reddit.com{permalink}">{redditPermalinkText}</a> \ + </b> \ + </div> \ + <div>{contentHtml}</div> \ + <hr>'.supplant({ + title: response.title, + youtubeCommentsText: video_data.youtube_comments_text, + redditPermalinkText: video_data.reddit_permalink_text, + permalink: response.permalink, + contentHtml: response.contentHtml + }); + + comments.children[0].children[0].children[0].onclick = toggle_comments; + comments.children[0].children[1].children[0].onclick = swap_comments; + }, + onNon200: onNon200, // declared above + }); } if (video_data.play_next) { player.on('ended', function () { var url = new URL('https://example.com/watch?v=' + video_data.next_video); - if (video_data.params.autoplay || video_data.params.continue_autoplay) { + if (video_data.params.autoplay || video_data.params.continue_autoplay) url.searchParams.set('autoplay', '1'); - } - - if (video_data.params.listen !== video_data.preferences.listen) { + if (video_data.params.listen !== video_data.preferences.listen) url.searchParams.set('listen', video_data.params.listen); - } - - if (video_data.params.speed !== video_data.preferences.speed) { + if (video_data.params.speed !== video_data.preferences.speed) url.searchParams.set('speed', video_data.params.speed); - } - - if (video_data.params.local !== video_data.preferences.local) { + if (video_data.params.local !== video_data.preferences.local) url.searchParams.set('local', video_data.params.local); - } - url.searchParams.set('continue', '1'); + location.assign(url.pathname + url.search); }); } -window.addEventListener('load', function (e) { - if (video_data.plid) { +addEventListener('load', function (e) { + if (video_data.plid) get_playlist(video_data.plid); - } if (video_data.params.comments[0] === 'youtube') { get_youtube_comments(); @@ -459,7 +187,7 @@ window.addEventListener('load', function (e) { } else if (video_data.params.comments[1] === 'reddit') { get_reddit_comments(); } else { - comments = document.getElementById('comments'); + var comments = document.getElementById('comments'); comments.innerHTML = ''; } }); diff --git a/assets/js/watched_indicator.js b/assets/js/watched_indicator.js new file mode 100644 index 00000000..e971cd80 --- /dev/null +++ b/assets/js/watched_indicator.js @@ -0,0 +1,24 @@ +'use strict'; +var save_player_pos_key = 'save_player_pos'; + +function get_all_video_times() { + return helpers.storage.get(save_player_pos_key) || {}; +} + +document.querySelectorAll('.watched-indicator').forEach(function (indicator) { + var watched_part = get_all_video_times()[indicator.dataset.id]; + var total = parseInt(indicator.dataset.length, 10); + if (watched_part === undefined) { + watched_part = total; + } + var percentage = Math.round((watched_part / total) * 100); + + if (percentage < 5) { + percentage = 5; + } + if (percentage > 90) { + percentage = 100; + } + + indicator.style.width = percentage + '%'; +}); diff --git a/assets/js/watched_widget.js b/assets/js/watched_widget.js index ba741974..f1ac9cb4 100644 --- a/assets/js/watched_widget.js +++ b/assets/js/watched_widget.js @@ -1,4 +1,6 @@ -var watched_data = JSON.parse(document.getElementById('watched_data').innerHTML); +'use strict'; +var watched_data = JSON.parse(document.getElementById('watched_data').textContent); +var payload = 'csrf_token=' + watched_data.csrf_token; function mark_watched(target) { var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; @@ -6,45 +8,27 @@ function mark_watched(target) { var url = '/watch_ajax?action_mark_watched=1&redirect=false' + '&id=' + target.getAttribute('data-id'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - tile.style.display = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + tile.style.display = ''; } - } - - xhr.send('csrf_token=' + watched_data.csrf_token); + }); } function mark_unwatched(target) { var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; tile.style.display = 'none'; - var count = document.getElementById('count') - count.innerText = count.innerText - 1; + var count = document.getElementById('count'); + count.textContent--; var url = '/watch_ajax?action_mark_unwatched=1&redirect=false' + '&id=' + target.getAttribute('data-id'); - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - xhr.onreadystatechange = function () { - if (xhr.readyState == 4) { - if (xhr.status != 200) { - count.innerText = count.innerText - 1 + 2; - tile.style.display = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + count.textContent++; + tile.style.display = ''; } - } - - xhr.send('csrf_token=' + watched_data.csrf_token); -}
\ No newline at end of file + }); +} diff --git a/assets/robots.txt b/assets/robots.txt index 316bd423..1f53798b 100644 --- a/assets/robots.txt +++ b/assets/robots.txt @@ -1,3 +1,2 @@ User-agent: * -Disallow: /search -Disallow: /login
\ No newline at end of file +Disallow: / diff --git a/assets/site.webmanifest b/assets/site.webmanifest index af9432d7..2db6ed9e 100644 --- a/assets/site.webmanifest +++ b/assets/site.webmanifest @@ -15,5 +15,7 @@ ], "theme_color": "#575757", "background_color": "#575757", - "display": "standalone" + "display": "standalone", + "description": "An alternative front-end to YouTube", + "start_url": "/" } diff --git a/assets/videojs/.gitignore b/assets/videojs/.gitignore new file mode 100644 index 00000000..5e7d2734 --- /dev/null +++ b/assets/videojs/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore diff --git a/config/config.example.yml b/config/config.example.yml index e8330705..a3a2eeb7 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,13 +1,949 @@ -channel_threads: 1 -feed_threads: 1 +######################################### +# +# Database and other external servers +# +######################################### + +## +## Database configuration with separate parameters. +## This setting is MANDATORY, unless 'database_url' is used. +## db: user: kemal password: kemal host: localhost port: 5432 dbname: invidious -# alternatively, the database URL can be provided directly - if both are set then the latter takes precedence -# database_url: postgres://kemal:kemal@localhost:5432/invidious -full_refresh: false -https_only: false + +## +## Database configuration using a single URI. This is an +## alternative to the 'db' parameter above. If both forms +## are used, then only database_url is used. +## This setting is MANDATORY, unless 'db' is used. +## +## Note: The 'database_url' setting allows the use of UNIX +## sockets. To do so, remove the IP address (or FQDN) and port +## and append the 'host' parameter. E.g: +## postgres://kemal:kemal@/invidious?host=/var/run/postgresql +## +## Accepted values: a postgres:// URI +## Default: postgres://kemal:kemal@localhost:5432/invidious +## +#database_url: postgres://kemal:kemal@localhost:5432/invidious + +## +## Enable automatic table integrity check. This will create +## the required tables and columns if anything is missing. +## +## Accepted values: true, false +## Default: false +## +#check_tables: false + + +## +## Path to an external signature resolver, used to emulate +## the Youtube client's Javascript. If no such server is +## available, some videos will not be playable. +## +## When this setting is commented out, no external +## resolver will be used. +## +## Accepted values: a path to a UNIX socket or "<IP>:<Port>" +## Default: <none> +## +#signature_server: + + +######################################### +# +# Server config +# +######################################### + +# ----------------------------- +# Network (inbound) +# ----------------------------- + +## +## Port to listen on for incoming connections. +## +## Note: Ports lower than 1024 requires either root privileges +## (not recommended) or the "CAP_NET_BIND_SERVICE" capability +## (See https://stackoverflow.com/a/414258 and `man capabilities`) +## +## Accepted values: 1-65535 +## Default: 3000 +## +#port: 3000 + +## +## When the invidious instance is behind a proxy, and the proxy +## listens on a different port than the instance does, this lets +## invidious know about it. This is used to craft absolute URLs +## to the instance (e.g in the API). +## +## Note: This setting is MANDATORY if invidious is behind a +## reverse proxy. +## +## Accepted values: 1-65535 +## Default: <none> +## +#external_port: + +## +## Interface address to listen on for incoming connections. +## +## Accepted values: a valid IPv4 or IPv6 address. +## default: 0.0.0.0 (listen on all interfaces) +## +#host_binding: 0.0.0.0 + +## +## Domain name under which this instance is hosted. This is +## used to craft absolute URLs to the instance (e.g in the API). +## The domain MUST be defined if your instance is accessed from +## a domain name (like 'example.com'). +## +## Accepted values: a fully qualified domain name (FQDN) +## Default: <none> +## domain: + +## +## Tell Invidious that it is behind a proxy that provides only +## HTTPS, so all links must use the https:// scheme. This +## setting MUST be set to true if invidious is behind a +## reverse proxy serving HTTPs. +## +## Accepted values: true, false +## Default: false +## +https_only: false + +## +## Enable/Disable 'Strict-Transport-Security'. Make sure that +## the domain specified under 'domain' is served securely. +## +## Accepted values: true, false +## Default: true +## +#hsts: true + + +# ----------------------------- +# Network (outbound) +# ----------------------------- + +## +## Disable proxying server-wide. Can be disable as a whole, or +## only for a single function. +## +## Accepted values: true, false, dash, livestreams, downloads, local +## Default: false +## +#disable_proxy: false + +## +## Size of the HTTP pool used to connect to youtube. Each +## domain ('youtube.com', 'ytimg.com', ...) has its own pool. +## +## Accepted values: a positive integer +## Default: 100 +## +#pool_size: 100 + + +## +## Additional cookies to be sent when requesting the youtube API. +## +## Accepted values: a string in the format "name1=value1; name2=value2..." +## Default: <none> +## +#cookies: + +## +## Force connection to youtube over a specific IP family. +## +## Note: This may sometimes resolve issues involving rate-limiting. +## See https://github.com/ytdl-org/youtube-dl/issues/21729. +## +## Accepted values: ipv4, ipv6 +## Default: <none> +## +#force_resolve: + +## +## Configuration for using a HTTP proxy +## +## If unset, then no HTTP proxy will be used. +## +http_proxy: + user: + password: + host: + port: + + +## +## Use Innertube's transcripts API instead of timedtext for closed captions +## +## Useful for larger instances as InnerTube is **not ratelimited**. See https://github.com/iv-org/invidious/issues/2567 +## +## Subtitle experience may differ slightly on Invidious. +## +## Accepted values: true, false +## Default: false +## +# use_innertube_for_captions: false + +## +## Send Google session informations. This is useful when Invidious is blocked +## by the message "This helps protect our community." +## See https://github.com/iv-org/invidious/issues/4734. +## +## Warning: These strings gives much more identifiable information to Google! +## +## Accepted values: String +## Default: <none> +## +# po_token: "" +# visitor_data: "" + +# ----------------------------- +# Logging +# ----------------------------- + +## +## Path to log file. Can be absolute or relative to the invidious +## binary. This is overridden if "-o OUTPUT" or "--output=OUTPUT" +## are passed on the command line. +## +## Accepted values: a filesystem path or 'STDOUT' +## Default: STDOUT +## +#output: STDOUT + +## +## Logging Verbosity. This is overridden if "-l LEVEL" or +## "--log-level=LEVEL" are passed on the command line. +## +## Accepted values: All, Trace, Debug, Info, Warn, Error, Fatal, Off +## Default: Info +## +#log_level: Info + +## +## Enables colors in logs. Useful for debugging purposes +## This is overridden if "-k" or "--colorize" +## are passed on the command line. +## Colors are also disabled if the environment variable +## NO_COLOR is present and has any value +## +## Accepted values: true, false +## Default: true +## +#colorize_logs: false + +# ----------------------------- +# Features +# ----------------------------- + +## +## Enable/Disable the "Popular" tab on the main page. +## +## Accepted values: true, false +## Default: true +## +#popular_enabled: true + +## +## Enable/Disable statstics (available at /api/v1/stats). +## The following data is available: +## - Software name ("invidious") and version+branch (same data as +## displayed in the footer, e.g: "2021.05.13-75e5b49" / "master") +## - The value of the 'registration_enabled' config (true/false) +## - Number of currently registered users +## - Number of registered users who connected in the last month +## - Number of registered users who connected in the last 6 months +## - Timestamp of the last server restart +## - Timestamp of the last "Channel Refresh" job execution +## +## Warning: This setting MUST be set to true if you plan to run +## a public instance. It is used by api.invidious.io to refresh +## your instance's status. +## +## Accepted values: true, false +## Default: false +## +#statistics_enabled: false + + +# ----------------------------- +# Users and accounts +# ----------------------------- + +## +## Allow/Forbid Invidious (local) account creation. Invidious +## accounts allow users to subscribe to channels and to create +## playlists without a Google account. +## +## Accepted values: true, false +## Default: true +## +#registration_enabled: true + +## +## Allow/Forbid users to log-in. +## +## Accepted values: true, false +## Default: true +## +#login_enabled: true + +## +## Enable/Disable the captcha challenge on the login page. +## +## Note: this is a basic captcha challenge that doesn't +## depend on any third parties. +## +## Accepted values: true, false +## Default: true +## +#captcha_enabled: true + +## +## List of usernames that will be granted administrator rights. +## A user with administrator rights will be able to change the +## server configuration options listed below in /preferences, +## in addition to the usual user preferences. +## +## Server-wide settings: +## - popular_enabled +## - captcha_enabled +## - login_enabled +## - registration_enabled +## - statistics_enabled +## Default user preferences: +## - default_home +## - feed_menu +## +## Accepted values: an array of strings +## Default: [""] +## +#admins: [""] + +## +## Enable/Disable the user notifications for all users +## +## Note: On large instances, it is recommended to set this option to 'false' +## in order to reduce the amount of data written to the database, and hence +## improve the overall performance of the instance. +## +## Accepted values: true, false +## Default: true +## +#enable_user_notifications: true + +# ----------------------------- +# Background jobs +# ----------------------------- + +## +## Number of threads to use when crawling channel videos (during +## subscriptions update). +## +## Notes: This setting is overridden if either "-c THREADS" or +## "--channel-threads=THREADS" is passed on the command line. +## +## Accepted values: a positive integer +## Default: 1 +## +channel_threads: 1 + +## +## Time interval between two executions of the job that crawls +## channel videos (subscriptions update). +## +## Accepted values: a valid time interval (like 1h30m or 90m) +## Default: 30m +## +#channel_refresh_interval: 30m + +## +## Forcefully dump and re-download the entire list of uploaded +## videos when crawling channel (during subscriptions update). +## +## Accepted values: true, false +## Default: false +## +full_refresh: false + +## +## Number of threads to use when updating RSS feeds. +## +## Notes: This setting is overridden if either "-f THREADS" or +## "--feed-threads=THREADS" is passed on the command line. +## +## Accepted values: a positive integer +## Default: 1 +## +feed_threads: 1 + + +jobs: + + ## Options for the database cleaning job + clear_expired_items: + + ## Enable/Disable job + ## + ## Accepted values: true, false + ## Default: true + ## + enable: true + + ## Options for the channels updater job + refresh_channels: + + ## Enable/Disable job + ## + ## Accepted values: true, false + ## Default: true + ## + enable: true + + ## Options for the RSS feeds updater job + refresh_feeds: + + ## Enable/Disable job + ## + ## Accepted values: true, false + ## Default: true + ## + enable: true + + +# ----------------------------- +# Miscellaneous +# ----------------------------- + +## +## custom banner displayed at the top of every page. This can +## used for instance announcements, e.g. +## +## Accepted values: any string. HTML is accepted. +## Default: <none> +## +#banner: + +## +## Subscribe to channels using PubSubHub (Google PubSubHubbub service). +## PubSubHub allows Invidious to be instantly notified when a new video +## is published on any subscribed channels. When PubSubHub is not used, +## Invidious will check for new videos every minute. +## +## Note: This setting is recommended for public instances. +## +## Note 2: +## - Requires a public instance (it uses /feed/webhook/v1) +## - Requires 'domain' and 'hmac_key' to be set. +## - Setting this parameter to any number greater than zero will +## enable channel subscriptions via PubSubHub, but will limit the +## amount of concurrent subscriptions. +## +## Accepted values: true, false, a positive integer +## Default: false +## +#use_pubsub_feeds: false + +## +## HMAC signing key used for CSRF tokens, cookies and pubsub +## subscriptions verification. +## +## Note: This parameter is mandatory and should be a random string. +## Such random string can be generated on linux with the following +## command: `pwgen 20 1` +## +## Accepted values: a string +## Default: <none> +## +hmac_key: "CHANGE_ME!!" + +## +## List of video IDs where the "download" widget must be +## disabled, in order to comply with DMCA requests. +## +## Accepted values: an array of string +## Default: <none> +## +#dmca_content: + +## +## Cache video annotations in the database. +## +## Warning: empty annotations or annotations that only contain +## cards won't be cached. +## +## Accepted values: true, false +## Default: false +## +#cache_annotations: false + +## +## Source code URL. If your instance is running a modified source +## code, you MUST publish it somewhere and set this option. +## +## Accepted values: a string +## Default: <none> +## +#modified_source_code_url: "" + +## +## Maximum custom playlist length limit. +## +## Accepted values: Integer +## Default: 500 +## +#playlist_length_limit: 500 + +######################################### +# +# Default user preferences +# +######################################### + +## +## NOTE: All the settings below define the default user +## preferences. They will apply to ALL users connecting +## without a preferences cookie (so either on the first +## connection to the instance or after clearing the +## browser's cookies). +## + +default_user_preferences: + + # ----------------------------- + # Internationalization + # ----------------------------- + + ## + ## Default user interface language (locale). + ## + ## Note: When hosting a public instance, overriding the + ## default (english) is not recommended, as it may + ## people using other languages. + ## + ## Accepted values: + ## ar (Arabic) + ## da (Danish) + ## de (German) + ## en-US (english, US) + ## el (Greek) + ## eo (Esperanto) + ## es (Spanish) + ## fa (Persian) + ## fi (Finnish) + ## fr (French) + ## he (Hebrew) + ## hr (Hungarian) + ## id (Indonesian) + ## is (Icelandic) + ## it (Italian) + ## ja (Japanese) + ## nb-NO (Norwegian, Bokmål) + ## nl (Dutch) + ## pl (Polish) + ## pt-BR (Portuguese, Brazil) + ## pt-PT (Portuguese, Portugal) + ## ro (Romanian) + ## ru (Russian) + ## sv (Swedish) + ## tr (Turkish) + ## uk (Ukrainian) + ## zh-CN (Chinese, China) (a.k.a "Simplified Chinese") + ## zh-TW (Chinese, Taiwan) (a.k.a "Traditional Chinese") + ## + ## Default: en-US + ## + #locale: en-US + + ## + ## Default geographical location for content. + ## + ## Accepted values: + ## 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 + ## + ## Default: US + ## + #region: US + + ## + ## Top 3 preferred languages for video captions. + ## + ## Note: overriding the default (no preferred + ## caption language) is not recommended, in order + ## to not penalize people using other languages. + ## + ## Accepted values: a three-entries array. + ## Each entry can be one of: + ## "English", "English (auto-generated)", + ## "Afrikaans", "Albanian", "Amharic", "Arabic", + ## "Armenian", "Azerbaijani", "Bangla", "Basque", + ## "Belarusian", "Bosnian", "Bulgarian", "Burmese", + ## "Catalan", "Cebuano", "Chinese (Simplified)", + ## "Chinese (Traditional)", "Corsican", "Croatian", + ## "Czech", "Danish", "Dutch", "Esperanto", "Estonian", + ## "Filipino", "Finnish", "French", "Galician", "Georgian", + ## "German", "Greek", "Gujarati", "Haitian Creole", "Hausa", + ## "Hawaiian", "Hebrew", "Hindi", "Hmong", "Hungarian", + ## "Icelandic", "Igbo", "Indonesian", "Irish", "Italian", + ## "Japanese", "Javanese", "Kannada", "Kazakh", "Khmer", + ## "Korean", "Kurdish", "Kyrgyz", "Lao", "Latin", "Latvian", + ## "Lithuanian", "Luxembourgish", "Macedonian", + ## "Malagasy", "Malay", "Malayalam", "Maltese", "Maori", + ## "Marathi", "Mongolian", "Nepali", "Norwegian Bokmål", + ## "Nyanja", "Pashto", "Persian", "Polish", "Portuguese", + ## "Punjabi", "Romanian", "Russian", "Samoan", + ## "Scottish Gaelic", "Serbian", "Shona", "Sindhi", + ## "Sinhala", "Slovak", "Slovenian", "Somali", + ## "Southern Sotho", "Spanish", "Spanish (Latin America)", + ## "Sundanese", "Swahili", "Swedish", "Tajik", "Tamil", + ## "Telugu", "Thai", "Turkish", "Ukrainian", "Urdu", + ## "Uzbek", "Vietnamese", "Welsh", "Western Frisian", + ## "Xhosa", "Yiddish", "Yoruba", "Zulu" + ## + ## Default: ["", "", ""] + ## + #captions: ["", "", ""] + + + # ----------------------------- + # Interface + # ----------------------------- + + ## + ## Enable/Disable dark mode. + ## + ## Accepted values: "dark", "light", "auto" + ## Default: "auto" + ## + #dark_mode: "auto" + + ## + ## Enable/Disable thin mode (no video thumbnails). + ## + ## Accepted values: true, false + ## Default: false + ## + #thin_mode: false + + ## + ## List of feeds available on the home page. + ## + ## Note: "Subscriptions" and "Playlists" are only visible + ## when the user is logged in. + ## + ## Accepted values: A list of strings + ## Each entry can be one of: "Popular", "Trending", + ## "Subscriptions", "Playlists" + ## + ## Default: ["Popular", "Trending", "Subscriptions", "Playlists"] (show all feeds) + ## + #feed_menu: ["Popular", "Trending", "Subscriptions", "Playlists"] + + ## + ## Default feed to display on the home page. + ## + ## Note: setting this option to "Popular" has no + ## effect when 'popular_enabled' is set to false. + ## + ## Accepted values: Popular, Trending, Subscriptions, Playlists, <none> + ## Default: Popular + ## + #default_home: Popular + + ## + ## Default number of results to display per page. + ## + ## Note: this affects invidious-generated pages only, such + ## as watch history and subscription feeds. Playlists, search + ## results and channel videos depend on the data returned by + ## the Youtube API. + ## + ## Accepted values: any positive integer + ## Default: 40 + ## + #max_results: 40 + + ## + ## Show/hide annotations. + ## + ## Accepted values: true, false + ## Default: false + ## + #annotations: false + + ## + ## Show/hide annotation. + ## + ## Accepted values: true, false + ## Default: false + ## + #annotations_subscribed: false + + ## + ## Type of comments to display below video. + ## + ## Accepted values: a two-entries array. + ## Each entry can be one of: "youtube", "reddit", "" + ## + ## Default: ["youtube", ""] + ## + #comments: ["youtube", ""] + + ## + ## Default player style. + ## + ## Accepted values: invidious, youtube + ## Default: invidious + ## + #player_style: invidious + + ## + ## Show/Hide the "related videos" sidebar when + ## watching a video. + ## + ## Accepted values: true, false + ## Default: true + ## + #related_videos: true + + + # ----------------------------- + # Video player behavior + # ----------------------------- + + ## + ## This option controls the value of the HTML5 <video> element's + ## "preload" attribute. + ## + ## If set to 'false', no video data will be loaded until the user + ## explicitly starts the video by clicking the "Play" button. + ## If set to 'true', the web browser will buffer some video data + ## while the page is loading. + ## + ## See: https://www.w3schools.com/tags/att_video_preload.asp + ## + ## Accepted values: true, false + ## Default: true + ## + #preload: true + + ## + ## Automatically play videos on page load. + ## + ## Accepted values: true, false + ## Default: false + ## + #autoplay: false + + ## + ## Automatically load the "next" video (either next in + ## playlist or proposed) when the current video ends. + ## + ## Accepted values: true, false + ## Default: false + ## + #continue: false + + ## + ## Autoplay next video by default. + ## + ## Note: Only effective if 'continue' is set to true. + ## + ## Accepted values: true, false + ## Default: true + ## + #continue_autoplay: true + + ## + ## Play videos in Audio-only mode by default. + ## + ## Accepted values: true, false + ## Default: false + ## + #listen: false + + ## + ## Loop videos automatically. + ## + ## Accepted values: true, false + ## Default: false + ## + #video_loop: false + + + # ----------------------------- + # Video playback settings + # ----------------------------- + + ## + ## Default video quality. + ## + ## Accepted values: dash, hd720, medium, small + ## Default: hd720 + ## + #quality: hd720 + + ## + ## Default dash video quality. + ## + ## Note: this setting only takes effet if the + ## 'quality' parameter is set to "dash". + ## + ## Accepted values: + ## auto, best, 4320p, 2160p, 1440p, 1080p, + ## 720p, 480p, 360p, 240p, 144p, worst + ## Default: auto + ## + #quality_dash: auto + + ## + ## Default video playback speed. + ## + ## Accepted values: 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0 + ## Default: 1.0 + ## + #speed: 1.0 + + ## + ## Default volume. + ## + ## Accepted values: 0-100 + ## Default: 100 + ## + #volume: 100 + + ## + ## Allow 360° videos to be played. + ## + ## Note: This feature requires a WebGL-enabled browser. + ## + ## Accepted values: true, false + ## Default: true + ## + #vr_mode: true + + ## + ## Save the playback position + ## Allow to continue watching at the previous position when + ## watching the same video. + ## + ## Accepted values: true, false + ## Default: false + ## + #save_player_pos: false + + # ----------------------------- + # Subscription feed + # ----------------------------- + + ## + ## In the "Subscription" feed, only show the latest video + ## of each channel the user is subscribed to. + ## + ## Note: when combined with 'unseen_only', the latest unseen + ## video of each channel will be displayed instead of the + ## latest by date. + ## + ## Accepted values: true, false + ## Default: false + ## + #latest_only: false + + ## + ## Enable/Disable user subscriptions desktop notifications. + ## + ## Accepted values: true, false + ## Default: false + ## + #notifications_only: false + + ## + ## In the "Subscription" feed, Only show the videos that the + ## user haven't watched yet (i.e which are not in their watch + ## history). + ## + ## Accepted values: true, false + ## Default: false + ## + #unseen_only: false + + ## + ## Default sorting parameter for subscription feeds. + ## + ## Accepted values: + ## 'alphabetically' + ## 'alphabetically - reverse' + ## 'channel name' + ## 'channel name - reverse' + ## 'published' + ## 'published - reverse' + ## + ## Default: published + ## + #sort: published + + + # ----------------------------- + # Miscellaneous + # ----------------------------- + + ## + ## Proxy videos through instance by default. + ## + ## Warning: As most users won't change this setting in their + ## preferences, defaulting to true will significantly + ## increase the instance's network usage, so make sure that + ## your server's connection can handle it. + ## + ## Accepted values: true, false + ## Default: false + ## + #local: false + + ## + ## Show the connected user's nick at the top right. + ## + ## Accepted values: true, false + ## Default: true + ## + #show_nick: true + + ## + ## Automatically redirect to a random instance when the user uses + ## any "switch invidious instance" link (For videos, it's the plane + ## icon, next to "watch on youtube" and "listen"). When set to false, + ## the user is sent to https://redirect.invidious.io instead, where + ## they can manually select an instance. + ## + ## Accepted values: true, false + ## Default: false + ## + #automatic_instance_redirect: false + + ## + ## Show the entire video description by default (when set to 'false', + ## only the first few lines of the description are shown and a + ## "show more" button allows to expand it). + ## + ## Accepted values: true, false + ## Default: false + ## + #extend_desc: false diff --git a/config/migrate-scripts/migrate-db-17cf077.sh b/config/migrate-scripts/migrate-db-17cf077.sh index 5e5bb214..1597311d 100755 --- a/config/migrate-scripts/migrate-db-17cf077.sh +++ b/config/migrate-scripts/migrate-db-17cf077.sh @@ -1,4 +1,7 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channels ADD COLUMN subscribed bool;" -psql invidious kemal -c "UPDATE channels SET subscribed = false;" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN subscribed bool;" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET subscribed = false;" diff --git a/config/migrate-scripts/migrate-db-1c8075c.sh b/config/migrate-scripts/migrate-db-1c8075c.sh index 63954397..b6f7b89c 100755 --- a/config/migrate-scripts/migrate-db-1c8075c.sh +++ b/config/migrate-scripts/migrate-db-1c8075c.sh @@ -1,7 +1,10 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channel_videos DROP COLUMN live_now CASCADE" -psql invidious kemal -c "ALTER TABLE channel_videos DROP COLUMN premiere_timestamp CASCADE" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious -psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN live_now bool" -psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos DROP COLUMN live_now CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos DROP COLUMN premiere_timestamp CASCADE" + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN live_now bool" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz" diff --git a/config/migrate-scripts/migrate-db-1eca969.sh b/config/migrate-scripts/migrate-db-1eca969.sh index f840d924..770a76d3 100755 --- a/config/migrate-scripts/migrate-db-1eca969.sh +++ b/config/migrate-scripts/migrate-db-1eca969.sh @@ -1,19 +1,22 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN title CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN views CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN likes CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN dislikes CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN wilson_score CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN published CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN description CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN language CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN author CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN ucid CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN allowed_regions CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN is_family_friendly CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN genre CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN genre_url CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN license CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN sub_count_text CASCADE" -psql invidious kemal -c "ALTER TABLE videos DROP COLUMN author_thumbnail CASCADE" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN title CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN views CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN likes CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN dislikes CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN wilson_score CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN published CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN description CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN language CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN author CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN ucid CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN allowed_regions CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN is_family_friendly CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN genre CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN genre_url CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN license CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN sub_count_text CASCADE" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN author_thumbnail CASCADE" diff --git a/config/migrate-scripts/migrate-db-30e6d29.sh b/config/migrate-scripts/migrate-db-30e6d29.sh index 3a377461..9d0b2d30 100755 --- a/config/migrate-scripts/migrate-db-30e6d29.sh +++ b/config/migrate-scripts/migrate-db-30e6d29.sh @@ -1,4 +1,7 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channels ADD COLUMN deleted bool;" -psql invidious kemal -c "UPDATE channels SET deleted = false;" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN deleted bool;" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET deleted = false;" diff --git a/config/migrate-scripts/migrate-db-3646395.sh b/config/migrate-scripts/migrate-db-3646395.sh index 830b85f2..b6efe239 100755 --- a/config/migrate-scripts/migrate-db-3646395.sh +++ b/config/migrate-scripts/migrate-db-3646395.sh @@ -1,5 +1,8 @@ #!/bin/sh -psql invidious kemal < config/sql/session_ids.sql -psql invidious kemal -c "INSERT INTO session_ids (SELECT unnest(id), email, CURRENT_TIMESTAMP FROM users) ON CONFLICT (id) DO NOTHING" -psql invidious kemal -c "ALTER TABLE users DROP COLUMN id" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" < config/sql/session_ids.sql +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "INSERT INTO session_ids (SELECT unnest(id), email, CURRENT_TIMESTAMP FROM users) ON CONFLICT (id) DO NOTHING" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE users DROP COLUMN id" diff --git a/config/migrate-scripts/migrate-db-3bcb98e.sh b/config/migrate-scripts/migrate-db-3bcb98e.sh index cb9fa6ab..444f65ed 100755 --- a/config/migrate-scripts/migrate-db-3bcb98e.sh +++ b/config/migrate-scripts/migrate-db-3bcb98e.sh @@ -1,3 +1,6 @@ #!/bin/sh -psql invidious kemal < config/sql/annotations.sql +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" < config/sql/annotations.sql diff --git a/config/migrate-scripts/migrate-db-52cb239.sh b/config/migrate-scripts/migrate-db-52cb239.sh index db8efeab..da977d97 100755 --- a/config/migrate-scripts/migrate-db-52cb239.sh +++ b/config/migrate-scripts/migrate-db-52cb239.sh @@ -1,3 +1,6 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN views bigint;" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN views bigint;" diff --git a/config/migrate-scripts/migrate-db-6e51189.sh b/config/migrate-scripts/migrate-db-6e51189.sh index ce728118..9132d3d7 100755 --- a/config/migrate-scripts/migrate-db-6e51189.sh +++ b/config/migrate-scripts/migrate-db-6e51189.sh @@ -1,4 +1,7 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN live_now bool;" -psql invidious kemal -c "UPDATE channel_videos SET live_now = false;" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN live_now bool;" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channel_videos SET live_now = false;" diff --git a/config/migrate-scripts/migrate-db-701b5ea.sh b/config/migrate-scripts/migrate-db-701b5ea.sh index 429531a2..46d60c00 100755 --- a/config/migrate-scripts/migrate-db-701b5ea.sh +++ b/config/migrate-scripts/migrate-db-701b5ea.sh @@ -1,3 +1,6 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE users ADD COLUMN feed_needs_update boolean" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE users ADD COLUMN feed_needs_update boolean" diff --git a/config/migrate-scripts/migrate-db-88b7097.sh b/config/migrate-scripts/migrate-db-88b7097.sh index 6bde8399..146ee92d 100755 --- a/config/migrate-scripts/migrate-db-88b7097.sh +++ b/config/migrate-scripts/migrate-db-88b7097.sh @@ -1,3 +1,6 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz;" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz;" diff --git a/config/migrate-scripts/migrate-db-8e884fe.sh b/config/migrate-scripts/migrate-db-8e884fe.sh index 1c8dafd1..0d5de828 100755 --- a/config/migrate-scripts/migrate-db-8e884fe.sh +++ b/config/migrate-scripts/migrate-db-8e884fe.sh @@ -1,5 +1,8 @@ #!/bin/sh -psql invidious kemal -c "ALTER TABLE channels DROP COLUMN subscribed" -psql invidious kemal -c "ALTER TABLE channels ADD COLUMN subscribed timestamptz" -psql invidious kemal -c "UPDATE channels SET subscribed = '2019-01-01 00:00:00+00'" +[ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal +[ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious + +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels DROP COLUMN subscribed" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN subscribed timestamptz" +psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET subscribed = '2019-01-01 00:00:00+00'" diff --git a/config/sql/annotations.sql b/config/sql/annotations.sql index 4ea077e7..3705829d 100644 --- a/config/sql/annotations.sql +++ b/config/sql/annotations.sql @@ -2,11 +2,11 @@ -- DROP TABLE public.annotations; -CREATE TABLE public.annotations +CREATE TABLE IF NOT EXISTS public.annotations ( id text NOT NULL, annotations xml, CONSTRAINT annotations_id_key UNIQUE (id) ); -GRANT ALL ON TABLE public.annotations TO kemal; +GRANT ALL ON TABLE public.annotations TO current_user; diff --git a/config/sql/channel_videos.sql b/config/sql/channel_videos.sql index cec57cd4..cd4e0ffd 100644 --- a/config/sql/channel_videos.sql +++ b/config/sql/channel_videos.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.channel_videos; -CREATE TABLE public.channel_videos +CREATE TABLE IF NOT EXISTS public.channel_videos ( id text NOT NULL, title text, @@ -17,13 +17,13 @@ CREATE TABLE public.channel_videos CONSTRAINT channel_videos_id_key UNIQUE (id) ); -GRANT ALL ON TABLE public.channel_videos TO kemal; +GRANT ALL ON TABLE public.channel_videos TO current_user; -- Index: public.channel_videos_ucid_idx -- DROP INDEX public.channel_videos_ucid_idx; -CREATE INDEX channel_videos_ucid_idx +CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx ON public.channel_videos USING btree (ucid COLLATE pg_catalog."default"); diff --git a/config/sql/channels.sql b/config/sql/channels.sql index b5a29b8f..55772da6 100644 --- a/config/sql/channels.sql +++ b/config/sql/channels.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.channels; -CREATE TABLE public.channels +CREATE TABLE IF NOT EXISTS public.channels ( id text NOT NULL, author text, @@ -12,13 +12,13 @@ CREATE TABLE public.channels CONSTRAINT channels_id_key UNIQUE (id) ); -GRANT ALL ON TABLE public.channels TO kemal; +GRANT ALL ON TABLE public.channels TO current_user; -- Index: public.channels_id_idx -- DROP INDEX public.channels_id_idx; -CREATE INDEX channels_id_idx +CREATE INDEX IF NOT EXISTS channels_id_idx ON public.channels USING btree (id COLLATE pg_catalog."default"); diff --git a/config/sql/nonces.sql b/config/sql/nonces.sql index 7b8ce9f2..644ac32a 100644 --- a/config/sql/nonces.sql +++ b/config/sql/nonces.sql @@ -2,20 +2,20 @@ -- DROP TABLE public.nonces; -CREATE TABLE public.nonces +CREATE TABLE IF NOT EXISTS public.nonces ( nonce text, expire timestamp with time zone, CONSTRAINT nonces_id_key UNIQUE (nonce) ); -GRANT ALL ON TABLE public.nonces TO kemal; +GRANT ALL ON TABLE public.nonces TO current_user; -- Index: public.nonces_nonce_idx -- DROP INDEX public.nonces_nonce_idx; -CREATE INDEX nonces_nonce_idx +CREATE INDEX IF NOT EXISTS nonces_nonce_idx ON public.nonces USING btree (nonce COLLATE pg_catalog."default"); diff --git a/config/sql/playlist_videos.sql b/config/sql/playlist_videos.sql index b2b8d5c4..4b48b46a 100644 --- a/config/sql/playlist_videos.sql +++ b/config/sql/playlist_videos.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.playlist_videos; -CREATE TABLE playlist_videos +CREATE TABLE IF NOT EXISTS public.playlist_videos ( title text, id text, @@ -16,4 +16,4 @@ CREATE TABLE playlist_videos PRIMARY KEY (index,plid) ); -GRANT ALL ON TABLE public.playlist_videos TO kemal; +GRANT ALL ON TABLE public.playlist_videos TO current_user; diff --git a/config/sql/playlists.sql b/config/sql/playlists.sql index 468496cb..83efce48 100644 --- a/config/sql/playlists.sql +++ b/config/sql/playlists.sql @@ -13,7 +13,7 @@ CREATE TYPE public.privacy AS ENUM -- DROP TABLE public.playlists; -CREATE TABLE public.playlists +CREATE TABLE IF NOT EXISTS public.playlists ( title text, id text primary key, @@ -26,4 +26,4 @@ CREATE TABLE public.playlists index int8[] ); -GRANT ALL ON public.playlists TO kemal; +GRANT ALL ON public.playlists TO current_user; diff --git a/config/sql/session_ids.sql b/config/sql/session_ids.sql index afbabb67..c493769a 100644 --- a/config/sql/session_ids.sql +++ b/config/sql/session_ids.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.session_ids; -CREATE TABLE public.session_ids +CREATE TABLE IF NOT EXISTS public.session_ids ( id text NOT NULL, email text, @@ -10,13 +10,13 @@ CREATE TABLE public.session_ids CONSTRAINT session_ids_pkey PRIMARY KEY (id) ); -GRANT ALL ON TABLE public.session_ids TO kemal; +GRANT ALL ON TABLE public.session_ids TO current_user; -- Index: public.session_ids_id_idx -- DROP INDEX public.session_ids_id_idx; -CREATE INDEX session_ids_id_idx +CREATE INDEX IF NOT EXISTS session_ids_id_idx ON public.session_ids USING btree (id COLLATE pg_catalog."default"); diff --git a/config/sql/users.sql b/config/sql/users.sql index 0f2cdba2..ad002ec2 100644 --- a/config/sql/users.sql +++ b/config/sql/users.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.users; -CREATE TABLE public.users +CREATE TABLE IF NOT EXISTS public.users ( updated timestamp with time zone, notifications text[], @@ -16,13 +16,13 @@ CREATE TABLE public.users CONSTRAINT users_email_key UNIQUE (email) ); -GRANT ALL ON TABLE public.users TO kemal; +GRANT ALL ON TABLE public.users TO current_user; -- Index: public.email_unique_idx -- DROP INDEX public.email_unique_idx; -CREATE UNIQUE INDEX email_unique_idx +CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx ON public.users USING btree (lower(email) COLLATE pg_catalog."default"); diff --git a/config/sql/videos.sql b/config/sql/videos.sql index 8def2f83..55da3967 100644 --- a/config/sql/videos.sql +++ b/config/sql/videos.sql @@ -2,7 +2,7 @@ -- DROP TABLE public.videos; -CREATE TABLE public.videos +CREATE UNLOGGED TABLE IF NOT EXISTS public.videos ( id text NOT NULL, info text, @@ -10,13 +10,13 @@ CREATE TABLE public.videos CONSTRAINT videos_pkey PRIMARY KEY (id) ); -GRANT ALL ON TABLE public.videos TO kemal; +GRANT ALL ON TABLE public.videos TO current_user; -- Index: public.id_idx -- DROP INDEX public.id_idx; -CREATE UNIQUE INDEX id_idx +CREATE UNIQUE INDEX IF NOT EXISTS id_idx ON public.videos USING btree (id COLLATE pg_catalog."default"); diff --git a/docker-compose.yml b/docker-compose.yml index bc292c53..afda8726 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,12 @@ -version: '3' +# Warning: This docker-compose file is made for development purposes. +# Using it will build an image from the locally cloned repository. +# +# If you want to use Invidious in production, see the docker-compose.yml file provided +# in the installation documentation: https://docs.invidious.io/installation/ + +version: "3" services: - postgres: - image: postgres:10 - restart: unless-stopped - volumes: - - postgresdata:/var/lib/postgresql/data - - ./config/sql:/config/sql - - ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh - environment: - POSTGRES_DB: invidious - POSTGRES_PASSWORD: kemal - POSTGRES_USER: kemal - healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres"] + invidious: build: context: . @@ -21,22 +15,41 @@ services: ports: - "127.0.0.1:3000:3000" environment: - # Adapted from ./config/config.yml + # Please read the following file for a comprehensive list of all available + # configuration options and their associated syntax: + # https://github.com/iv-org/invidious/blob/master/config/config.example.yml INVIDIOUS_CONFIG: | - channel_threads: 1 - check_tables: true - feed_threads: 1 db: + dbname: invidious user: kemal password: kemal - host: postgres + host: invidious-db port: 5432 - dbname: invidious - full_refresh: false - https_only: false - domain: - depends_on: - - postgres + check_tables: true + # external_port: + # domain: + # https_only: false + # statistics_enabled: false + hmac_key: "CHANGE_ME!!" + healthcheck: + test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/trending || exit 1 + interval: 30s + timeout: 5s + retries: 2 + + invidious-db: + image: docker.io/library/postgres:14 + restart: unless-stopped + volumes: + - postgresdata:/var/lib/postgresql/data + - ./config/sql:/config/sql + - ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh + environment: + POSTGRES_DB: invidious + POSTGRES_USER: kemal + POSTGRES_PASSWORD: kemal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] volumes: postgresdata: diff --git a/docker/Dockerfile b/docker/Dockerfile index 87884403..900c9e74 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,32 +1,52 @@ -FROM crystallang/crystal:0.36.1-alpine AS builder -RUN apk add --no-cache curl sqlite-static yaml-static +FROM crystallang/crystal:1.12.2-alpine AS builder + +RUN apk add --no-cache sqlite-static yaml-static + +ARG release + WORKDIR /invidious COPY ./shard.yml ./shard.yml COPY ./shard.lock ./shard.lock -RUN shards install && \ - curl -Lo ./lib/lsquic/src/lsquic/ext/liblsquic.a https://github.com/iv-org/lsquic-static-alpine/releases/download/v2.18.1/liblsquic.a +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/ -RUN crystal build ./src/invidious.cr \ - --static --warnings all \ - --link-flags "-lxml2 -llzma" -FROM alpine:latest -RUN apk add --no-cache librsvg ttf-opensans +# 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" +RUN if [[ "${release}" == 1 ]] ; then \ + crystal build ./src/invidious.cr \ + --release \ + --static --warnings all \ + --link-flags "-lxml2 -llzma"; \ + else \ + crystal build ./src/invidious.cr \ + --static --warnings all \ + --link-flags "-lxml2 -llzma"; \ + fi + +FROM alpine:3.20 +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 ./assets/ ./assets/ COPY --chown=invidious ./config/config.* ./config/ RUN mv -n config/config.example.yml config/config.yml -RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: postgres/' config/config.yml +RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: invidious-db/' config/config.yml COPY ./config/sql/ ./config/sql/ COPY ./locales/ ./locales/ +COPY --from=builder /invidious/assets ./assets/ COPY --from=builder /invidious/invidious . RUN chmod o+rX -R ./assets ./config ./locales EXPOSE 3000 USER invidious +ENTRYPOINT ["/sbin/tini", "--"] CMD [ "/invidious/invidious" ] diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64 new file mode 100644 index 00000000..ce9bab08 --- /dev/null +++ b/docker/Dockerfile.arm64 @@ -0,0 +1,53 @@ +FROM alpine:3.20 AS builder +RUN apk add --no-cache 'crystal=1.12.2-r0' shards sqlite-static yaml-static yaml-dev 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" + +RUN if [[ "${release}" == 1 ]] ; then \ + crystal build ./src/invidious.cr \ + --release \ + --static --warnings all \ + --link-flags "-lxml2 -llzma"; \ + else \ + crystal build ./src/invidious.cr \ + --static --warnings all \ + --link-flags "-lxml2 -llzma"; \ + fi + +FROM alpine:3.20 +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" ] diff --git a/docker/init-invidious-db.sh b/docker/init-invidious-db.sh index 3808e673..22b4cc5f 100755 --- a/docker/init-invidious-db.sh +++ b/docker/init-invidious-db.sh @@ -1,16 +1,12 @@ #!/bin/bash set -eou pipefail -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - CREATE USER postgres; -EOSQL - -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/users.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/session_ids.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/users.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/session_ids.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql +psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql diff --git a/kubernetes/.gitignore b/kubernetes/.gitignore deleted file mode 100644 index 0ad51707..00000000 --- a/kubernetes/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/charts/*.tgz diff --git a/kubernetes/Chart.lock b/kubernetes/Chart.lock deleted file mode 100644 index 1799798b..00000000 --- a/kubernetes/Chart.lock +++ /dev/null @@ -1,6 +0,0 @@ -dependencies: -- name: postgresql - repository: https://kubernetes-charts.storage.googleapis.com/ - version: 8.3.0 -digest: sha256:1feec3c396cbf27573dc201831ccd3376a4a6b58b2e7618ce30a89b8f5d707fd -generated: "2020-02-07T13:39:38.624846+01:00" diff --git a/kubernetes/Chart.yaml b/kubernetes/Chart.yaml deleted file mode 100644 index 9e4b793e..00000000 --- a/kubernetes/Chart.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: v2 -name: invidious -description: Invidious is an alternative front-end to YouTube -version: 1.1.0 -appVersion: 0.20.1 -keywords: -- youtube -- proxy -- video -- privacy -home: https://invidio.us/ -icon: https://raw.githubusercontent.com/iv-org/invidious/05988c1c49851b7d0094fca16aeaf6382a7f64ab/assets/favicon-32x32.png -sources: -- https://github.com/iv-org/invidious -maintainers: -- name: Leon Klingele - email: mail@leonklingele.de -dependencies: -- name: postgresql - version: ~8.3.0 - repository: "https://kubernetes-charts.storage.googleapis.com/" -engine: gotpl diff --git a/kubernetes/README.md b/kubernetes/README.md index 35478f99..e71f6a86 100644 --- a/kubernetes/README.md +++ b/kubernetes/README.md @@ -1,41 +1 @@ -# Invidious Helm chart - -Easily deploy Invidious to Kubernetes. - -## Installing Helm chart - -```sh -# Build Helm dependencies -$ helm dep build - -# Add PostgreSQL init scripts -$ kubectl create configmap invidious-postgresql-init \ - --from-file=../config/sql/channels.sql \ - --from-file=../config/sql/videos.sql \ - --from-file=../config/sql/channel_videos.sql \ - --from-file=../config/sql/users.sql \ - --from-file=../config/sql/session_ids.sql \ - --from-file=../config/sql/nonces.sql \ - --from-file=../config/sql/annotations.sql \ - --from-file=../config/sql/playlists.sql \ - --from-file=../config/sql/playlist_videos.sql - -# Install Helm app to your Kubernetes cluster -$ helm install invidious ./ -``` - -## Upgrading - -```sh -# Upgrading is easy, too! -$ helm upgrade invidious ./ -``` - -## Uninstall - -```sh -# Get rid of everything (except database) -$ helm delete invidious - -# To also delete the database, remove all invidious-postgresql PVCs -``` +The Helm chart has moved to a dedicated GitHub repository: https://github.com/iv-org/invidious-helm-chart/tree/master/invidious
\ No newline at end of file diff --git a/kubernetes/templates/_helpers.tpl b/kubernetes/templates/_helpers.tpl deleted file mode 100644 index 52158b78..00000000 --- a/kubernetes/templates/_helpers.tpl +++ /dev/null @@ -1,16 +0,0 @@ -{{/* vim: set filetype=mustache: */}} -{{/* -Expand the name of the chart. -*/}} -{{- define "invidious.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -*/}} -{{- define "invidious.fullname" -}} -{{- $name := default .Chart.Name .Values.nameOverride -}} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} -{{- end -}} diff --git a/kubernetes/templates/configmap.yaml b/kubernetes/templates/configmap.yaml deleted file mode 100644 index 58542a31..00000000 --- a/kubernetes/templates/configmap.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ template "invidious.fullname" . }} - labels: - app: {{ template "invidious.name" . }} - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: {{ .Release.Name }} -data: - INVIDIOUS_CONFIG: | -{{ toYaml .Values.config | indent 4 }} diff --git a/kubernetes/templates/deployment.yaml b/kubernetes/templates/deployment.yaml deleted file mode 100644 index bb0b832f..00000000 --- a/kubernetes/templates/deployment.yaml +++ /dev/null @@ -1,61 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ template "invidious.fullname" . }} - labels: - app: {{ template "invidious.name" . }} - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: {{ .Release.Name }} -spec: - replicas: {{ .Values.replicaCount }} - selector: - matchLabels: - app: {{ template "invidious.name" . }} - release: {{ .Release.Name }} - template: - metadata: - labels: - app: {{ template "invidious.name" . }} - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: {{ .Release.Name }} - spec: - securityContext: - runAsUser: {{ .Values.securityContext.runAsUser }} - runAsGroup: {{ .Values.securityContext.runAsGroup }} - fsGroup: {{ .Values.securityContext.fsGroup }} - initContainers: - - name: wait-for-postgresql - image: postgres - args: - - /bin/sh - - -c - - until pg_isready -h {{ .Values.config.db.host }} -p {{ .Values.config.db.port }} -U {{ .Values.config.db.user }}; do echo waiting for database; sleep 2; done; - containers: - - name: {{ .Chart.Name }} - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - ports: - - containerPort: 3000 - env: - - name: INVIDIOUS_CONFIG - valueFrom: - configMapKeyRef: - key: INVIDIOUS_CONFIG - name: {{ template "invidious.fullname" . }} - securityContext: - allowPrivilegeEscalation: {{ .Values.securityContext.allowPrivilegeEscalation }} - capabilities: - drop: - - ALL - resources: -{{ toYaml .Values.resources | indent 10 }} - readinessProbe: - httpGet: - port: 3000 - path: / - livenessProbe: - httpGet: - port: 3000 - path: / - initialDelaySeconds: 15 - restartPolicy: Always diff --git a/kubernetes/templates/hpa.yaml b/kubernetes/templates/hpa.yaml deleted file mode 100644 index c6fbefe2..00000000 --- a/kubernetes/templates/hpa.yaml +++ /dev/null @@ -1,18 +0,0 @@ -{{- if .Values.autoscaling.enabled }} -apiVersion: autoscaling/v1 -kind: HorizontalPodAutoscaler -metadata: - name: {{ template "invidious.fullname" . }} - labels: - app: {{ template "invidious.name" . }} - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: {{ .Release.Name }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ template "invidious.fullname" . }} - minReplicas: {{ .Values.autoscaling.minReplicas }} - maxReplicas: {{ .Values.autoscaling.maxReplicas }} - targetCPUUtilizationPercentage: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} -{{- end }} diff --git a/kubernetes/templates/service.yaml b/kubernetes/templates/service.yaml deleted file mode 100644 index 01454d4e..00000000 --- a/kubernetes/templates/service.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ template "invidious.fullname" . }} - labels: - app: {{ template "invidious.name" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -spec: - type: {{ .Values.service.type }} - ports: - - name: http - port: {{ .Values.service.port }} - targetPort: 3000 - selector: - app: {{ template "invidious.name" . }} - release: {{ .Release.Name }} -{{- if .Values.service.loadBalancerIP }} - loadBalancerIP: {{ .Values.service.loadBalancerIP }} -{{- end }} diff --git a/kubernetes/values.yaml b/kubernetes/values.yaml deleted file mode 100644 index 08def6e4..00000000 --- a/kubernetes/values.yaml +++ /dev/null @@ -1,56 +0,0 @@ -name: invidious - -image: - repository: iv-org/invidious - tag: latest - pullPolicy: Always - -replicaCount: 1 - -autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 16 - targetCPUUtilizationPercentage: 50 - -service: - type: clusterIP - port: 3000 - #loadBalancerIP: - -resources: {} - #requests: - # cpu: 100m - # memory: 64Mi - #limits: - # cpu: 800m - # memory: 512Mi - -securityContext: - allowPrivilegeEscalation: false - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - -# See https://github.com/helm/charts/tree/master/stable/postgresql -postgresql: - postgresqlUsername: kemal - postgresqlPassword: kemal - postgresqlDatabase: invidious - initdbUsername: kemal - initdbPassword: kemal - initdbScriptsConfigMap: invidious-postgresql-init - -# Adapted from ../config/config.yml -config: - channel_threads: 1 - feed_threads: 1 - db: - user: kemal - password: kemal - host: invidious-postgresql - port: 5432 - dbname: invidious - full_refresh: false - https_only: false - domain: diff --git a/locales/af.json b/locales/af.json new file mode 100644 index 00000000..35f40a13 --- /dev/null +++ b/locales/af.json @@ -0,0 +1,15 @@ +{ + "generic_views_count": "{{count}} kyk", + "generic_views_count_plural": "{{count}} kyke", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videos", + "generic_playlists_count": "{{count}} snitlys", + "generic_playlists_count_plural": "{{count}} snitlyste", + "generic_subscriptions_count": "{{count}} intekening", + "generic_subscriptions_count_plural": "{{count}} intekeninge", + "LIVE": "LEWENDIG", + "generic_subscribers_count": "{{count}} intekenaar", + "generic_subscribers_count_plural": "{{count}} intekenare", + "Shared `x` ago": "`x` gelede gedeel", + "New passwords must match": "Nuwe wagwoord moet ooreenstem" +} diff --git a/locales/ar.json b/locales/ar.json index af49f514..b6bab59b 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -1,147 +1,124 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` المشتركين", - "": "`x` المشتركين" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` الفيديوهات", - "": "`x` الفيديوهات" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` قوائم التشغيل", - "": "`x` قوائم التشغيل" - }, - "LIVE": "مباشر", - "Shared `x` ago": "تم رفع الفيديو منذ `x`", - "Unsubscribe": "إلغاء الإشتراك", - "Subscribe": "إشتراك", - "View channel on YouTube": "زيارة القناة على موقع يوتيوب", - "View playlist on YouTube": "عرض قائمة التشغيل على اليوتيوب", - "newest": "الأجدد", + "LIVE": "مُباشِر", + "Shared `x` ago": "تمَّ الرفع مُنذ `x`", + "Unsubscribe": "إلغاء الاشتراك", + "Subscribe": "الاشتراك", + "View channel on YouTube": "زيارة القناة على يوتيوب", + "View playlist on YouTube": "عرض قائمة التشغيل على يوتيوب", + "newest": "الأحدث", "oldest": "الأقدم", "popular": "الأكثر شعبية", - "last": "اخر قوائم التشغيل المعدلة", - "Next page": "الصفحة الثانية", + "last": "الأخيرة", + "Next page": "الصفحة التالية", "Previous page": "الصفحة السابقة", - "Clear watch history?": "مسح السجل ؟", - "New password": "الرقم السرى الجديد", - "New passwords must match": "الأرقام السرية يجب ان تكون متطابقة", - "Cannot change password for Google accounts": "لا يستطيع تغيير الرقم السرى لحساب جوجل", - "Authorize token?": "رمز الإذن ؟", - "Authorize token for `x`?": "تصريح الرمز لـ `x` ؟", + "Clear watch history?": "هل تريد محو سجل المشاهدة؟", + "New password": "كلمة مرور جديدة", + "New passwords must match": "يَجبُ أن تكون كلمتا المرور متطابقتين", + "Authorize token?": "رمز التفويض؟", + "Authorize token for `x`?": "السماح بالرمز المميز ل `x`؟", "Yes": "نعم", "No": "لا", - "Import and Export Data": "استخراج و إضافة البيانات", - "Import": "إضافة", - "Import Invidious data": "إضافة بيانات Invidious", - "Import YouTube subscriptions": "إضافةالإشتراكات من موقع يوتيوب", - "Import FreeTube subscriptions (.db)": "إضافةالمشتركين من FreeTube (.db)", - "Import NewPipe subscriptions (.json)": "إضافة المشتركين من NewPipe (.json)", - "Import NewPipe data (.zip)": "إضافة بيانات NewPipe (.zip)", - "Export": "استخراج", - "Export subscriptions as OPML": "استخراج المشتركين كـ OPML", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "استخراج المشتركين كـ OPML (لـ NewPipe و FreeTube)", - "Export data as JSON": "استخراج البيانات كـ JSON", - "Delete account?": "حذف الحساب ؟", - "History": "السجل", - "An alternative front-end to YouTube": "البديل الكامل لموقع يوتيوب", - "JavaScript license information": "معلومات ترخيص JavaScript", + "Import and Export Data": "اِستيراد البيانات وتصديرها", + "Import": "استيراد", + "Import Invidious data": "استيراد بيانات JSON Invidious", + "Import YouTube subscriptions": "استيراد الاشتراكات YouTube بتنسيق CSV أو OPML", + "Import FreeTube subscriptions (.db)": "استيراد اشتراكات فريتيوب (.db)", + "Import NewPipe subscriptions (.json)": "استيراد اشتراكات نيو بايب (.json)", + "Import NewPipe data (.zip)": "استيراد بيانات نيو بايب (.zip)", + "Export": "تصدير", + "Export subscriptions as OPML": "تصدير الاشتراكات كـOPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "تصدير الاشتراكات كـOPML (لِنيو بايب و فريتيوب)", + "Export data as JSON": "تصدير بيانات Invidious كـ JSON", + "Delete account?": "حذف الحساب؟", + "History": "السِّجل", + "An alternative front-end to YouTube": "واجهة أمامية بديلة لموقع يوتيوب", + "JavaScript license information": "معلومات ترخيص جافا سكربت", "source": "المصدر", "Log in": "تسجيل الدخول", - "Log in/register": "تسجيل الدخول\\إنشاء حساب", - "Log in with Google": "تسجيل الدخول بإستخدام جوجل", - "User ID": "إسم المستخدم", - "Password": "الرقم السرى", - "Time (h:mm:ss):": "(يجب ان يكتب مثل هذا التنسيق) الوقت (h(ساعات):mm(دقائق):ss(ثوانى)):", - "Text CAPTCHA": "CAPTCHA كلامية", - "Image CAPTCHA": "CAPTCHA صورية", - "Sign In": "تسجيل الدخول", - "Register": "انشاء الحساب", - "E-mail": "الإيميل", - "Google verification code": "رمز تحقق جوجل", - "Preferences": "التفضيلات", - "Player preferences": "التفضيلات المشغل", - "Always loop: ": "كرر الفيديو دائما: ", - "Autoplay: ": "تشغيل تلقائى: ", - "Play next by default: ": "شغل الفيديو التالي تلقائيا: ", - "Autoplay next video: ": "شغل الفيديو التالي تلقائيا (في قوائم التشغيل) ", - "Listen by default: ": "تشغيل النسخة السمعية تلقائى: ", - "Proxy videos: ": "عرض الفيديوهات عن طريق البروكسي؟ ", - "Default speed: ": "السرعة الإفتراضية: ", - "Preferred video quality: ": "الجودة المفضلة للفيديوهات: ", - "Player volume: ": "صوت المشغل: ", - "Default comments: ": "إضهار التعليقات الإفتراضية لـ: ", + "Log in/register": "تسجيل الدخول \\ إنشاء حساب", + "User ID": "مُعرِّف المُستخدم", + "Password": "كلمة المرور", + "Time (h:mm:ss):": "الوقت (h:mm:ss):", + "Text CAPTCHA": "نص الكابتشا", + "Image CAPTCHA": "صورة الكابتشا", + "Sign In": "إنشاء حساب", + "Register": "التسجيل", + "E-mail": "البريد الإلكتروني", + "Preferences": "الإعدادات", + "preferences_category_player": "إعدادات المُشغِّل", + "preferences_video_loop_label": "كرر المقطع المرئيّ دائما: ", + "preferences_autoplay_label": "تشغيل تلقائي: ", + "preferences_continue_label": "تشغيل المقطع التالي تلقائيًا: ", + "preferences_continue_autoplay_label": "شغل المقطع التالي تلقائيًا: . ", + "preferences_listen_label": "تشغيل النسخة السمعية تلقائيًا: ", + "preferences_local_label": "بروكسي المقاطع المرئيّة؟ ", + "preferences_speed_label": "السرعة الافتراضية: ", + "preferences_quality_label": "الجودة المفضلة للمقاطع: ", + "preferences_volume_label": "صوت المشغل: ", + "preferences_comments_label": "التعليقات الافتراضية: ", "youtube": "يوتيوب", - "reddit": "Reddit", - "Default captions: ": "الترجمات الإفتراضية: ", - "Fallback captions: ": "الترجمات المصاحبة: ", - "Show related videos: ": "اعرض الفيديوهات ذات الصلة: ", - "Show annotations by default: ": "اعرض الملاحظات في الفيديو تلقائيا: ", - "Automatically extend video description: ": "", - "Visual preferences": "التفضيلات المرئية", - "Player style: ": "شكل مشغل الفيديوهات: ", - "Dark mode: ": "الوضع الليلى: ", - "Theme: ": "المظهر: ", + "reddit": "ريديت", + "preferences_captions_label": "التسميات التوضيحية الإفتراضية: ", + "Fallback captions: ": "التسميات التوضيحية الاحتياطيَّة: ", + "preferences_related_videos_label": "اعرض الفيديوهات ذات الصلة: ", + "preferences_annotations_label": "اعرض الملاحظات في الفيديو تلقائيا: ", + "preferences_extend_desc_label": "توسيع وصف الفيديو تلقائيا: ", + "preferences_vr_mode_label": "مقاطع فيديو تفاعلية بزاوية 360 درجة (تتطلب WebGL): ", + "preferences_category_visual": "التفضيلات المرئية", + "preferences_player_style_label": "شكل مشغل الفيديوهات: ", + "Dark mode: ": "الوضع الليلي: ", + "preferences_dark_mode_label": "المظهر: ", "dark": "غامق (اسود)", "light": "فاتح (ابيض)", - "Thin mode: ": "الوضع الخفيف: ", - "Subscription preferences": "تفضيلات الإشتراك", - "Show annotations by default for subscribed channels: ": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ", + "preferences_thin_mode_label": "الوضع الخفيف: ", + "preferences_category_misc": "تفضيلات متنوعة", + "preferences_automatic_instance_redirect_label": "إعادة توجيه المثيل التلقائي (إعادة التوجيه إلى redirect.invidious.io): ", + "preferences_category_subscription": "تفضيلات الاشتراك", + "preferences_annotations_subscribed_label": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ", "Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ", - "Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ", - "Sort videos by: ": "ترتيب الفيديو بـ: ", - "published": "احدث فيديو", - "published - reverse": "احدث فيديو - عكسى", - "alphabetically": "ترتيب ابجدى", - "alphabetically - reverse": "ابجدى - عكسى", - "channel name": "بإسم القناة", - "channel name - reverse": "بإسم القناة - عكسى", - "Only show latest video from channel: ": "فقط إظهر اخر فيديو من القناة: ", - "Only show latest unwatched video from channel: ": "فقط اظهر اخر فيديو لم يتم رؤيتة من القناة: ", - "Only show unwatched: ": "فقط اظهر الذى لم يتم رؤيتة: ", - "Only show notifications (if there are any): ": "إظهار الإشعارات فقط (إذا كان هناك أي): ", + "preferences_max_results_label": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ", + "preferences_sort_label": "ترتيب الفيديوهات بـ: ", + "published": "أحدث فيديو", + "published - reverse": "أحدث فيديو - عكسي", + "alphabetically": "ترتيب أبجدي", + "alphabetically - reverse": "أبجدي - عكسي", + "channel name": "باسم القناة", + "channel name - reverse": "باسم القناة - عكسى", + "Only show latest video from channel: ": "فقط أظهر آخر فيديو من القناة: ", + "Only show latest unwatched video from channel: ": "فقط أظهر آخر فيديو لم يتم رؤيته من القناة: ", + "preferences_unseen_only_label": "فقط أظهر الذي لم يتم رؤيته: ", + "preferences_notifications_only_label": "إظهار الإشعارات فقط (إذا كان هناك أي): ", "Enable web notifications": "تفعيل إشعارات المتصفح", "`x` uploaded a video": "`x` رفع فيديو", - "`x` is live": "`x` فى بث مباشر", - "Data preferences": "إعدادات التفضيلات", + "`x` is live": "`x` في بث مباشر", + "preferences_category_data": "إعدادات التفضيلات", "Clear watch history": "حذف سجل المشاهدة", - "Import/export data": "إضافة\\إستخراج البيانات", - "Change password": "غير الرقم السرى", - "Manage subscriptions": "إدارة المشتركين", + "Import/export data": "إستيراد و تصدير البيانات", + "Change password": "تغير كلمة السر", + "Manage subscriptions": "إدارة الاشتراكات", "Manage tokens": "إدارة الرموز", "Watch history": "سجل المشاهدة", "Delete account": "حذف الحساب", - "Administrator preferences": "إعدادات المدير", - "Default homepage: ": "الصفحة الرئيسية الافتراضية ", - "Feed menu: ": "قائمة التدفقات: ", + "preferences_category_admin": "إعدادات المدير", + "preferences_default_home_label": "الصفحة الرئيسية الافتراضية: ", + "preferences_feed_menu_label": "قائمة التدفقات: ", + "preferences_show_nick_label": "إظهار اللقب في الأعلى: ", "Top enabled: ": "تفعيل 'الأفضل' ؟ ", "CAPTCHA enabled: ": "تفعيل الكابتشا: ", - "Login enabled: ": "تفعيل الولوج: ", + "Login enabled: ": "تمكين تسجيل الدخول: ", "Registration enabled: ": "تفعيل التسجيل: ", - "Report statistics: ": "الإبلاغ عن الإحصائيات: ", - "Save preferences": "حفظ التفضيلات", - "Subscription manager": "مدير الإشتراكات", + "Report statistics: ": "تقرير الإحصائيات: ", + "Save preferences": "حفظ الإعدادات", + "Subscription manager": "مدير الاشتراكات", "Token manager": "إداره الرمز", "Token": "الرمز", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشتركين", - "": "`x` مشتركين" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` رموز", - "": "`x` رموز" - }, - "Import/export": "إضافة\\إستخراج", - "unsubscribe": "إلغاء الإشتراك", + "Import/export": "استيراد/تصدير", + "unsubscribe": "إلغاء الاشتراك", "revoke": "مسح", - "Subscriptions": "الإشتراكات", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` إشعارات لم تشاهدها بعد", - "": "`x` إشعارات لم تشاهدها بعد" - }, + "Subscriptions": "الاشتراكات", "search": "بحث", "Log out": "تسجيل الخروج", - "Released under the AGPLv3 by Omar Roth.": "تم الإنشاء تحت AGPLv3 بواسطة عمر روث.", + "Released under the AGPLv3 on Github.": "صدر تحت AGPLv3 على GitHub.", "Source available here.": "الأكواد متوفرة هنا.", "View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.", "View privacy policy.": "عرض سياسة الخصوصية.", @@ -151,93 +128,77 @@ "Private": "خاص", "View all playlists": "عرض جميع قوائم التشغيل", "Updated `x` ago": "تم تحديثه منذ `x`", - "Delete playlist `x`?": "حذف قائمه التشغيل `x` ?", - "Delete playlist": "حذف قائمه التغشيل", - "Create playlist": "إنشاء قائمه تشغيل", + "Delete playlist `x`?": "حذف قائمة التشغيل `x`؟", + "Delete playlist": "حذف قائمة التغشيل", + "Create playlist": "إنشاء قائمة تشغيل", "Title": "العنوان", - "Playlist privacy": "إعدادات الخصوصيه", - "Editing playlist `x`": "تعديل قائمه التشفيل `x`", - "Show more": "", - "Show less": "", + "Playlist privacy": "إعدادات الخصوصية", + "Editing playlist `x`": "تعديل قائمة التشغيل `x`", + "Show more": "عرض المزيد", + "Show less": "عرض اقل", "Watch on YouTube": "مشاهدة الفيديو على اليوتيوب", - "Hide annotations": "إخفاء الملاحظات فى الفيديو", - "Show annotations": "عرض الملاحظات فى الفيديو", + "Switch Invidious Instance": "تبديل المثيل Invidious", + "Hide annotations": "إخفاء الملاحظات في الفيديو", + "Show annotations": "عرض الملاحظات في الفيديو", "Genre: ": "النوع: ", "License: ": "التراخيص: ", - "Family friendly? ": "محتوى عائلى? ", + "Family friendly? ": "محتوى عائلي؟ ", "Wilson score: ": "درجة ويلسون: ", - "Engagement: ": "نسبة المشاركة (عدد المشاهدات\\عدد الإعجابات): ", + "Engagement: ": "نسبة التفاعل: ", "Whitelisted regions: ": "الدول المسموح فيها هذا الفيديو: ", - "Blacklisted regions: ": "الدول الحظور فيها هذا الفيديو: ", - "Shared `x`": "شارك منذ `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشاهدات", - "": "`x` مشاهدات" - }, + "Blacklisted regions: ": "الدول المحظور فيها هذا الفيديو: ", + "Shared `x`": "تمت المشاركة في `x`", "Premieres in `x`": "يعرض فى `x`", "Premieres `x`": "يعرض `x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "اهلا! يبدو ان الجافاسكريبت معطلة. اضغط هنا لعرض التعليقات, ضع فى إعتبارك انها ستأخذ وقت اطول للعرض.", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "أهلًا! يبدو أن جافاسكريبت معطلٌ لديك. اضغط هنا لعرض التعليقات، وَضَع في اعتبارك أنها ستأخذ وقتًا أطول للتحميل.", "View YouTube comments": "عرض تعليقات اليوتيوب", - "View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع Reddit", + "View more comments on Reddit": "عرض المزيد من التعليقات على\\من موقع ريديت", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` تعليقات", - "": "عرض `x` تعليقات" + "": "عرض `x` تعليقات." }, - "View Reddit comments": "عرض تعليقات ريدإت Reddit", + "View Reddit comments": "عرض تعليقات ريديت", "Hide replies": "إخفاء الردود", "Show replies": "عرض الردود", - "Incorrect password": "الرقم السرى غير صحيح", - "Quota exceeded, try again in a few hours": "تم تجاوز عدد المرات المسموح بها, حاول مرة اخرى بعد عدة ساعات", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "غير قادر على تسجيل الدخول, تأكد من تشغيل المصادقة الثنائية 2FA.", - "Invalid TFA code": "كود مصادقة ثنائية 2FA غير صحيح", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "لم يتم تسجيل الدخول. هذا ربما بسبب ان المصادقة الثنائية 2FA معطلة فى حسابك.", + "Incorrect password": "كلمة السر غير صحيحة", "Wrong answer": "إجابة خاطئة", "Erroneous CAPTCHA": "الكابتشا CAPTCHA غير صاحلة", "CAPTCHA is a required field": "مكان الكابتشا CAPTCHA مطلوب", - "User ID is a required field": "مكان إسم المستخدم مطلوب", - "Password is a required field": "مكان الرقم السرى مطلوب", - "Wrong username or password": "إسم المستخدم او الرقم السرى غير صحيح", - "Please sign in using 'Log in with Google'": "الرجاء تسجيل الدخول 'تسجيل الدخول بواسطة جوجل'", - "Password cannot be empty": "الرقم السرى لايمكن ان يكون فارغ", - "Password cannot be longer than 55 characters": "الرقم السرى لا يتعدى 55 حرف", + "User ID is a required field": "مكان اسم المستخدم مطلوب", + "Password is a required field": "مكان كلمة السر مطلوب", + "Wrong username or password": "اسم المستخدم او كلمة السر غير صحيح", + "Password cannot be empty": "لا يمكن أن تكون كلمة السر فارغة", + "Password cannot be longer than 55 characters": "يجب أن لا تتعدى كلمة السر 55 حرفًا", "Please log in": "الرجاء تسجيل الدخول", - "Invidious Private Feed for `x`": "صفحة Invidious للمشتركين الخاصة\\مخفية لـ `x`", + "Invidious Private Feed for `x`": "تغذية Invidious خاصة ل `x`", "channel:`x`": "قناة:`x`", "Deleted or invalid channel": "قناة ممسوحة او غير صالحة", - "This channel does not exist.": "القناة غير موجودة.", - "Could not get channel info.": "لم يستطع الحصول على معلومات القناة.", - "Could not fetch comments": "لم يتمكن من إحضار التعليقات", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "عرض `x` ردود", - "": "عرض `x` ردود" - }, + "This channel does not exist.": "هذه القناة غير موجودة.", + "Could not get channel info.": "لم يتمكن الحصول على معلومات القناة.", + "Could not fetch comments": "لا يتمكن إحضار التعليقات", "`x` ago": "`x` منذ", - "Load more": "عرض المزيد", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` نقاط", - "": "`x` نقاط" - }, - "Could not create mix.": "لم يستطع عمل خلط.", + "Load more": "تحميل المزيد", + "Could not create mix.": "تعذر إنشاء مزيج.", "Empty playlist": "قائمة التشغيل فارغة", "Not a playlist.": "قائمة التشغيل غير صالحة.", "Playlist does not exist.": "قائمة التشغيل غير موجودة.", - "Could not pull trending pages.": "لم يستطع عرض الصفحات الراجئة.", - "Hidden field \"challenge\" is a required field": "مكان مخفى \"تحدى\" مكان مطلوب", - "Hidden field \"token\" is a required field": "مكان مخفى \"رمز\" مكان مطلوب", - "Erroneous challenge": "تحدى غير صالح", - "Erroneous token": "روز غير صالح", - "No such user": "مستخدم غير صالح", - "Token is expired, please try again": "الرمز منتهى الصلاحية , الرجاء المحاولة مرة اخرى", - "English": "إنجليزى", - "English (auto-generated)": "إنجليزى (تم إنشائة تلقائى)", + "Could not pull trending pages.": "لا يتمكن عرض الصفحات الراجئة.", + "Hidden field \"challenge\" is a required field": "الحقل المخفي \"تحدي\" حقل مطلوب", + "Hidden field \"token\" is a required field": "الحقل المخفي \"رمز\" حقل مطلوب", + "Erroneous challenge": "تحدي خاطئ", + "Erroneous token": "رمز مميز خاطئ", + "No such user": "مستخدم غير موجود", + "Token is expired, please try again": "الرمز منتهى الصلاحية، الرجاء المحاولة مرة اخرى", + "English": "إنجليزي", + "English (auto-generated)": "إنجليزي (تم إنشائه تلقائيًا)", "Afrikaans": "الأفريكانية", "Albanian": "الألبانية", "Amharic": "الأمهرية", "Arabic": "العربية", - "Armenian": "الأرميني", - "Azerbaijani": "أذربيجان", + "Armenian": "الأرمينية", + "Azerbaijani": "أذربيجانية", "Bangla": "البنغالية", - "Basque": "الباسكي", + "Basque": "الباسكية", "Belarusian": "البيلاروسية", "Bosnian": "البوسنية", "Bulgarian": "البلغارية", @@ -334,41 +295,13 @@ "Yiddish": "اليديشية", "Yoruba": "اليوروبا", "Zulu": "الزولو", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` سنوات", - "": "`x` سنوات" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` شهور", - "": "`x` شهور" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` اسابيع", - "": "`x` اسابيع" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ايام", - "": "`x` ايام" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ساعات", - "": "`x` ساعات" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` دقائق", - "": "`x` دقائق" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ثوانى", - "": "`x` ثوانى" - }, "Fallback comments: ": "التعليقات البديلة: ", "Popular": "الأكثر شعبية", - "Search": "", + "Search": "بحث", "Top": "الأفضل", "About": "حول", "Rating: ": "التقييم: ", - "Language: ": "اللغة: ", + "preferences_locale_label": "اللغة: ", "View as playlist": "عرض كا قائمة التشغيل", "Default": "الكل", "Music": "الاغانى", @@ -376,43 +309,260 @@ "News": "الأخبار", "Movies": "الأفلام", "Download": "نزّل", - "Download as: ": "نزّله كـ: ", + "Download as: ": "نزله كـ: ", "%A %B %-d, %Y": "%A %-d %B %Y", - "(edited)": "(تم تعديلة)", + "(edited)": "(معدّل)", "YouTube comment permalink": "رابط التعليق على اليوتيوب", "permalink": "الرابط", - "`x` marked it with a ❤": "`x` اعجب بهذا", - "Audio mode": "الوضع الصوتى", + "`x` marked it with a ❤": "`x` أعجب بهذا", + "Audio mode": "الوضع الصوتي", "Video mode": "وضع الفيديو", - "Videos": "الفيديوهات", + "channel_tab_videos_label": "الفيديوهات", "Playlists": "قوائم التشغيل", - "Community": "المجتمع", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "الإصدار الحالي: " + "channel_tab_community_label": "المجتمع", + "search_filters_sort_option_relevance": "ملائمة", + "search_filters_sort_option_rating": "تقييم", + "search_filters_sort_option_date": "التاريخ", + "search_filters_sort_option_views": "مشاهدات", + "search_filters_type_label": "نوع المحتوى", + "search_filters_duration_label": "المدة", + "search_filters_features_label": "الميزات", + "search_filters_sort_label": "فرز", + "search_filters_date_option_hour": "آخر ساعة", + "search_filters_date_option_today": "اليوم", + "search_filters_date_option_week": "هذا الأسبوع", + "search_filters_date_option_month": "هذا الشهر", + "search_filters_date_option_year": "هذه السنة", + "search_filters_type_option_video": "فيديو", + "search_filters_type_option_channel": "قناة", + "search_filters_type_option_playlist": "قائمة التشغيل", + "search_filters_type_option_movie": "فيلم", + "search_filters_type_option_show": "عرض", + "search_filters_features_option_hd": "عالية الدقة", + "search_filters_features_option_subtitles": "ترجمات", + "search_filters_features_option_c_commons": "المشاع الإبداعي", + "search_filters_features_option_three_d": "ثلاثي الأبعاد", + "search_filters_features_option_live": "مباشر", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "المكان", + "search_filters_features_option_hdr": "وضع التباين العالي", + "Current version: ": "الإصدار الحالي: ", + "next_steps_error_message": "بعد ذلك يجب أن تحاول: ", + "next_steps_error_message_refresh": "تحديث", + "next_steps_error_message_go_to_youtube": "انتقل إلى يوتيوب", + "search_filters_duration_option_short": "قصير (< 4 دقائق)", + "search_filters_duration_option_long": "طويل (> 20 دقيقة)", + "footer_source_code": "الكود المصدر", + "footer_original_source_code": "الكود المصدر الأصلي", + "footer_modfied_source_code": "الكود المصدر المعدل", + "adminprefs_modified_source_code_url_label": "URL إلى مستودع الكود المصدر المعدل", + "footer_documentation": "التوثيق", + "footer_donate_page": "تبرّع", + "preferences_region_label": "بلد المحتوى: ", + "preferences_quality_dash_label": "جودة فيديو DASH المفضلة: ", + "preferences_quality_option_dash": "DASH (الجودة التلقائية)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "متوسطة", + "preferences_quality_option_small": "صغيرة", + "preferences_quality_dash_option_auto": "تلقائي", + "preferences_quality_dash_option_best": "الأفضل", + "preferences_quality_dash_option_worst": "أسوأ", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "search_filters_features_option_purchased": "تم شراؤها", + "none": "لاشيء", + "videoinfo_started_streaming_x_ago": "بدأ البث منذ `x`", + "videoinfo_watch_on_youTube": "مشاهدة على يوتيوب", + "videoinfo_youTube_embed_link": "مضمن", + "videoinfo_invidious_embed_link": "رابط مضمن", + "user_created_playlists": "`x` إنشاء قوائم التشغيل", + "user_saved_playlists": "قوائم التشغيل المحفوظة `x`", + "Video unavailable": "الفيديو غير متوفر", + "search_filters_features_option_three_sixty": "360°", + "download_subtitles": "ترجمات - `x` (.vtt)", + "invidious": "الخيالي", + "preferences_save_player_pos_label": "حفظ موضع التشغيل: ", + "crash_page_you_found_a_bug": "يبدو أنك قد وجدت خطأً برمجيًّا في Invidious!", + "generic_videos_count_0": "لا يوجد فيديوهات", + "generic_videos_count_1": "فيديو واحد", + "generic_videos_count_2": "فيديوهين", + "generic_videos_count_3": "{{count}} فيديوهات", + "generic_videos_count_4": "{{count}} فيديو", + "generic_videos_count_5": "{{count}} فيديو", + "generic_subscribers_count_0": "لا يوجد مشترك", + "generic_subscribers_count_1": "مشترك واحد", + "generic_subscribers_count_2": "مشتركان", + "generic_subscribers_count_3": "{{count}} مشتركين", + "generic_subscribers_count_4": "{{count}} مشترك", + "generic_subscribers_count_5": "{{count}} مشترك", + "generic_views_count_0": "لا يوجد مشاهدة", + "generic_views_count_1": "مشاهدة واحدة", + "generic_views_count_2": "مشاهدتان", + "generic_views_count_3": "{{count}} مشاهدات", + "generic_views_count_4": "{{count}} مشاهدة", + "generic_views_count_5": "{{count}} مشاهدة", + "generic_subscriptions_count_0": "لا يوجد اشتراك", + "generic_subscriptions_count_1": "اشتراك واحد", + "generic_subscriptions_count_2": "اشتراكان", + "generic_subscriptions_count_3": "{{count}} اشتراكات", + "generic_subscriptions_count_4": "{{count}} اشتراك", + "generic_subscriptions_count_5": "{{count}} اشتراك", + "generic_playlists_count_0": "لا يوجد قوائم تشغيل", + "generic_playlists_count_1": "قائمة تشغيل واحدة", + "generic_playlists_count_2": "قائمتا تشغيل", + "generic_playlists_count_3": "{{count}} قوائم تشغيل", + "generic_playlists_count_4": "{{count}} قائمة تشغيل", + "generic_playlists_count_5": "{{count}} قائمة تشغيل", + "English (United States)": "الإنجليزية (الولايات المتحدة)", + "Indonesian (auto-generated)": "إندونيسي (مُنشأ تلقائيًا)", + "Interlingue": "إنترلينغوي", + "Italian (auto-generated)": "الإيطالية (مُنشأة تلقائيًا)", + "Spanish (auto-generated)": "الأسبانية (تم إنشاؤه تلقائيًا)", + "crash_page_before_reporting": "قبل الإبلاغ عن خطأ، تأكد من وجود:", + "French (auto-generated)": "الفرنسية (مُنشأة تلقائيًا)", + "Portuguese (auto-generated)": "البرتغالية (تم إنشاؤه تلقائيًا)", + "Turkish (auto-generated)": "التركية (تم إنشاؤها تلقائيًا)", + "crash_page_refresh": "حاول <a href=\"`x`\"> تحديث الصفحة </a>", + "crash_page_switch_instance": "حاول <a href=\"`x`\"> استخدام مثيل آخر </a>", + "Korean (auto-generated)": "كوري (تم إنشاؤه تلقائيًا)", + "Spanish (Mexico)": "الإسبانية (المكسيك)", + "Vietnamese (auto-generated)": "فيتنامي (تم إنشاؤه تلقائيًا)", + "crash_page_report_issue": "إذا لم يساعد أي مما سبق، يرجى فتح <a href=\"`x`\"> مشكلة جديدة على GitHub </a> (ويفضل أن يكون باللغة الإنجليزية) وتضمين النص التالي في رسالتك (لا تترجم هذا النص):", + "crash_page_read_the_faq": "قراءة <a href=\"`x`\"> الأسئلة المتكررة (الأسئلة الشائعة) </a>", + "preferences_watch_history_label": "تمكين سجل المشاهدة: ", + "English (United Kingdom)": "الإنجليزية (المملكة المتحدة)", + "Cantonese (Hong Kong)": "الكانتونية (هونغ كونغ)", + "Chinese": "الصينية", + "Chinese (China)": "الصينية (الصين)", + "Chinese (Hong Kong)": "الصينية (هونج كونج)", + "Chinese (Taiwan)": "الصينية (تايوان)", + "Dutch (auto-generated)": "هولندي (تم إنشاؤه تلقائيًا)", + "German (auto-generated)": "ألماني (تم إنشاؤه تلقائيًا)", + "Japanese (auto-generated)": "اليابانية (مُنشأة تلقائيًا)", + "Portuguese (Brazil)": "البرتغالية (البرازيل)", + "Russian (auto-generated)": "الروسية (منشأة تلقائيا)", + "Spanish (Spain)": "الإسبانية (إسبانيا)", + "crash_page_search_issue": "بحثت عن <a href=\"`x`\"> المشكلات الموجودة على GitHub </a>", + "search_filters_title": "معامل الفرز", + "search_message_no_results": "لا توجد نتائج.", + "search_message_change_filters_or_query": "حاول توسيع استعلام البحث و / أو تغيير عوامل التصفية.", + "search_filters_date_label": "تاريخ الرفع", + "generic_count_weeks_0": "{{count}} أسبوع", + "generic_count_weeks_1": "أسبوع واحد", + "generic_count_weeks_2": "أسبوعين", + "generic_count_weeks_3": "{{count}} أسابيع", + "generic_count_weeks_4": "{{count}} أسبوع", + "generic_count_weeks_5": "{{count}} أسبوع", + "Popular enabled: ": "تم تمكين الشعبية: ", + "search_filters_duration_option_medium": "متوسط (4-20 دقيقة)", + "search_filters_date_option_none": "أي تاريخ", + "search_filters_type_option_all": "أي نوع", + "search_filters_features_option_vr180": "VR180", + "generic_count_minutes_0": "{{count}} دقيقة", + "generic_count_minutes_1": "دقيقة واحدة", + "generic_count_minutes_2": "دقيقتين", + "generic_count_minutes_3": "{{count}} دقائق", + "generic_count_minutes_4": "{{count}} دقيقة", + "generic_count_minutes_5": "{{count}} دقيقة", + "generic_count_hours_0": "{{count}} ساعة", + "generic_count_hours_1": "ساعة واحدة", + "generic_count_hours_2": "ساعتين", + "generic_count_hours_3": "{{count}} ساعات", + "generic_count_hours_4": "{{count}} ساعة", + "generic_count_hours_5": "{{count}} ساعة", + "comments_view_x_replies_0": "عرض رد {{count}}", + "comments_view_x_replies_1": "عرض رد {{count}}", + "comments_view_x_replies_2": "عرض رد {{count}}", + "comments_view_x_replies_3": "عرض رد {{count}}", + "comments_view_x_replies_4": "عرض الردود {{count}}", + "comments_view_x_replies_5": "عرض رد {{count}}", + "search_message_use_another_instance": "يمكنك أيضًا البحث عن <a href=\"`x`\"> في مثيل آخر </a>.", + "comments_points_count_0": "{{count}} نقطة", + "comments_points_count_1": "نقطة واحدة", + "comments_points_count_2": "نقطتان", + "comments_points_count_3": "{{count}} نقط", + "comments_points_count_4": "{{count}} نقطة", + "comments_points_count_5": "{{count}} نقطة", + "generic_count_years_0": "{{count}} السنة", + "generic_count_years_1": "{{count}} السنة", + "generic_count_years_2": "{{count}} السنة", + "generic_count_years_3": "{{count}} السنة", + "generic_count_years_4": "{{count}} سنوات", + "generic_count_years_5": "{{count}} السنة", + "tokens_count_0": "الرمز المميز {{count}}", + "tokens_count_1": "الرمز المميز {{count}}", + "tokens_count_2": "الرمز المميز {{count}}", + "tokens_count_3": "الرمز المميز {{count}}", + "tokens_count_4": "الرموز المميزة {{count}}", + "tokens_count_5": "الرمز المميز {{count}}", + "search_filters_apply_button": "تطبيق الفلاتر المحددة", + "search_filters_duration_option_none": "أي مدة", + "subscriptions_unseen_notifs_count_0": "{{count}} إشعار جديد", + "subscriptions_unseen_notifs_count_1": "إشعار واحد جديد", + "subscriptions_unseen_notifs_count_2": "إشعارين جديدين", + "subscriptions_unseen_notifs_count_3": "{{count}} إشعارات جديدة", + "subscriptions_unseen_notifs_count_4": "{{count}} إشعارا جديد", + "subscriptions_unseen_notifs_count_5": "{{count}} إشعار جديد", + "generic_count_days_0": "{{count}} يوم", + "generic_count_days_1": "يوم واحد", + "generic_count_days_2": "يومين", + "generic_count_days_3": "{{count}} أيام", + "generic_count_days_4": "{{count}} يوم", + "generic_count_days_5": "{{count}} يوم", + "generic_count_months_0": "{{count}} شهر", + "generic_count_months_1": "{{count}} شهر", + "generic_count_months_2": "{{count}} شهر", + "generic_count_months_3": "{{count}} شهر", + "generic_count_months_4": "{{count}} شهور", + "generic_count_months_5": "{{count}} شهر", + "generic_count_seconds_0": "{{count}} ثانية", + "generic_count_seconds_1": "ثانية واحدة", + "generic_count_seconds_2": "ثانيتين", + "generic_count_seconds_3": "{{count}} ثوانٍ", + "generic_count_seconds_4": "{{count}} ثانية", + "generic_count_seconds_5": "{{count}} ثانية", + "error_video_not_in_playlist": "الفيديو المطلوب غير موجود في قائمة التشغيل هذه. <a href=\"`x`\"> انقر هنا للحصول على الصفحة الرئيسية لقائمة التشغيل. </a>", + "channel_tab_shorts_label": "الفيديوهات القصيرة", + "channel_tab_streams_label": "البث المباشر", + "channel_tab_playlists_label": "قوائم التشغيل", + "channel_tab_channels_label": "القنوات", + "Music in this video": "الموسيقى في هذا الفيديو", + "Album: ": "الألبوم: ", + "Artist: ": "الفنان: ", + "Song: ": "أغنية: ", + "Channel Sponsor": "راعي القناة", + "Standard YouTube license": "ترخيص YouTube القياسي", + "Download is disabled": "تم تعطيل التحميلات", + "Import YouTube playlist (.csv)": "استيراد قائمة تشغيل YouTube (.csv)", + "generic_button_save": "حفظ", + "generic_button_delete": "حذف", + "generic_button_edit": "تحرير", + "generic_button_cancel": "الغاء", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "الإصدارات", + "playlist_button_add_items": "إضافة مقاطع فيديو", + "channel_tab_podcasts_label": "البودكاست", + "generic_channels_count_0": "{{count}} قناة", + "generic_channels_count_1": "{{count}} قناة", + "generic_channels_count_2": "{{count}} قناتان", + "generic_channels_count_3": "{{count}} قنوات", + "generic_channels_count_4": "{{count}} قنوات", + "generic_channels_count_5": "{{count}} قناة", + "Import YouTube watch history (.json)": "استيراد سجل مشاهدة YouTube بصيغة (.json)", + "toggle_theme": "تبديل الموضوع", + "Add to playlist": "أضف إلى قائمة التشغيل", + "Add to playlist: ": "أضف إلى قائمة التشغيل: ", + "Answer": "الرد", + "Search for videos": "ابحث عن مقاطع الفيديو", + "The Popular feed has been disabled by the administrator.": "تم تعطيل الخلاصة الشائعة من قبل المسؤول.", + "carousel_slide": "الشريحة {{current}} من {{total}}", + "carousel_skip": "تخطي الكاروسيل", + "carousel_go_to": "انتقل إلى الشريحة `x`" } diff --git a/locales/az.json b/locales/az.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/az.json @@ -0,0 +1 @@ +{} diff --git a/locales/be.json b/locales/be.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/be.json @@ -0,0 +1 @@ +{} diff --git a/locales/bg.json b/locales/bg.json new file mode 100644 index 00000000..baa683c9 --- /dev/null +++ b/locales/bg.json @@ -0,0 +1,497 @@ +{ + "Korean (auto-generated)": "Корейски (автоматично генерирано)", + "search_filters_features_option_three_sixty": "360°", + "published - reverse": "публикувани - в обратен ред", + "preferences_quality_dash_option_worst": "Най-ниско качество", + "Password is a required field": "Парола е задължитело поле", + "channel_tab_podcasts_label": "Подкасти", + "Token is expired, please try again": "Токенът е изтекъл, моля опитайте отново", + "Turkish": "Турски", + "preferences_save_player_pos_label": "Запази позицията на плейъра: ", + "View Reddit comments": "Виж Reddit коментари", + "Export data as JSON": "Експортиране на Invidious информацията като JSON", + "About": "За сайта", + "Save preferences": "Запази промените", + "Load more": "Зареди още", + "Import/export": "Импортиране/експортиране", + "Albanian": "Албански", + "New password": "Нова парола", + "Southern Sotho": "Южен Сото", + "channel_tab_videos_label": "Видеа", + "Spanish (Mexico)": "Испански (Мексико)", + "preferences_player_style_label": "Стил на плейъра: ", + "preferences_region_label": "Държавата на съдържанието: ", + "Premieres in `x`": "Премиера в `x`", + "Watch history": "История на гледане", + "generic_subscriptions_count": "{{count}} абонамент", + "generic_subscriptions_count_plural": "{{count}} абонамента", + "preferences_continue_label": "Пускай следващото видео автоматично: ", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Здравей! Изглежда си изключил JavaScript. Натисни тук за да видиш коментарите, но обърни внимание, че може да отнеме повече време да заредят.", + "Polish": "Полски", + "Icelandic": "Исландски", + "preferences_local_label": "Пускане на видеа през прокси: ", + "Hebrew": "Иврит", + "Fallback captions: ": "Резервни надписи: ", + "search_filters_title": "Филтри", + "search_filters_apply_button": "Приложете избрани филтри", + "Download is disabled": "Изтеглянето е деактивирано", + "User ID is a required field": "Потребителско име е задължително поле", + "comments_points_count": "{{count}} точка", + "comments_points_count_plural": "{{count}} точки", + "next_steps_error_message_go_to_youtube": "Отидеш в YouTube", + "preferences_quality_dash_option_2160p": "2160p", + "search_filters_type_option_video": "Видео", + "Spanish (Latin America)": "Испански (Латинска Америка)", + "Download as: ": "Изтегли като: ", + "Default": "По подразбиране", + "search_filters_sort_option_views": "Гледания", + "search_filters_features_option_four_k": "4K", + "Igbo": "Игбо", + "Subscriptions": "Абонаменти", + "German (auto-generated)": "Немски (автоматично генерирано)", + "`x` is live": "`x` е на живо", + "Azerbaijani": "Азербайджански", + "Premieres `x`": "Премиера `x`", + "Japanese (auto-generated)": "Японски (автоматично генерирано)", + "preferences_quality_option_medium": "Средно", + "footer_donate_page": "Даряване", + "Show replies": "Покажи отговорите", + "Esperanto": "Есперанто", + "search_message_change_filters_or_query": "Опитай да разшириш търсенето си и/или да смениш филтрите.", + "CAPTCHA enabled: ": "Активиране на CAPTCHA: ", + "View playlist on YouTube": "Виж плейлиста в YouTube", + "crash_page_before_reporting": "Преди докладването на бъг, бъди сигурен, че си:", + "Top enabled: ": "Активиране на страница с топ видеа: ", + "preferences_quality_dash_option_best": "Най-високо", + "search_filters_duration_label": "Продължителност", + "Slovak": "Словашки", + "Channel Sponsor": "Канален спонсор", + "generic_videos_count": "{{count}} видео", + "generic_videos_count_plural": "{{count}} видеа", + "videoinfo_started_streaming_x_ago": "Започна да излъчва преди `x`", + "videoinfo_youTube_embed_link": "Вграждане", + "channel_tab_streams_label": "Стриймове", + "oldest": "най-стари", + "playlist_button_add_items": "Добавяне на видеа", + "Import NewPipe data (.zip)": "Импортиране на NewPipe информация (.zip)", + "Clear watch history": "Изчистване на историята на гледане", + "generic_count_minutes": "{{count}} минута", + "generic_count_minutes_plural": "{{count}} минути", + "published": "публикувани", + "Show annotations": "Покажи анотации", + "Login enabled: ": "Активиране на впизване: ", + "Somali": "Сомалийски", + "YouTube comment permalink": "Постоянна връзка на коментарите на YouTube", + "Kurdish": "Кюрдски", + "search_filters_date_option_hour": "Последния час", + "Lao": "Лаоски", + "Maltese": "Малтийски", + "Register": "Регистрация", + "View channel on YouTube": "Виж канала в YouTube", + "Playlist privacy": "Поверителен плейлист", + "preferences_unseen_only_label": "Показвай само негледаните: ", + "Gujarati": "Гуджарати", + "Please log in": "Моля влезте", + "search_filters_sort_option_rating": "Рейтинг", + "Manage subscriptions": "Управление на абонаментите", + "preferences_quality_dash_option_720p": "720p", + "preferences_watch_history_label": "Активирай историята на гледане: ", + "user_saved_playlists": "`x` запази плейлисти", + "preferences_extend_desc_label": "Автоматично разшири описанието на видеото ", + "preferences_max_results_label": "Брой видеа показани на началната страница: ", + "Spanish (Spain)": "Испански (Испания)", + "invidious": "Invidious", + "crash_page_refresh": "пробвал да <a href=\"`x`\">опресниш страницата</a>", + "Image CAPTCHA": "CAPTCHA с Изображение", + "search_filters_features_option_hd": "HD", + "Chinese (Hong Kong)": "Китайски (Хонг Конг)", + "Import Invidious data": "Импортиране на Invidious JSON информацията", + "Blacklisted regions: ": "Неразрешени региони: ", + "Only show latest video from channel: ": "Показвай само най-новите видеа в канала: ", + "Hmong": "Хмонг", + "French": "Френски", + "search_filters_type_option_channel": "Канал", + "Artist: ": "Артист: ", + "generic_count_months": "{{count}} месец", + "generic_count_months_plural": "{{count}} месеца", + "preferences_annotations_subscribed_label": "Показвай анотаций по подразбиране за абонирани канали? ", + "search_message_use_another_instance": " Можеш също да <a href=\"`x`\">търсиш на друга инстанция</a>.", + "Danish": "Датски", + "generic_subscribers_count": "{{count}} абонат", + "generic_subscribers_count_plural": "{{count}} абоната", + "Galician": "Галисий", + "newest": "най-нови", + "Empty playlist": "Плейлиста е празен", + "download_subtitles": "Субритри - `x` (.vtt)", + "preferences_category_misc": "Различни предпочитания", + "Uzbek": "Узбекски", + "View JavaScript license information.": "Виж Javascript лиценза.", + "Filipino": "Филипински", + "Malagasy": "Мадагаскарски", + "generic_button_save": "Запиши", + "Dark mode: ": "Тъмен режим: ", + "Public": "Публичен", + "Basque": "Баскски", + "channel:`x`": "Канал:`x`", + "Armenian": "Арменски", + "This channel does not exist.": "Този канал не съществува.", + "Luxembourgish": "Люксембургски", + "preferences_related_videos_label": "Покажи подобни видеа: ", + "English": "Английски", + "Delete account": "Изтриване на акаунт", + "Gaming": "Игри", + "Video mode": "Видео режим", + "preferences_dark_mode_label": "Тема: ", + "crash_page_search_issue": "потърсил за <a href=\"`x`\">съществуващи проблеми в GitHub</a>", + "preferences_category_subscription": "Предпочитания за абонаменти", + "last": "най-скорощни", + "Chinese (Simplified)": "Китайски (Опростен)", + "Could not create mix.": "Създаването на микс е неуспешно.", + "generic_button_cancel": "Отказ", + "search_filters_type_option_movie": "Филм", + "search_filters_date_option_year": "Тази година", + "Swedish": "Шведски", + "Previous page": "Предишна страница", + "none": "нищо", + "popular": "най-популярни", + "Unsubscribe": "Отписване", + "Slovenian": "Словенски", + "Nepali": "Непалски", + "Time (h:mm:ss):": "Време (h:mm:ss):", + "English (auto-generated)": "Английски (автоматично генерирано)", + "search_filters_sort_label": "Сортирай по", + "View more comments on Reddit": "Виж повече коментари в Reddit", + "Sinhala": "Синхалски", + "preferences_feed_menu_label": "Меню с препоръки: ", + "preferences_autoplay_label": "Автоматично пускане: ", + "Pashto": "Пущунски", + "English (United States)": "Английски (САЩ)", + "Sign In": "Вход", + "subscriptions_unseen_notifs_count": "{{count}} невидяно известие", + "subscriptions_unseen_notifs_count_plural": "{{count}} невидяни известия", + "Log in": "Вход", + "Engagement: ": "Участие: ", + "Album: ": "Албум: ", + "preferences_speed_label": "Скорост по подразбиране: ", + "Import FreeTube subscriptions (.db)": "Импортиране на FreeTube абонаменти (.db)", + "preferences_quality_option_dash": "DASH (адаптивно качество)", + "preferences_show_nick_label": "Показвай потребителското име отгоре: ", + "Private": "Частен", + "Samoan": "Самоански", + "preferences_notifications_only_label": "Показвай само известията (ако има такива): ", + "Create playlist": "Създаване на плейлист", + "next_steps_error_message_refresh": "Опресниш", + "Top": "Топ", + "preferences_quality_dash_option_1080p": "1080p", + "Malayalam": "Малаялам", + "Token": "Токен", + "preferences_comments_label": "Коментари по подразбиране: ", + "Movies": "Филми", + "light": "светла", + "Unlisted": "Скрит", + "preferences_category_admin": "Администраторни предпочитания", + "Erroneous token": "Невалиден токен", + "No": "Не", + "CAPTCHA is a required field": "CAPTCHA е задължително поле", + "Video unavailable": "Неналично видео", + "footer_source_code": "Изходен код", + "New passwords must match": "Новите пароли трябва да съвпадат", + "Playlist does not exist.": "Плейлиста не съществува.", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Експортиране на абонаментите като OPML (за NewPipe и FreeTube)", + "search_filters_duration_option_short": "Кратко (< 4 минути)", + "search_filters_duration_option_long": "Дълго (> 20 минути)", + "tokens_count": "{{count}} токен", + "tokens_count_plural": "{{count}} токена", + "Yes": "Да", + "Dutch": "Холандски", + "Arabic": "Арабски", + "An alternative front-end to YouTube": "Алтернативен преден план на YouTube", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Виж `x` коментар", + "": "Виж `x` коментари" + }, + "Chinese (China)": "Китайски (Китай)", + "Italian (auto-generated)": "Италиански (автоматично генерирано)", + "alphabetically - reverse": "обратно на азбучния ред", + "channel_tab_shorts_label": "Shorts", + "`x` marked it with a ❤": "`x` го маркира със ❤", + "Current version: ": "Текуща версия: ", + "channel_tab_community_label": "Общност", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_360p": "360p", + "`x` uploaded a video": "`x` качи видео", + "Welsh": "Уелски", + "search_message_no_results": "Няма намерени резултати.", + "channel_tab_releases_label": "Версии", + "Bangla": "Бенгалски", + "preferences_quality_dash_option_144p": "144p", + "Indonesian": "Индонезийски", + "`x` ago": "преди `x`", + "Invidious Private Feed for `x`": "Invidious персонални видеа за `x`", + "Finnish": "Финландски", + "Amharic": "Амхарски", + "Malay": "Малайски", + "Interlingue": "Интерлинг", + "search_filters_date_option_month": "Този месец", + "Georgian": "Грузински", + "Xhosa": "Кхоса", + "Marathi": "Маратхи", + "Yoruba": "Йоруба", + "Song: ": "Музика: ", + "Scottish Gaelic": "Шотландски гелски", + "search_filters_features_label": "Функции", + "preferences_quality_label": "Предпочитано качество на видеото: ", + "generic_channels_count": "{{count}} канал", + "generic_channels_count_plural": "{{count}} канала", + "Croatian": "Хърватски", + "Thai": "Тайски", + "Chinese (Taiwan)": "Китайски (Тайван)", + "youtube": "YouTube", + "Source available here.": "Източник наличен тук.", + "LIVE": "На живо", + "Ukrainian": "Украински", + "Russian": "Руски", + "Tajik": "Таджикски", + "Token manager": "Управляване на токени", + "preferences_quality_dash_label": "Предпочитано DASH качество на видеото: ", + "adminprefs_modified_source_code_url_label": "URL до хранилището на променения изходен код", + "Japanese": "Японски", + "Title": "Заглавие", + "Authorize token for `x`?": "Разреши токена за `x`?", + "reddit": "Reddit", + "permalink": "постоянна връзка", + "Trending": "На върха", + "Turkish (auto-generated)": "Турски (автоматично генерирано)", + "Bulgarian": "Български", + "Indonesian (auto-generated)": "Индонезийски (автоматично генерирано)", + "Enable web notifications": "Активирай уеб известия", + "Western Frisian": "Западен фризски", + "search_filters_date_option_week": "Тази седмица", + "Yiddish": "Идиш", + "preferences_category_player": "Предпочитания за плейъра", + "Shared `x` ago": "Споделено преди `x`", + "Swahili": "Суахили", + "Portuguese (auto-generated)": "Португалски (автоматично генерирано)", + "generic_count_years": "{{count}} година", + "generic_count_years_plural": "{{count}} години", + "Wilson score: ": "Wilson оценка: ", + "Genre: ": "Жанр: ", + "videoinfo_invidious_embed_link": "Вграждане на линк", + "Popular enabled: ": "Активиране на популярната страница: ", + "Wrong username or password": "Грешно потребителско име или парола", + "Vietnamese": "Виетнамски", + "alphabetically": "по азбучен ред", + "Afrikaans": "Африкаанс", + "Zulu": "Зулуски", + "(edited)": "(редактирано)", + "Whitelisted regions: ": "Разрешени региони: ", + "Spanish (auto-generated)": "Испански (автоматично генерирано)", + "Could not fetch comments": "Получаването на коментарите е неуспешно", + "Sindhi": "Синдхи", + "News": "Новини", + "preferences_video_loop_label": "Винаги повтаряй: ", + "%A %B %-d, %Y": "%-d %B %Y, %A", + "preferences_quality_option_small": "Ниско", + "English (United Kingdom)": "Английски (Великобритания)", + "Rating: ": "Рейтинг: ", + "channel_tab_playlists_label": "Плейлисти", + "generic_button_edit": "Редактирай", + "Report statistics: ": "Активиране на статистики за репортиране: ", + "Cebuano": "Себуано", + "Chinese (Traditional)": "Китайски (Традиционен)", + "generic_playlists_count": "{{count}} плейлист", + "generic_playlists_count_plural": "{{count}} плейлиста", + "Import NewPipe subscriptions (.json)": "Импортиране на NewPipe абонаменти (.json)", + "Preferences": "Предпочитания", + "Subscribe": "Абониране", + "Import and Export Data": "Импортиране и експортиране на информация", + "preferences_quality_option_hd720": "HD720", + "search_filters_type_option_playlist": "Плейлист", + "Serbian": "Сръбски", + "Kazakh": "Казахски", + "Telugu": "Телугу", + "search_filters_features_option_purchased": "Купено", + "revoke": "отмяна", + "search_filters_sort_option_date": "Дата на качване", + "preferences_category_data": "Предпочитания за информацията", + "search_filters_date_option_none": "Всякаква дата", + "Log out": "Излизане", + "Search": "Търсене", + "preferences_quality_dash_option_auto": "Автоматично", + "dark": "тъмна", + "Cantonese (Hong Kong)": "Кантонски (Хонг Конг)", + "crash_page_report_issue": "Ако никои от горепосочените не помогнаха, моля <a href=\"`x`\">отворете нов проблем в GitHub</a> (предпочитано на Английски) и добавете следния текст в съобщението (НЕ превеждайте този текст):", + "Czech": "Чешки", + "crash_page_switch_instance": "пробвал да <a href=\"`x`\">ползваш друга инстанция</a>", + "generic_count_weeks": "{{count}} седмица", + "generic_count_weeks_plural": "{{count}} седмици", + "search_filters_features_option_subtitles": "Субтитри", + "videoinfo_watch_on_youTube": "Виж в YouTube", + "Portuguese": "Португалски", + "Music in this video": "Музика в това видео", + "Hide replies": "Скрий отговорите", + "Password cannot be longer than 55 characters": "Паролата не може да бъде по-дълга от 55 символа", + "footer_modfied_source_code": "Променен изходен код", + "Bosnian": "Босненски", + "Deleted or invalid channel": "Изтрит или невалиден канал", + "Popular": "Популярно", + "search_filters_type_label": "Тип", + "preferences_locale_label": "Език: ", + "Playlists": "Плейлисти", + "generic_button_rss": "RSS", + "Export": "Експортиране", + "preferences_quality_dash_option_4320p": "4320p", + "Erroneous challenge": "Невалиден тест", + "History": "История", + "generic_count_hours": "{{count}} час", + "generic_count_hours_plural": "{{count}} часа", + "Registration enabled: ": "Активиране на регистрация: ", + "Music": "Музика", + "Incorrect password": "Грешна парола", + "Persian": "Перскийски", + "Import": "Импортиране", + "Import/export data": "Импортиране/Експортиране на информация", + "Shared `x`": "Споделено `x`", + "Javanese": "Явански", + "French (auto-generated)": "Френски (автоматично генерирано)", + "Norwegian Bokmål": "Норвежки", + "Catalan": "Каталунски", + "Hindi": "Хинди", + "Tamil": "Тамилски", + "search_filters_features_option_live": "На живо", + "crash_page_read_the_faq": "прочел <a href=\"`x`\">Често задавани въпроси (FAQ)</a>", + "preferences_default_home_label": "Начална страница по подразбиране: ", + "Download": "Изтегляне", + "Show less": "Покажи по-малко", + "Password": "Парола", + "User ID": "Потребителско име", + "Subscription manager": "Управляване на абонаменти", + "search": "търсене", + "No such user": "Няма такъв потребител", + "View privacy policy.": "Виж политиката за поверителност.", + "Only show latest unwatched video from channel: ": "Показвай само най-новите негледани видеа в канала: ", + "user_created_playlists": "`x` създаде плейлисти", + "Editing playlist `x`": "Редактиране на плейлист `x`", + "preferences_thin_mode_label": "Тънък режим: ", + "E-mail": "Имейл", + "Haitian Creole": "Хаитянски креол", + "Irish": "Ирландски", + "channel_tab_channels_label": "Канали", + "Delete account?": "Изтрий акаунта?", + "Redirect homepage to feed: ": "Препращане на началната страница до препоръки ", + "Urdu": "Урду", + "preferences_vr_mode_label": "Интерактивни 360 градусови видеа (изисква WebGL): ", + "Password cannot be empty": "Паролата не може да бъде празна", + "Mongolian": "Монголски", + "Authorize token?": "Разреши токена?", + "search_filters_type_option_all": "Всякакъв тип", + "Romanian": "Румънски", + "Belarusian": "Беларуски", + "channel name - reverse": "име на канал - в обратен ред", + "Erroneous CAPTCHA": "Невалидна CAPTCHA", + "Watch on YouTube": "Гледай в YouTube", + "search_filters_features_option_location": "Местоположение", + "Could not pull trending pages.": "Получаването на трендинг страниците е неуспешно.", + "German": "Немски", + "search_filters_features_option_c_commons": "Creative Commons", + "Family friendly? ": "За всяка възраст? ", + "Hidden field \"token\" is a required field": "Скритото поле \"токен\" е задължително поле", + "Russian (auto-generated)": "Руски (автоматично генерирано)", + "preferences_quality_dash_option_480p": "480p", + "Corsican": "Корсикански", + "Macedonian": "Македонски", + "comments_view_x_replies": "Виж {{count}} отговор", + "comments_view_x_replies_plural": "Виж {{count}} отговора", + "footer_original_source_code": "Оригинален изходен код", + "Import YouTube subscriptions": "Импортиране на YouTube/OPML абонаменти", + "Lithuanian": "Литовски", + "Nyanja": "Нянджа", + "Updated `x` ago": "Актуализирано преди `x`", + "JavaScript license information": "Информация за Javascript лиценза", + "Spanish": "Испански", + "Latin": "Латински", + "Shona": "Шона", + "Portuguese (Brazil)": "Португалски (Бразилия)", + "Show more": "Покажи още", + "Clear watch history?": "Изчисти историята на търсене?", + "Manage tokens": "Управление на токени", + "Hausa": "Хауса", + "search_filters_features_option_vr180": "VR180", + "preferences_category_visual": "Визуални предпочитания", + "Italian": "Италиански", + "preferences_volume_label": "Сила на звука на плейъра: ", + "error_video_not_in_playlist": "Заявеното видео не съществува в този плейлист. <a href=\"`x`\">Натиснете тук за началната страница на плейлиста.</a>", + "preferences_listen_label": "Само звук по подразбиране: ", + "Dutch (auto-generated)": "Холандски (автоматично генерирано)", + "preferences_captions_label": "Надписи по подразбиране: ", + "generic_count_days": "{{count}} ден", + "generic_count_days_plural": "{{count}} дни", + "Hawaiian": "Хавайски", + "Could not get channel info.": "Получаването на информация за канала е неуспешно.", + "View as playlist": "Виж като плейлист", + "Vietnamese (auto-generated)": "Виетнамски (автоматично генерирано)", + "search_filters_duration_option_none": "Всякаква продължителност", + "preferences_quality_dash_option_240p": "240p", + "Latvian": "Латвийски", + "search_filters_features_option_hdr": "HDR", + "preferences_sort_label": "Сортирай видеата по: ", + "Estonian": "Естонски", + "Hidden field \"challenge\" is a required field": "Скритото поле \"тест\" е задължително поле", + "footer_documentation": "Документация", + "Kyrgyz": "Киргизски", + "preferences_continue_autoplay_label": "Пускай следващотото видео автоматично: ", + "Chinese": "Китайски", + "search_filters_sort_option_relevance": "Уместност", + "source": "източник", + "Fallback comments: ": "Резервни коментари: ", + "preferences_automatic_instance_redirect_label": "Автоматично препращане на инстанция (чрез redirect.invidious.io): ", + "Maori": "Маори", + "generic_button_delete": "Изтрий", + "Import YouTube playlist (.csv)": "Импортиране на YouTube плейлист (.csv)", + "Switch Invidious Instance": "Смени Invidious инстанция", + "channel name": "име на канал", + "Audio mode": "Аудио режим", + "search_filters_type_option_show": "Сериал", + "search_filters_date_option_today": "Днес", + "search_filters_features_option_three_d": "3D", + "next_steps_error_message": "След което можеш да пробваш да: ", + "Hide annotations": "Скрий анотации", + "Standard YouTube license": "Стандартен YouTube лиценз", + "Text CAPTCHA": "Текст CAPTCHA", + "Log in/register": "Вход/регистрация", + "Punjabi": "Пенджаби", + "Change password": "Смяна на паролата", + "License: ": "Лиценз: ", + "search_filters_duration_option_medium": "Средно (4 - 20 минути)", + "Delete playlist": "Изтриване на плейлист", + "Delete playlist `x`?": "Изтрий плейлиста `x`?", + "Korean": "Корейски", + "Export subscriptions as OPML": "Експортиране на абонаментите като OPML", + "unsubscribe": "отписване", + "View YouTube comments": "Виж YouTube коментарите", + "Kannada": "Каннада", + "Not a playlist.": "Невалиден плейлист.", + "Wrong answer": "Грешен отговор", + "Released under the AGPLv3 on Github.": "Публикувано под AGPLv3 в GitHub.", + "Burmese": "Бирмански", + "Sundanese": "Сундански", + "Hungarian": "Унгарски", + "generic_count_seconds": "{{count}} секунда", + "generic_count_seconds_plural": "{{count}} секунди", + "search_filters_date_label": "Дата на качване", + "Greek": "Гръцки", + "crash_page_you_found_a_bug": "Изглежда намери бъг в Invidious!", + "View all playlists": "Виж всички плейлисти", + "Khmer": "Кхмерски", + "preferences_annotations_label": "Покажи анотаций по подразбиране: ", + "generic_views_count": "{{count}} гледане", + "generic_views_count_plural": "{{count}} гледания", + "Next page": "Следваща страница", + "Import YouTube watch history (.json)": "Импортиране на историята на гледане от YouTube (.json)", + "toggle_theme": "Смени темата", + "Add to playlist": "Добави към плейлист", + "Add to playlist: ": "Добави към плейлист: ", + "Answer": "Отговор", + "Search for videos": "Търсене на видеа", + "The Popular feed has been disabled by the administrator.": "Популярната страница е деактивирана от администратора." +} diff --git a/locales/bn.json b/locales/bn.json new file mode 100644 index 00000000..501a1ca3 --- /dev/null +++ b/locales/bn.json @@ -0,0 +1,96 @@ +{ + "Subscribe": "সাবস্ক্রাইব", + "View channel on YouTube": "ইউটিউবে চ্যানেল দেখুন", + "View playlist on YouTube": "ইউটিউবে প্লেলিস্ট দেখুন", + "newest": "সর্ব-নতুন", + "oldest": "পুরানতম", + "popular": "জনপ্রিয়", + "last": "শেষটা", + "Next page": "পরের পৃষ্ঠা", + "Previous page": "আগের পৃষ্ঠা", + "Clear watch history?": "দেখার ইতিহাস সাফ করবেন?", + "New password": "নতুন পাসওয়ার্ড", + "New passwords must match": "নতুন পাসওয়ার্ড অবশ্যই মিলতে হবে", + "Authorize token?": "টোকেন অনুমোদন করবেন?", + "Authorize token for `x`?": "`x` -এর জন্য টোকেন অনুমোদন?", + "Yes": "হ্যাঁ", + "No": "না", + "Import and Export Data": "তথ্য আমদানি ও রপ্তানি", + "Import": "আমদানি", + "Import Invidious data": "ইনভিডিয়াস তথ্য আমদানি", + "Import YouTube subscriptions": "ইউটিউব সাবস্ক্রিপশন আনুন", + "Import FreeTube subscriptions (.db)": "ফ্রিটিউব সাবস্ক্রিপশন (.db) আনুন", + "Import NewPipe subscriptions (.json)": "নতুন পাইপ সাবস্ক্রিপশন আনুন (.json)", + "Import NewPipe data (.zip)": "নিউপাইপ তথ্য আনুন (.zip)", + "Export": "তথ্য বের করুন", + "Export subscriptions as OPML": "সাবস্ক্রিপশন OPML হিসাবে আনুন", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML-এ সাবস্ক্রিপশন বের করুন(নিউ পাইপ এবং ফ্রিউটিউব এর জন্য)", + "Export data as JSON": "JSON হিসাবে তথ্য বের করুন", + "Delete account?": "অ্যাকাউন্ট মুছে ফেলবেন?", + "History": "ইতিহাস", + "An alternative front-end to YouTube": "ইউটিউবের একটি বিকল্পস্বরূপ সম্মুখ-প্রান্ত", + "JavaScript license information": "জাভাস্ক্রিপ্ট লাইসেন্সের তথ্য", + "source": "সূত্র", + "Log in": "লগ ইন", + "Log in/register": "লগ ইন/রেজিস্টার", + "User ID": "ইউজার আইডি", + "Password": "পাসওয়ার্ড", + "Time (h:mm:ss):": "সময় (ঘণ্টা:মিনিট:সেকেন্ড):", + "Text CAPTCHA": "টেক্সট ক্যাপচা", + "Image CAPTCHA": "চিত্র ক্যাপচা", + "Sign In": "সাইন ইন", + "Register": "নিবন্ধন", + "E-mail": "ই-মেইল", + "Preferences": "পছন্দসমূহ", + "preferences_category_player": "প্লেয়ারের পছন্দসমূহ", + "preferences_video_loop_label": "সর্বদা লুপ: ", + "preferences_autoplay_label": "স্বয়ংক্রিয় চালু: ", + "preferences_continue_label": "ডিফল্টভাবে পরবর্তী চালাও: ", + "preferences_continue_autoplay_label": "পরবর্তী ভিডিও স্বয়ংক্রিয়ভাবে চালাও: ", + "preferences_listen_label": "সহজাতভাবে শোনো: ", + "preferences_local_label": "ভিডিও প্রক্সি করো: ", + "preferences_speed_label": "সহজাত গতি: ", + "preferences_quality_label": "পছন্দের ভিডিও মান: ", + "preferences_volume_label": "প্লেয়ার শব্দের মাত্রা: ", + "LIVE": "লাইভ", + "Shared `x` ago": "`x` আগে শেয়ার করা হয়েছে", + "Unsubscribe": "আনসাবস্ক্রাইব", + "generic_views_count": "{{count}}জন দেখেছে", + "generic_views_count_plural": "{{count}}জন দেখেছে", + "generic_videos_count": "{{count}}টি ভিডিও", + "generic_videos_count_plural": "{{count}}টি ভিডিও", + "generic_subscribers_count": "{{count}}জন অনুসরণকারী", + "generic_subscribers_count_plural": "{{count}}জন অনুসরণকারী", + "preferences_watch_history_label": "দেখার ইতিহাস চালু করো: ", + "preferences_quality_option_dash": "ড্যাশ (সময়োপযোগী মান)", + "preferences_quality_dash_option_auto": "স্বয়ংক্রিয়", + "preferences_quality_dash_option_best": "সেরা", + "preferences_quality_dash_option_worst": "মন্দতম", + "preferences_quality_dash_option_4320p": "৪৩২০পি", + "preferences_quality_dash_option_2160p": "২১৬০পি", + "preferences_quality_dash_option_1440p": "১৪৪০পি", + "preferences_quality_dash_option_480p": "৪৮০পি", + "preferences_quality_dash_option_360p": "৩৬০পি", + "preferences_quality_dash_option_240p": "২৪০পি", + "preferences_quality_dash_option_144p": "১৪৪পি", + "preferences_comments_label": "সহজাত মন্তব্য: ", + "youtube": "ইউটিউব", + "Fallback captions: ": "বিকল্প উপাখ্যান: ", + "preferences_related_videos_label": "সম্পর্কিত ভিডিও দেখাও: ", + "preferences_annotations_label": "সহজাতভাবে টীকা দেখাও ", + "preferences_quality_option_hd720": "উচ্চ৭২০", + "preferences_quality_dash_label": "পছন্দের ড্যাশ ভিডিও মান: ", + "preferences_captions_label": "সহজাত উপাখ্যান: ", + "generic_playlists_count": "{{count}}টি চালুতালিকা", + "generic_playlists_count_plural": "{{count}}টি চালুতালিকা", + "reddit": "রেডিট", + "invidious": "ইনভিডিয়াস", + "generic_subscriptions_count": "{{count}}টি অনুসরণ", + "generic_subscriptions_count_plural": "{{count}}টি অনুসরণ", + "preferences_quality_option_medium": "মধ্যম", + "preferences_quality_option_small": "ছোট", + "preferences_quality_dash_option_1080p": "১০৮০পি", + "preferences_quality_dash_option_720p": "৭২০পি", + "Add to playlist": "প্লেলিস্টে যোগ করুন", + "Add to playlist: ": "প্লেলিস্টে যোগ করুন: " +} diff --git a/locales/bn_BD.json b/locales/bn_BD.json index b6df0470..a82b0da7 100644 --- a/locales/bn_BD.json +++ b/locales/bn_BD.json @@ -1,10 +1,4 @@ { - "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "`x` সাবস্ক্রাইবার।([^.,0-9]|^)1([^.,0-9]|$)", - "`x` subscribers.": "`x` সাবস্ক্রাইবার।", - "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "`x` ভিডিও।([^.,0-9]|^)1([^.,0-9]|$)", - "`x` videos.": "`x` ভিডিও।", - "`x` playlists.([^.,0-9]|^)1([^.,0-9]|$)": "`x` প্লেলিস্ট।[^.,0-9]|^)1([^.,0-9]|$)", - "`x` playlists.": "`x` প্লেলিস্ট।", "LIVE": "লাইভ", "Shared `x` ago": "`x` আগে শেয়ার করা হয়েছে", "Unsubscribe": "আনসাবস্ক্রাইব", @@ -20,7 +14,6 @@ "Clear watch history?": "দেখার ইতিহাস সাফ করবেন?", "New password": "নতুন পাসওয়ার্ড", "New passwords must match": "নতুন পাসওয়ার্ড অবশ্যই মিলতে হবে", - "Cannot change password for Google accounts": "গুগল অ্যাকাউন্টগুলোর জন্য পাসওয়ার্ড পরিবর্তন করা যায় না", "Authorize token?": "টোকেন অনুমোদন করবেন?", "Authorize token for `x`?": "`x` -এর জন্য টোকেন অনুমোদন?", "Yes": "হ্যাঁ", @@ -43,7 +36,6 @@ "source": "সূত্র", "Log in": "লগ ইন", "Log in/register": "লগ ইন/রেজিস্টার", - "Log in with Google": "গুগল দিয়ে লগ ইন করুন", "User ID": "ইউজার আইডি", "Password": "পাসওয়ার্ড", "Time (h:mm:ss):": "সময় (ঘণ্টা:মিনিট:সেকেন্ড):", @@ -52,306 +44,15 @@ "Sign In": "সাইন ইন", "Register": "নিবন্ধন", "E-mail": "ই-মেইল", - "Google verification code": "গুগল যাচাইকরণ কোড", "Preferences": "পছন্দসমূহ", - "Player preferences": "প্লেয়ারের পছন্দসমূহ", - "Always loop: ": "সর্বদা লুপ: ", - "Autoplay: ": "স্বয়ংক্রিয় চালু: ", - "Play next by default: ": "ডিফল্টভাবে পরবর্তী চালাও: ", - "Autoplay next video: ": "পরবর্তী ভিডিও স্বয়ংক্রিয়ভাবে চালাও: ", - "Listen by default: ": "সহজাতভাবে শোনো: ", - "Proxy videos: ": "ভিডিও প্রক্সি করো: ", - "Default speed: ": "সহজাত গতি: ", - "Preferred video quality: ": "পছন্দের ভিডিও মান: ", - "Player volume: ": "প্লেয়ার শব্দের মাত্রা: ", - "Default comments: ": "", - "youtube": "", - "reddit": "", - "Default captions: ": "", - "Fallback captions: ": "", - "Show related videos: ": "", - "Show annotations by default: ": "", - "Automatically extend video description: ": "", - "Visual preferences": "", - "Player style: ": "", - "Dark mode: ": "", - "Theme: ": "", - "dark": "", - "light": "", - "Thin mode: ": "", - "Subscription preferences": "", - "Show annotations by default for subscribed channels: ": "", - "Redirect homepage to feed: ": "", - "Number of videos shown in feed: ": "", - "Sort videos by: ": "", - "published": "", - "published - reverse": "", - "alphabetically": "", - "alphabetically - reverse": "", - "channel name": "", - "channel name - reverse": "", - "Only show latest video from channel: ": "", - "Only show latest unwatched video from channel: ": "", - "Only show unwatched: ": "", - "Only show notifications (if there are any): ": "", - "Enable web notifications": "", - "`x` uploaded a video": "", - "`x` is live": "", - "Data preferences": "", - "Clear watch history": "", - "Import/export data": "", - "Change password": "", - "Manage subscriptions": "", - "Manage tokens": "", - "Watch history": "", - "Delete account": "", - "Administrator preferences": "", - "Default homepage: ": "", - "Feed menu: ": "", - "Top enabled: ": "", - "CAPTCHA enabled: ": "", - "Login enabled: ": "", - "Registration enabled: ": "", - "Report statistics: ": "", - "Save preferences": "", - "Subscription manager": "", - "Token manager": "", - "Token": "", - "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` subscriptions.": "", - "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` tokens.": "", - "Import/export": "", - "unsubscribe": "", - "revoke": "", - "Subscriptions": "", - "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` unseen notifications.": "", - "search": "", - "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", - "Source available here.": "", - "View JavaScript license information.": "", - "View privacy policy.": "", - "Trending": "", - "Public": "", - "Unlisted": "", - "Private": "", - "View all playlists": "", - "Updated `x` ago": "", - "Delete playlist `x`?": "", - "Delete playlist": "", - "Create playlist": "", - "Title": "", - "Playlist privacy": "", - "Editing playlist `x`": "", - "Show more": "", - "Show less": "", - "Watch on YouTube": "", - "Hide annotations": "", - "Show annotations": "", - "Genre: ": "", - "License: ": "", - "Family friendly? ": "", - "Wilson score: ": "", - "Engagement: ": "", - "Whitelisted regions: ": "", - "Blacklisted regions: ": "", - "Shared `x`": "", - "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` views.": "", - "Premieres in `x`": "", - "Premieres `x`": "", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", - "View YouTube comments": "", - "View more comments on Reddit": "", - "View `x` comments.([^.,0-9]|^)1([^.,0-9]|$)": "", - "View `x` comments.": "", - "View Reddit comments": "", - "Hide replies": "", - "Show replies": "", - "Incorrect password": "", - "Quota exceeded, try again in a few hours": "", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", - "Invalid TFA code": "", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "", - "Wrong answer": "", - "Erroneous CAPTCHA": "", - "CAPTCHA is a required field": "", - "User ID is a required field": "", - "Password is a required field": "", - "Wrong username or password": "", - "Please sign in using 'Log in with Google'": "", - "Password cannot be empty": "", - "Password cannot be longer than 55 characters": "", - "Please log in": "", - "Invidious Private Feed for `x`": "", - "channel:`x`": "", - "Deleted or invalid channel": "", - "This channel does not exist.": "", - "Could not get channel info.": "", - "Could not fetch comments": "", - "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "", - "View `x` replies.": "", - "`x` ago": "", - "Load more": "", - "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` points.": "", - "Could not create mix.": "", - "Empty playlist": "", - "Not a playlist.": "", - "Playlist does not exist.": "", - "Could not pull trending pages.": "", - "Hidden field \"challenge\" is a required field": "", - "Hidden field \"token\" is a required field": "", - "Erroneous challenge": "", - "Erroneous token": "", - "No such user": "", - "Token is expired, please try again": "", - "English": "", - "English (auto-generated)": "", - "Afrikaans": "", - "Albanian": "", - "Amharic": "", - "Arabic": "", - "Armenian": "", - "Azerbaijani": "", - "Bangla": "", - "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", - "Hungarian": "", - "Icelandic": "", - "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian Bokmål": "", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", - "Russian": "", - "Samoan": "", - "Scottish Gaelic": "", - "Serbian": "", - "Shona": "", - "Sindhi": "", - "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", - "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", - "Swedish": "", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", - "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", - "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` years.": "", - "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` months.": "", - "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` weeks.": "", - "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` days.": "", - "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` hours.": "", - "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` minutes.": "", - "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` seconds.": "", - "Fallback comments: ": "", - "Popular": "", - "Search": "", - "Top": "", - "About": "", - "Rating: ": "", - "Language: ": "", - "View as playlist": "", - "Default": "", - "Music": "", - "Gaming": "", - "News": "", - "Movies": "", - "Download": "", - "Download as: ": "", - "%A %B %-d, %Y": "", - "(edited)": "", - "YouTube comment permalink": "", - "permalink": "", - "`x` marked it with a ❤": "", - "Audio mode": "", - "Video mode": "", - "Videos": "", - "Playlists": "", - "Community": "", - "Current version: ": "" -}
\ No newline at end of file + "preferences_category_player": "প্লেয়ারের পছন্দসমূহ", + "preferences_video_loop_label": "সর্বদা লুপ: ", + "preferences_autoplay_label": "স্বয়ংক্রিয় চালু: ", + "preferences_continue_label": "ডিফল্টভাবে পরবর্তী চালাও: ", + "preferences_continue_autoplay_label": "পরবর্তী ভিডিও স্বয়ংক্রিয়ভাবে চালাও: ", + "preferences_listen_label": "সহজাতভাবে শোনো: ", + "preferences_local_label": "ভিডিও প্রক্সি করো: ", + "preferences_speed_label": "সহজাত গতি: ", + "preferences_quality_label": "পছন্দের ভিডিও মান: ", + "preferences_volume_label": "প্লেয়ার শব্দের মাত্রা: " +} diff --git a/locales/ca.json b/locales/ca.json new file mode 100644 index 00000000..bbcadf89 --- /dev/null +++ b/locales/ca.json @@ -0,0 +1,493 @@ +{ + "oldest": "més antic", + "Yes": "Sí", + "preferences_quality_label": "Qualitat de vídeo preferida: ", + "newest": "més nou", + "No": "No", + "User ID": "ID d'usuari", + "Preferences": "Preferències", + "Dark mode: ": "Mode fosc: ", + "dark": "fosc", + "light": "clar", + "published": "publicat", + "published - reverse": "publicat - invers", + "alphabetically": "alfabèticament", + "alphabetically - reverse": "alfabèticament - invers", + "channel name - reverse": "nom del canal - invers", + "preferences_category_data": "Preferències de dades", + "Delete account": "Elimina compte", + "Save preferences": "Guarda preferències", + "Private": "Privat", + "Show more": "Mostra'n més", + "Show less": "Mostra'n menys", + "Hide replies": "Amaga respostes", + "Arabic": "Àrab", + "Armenian": "Armeni", + "Basque": "Basc", + "Filipino": "Filipí", + "Finnish": "Finès", + "German": "Alemany", + "Greek": "Grec", + "Hungarian": "Hongarès", + "Icelandic": "Islandès", + "Italian": "Italià", + "Japanese": "Japonès", + "Korean": "Coreà", + "Kurdish": "Kurd", + "Lithuanian": "Lituà", + "Luxembourgish": "Luxemburguès", + "Macedonian": "Macedoni", + "Polish": "Polonès", + "Portuguese": "Portuguès", + "Romanian": "Romanès", + "Russian": "Rus", + "Serbian": "Serbi", + "Spanish (Latin America)": "Castellà (Amèrica llatina)", + "Turkish": "Turc", + "Ukrainian": "Ucraïnès", + "preferences_locale_label": "Idioma: ", + "Gaming": "Jocs", + "Movies": "Películes", + "Download": "Descarrega", + "Download as: ": "Descarrega com: ", + "channel_tab_videos_label": "Vídeos", + "search_filters_type_label": "Tipus", + "search_filters_duration_label": "Duració", + "search_filters_sort_label": "Ordena per", + "search_filters_date_option_week": "Aquesta setmana", + "search_filters_date_option_month": "Aquest mes", + "search_filters_date_option_year": "Aquest any", + "search_filters_type_option_video": "Vídeo", + "search_filters_type_option_channel": "Canal", + "search_filters_duration_option_short": "Curt (< 4 minuts)", + "search_filters_duration_option_long": "Llarg (> 20 minuts)", + "Current version: ": "Versió actual: ", + "Malay": "Malai", + "Persian": "Persa", + "Slovak": "Eslovac", + "Search": "Cerca", + "Show annotations": "Mostra anotacions", + "preferences_region_label": "País del contingut: ", + "preferences_sort_label": "Ordena vídeos per: ", + "Import/export": "Importa/exporta", + "channel name": "nom del canal", + "Title": "Títol", + "Belarusian": "Bielorús", + "Enable web notifications": "Activa notificacions web", + "search": "Cerca", + "Catalan": "Català", + "Croatian": "Croat", + "preferences_category_admin": "Preferències d'administrador", + "Hide annotations": "Amaga anotacions", + "Show replies": "Mostra respostes", + "Bulgarian": "Búlgar", + "Albanian": "Albanès", + "French": "Francès", + "Irish": "Irlandès", + "Maltese": "Maltès", + "Danish": "Danès", + "Galician": "Gallec", + "Hebrew": "Hebreu", + "Indonesian": "Indonesi", + "Spanish": "Castellà", + "Vietnamese": "Vietnamita", + "News": "Notícies", + "search_filters_type_option_show": "Mostra", + "footer_documentation": "Documentació", + "Thai": "Tailandès", + "Music": "Música", + "search_filters_sort_option_relevance": "Rellevància", + "search_filters_date_option_hour": "Última hora", + "search_filters_date_option_today": "Avui", + "preferences_volume_label": "Volum del reproductor: ", + "invidious": "Invidious", + "preferences_quality_dash_option_144p": "144p", + "Turkish (auto-generated)": "Turc (generat automàticament)", + "Urdu": "Urdú", + "Vietnamese (auto-generated)": "Vietnamita (generat automàticament)", + "Welsh": "Gal·lès", + "Yoruba": "Ioruba", + "YouTube comment permalink": "Enllaç permanent de comentari de YouTube", + "Channel Sponsor": "Patrocinador del canal", + "Audio mode": "Mode d'àudio", + "search_filters_date_option_none": "Qualsevol data", + "search_filters_type_option_playlist": "Llista de reproducció", + "search_filters_type_option_movie": "Pel·lícula", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_subtitles": "Subtítols/CC", + "search_filters_features_option_live": "Directe", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_hdr": "HDR", + "search_filters_features_option_location": "Ubicació", + "search_filters_apply_button": "Aplica els filtres seleccionats", + "videoinfo_started_streaming_x_ago": "Ha començat el directe fa `x`", + "next_steps_error_message_go_to_youtube": "Vés a YouTube", + "footer_donate_page": "Feu un donatiu", + "footer_original_source_code": "Codi font original", + "videoinfo_watch_on_youTube": "Veure a YouTube", + "user_saved_playlists": "`x` llistes de reproducció guardades", + "adminprefs_modified_source_code_url_label": "URL al repositori de codi font modificat", + "none": "cap", + "footer_modfied_source_code": "Codi font modificat", + "videoinfo_invidious_embed_link": "Incrusta l'enllaç", + "download_subtitles": "Subtítols - `x` (.vtt)", + "user_created_playlists": "`x`llistes de reproducció creades", + "Video unavailable": "Vídeo no disponible", + "channel_tab_channels_label": "Canals", + "channel_tab_playlists_label": "Llistes de reproducció", + "channel_tab_community_label": "Comunitat", + "Czech": "Txec", + "Default": "Per defecte", + "Amharic": "Amàric", + "preferences_automatic_instance_redirect_label": "Redirecció automàtica d'instàncies (retorna a redirect.invidious.io): ", + "Login enabled: ": "Activa inici de sessió: ", + "Registration enabled: ": "Activa registre: ", + "Whitelisted regions: ": "Regions a la llista blanca: ", + "Chinese (Simplified)": "Xinès (Simplificat)", + "Corsican": "Cors", + "Estonian": "Estonià", + "Japanese (auto-generated)": "Japonès (generat automàticament)", + "English (United States)": "Anglès (Estats Units)", + "English (auto-generated)": "Anglès (generat automàticament)", + "Cebuano": "Cebuà", + "Esperanto": "Esperanto", + "Scottish Gaelic": "Gaèlic escocès", + "Playlists": "Llistes de reproducció", + "search_filters_title": "Filtres", + "search_filters_type_option_all": "Qualsevol tipus", + "search_filters_duration_option_none": "Qualsevol duració", + "next_steps_error_message": "Després d'això, hauríeu d'intentar: ", + "next_steps_error_message_refresh": "Recarregar la pàgina", + "crash_page_refresh": "ha intentat <a href=\"`x`\">actualitzar la pàgina</a>", + "crash_page_report_issue": "Si cap de les anteriors no ha ajudat, <a href=\"`x`\">obre un nou issue a GitHub</a> (preferiblement en anglès) i inclou el text següent al missatge (NO tradueixis aquest text):", + "generic_subscriptions_count": "{{count}} subscripció", + "generic_subscriptions_count_plural": "{{count}} subscripcions", + "error_video_not_in_playlist": "El vídeo sol·licitat no existeix en aquesta llista de reproducció. <a href=\"`x`\">Feu clic aquí per a la pàgina d'inici de la llista de reproducció.</a>", + "comments_points_count": "{{count}} punt", + "comments_points_count_plural": "{{count}} punts", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "Create playlist": "Crear llista de reproducció", + "Text CAPTCHA": "Text CAPTCHA", + "Next page": "Pàgina següent", + "preferences_category_visual": "Preferències visuals", + "preferences_unseen_only_label": "Mostra només no vistos: ", + "preferences_listen_label": "Escolta per defecte: ", + "Import": "Importar", + "Token": "Testimoni", + "Wilson score: ": "Puntuació de Wilson: ", + "search_filters_date_label": "Data de càrrega", + "search_filters_features_option_three_sixty": "360°", + "source": "font", + "preferences_default_home_label": "Pàgina d'inici per defecte: ", + "preferences_comments_label": "Comentaris per defecte: ", + "`x` uploaded a video": "`x` ha penjat un vídeo", + "Released under the AGPLv3 on Github.": "Publicat sota l'AGPLv3 a GitHub.", + "Token manager": "Gestor de testimonis", + "Watch history": "Historial de reproduccions", + "Authorize token?": "Autoritzar testimoni?", + "Source available here.": "Font disponible aquí.", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporta subscripcions com a OPML (per a NewPipe i FreeTube)", + "Log in": "Inicia sessió", + "search_filters_sort_option_date": "Data de càrrega", + "Unlisted": "No llistat", + "View privacy policy.": "Veure política de privadesa.", + "Public": "Públic", + "View all playlists": "Veure totes les llistes de reproducció", + "reddit": "Reddit", + "Manage tokens": "Gestiona testimonis", + "Not a playlist.": "No és una llista de reproducció.", + "preferences_local_label": "Vídeos de Proxy: ", + "View channel on YouTube": "Veure canal a Youtube", + "preferences_quality_dash_option_1080p": "1080p", + "Top enabled: ": "Activa top: ", + "Delete playlist `x`?": "Eliminar llista de reproducció `x`?", + "View JavaScript license information.": "Consulta la informació de la llicència de JavaScript.", + "Playlist privacy": "Privacitat de la llista de reproducció", + "search_message_no_results": "No s'han trobat resultats.", + "search_message_use_another_instance": " També es pot <a href=\"`x`\">buscar en una altra instància</a>.", + "Genre: ": "Gènere: ", + "Hidden field \"challenge\" is a required field": "El camp ocult \"repte\" és un camp obligatori", + "Burmese": "Birmà", + "View as playlist": "Mostra com a llista de reproducció", + "preferences_category_subscription": "Preferències de subscripció", + "Music in this video": "Música en aquest vídeo", + "Artist: ": "Artista: ", + "Album: ": "Àlbum: ", + "Shared `x`": "Compartit `x`", + "Premieres `x`": "Estrena `x`", + "View more comments on Reddit": "Veure més comentaris a Reddit", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Veure `x` comentari", + "": "Veure `x` comentaris" + }, + "View Reddit comments": "Veure comentaris de Reddit", + "Incorrect password": "Contrasenya incorrecta", + "Erroneous CAPTCHA": "CAPTCHA erroni", + "CAPTCHA is a required field": "El CAPTCHA és un camp obligatori", + "Korean (auto-generated)": "Coreà (generat automàticament)", + "Kyrgyz": "Kirguís", + "Latin": "Llatí", + "Malagasy": "Malgaix", + "Maori": "Maori", + "Marathi": "Marathi", + "Norwegian Bokmål": "Bokmål Noruec", + "Nyanja": "Nyanja", + "Portuguese (Brazil)": "Portuguès (Brazil)", + "Punjabi": "Panjabi", + "Russian (auto-generated)": "Rus (generat automàticament)", + "Samoan": "Samoà", + "Somali": "Somali", + "Southern Sotho": "Sesotho", + "Spanish (Mexico)": "Espanyol (Mèxic)", + "Spanish (Spain)": "Espanyol (Espanya)", + "Sundanese": "Sondanès", + "Swahili": "Suahili", + "Tamil": "Tàmil", + "Telugu": "Telugu", + "Zulu": "Zulu", + "generic_count_months": "{{count}} mes", + "generic_count_months_plural": "{{count}} mesos", + "generic_count_weeks": "{{count}} setmana", + "generic_count_weeks_plural": "{{count}} setmanes", + "About": "Sobre", + "`x` marked it with a ❤": "`x`marca'l amb un ❤", + "Video mode": "Mode de vídeo", + "search_filters_features_label": "Característiques", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_vr180": "VR180", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_purchased": "Comprat", + "Chinese (Hong Kong)": "Xinès (Hong Kong)", + "Chinese (Taiwan)": "Xinès (Taiwan)", + "Hmong": "Hmong", + "Kazakh": "Kazakh", + "Igbo": "Igbo", + "Javanese": "Javanès", + "Indonesian (auto-generated)": "Indonesi (generat automàticament)", + "Interlingue": "Interlingüe", + "Khmer": "Khmer", + "This channel does not exist.": "Aquest canal no existeix.", + "Song: ": "Cançó: ", + "channel:`x`": "canal: `x`", + "Deleted or invalid channel": "Canal suprimit o no vàlid", + "Could not get channel info.": "No s'ha pogut obtenir la informació del canal.", + "Could not pull trending pages.": "No s'han pogut extreure les pàgines de tendència.", + "comments_view_x_replies": "Veure {{count}} resposta", + "comments_view_x_replies_plural": "Veure {{count}} respostes", + "Subscriptions": "Subscripcions", + "generic_count_seconds": "{{count}} segon", + "generic_count_seconds_plural": "{{count}} segons", + "channel_tab_shorts_label": "Vídeos curts", + "preferences_save_player_pos_label": "Desa la posició de reproducció: ", + "crash_page_before_reporting": "Abans d'informar d'un error, assegureu-vos que teniu:", + "crash_page_switch_instance": "ha intentat <a href=\"`x`\">utilitzar una altra instància</a>", + "crash_page_read_the_faq": "heu llegit les <a href=\"`x`\">Preguntes més freqüents (FAQ)</a>", + "crash_page_search_issue": "ha cercat <a href=\"`x`\">problemes existents a GitHub</a>", + "User ID is a required field": "L'identificador d'usuari és un camp obligatori", + "Password is a required field": "La contrasenya és un camp obligatori", + "Wrong username or password": "Nom d'usuari o contrasenya incorrectes", + "Password cannot be longer than 55 characters": "La contrasenya no pot tenir més de 55 caràcters", + "Invidious Private Feed for `x`": "Feed privat Invidious per a `x`", + "generic_views_count": "{{count}} visualització", + "generic_views_count_plural": "{{count}} visualitzacions", + "generic_videos_count": "{{count}} vídeo", + "generic_videos_count_plural": "{{count}} vídeos", + "Token is expired, please try again": "El testimoni ha caducat, torna-ho a provar", + "English": "Anglès", + "Kannada": "Kanarès", + "Erroneous token": "Testimoni erroni", + "`x` ago": "fa `x`", + "Empty playlist": "Llista de reproducció buida", + "Playlist does not exist.": "La llista de reproducció no existeix.", + "No such user": "No hi ha tal usuari", + "Afrikaans": "Afrikàans", + "Azerbaijani": "Azerbaidjana", + "Cantonese (Hong Kong)": "Cantonès (Hong Kong)", + "Chinese": "Xinès", + "Chinese (China)": "Xinès (Xina)", + "Chinese (Traditional)": "Xinès (Tradicional)", + "Dutch": "Holandès", + "Dutch (auto-generated)": "Holandès (generat automàticament)", + "French (auto-generated)": "Francès (generat automàticament)", + "Georgian": "Georgià", + "German (auto-generated)": "Alemany (generat automàticament)", + "Gujarati": "Gujarati", + "Hawaiian": "Hawaià", + "generic_count_years": "{{count}} any", + "generic_count_years_plural": "{{count}} anys", + "Popular": "Popular", + "Rating: ": "Valoració: ", + "permalink": "enllaç permanent", + "preferences_quality_dash_option_worst": "Pitjor", + "Yiddish": "Ídix", + "preferences_quality_dash_option_auto": "Automàtic", + "Western Frisian": "Frisó occidental", + "Swedish": "Suec", + "Only show latest unwatched video from channel: ": "Mostra només l'últim vídeo no vist del canal: ", + "preferences_continue_label": "Reprodueix el següent per defecte: ", + "Import YouTube subscriptions": "Importar subscripcions de YouTube", + "search_filters_sort_option_rating": "Valoració", + "preferences_thin_mode_label": "Mode prim: ", + "preferences_quality_option_small": "Petit", + "CAPTCHA enabled: ": "activa CAPTCHA: ", + "Import and Export Data": "Importar i exportar dades", + "preferences_quality_dash_option_360p": "360p", + "Popular enabled: ": "Activa popular: ", + "Password": "Contrasenya", + "Blacklisted regions: ": "Regions a la llista negra: ", + "Register": "Registra't", + "Shared `x` ago": "Compartit fa `x`", + "search_filters_sort_option_views": "Recompte de visualitzacions", + "Import Invidious data": "Importa dades JSON d'Invidious", + "preferences_related_videos_label": "Mostra vídeos relacionats: ", + "preferences_show_nick_label": "Mostra l'àlies a la part superior: ", + "Time (h:mm:ss):": "Temps (h:mm:ss):", + "Could not fetch comments": "No s'han pogut obtenir els comentaris", + "New password": "Nova contrasenya", + "preferences_notifications_only_label": "Mostra només notificacions (si n'hi ha): ", + "preferences_annotations_label": "Mostra anotacions per defecte: ", + "Import FreeTube subscriptions (.db)": "Importar subscripcions de FreeTube (.db)", + "Fallback captions: ": "Subtítols alternatius: ", + "Log out": "Tancar sessió", + "preferences_quality_dash_option_2160p": "2160p", + "Unsubscribe": "Cancel·la la subscripció", + "Log in/register": "Inicia sessió/registra't", + "Nepali": "Nepalí", + "Xhosa": "Xosa", + "preferences_captions_label": "Subtítols per defecte: ", + "preferences_autoplay_label": "Reproducció automàtica: ", + "`x` is live": "`x` està en directe", + "Uzbek": "Uzbek", + "Hausa": "Haussa", + "Bosnian": "Bosnià", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hola! Sembla que tens JavaScript desactivat. Feu clic aquí per veure els comentaris, tingueu en compte que poden trigar una mica més a carregar-se.", + "Password cannot be empty": "La contrasenya no pot estar buida", + "preferences_video_loop_label": "Sempre en bucle: ", + "preferences_quality_option_dash": "DASH (qualitat adaptativa)", + "Change password": "Canvia la contrasenya", + "Export data as JSON": "Exporta dades d'Invidious com a JSON", + "Wrong answer": "Resposta incorrecta", + "Clear watch history": "Neteja l'historial de reproduccions", + "Mongolian": "Mongol", + "preferences_quality_dash_option_best": "Millor", + "Authorize token for `x`?": "Autoritzar testimoni per a `x`?", + "Report statistics: ": "Estadístiques de l'informe: ", + "Switch Invidious Instance": "Canvia la instància d'Invidious", + "History": "Historial", + "Portuguese (auto-generated)": "Portuguès (generat automàticament)", + "footer_source_code": "Codi font", + "videoinfo_youTube_embed_link": "Insereix", + "generic_count_minutes": "{{count}} minut", + "generic_count_minutes_plural": "{{count}} minuts", + "preferences_category_player": "Preferències del reproductor", + "Sign In": "Inicia Sessió", + "preferences_continue_autoplay_label": "Reprodueix automàticament el següent vídeo: ", + "generic_playlists_count": "{{count}} llista de reproducció", + "generic_playlists_count_plural": "{{count}} llistes de reproducció", + "Delete account?": "Esborrar compte?", + "Please log in": "Si us plau inicieu sessió", + "Import NewPipe data (.zip)": "Importar dades de NewPipe (.zip)", + "Image CAPTCHA": "Imatge CAPTCHA", + "channel_tab_streams_label": "Transmissions en directe", + "preferences_category_misc": "Preferències diverses", + "preferences_annotations_subscribed_label": "Mostra les anotacions per defecte dels canals subscrits? ", + "Tajik": "Tadjik", + "preferences_player_style_label": "Estil del reproductor: ", + "Load more": "Carrega més", + "preferences_vr_mode_label": "Vídeos interactius de 360 graus (requereix WebGL): ", + "Manage subscriptions": "Gestionar les subscripcions", + "preferences_quality_option_medium": "Mitjà", + "Editing playlist `x`": "Editant la llista de reproducció `x`", + "search_filters_duration_option_medium": "Mitjà (4 - 20 minuts)", + "E-mail": "Correu electrònic", + "Spanish (auto-generated)": "Castellà (generat automàticament)", + "Export": "Exportar", + "preferences_quality_dash_option_4320p": "4320p", + "JavaScript license information": "Informació de la llicència de JavaScript", + "Hidden field \"token\" is a required field": "El camp ocult \"testimoni\" és un camp obligatori", + "Shona": "Xona", + "Family friendly? ": "Apte per a tots els públics? ", + "preferences_quality_dash_label": "Qualitat de vídeo DASH preferida: ", + "Hindi": "Hindi", + "An alternative front-end to YouTube": "Una interfície alternativa a YouTube", + "Export subscriptions as OPML": "Exporta subscripcions com a OPML", + "Watch on YouTube": "Veure a YouTube", + "Lao": "Laosià", + "search_message_change_filters_or_query": "Proveu d'ampliar la vostra consulta de cerca i/o canviar els filtres.", + "View YouTube comments": "Veure comentaris de YouTube", + "New passwords must match": "Les contrasenyes noves han de coincidir", + "Subscription manager": "Gestor de subscripcions", + "Premieres in `x`": "Estrena en `x`", + "youtube": "YouTube", + "Latvian": "Letó", + "LIVE": "EN VIU", + "Could not create mix.": "No s'ha pogut crear la barreja.", + "preferences_speed_label": "Velocitat per defecte: ", + "preferences_extend_desc_label": "Amplieu automàticament la descripció del vídeo: ", + "popular": "popular", + "Erroneous challenge": "Repte erroni", + "last": "darrer", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_1440p": "1440p", + "Previous page": "Pàgina anterior", + "Only show latest video from channel: ": "Mostra només l'últim vídeo del canal: ", + "unsubscribe": "cancel·la la subscripció", + "View playlist on YouTube": "Veure llista de reproducció a YouTube", + "Import NewPipe subscriptions (.json)": "Importar subscripcions de NewPipe (.json)", + "crash_page_you_found_a_bug": "Heu trobat un error a Invidious!", + "Subscribe": "Subscriu-me", + "generic_count_days": "{{count}} dia", + "generic_count_days_plural": "{{count}} dies", + "Trending": "Tendència", + "Updated `x` ago": "Actualitzat fa `x`", + "Haitian Creole": "Crioll Haitià", + "preferences_watch_history_label": "Habilita historial de reproduccions: ", + "generic_count_hours": "{{count}} hora", + "generic_count_hours_plural": "{{count}} hores", + "Malayalam": "Maialàiam", + "Clear watch history?": "Neteja historial de reproduccions?", + "Import/export data": "Importa/exporta dades", + "Sinhala": "Singalès", + "Delete playlist": "Eliminar llista de reproducció", + "Bangla": "Bengalí", + "Italian (auto-generated)": "Italià (generat automàticament)", + "License: ": "Llicència: ", + "(edited)": "(editat)", + "Pashto": "Paixtu", + "preferences_dark_mode_label": "Tema: ", + "revoke": "revocar", + "English (United Kingdom)": "Anglès (Regne Unit)", + "preferences_quality_option_hd720": "HD720", + "tokens_count": "{{count}} testimoni", + "tokens_count_plural": "{{count}} testimonis", + "subscriptions_unseen_notifs_count": "{{count}} notificació no vista", + "subscriptions_unseen_notifs_count_plural": "{{count}} notificacions no vistes", + "generic_subscribers_count": "{{count}} subscriptor", + "generic_subscribers_count_plural": "{{count}} subscriptors", + "Sindhi": "Sindhi", + "Slovenian": "Eslovè", + "preferences_feed_menu_label": "Menú del feed: ", + "Fallback comments: ": "Comentaris alternatius: ", + "Top": "Millors", + "preferences_max_results_label": "Nombre de vídeos mostrats al feed: ", + "Engagement: ": "Atracció: ", + "Redirect homepage to feed: ": "Redirigeix la pàgina d'inici al feed: ", + "Standard YouTube license": "Llicència estàndard de YouTube", + "Download is disabled": "Les baixades s'han inhabilitat", + "Import YouTube playlist (.csv)": "Importar llista de reproducció de YouTube (.csv)", + "channel_tab_podcasts_label": "Podcasts", + "playlist_button_add_items": "Afegeix vídeos", + "generic_button_save": "Desa", + "generic_button_cancel": "Cancel·la", + "channel_tab_releases_label": "Publicacions", + "generic_channels_count": "{{count}} canal", + "generic_channels_count_plural": "{{count}} canals", + "generic_button_edit": "Edita", + "generic_button_rss": "RSS", + "generic_button_delete": "Suprimeix", + "Import YouTube watch history (.json)": "Importa l'historial de visualitzacions de YouTube (.json)", + "Answer": "Resposta", + "toggle_theme": "Commuta el tema" +} diff --git a/locales/cs.json b/locales/cs.json index 6c37444a..6e66178d 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -1,22 +1,10 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` odběratelů.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` odběratelů." - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videí.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` videí." - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, "LIVE": "ŽIVĚ", - "Shared `x` ago": "", + "Shared `x` ago": "Zveřejněno před `x`", "Unsubscribe": "Odhlásit odběr", "Subscribe": "Odebírat", "View channel on YouTube": "Otevřít kanál na YouTube", - "View playlist on YouTube": "", + "View playlist on YouTube": "Zobrazit playlist na YouTube", "newest": "nejnovější", "oldest": "nejstarší", "popular": "populární", @@ -25,23 +13,22 @@ "Previous page": "Předchozí strana", "Clear watch history?": "Smazat historii?", "New password": "Nové heslo", - "New passwords must match": "Hesla se musí schodovat", - "Cannot change password for Google accounts": "Nelze změnit heslo pro účty Google", + "New passwords must match": "Hesla se musí shodovat", "Authorize token?": "Autorizovat token?", - "Authorize token for `x`?": "", + "Authorize token for `x`?": "Autorizovat token pro `x`?", "Yes": "Ano", "No": "Ne", - "Import and Export Data": "Import a Export údajů", - "Import": "Inport", - "Import Invidious data": "Importovat údaje Invidious", - "Import YouTube subscriptions": "Importovat odběry z YouTube", + "Import and Export Data": "Import a export dat", + "Import": "Importovat", + "Import Invidious data": "Importovat JSON údaje Invidious", + "Import YouTube subscriptions": "Importovat odběry z YouTube CSV nebo OPML", "Import FreeTube subscriptions (.db)": "Importovat odběry z FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importovat odběry z NewPipe (.json)", "Import NewPipe data (.zip)": "Importovat údeje z NewPipe (.zip)", "Export": "Exportovat", "Export subscriptions as OPML": "Exportovat odběry jako OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportovat údaje jako OPML (na NewPipe a FreeTube)", - "Export data as JSON": "Exportovat data jako JSON", + "Export data as JSON": "Exportovat data Invidious jako JSON", "Delete account?": "Smazat účet?", "History": "Historie", "An alternative front-end to YouTube": "Alternativní front-end pro YouTube", @@ -49,8 +36,7 @@ "source": "zdrojový kód", "Log in": "Přihlásit se", "Log in/register": "Přihlásit se/vytvořit účet", - "Log in with Google": "Přihlásit se s Googlem", - "User ID": "Uživatelské IČ", + "User ID": "ID uživatele", "Password": "Heslo", "Time (h:mm:ss):": "Čas (h:mm:ss):", "Text CAPTCHA": "Textové CAPTCHA", @@ -58,361 +44,474 @@ "Sign In": "Přihlásit se", "Register": "Vytvořit účet", "E-mail": "E-mail", - "Google verification code": "Verifikační číslo Google", "Preferences": "Nastavení", - "Player preferences": "Nastavení přehravače", - "Always loop: ": "Vždy opakovat: ", - "Autoplay: ": "Automatické přehrávání: ", - "Play next by default: ": "", - "Autoplay next video: ": "", - "Listen by default: ": "", - "Proxy videos: ": "", - "Default speed: ": "", - "Preferred video quality: ": "", - "Player volume: ": "Hlasitost přehrávače: ", - "Default comments: ": "", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "", - "Fallback captions: ": "", - "Show related videos: ": "Zobrazit podobné videa: ", - "Show annotations by default: ": "", - "Automatically extend video description: ": "", - "Visual preferences": "", - "Player style: ": "Styl přehrávače ", + "preferences_category_player": "Nastavení přehravače", + "preferences_video_loop_label": "Vždy opakovat: ", + "preferences_autoplay_label": "Automatické přehrávání: ", + "preferences_continue_label": "Automaticky přehrát další: ", + "preferences_continue_autoplay_label": "Automaticky přehrát další video: ", + "preferences_listen_label": "Poslouchat ve výchozím nastavení: ", + "preferences_local_label": "Video přes proxy: ", + "preferences_speed_label": "Výchozí rychlost: ", + "preferences_quality_label": "Preferovaná kvalita videa: ", + "preferences_volume_label": "Hlasitost přehrávače: ", + "preferences_comments_label": "Předpřipravené komentáře: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Výchozí titulky: ", + "Fallback captions: ": "Záložní titulky: ", + "preferences_related_videos_label": "Zobrazit podobná videa: ", + "preferences_annotations_label": "Zobrazovat poznámky ve výchozím nastavení: ", + "preferences_extend_desc_label": "Rozšířit automaticky popis u videa: ", + "preferences_category_visual": "Nastavení vzhledu", + "preferences_player_style_label": "Styl přehrávače ", "Dark mode: ": "Tmavý režim ", - "Theme: ": "", - "dark": "", - "light": "", - "Thin mode: ": "", - "Subscription preferences": "", - "Show annotations by default for subscribed channels: ": "", - "Redirect homepage to feed: ": "", - "Number of videos shown in feed: ": "", - "Sort videos by: ": "Roztřídit videa podle: ", + "preferences_dark_mode_label": "Vzhled: ", + "dark": "tmavý", + "light": "světlý", + "preferences_thin_mode_label": "Kompaktní režim: ", + "preferences_category_subscription": "Nastavení předplatných", + "preferences_annotations_subscribed_label": "Ve výchozím nastavení zobrazovat poznámky u odebíraných kanálů: ", + "Redirect homepage to feed: ": "Přesměrovávat domovskou stránku na informační kanál: ", + "preferences_max_results_label": "Počet videí zobrazovaných v informačním kanále: ", + "preferences_sort_label": "Roztřídit videa podle: ", "published": "publikováno", - "published - reverse": "", + "published - reverse": "podle publikování - obrátit", "alphabetically": "podle abecedy", - "alphabetically - reverse": "", + "alphabetically - reverse": "podle abecedy - převrátit", "channel name": "název kanálu", - "channel name - reverse": "", + "channel name - reverse": "podle jména kanálu - převrátit", "Only show latest video from channel: ": "Jenom zobrazit nejnovjejší video z kanálu: ", - "Only show latest unwatched video from channel: ": "", - "Only show unwatched: ": "", - "Only show notifications (if there are any): ": "", - "Enable web notifications": "Povolit webové upozornění", + "Only show latest unwatched video from channel: ": "Zobrazit jen nejnovější nezhlédnuté video z daného kanálu: ", + "preferences_unseen_only_label": "Zobrazit jen již nezhlédnuté: ", + "preferences_notifications_only_label": "Zobrazit pouze upozornění (pokud nějaká jsou): ", + "Enable web notifications": "Povolit webová upozornění", "`x` uploaded a video": "`x` nahrál(a) video", "`x` is live": "`x` je živě", - "Data preferences": "", + "preferences_category_data": "Nastavení dat", "Clear watch history": "Smazat historii", - "Import/export data": "", + "Import/export data": "Importovat/exportovat data", "Change password": "Změnit heslo", - "Manage subscriptions": "", - "Manage tokens": "", - "Watch history": "", - "Delete account": "", - "Administrator preferences": "", - "Default homepage: ": "", - "Feed menu: ": "", - "Top enabled: ": "", - "CAPTCHA enabled: ": "", - "Login enabled: ": "", - "Registration enabled: ": "", - "Report statistics: ": "", - "Save preferences": "", - "Subscription manager": "", - "Token manager": "", - "Token": "", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Import/export": "", - "unsubscribe": "", - "revoke": "", - "Subscriptions": "", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "search": "", - "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", - "Source available here.": "", - "View JavaScript license information.": "", - "View privacy policy.": "", - "Trending": "", - "Public": "", - "Unlisted": "", - "Private": "", - "View all playlists": "", - "Updated `x` ago": "", - "Delete playlist `x`?": "", - "Delete playlist": "", - "Create playlist": "", - "Title": "", - "Playlist privacy": "", - "Editing playlist `x`": "", - "Show more": "", - "Show less": "", - "Watch on YouTube": "", - "Hide annotations": "", - "Show annotations": "", - "Genre: ": "", - "License: ": "", - "Family friendly? ": "", - "Wilson score: ": "", - "Engagement: ": "", - "Whitelisted regions: ": "", - "Blacklisted regions: ": "", - "Shared `x`": "", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Premieres in `x`": "", - "Premieres `x`": "", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", - "View YouTube comments": "", - "View more comments on Reddit": "", - "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "View Reddit comments": "", - "Hide replies": "", - "Show replies": "", - "Incorrect password": "", - "Quota exceeded, try again in a few hours": "", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", - "Invalid TFA code": "", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "", - "Wrong answer": "", - "Erroneous CAPTCHA": "", - "CAPTCHA is a required field": "", - "User ID is a required field": "", - "Password is a required field": "", - "Wrong username or password": "", - "Please sign in using 'Log in with Google'": "", - "Password cannot be empty": "", - "Password cannot be longer than 55 characters": "", - "Please log in": "", - "Invidious Private Feed for `x`": "", - "channel:`x`": "", - "Deleted or invalid channel": "", - "This channel does not exist.": "", - "Could not get channel info.": "", - "Could not fetch comments": "", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` ago": "", - "Load more": "", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Could not create mix.": "", - "Empty playlist": "", - "Not a playlist.": "", - "Playlist does not exist.": "", - "Could not pull trending pages.": "", - "Hidden field \"challenge\" is a required field": "", - "Hidden field \"token\" is a required field": "", - "Erroneous challenge": "", - "Erroneous token": "", - "No such user": "", - "Token is expired, please try again": "", - "English": "", - "English (auto-generated)": "", - "Afrikaans": "", - "Albanian": "", - "Amharic": "", - "Arabic": "", - "Armenian": "", - "Azerbaijani": "", - "Bangla": "", - "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", - "Hungarian": "", - "Icelandic": "", - "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian Bokmål": "", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", - "Russian": "", - "Samoan": "", - "Scottish Gaelic": "", - "Serbian": "", - "Shona": "", - "Sindhi": "", - "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", - "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", - "Swedish": "", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", - "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Fallback comments: ": "", - "Popular": "", - "Search": "", - "Top": "", + "Manage subscriptions": "Spravovat odebírané kanály", + "Manage tokens": "Spravovat tokeny", + "Watch history": "Historie sledování", + "Delete account": "Smazat účet", + "preferences_category_admin": "Administrátorská nastavení", + "preferences_default_home_label": "Základní domovská stránka: ", + "preferences_feed_menu_label": "Menu doporučených: ", + "CAPTCHA enabled: ": "CAPTCHA povolena: ", + "Login enabled: ": "Přihlášení povoleno: ", + "Registration enabled: ": "Registrace povolena ", + "Report statistics: ": "Oznámit statistiky: ", + "Save preferences": "Uložit nastavení", + "Subscription manager": "Správa odběrů", + "Token manager": "Správa tokenů", + "Token": "Token", + "Import/export": "Importovat/exportovat", + "unsubscribe": "odhlásit odběr", + "revoke": "vrátit zpět", + "Subscriptions": "Odběry", + "search": "hledat", + "Log out": "Odhlásit se", + "Source available here.": "Zdrojový kód dostupný zde.", + "View JavaScript license information.": "Zobrazit informace o licenci JavaScript .", + "View privacy policy.": "Zobrazit zásady ochrany osobních údajů.", + "Trending": "Trendy", + "Public": "Veřejné", + "Unlisted": "Neveřejné", + "Private": "Soukromé", + "View all playlists": "Zobrazit všechny playlisty", + "Updated `x` ago": "Aktualizováno před `x`", + "Delete playlist `x`?": "Smazat playlist `x`?", + "Delete playlist": "Smazat playlist", + "Create playlist": "Vytvořit playlist", + "Title": "Název", + "Editing playlist `x`": "Upravování playlistu `x`", + "Show more": "Zobrazit více", + "Show less": "Zobrazit méně", + "Watch on YouTube": "Sledovat na YouTube", + "Hide annotations": "Skrýt poznámky", + "Show annotations": "Zobrazit poznámky", + "Genre: ": "Žánr: ", + "License: ": "Licence: ", + "Family friendly? ": "Vhodné pro rodiny? ", + "Engagement: ": "Zapojení: ", + "English": "Angličtina", + "English (auto-generated)": "Angličtina (automaticky generováno)", + "Afrikaans": "Afrikánština", + "Albanian": "Albánština", + "Amharic": "Amharština", + "Arabic": "Arabština", + "Armenian": "Arménština", + "Azerbaijani": "Azerbajdžánština", + "Bangla": "Bengálština", + "Basque": "Baskičtina", + "Belarusian": "Běloruština", + "Bosnian": "Bosenština", + "Bulgarian": "Bulharština", + "Burmese": "Barmština", + "Catalan": "Katalánština", + "Cebuano": "Cebuánština", + "Chinese (Simplified)": "Čínština (zjednodušená)", + "Chinese (Traditional)": "Čínština (tradiční)", + "Corsican": "Korsičtina", + "Croatian": "Chorvatština", + "Czech": "Čeština", + "Danish": "Dánština", + "Dutch": "Nizozemština", + "Esperanto": "Esperanto", + "Estonian": "Estonština", + "Filipino": "Filipínština", + "Finnish": "Finština", + "French": "Francouzština", + "Galician": "Galicijština", + "Georgian": "Gruzínština", + "German": "Němčina", + "Greek": "Řečtina", + "Gujarati": "Gudžarátština", + "Haitian Creole": "Haitská kreolština", + "Hausa": "Hauština", + "Hawaiian": "Havajština", + "Hebrew": "Hebrejština", + "Hindi": "Hindština", + "Hmong": "Hmongština", + "Hungarian": "Maďarština", + "Icelandic": "Islandština", + "Igbo": "Igboština", + "Indonesian": "Indonéština", + "Irish": "Irština", + "Italian": "Italština", + "Japanese": "Japonština", + "Javanese": "Javánština", + "Kannada": "Kannadština", + "Kazakh": "Kazaština", + "Khmer": "Khmerština", + "Korean": "Korejština", + "Kurdish": "Kurdština", + "Kyrgyz": "Kyrgyzština", + "Lao": "Laoština", + "Latin": "Latina", + "Latvian": "Lotyština", + "Lithuanian": "Litevština", + "Luxembourgish": "Lucemburština", + "Macedonian": "Makedonština", + "Malagasy": "Malgaština", + "Malay": "Malajština", + "Malayalam": "Malajálamština", + "Maltese": "Maltština", + "Maori": "Maorština", + "Marathi": "Maráthština", + "Mongolian": "Mongolština", + "Nepali": "Nepálština", + "Norwegian Bokmål": "Norština Bokmål", + "Nyanja": "Čičevština", + "Pashto": "Paštština", + "Persian": "Perština", + "Polish": "Polština", + "Portuguese": "Portugalština", + "Punjabi": "Paňdžábština", + "Romanian": "Rumunština", + "Russian": "Ruština", + "Samoan": "Samojština", + "Scottish Gaelic": "Skotská gaelština", + "Serbian": "Srbština", + "Shona": "Shona", + "Sindhi": "Sindhština", + "Sinhala": "Sinhálština", + "Slovak": "Slovenština", + "Slovenian": "Slovinština", + "Somali": "Somálština", + "Southern Sotho": "Sesothština", + "Spanish": "Španělština", + "Spanish (Latin America)": "Španělština (Latinská Amerika)", + "Sundanese": "Sundština", + "Swahili": "Svahilština", + "Swedish": "Švédština", + "Tajik": "Tádžičtina", + "Tamil": "Tamilština", + "Telugu": "Telugština", + "Thai": "Thajština", + "Turkish": "Turečtina", + "Ukrainian": "Ukrajinština", + "Urdu": "Urdština", + "Uzbek": "Uzbečtina", + "Vietnamese": "Vietnamština", + "Welsh": "Velština", + "Western Frisian": "Západofríština", + "Xhosa": "Xhoština", + "Yiddish": "Jidiš", + "Yoruba": "Jorubština", + "Zulu": "Zuluština", + "Popular": "Populární", "About": "Informace", "Rating: ": "Hodnocení: ", - "Language: ": "Jazyk: ", - "View as playlist": "", - "Default": "", + "preferences_locale_label": "Jazyk: ", + "Default": "Výchozí", "Music": "Hudba", - "Gaming": "", + "Gaming": "Hry", "News": "Zprávy", - "Movies": "", + "Movies": "Filmy", "Download": "Stáhnout", "Download as: ": "Stáhnout jako: ", - "%A %B %-d, %Y": "", "(edited)": "(upraveno)", - "YouTube comment permalink": "", - "permalink": "", "`x` marked it with a ❤": "`x` to označil(a) se ❤", "Audio mode": "Audiový režim", "Video mode": "Videový režim", - "Videos": "Videa", - "Playlists": "", - "Community": "Komunita", - "relevance": "", - "rating": "hodnocení", - "date": "datum", - "views": "zhlédnutí", - "content_type": "", - "duration": "délka", - "features": "", - "sort": "", - "hour": "hodina", - "today": "dnes", - "week": "týden", - "month": "měsíc", - "year": "rok", - "video": "video", - "channel": "kanál", - "playlist": "", - "movie": "", - "show": "zobrazit", - "hd": "HD", - "subtitles": "titulky", - "creative_commons": "", - "3d": "3D", - "live": "živě", - "4k": "4k", - "location": "umístění", - "hdr": "HDR", - "filter": "filtr", - "Current version: ": "" -}
\ No newline at end of file + "channel_tab_videos_label": "Videa", + "channel_tab_community_label": "Komunita", + "search_filters_sort_option_rating": "Hodnocení", + "search_filters_sort_option_date": "Datum nahrání", + "search_filters_sort_option_views": "Počet zhlédnutí", + "search_filters_duration_label": "Délka", + "search_filters_date_option_hour": "Poslední hodina", + "search_filters_date_option_today": "Dnes", + "search_filters_date_option_week": "Tento týden", + "search_filters_date_option_month": "Tento měsíc", + "search_filters_date_option_year": "Tento rok", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Kanál", + "search_filters_type_option_playlist": "Playlist", + "search_filters_type_option_movie": "Film", + "search_filters_type_option_show": "Seriál", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Titulky", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Živě", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Umístění", + "search_filters_features_option_hdr": "HDR", + "generic_count_days_0": "{{count}} dnem", + "generic_count_days_1": "{{count}} dny", + "generic_count_days_2": "{{count}} dny", + "generic_count_hours_0": "{{count}} hodinou", + "generic_count_hours_1": "{{count}} hodinami", + "generic_count_hours_2": "{{count}} hodinami", + "crash_page_refresh": "zkusili <a href=\"`x`\">obnovit stránku</a>", + "crash_page_switch_instance": "zkusili <a href=\"`x`\">použít jinou instanci</a>", + "preferences_vr_mode_label": "Interaktivní 360-stupňová videa (vyžaduje WebGL): ", + "English (United Kingdom)": "Angličtina (Spojené království)", + "Chinese (China)": "Čínština (Čína)", + "Chinese (Hong Kong)": "Čínština (Hong Kong)", + "Chinese (Taiwan)": "Čínština (Taiwan)", + "Portuguese (auto-generated)": "Portugalština (automaticky generováno)", + "Spanish (auto-generated)": "Španělština (automaticky generováno)", + "Spanish (Mexico)": "Španělština (Mexiko)", + "Spanish (Spain)": "Španělština (Španělsko)", + "generic_count_years_0": "{{count}} rokem", + "generic_count_years_1": "{{count}} lety", + "generic_count_years_2": "{{count}} lety", + "Fallback comments: ": "Záložní komentáře: ", + "Search": "Hledat", + "Top": "Nejlepší", + "Playlists": "Playlisty", + "videoinfo_started_streaming_x_ago": "Stream spuštěn před `x`", + "videoinfo_watch_on_youTube": "Sledovat na YouTube", + "videoinfo_youTube_embed_link": "Vložení", + "crash_page_read_the_faq": "si přečetli <a href=\"`x`\">často kladené otázky (FAQ)</a>", + "crash_page_before_reporting": "Před nahlášením chyby se ujistěte, že jste:", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_dash": "DASH (adaptivní kvalita)", + "generic_views_count_0": "{{count}} zhlédnutí", + "generic_views_count_1": "{{count}} zhlédnutí", + "generic_views_count_2": "{{count}} zhlédnutí", + "generic_subscriptions_count_0": "{{count}} odběr", + "generic_subscriptions_count_1": "{{count}} odběry", + "generic_subscriptions_count_2": "{{count}} odběrů", + "preferences_quality_dash_option_4320p": "4320p", + "generic_videos_count_0": "{{count}} video", + "generic_videos_count_1": "{{count}} videa", + "generic_videos_count_2": "{{count}} videí", + "preferences_quality_option_small": "Nízká", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_144p": "144p", + "preferences_quality_option_medium": "Střední", + "preferences_quality_dash_option_1440p": "1440p", + "invidious": "Invidious", + "View more comments on Reddit": "Zobrazit více komentářů na Redditu", + "generic_playlists_count_0": "{{count}} playlist", + "generic_playlists_count_1": "{{count}} playlisty", + "generic_playlists_count_2": "{{count}} playlistů", + "generic_subscribers_count_0": "{{count}} odběratel", + "generic_subscribers_count_1": "{{count}} odběratelé", + "generic_subscribers_count_2": "{{count}} odběratelů", + "preferences_watch_history_label": "Povolit historii sledování: ", + "preferences_quality_dash_option_240p": "240p", + "preferences_region_label": "Země obsahu: ", + "subscriptions_unseen_notifs_count_0": "{{count}} nezobrazené oznámení", + "subscriptions_unseen_notifs_count_1": "{{count}} nezobrazená oznámení", + "subscriptions_unseen_notifs_count_2": "{{count}} nezobrazených oznámení", + "Show replies": "Zobrazit odpovědi", + "Password cannot be longer than 55 characters": "Heslo nesmí být delší než 55 znaků", + "comments_view_x_replies_0": "Zobrazit {{count}} odpověď", + "comments_view_x_replies_1": "Zobrazit {{count}} odpovědi", + "comments_view_x_replies_2": "Zobrazit {{count}} odpovědí", + "comments_points_count_0": "{{count}} bod", + "comments_points_count_1": "{{count}} body", + "comments_points_count_2": "{{count}} bodů", + "German (auto-generated)": "Němčina (automaticky generováno)", + "Indonesian (auto-generated)": "Indonéština (automaticky generováno)", + "Interlingue": "Interlingue", + "Italian (auto-generated)": "Italština (automaticky generováno)", + "Japanese (auto-generated)": "Japonština (automaticky generováno)", + "Korean (auto-generated)": "Korejština (automaticky generováno)", + "Russian (auto-generated)": "Ruština (automaticky generováno)", + "generic_count_months_0": "{{count}} měsícem", + "generic_count_months_1": "{{count}} měsíci", + "generic_count_months_2": "{{count}} měsíci", + "generic_count_weeks_0": "{{count}} týdnem", + "generic_count_weeks_1": "{{count}} týdny", + "generic_count_weeks_2": "{{count}} týdny", + "generic_count_minutes_0": "{{count}} minutou", + "generic_count_minutes_1": "{{count}} minutami", + "generic_count_minutes_2": "{{count}} minutami", + "footer_documentation": "Dokumentace", + "next_steps_error_message_refresh": "Obnovit stránku", + "Chinese": "Čínština", + "Dutch (auto-generated)": "Nizozemština (automaticky generováno)", + "Erroneous token": "Chybný token", + "tokens_count_0": "{{count}} token", + "tokens_count_1": "{{count}} tokeny", + "tokens_count_2": "{{count}} tokenů", + "Portuguese (Brazil)": "Portugalština (Brazílie)", + "Token is expired, please try again": "Token vypršel, zkuste to prosím znovu", + "English (United States)": "Angličtina (Spojené státy)", + "Cantonese (Hong Kong)": "Kantonština (Hong Kong)", + "French (auto-generated)": "Francouzština (automaticky generováno)", + "Turkish (auto-generated)": "Turečtina (automaticky generováno)", + "Vietnamese (auto-generated)": "Vietnamština (automaticky generováno)", + "Current version: ": "Aktuální verze: ", + "next_steps_error_message": "Měli byste zkusit: ", + "footer_donate_page": "Přispět", + "download_subtitles": "Titulky - `x` (.vtt)", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "YouTube comment permalink": "Permanentní odkaz YouTube komentáře", + "permalink": "permalink", + "footer_original_source_code": "Původní zdrojový kód", + "adminprefs_modified_source_code_url_label": "URL repozitáře s upraveným zdrojovým kódem", + "Video unavailable": "Video není dostupné", + "next_steps_error_message_go_to_youtube": "Jít na YouTube", + "footer_modfied_source_code": "Upravený zdrojový kód", + "none": "žádné", + "videoinfo_invidious_embed_link": "Odkaz na vložení", + "user_saved_playlists": "`x` uložených playlistů", + "crash_page_you_found_a_bug": "Vypadá to, že jste našli chybu v Invidious!", + "user_created_playlists": "`x` vytvořených playlistů", + "crash_page_search_issue": "vyhledali <a href=\"`x`\">existující problémy na GitHubu</a>", + "crash_page_report_issue": "Pokud nepomohlo nic z výše uvedeného, <a href=\"`x`\">otevřete prosím nový problém na GitHubu</a> (pokud možno v angličtině) a zahrňte do zprávy následující text (NEpřekládejte jej):", + "preferences_quality_dash_label": "Preferovaná kvalita videí DASH: ", + "preferences_quality_dash_option_auto": "Automatická", + "preferences_quality_dash_option_best": "Nejlepší", + "preferences_quality_dash_option_worst": "Nejhorší", + "preferences_quality_dash_option_480p": "480p", + "Top enabled: ": "Povoleny nejlepší: ", + "generic_count_seconds_0": "{{count}} sekundou", + "generic_count_seconds_1": "{{count}} sekundami", + "generic_count_seconds_2": "{{count}} sekundami", + "preferences_save_player_pos_label": "Uložit pozici přehrávání: ", + "Incorrect password": "Nesprávné heslo", + "View as playlist": "Zobrazit jako playlist", + "View Reddit comments": "Zobrazit komentáře z Redditu", + "No such user": "Uživatel nenalezen", + "Playlist privacy": "Soukromí playlistu", + "Wrong answer": "Špatná odpověď", + "Could not pull trending pages.": "Nepodařilo se získat trendy stránky.", + "Erroneous CAPTCHA": "Chybná CAPTCHA", + "Password is a required field": "Heslo je vyžadované pole", + "preferences_automatic_instance_redirect_label": "Automatické přesměrování instance (fallback na redirect.invidious.io): ", + "Switch Invidious Instance": "Přepnout instanci Invidious", + "Empty playlist": "Prázdný playlist", + "footer_source_code": "Zdrojový kód", + "View YouTube comments": "Zobrazit YouTube komentáře", + "Blacklisted regions: ": "Oblasti na černé listině: ", + "Wrong username or password": "Nesprávné uživatelské jméno nebo heslo", + "Password cannot be empty": "Heslo nemůže být prázné", + "preferences_category_misc": "Různá nastavení", + "preferences_show_nick_label": "Zobrazit přezdívku na vrchu: ", + "Whitelisted regions: ": "Oblasti na bílé listině: ", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Zdravíme! Zdá se, že máte vypnutý JavaScript. Klikněte sem pro zobrazení komentářů - nezapomeňte, že se mohou načítat trochu déle.", + "User ID is a required field": "ID uživatele je vyžadované pole", + "Please log in": "Přihlaste se prosím", + "Invidious Private Feed for `x`": "Soukromý kanál Invidious pro `x`", + "Deleted or invalid channel": "Smazaný nebo neplatný kanál", + "This channel does not exist.": "Tento kanál neexistuje.", + "Hidden field \"token\" is a required field": "Skryté pole \"token\" je vyžadované", + "Wilson score: ": "Skóre Wilson: ", + "Shared `x`": "Zveřejněno `x`", + "Premieres in `x`": "Premiéra za `x`", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Zobrazit `x` komentář", + "": "Zobrazit `x` komentářů" + }, + "Could not get channel info.": "Nepodařilo se získat informace o kanálu.", + "Could not fetch comments": "Nepodařilo se získat komentáře", + "Could not create mix.": "Nepodařilo se vytvořit mix.", + "Hidden field \"challenge\" is a required field": "Skryté pole \"challenge\" je vyžadované", + "Released under the AGPLv3 on Github.": "Vydáno pod licencí AGPLv3 na GitHubu.", + "Hide replies": "Skrýt odpovědi", + "channel:`x`": "kanál: `x`", + "Load more": "Načíst další", + "Not a playlist.": "Není playlist.", + "Playlist does not exist.": "Playlist neexistuje.", + "Erroneous challenge": "Chybná výzva", + "Premieres `x`": "Premiéra `x`", + "CAPTCHA is a required field": "CAPTCHA je vyžadované pole", + "`x` ago": "Před `x`", + "search_message_change_filters_or_query": "Zkuste rozšířit vyhledávaný dotaz a/nebo změnit filtry.", + "search_filters_date_option_none": "Jakékoli datum", + "search_filters_date_label": "Datum nahrání", + "search_filters_type_option_all": "Jakýkoli typ", + "search_filters_duration_option_none": "Jakákoli délka", + "search_filters_type_label": "Typ", + "search_filters_duration_option_short": "Krátká (< 4 minuty)", + "search_message_no_results": "Nenalezeny žádné výsledky.", + "search_filters_title": "Filtry", + "search_filters_duration_option_medium": "Střední (4 - 20 minut)", + "search_filters_duration_option_long": "Dlouhá (> 20 minut)", + "search_message_use_another_instance": "Můžete také <a href=\"`x`\">hledat na jiné instanci</a>.", + "search_filters_features_label": "Vlastnosti", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_vr180": "VR180", + "search_filters_features_option_purchased": "Zakoupeno", + "search_filters_sort_label": "Řadit dle", + "search_filters_sort_option_relevance": "Relevantnost", + "search_filters_apply_button": "Použít vybrané filtry", + "Popular enabled: ": "Populární povoleno: ", + "error_video_not_in_playlist": "Požadované video v tomto playlistu neexistuje. <a href=\"`x`\">Klikněte sem pro navštívení domovské stránky playlistu.</a>", + "channel_tab_shorts_label": "Shorts", + "channel_tab_playlists_label": "Playlisty", + "channel_tab_channels_label": "Kanály", + "channel_tab_streams_label": "Živé přenosy", + "Music in this video": "Hudba v tomto videu", + "Artist: ": "Umělec: ", + "Album: ": "Album: ", + "Channel Sponsor": "Sponzor kanálu", + "Song: ": "Skladba: ", + "Standard YouTube license": "Standardní licence YouTube", + "Download is disabled": "Stahování je zakázáno", + "Import YouTube playlist (.csv)": "Importovat YouTube playlist (.csv)", + "generic_button_save": "Uložit", + "generic_button_delete": "Odstranit", + "generic_button_cancel": "Zrušit", + "channel_tab_podcasts_label": "Podcasty", + "channel_tab_releases_label": "Vydání", + "generic_button_edit": "Upravit", + "generic_button_rss": "RSS", + "playlist_button_add_items": "Přidat videa", + "generic_channels_count_0": "{{count}} kanál", + "generic_channels_count_1": "{{count}} kanály", + "generic_channels_count_2": "{{count}} kanálů", + "Import YouTube watch history (.json)": "Importovat historii sledování z YouTube (.json)", + "toggle_theme": "Přepnout motiv", + "Add to playlist": "Přidat do playlistu", + "Add to playlist: ": "Přidat do playlistu: ", + "Answer": "Odpověď", + "Search for videos": "Hledat videa", + "The Popular feed has been disabled by the administrator.": "Kategorie Populární byla zakázána administrátorem.", + "carousel_slide": "Snímek {{current}} z {{total}}", + "carousel_skip": "Přeskočit galerii", + "carousel_go_to": "Přejít na snímek `x`" +} diff --git a/locales/cy.json b/locales/cy.json new file mode 100644 index 00000000..566e73e1 --- /dev/null +++ b/locales/cy.json @@ -0,0 +1,385 @@ +{ + "Time (h:mm:ss):": "Amser (h:mm:ss):", + "Password": "Cyfrinair", + "preferences_quality_dash_option_auto": "Awtomatig", + "preferences_quality_dash_option_best": "Gorau", + "preferences_quality_dash_option_worst": "Gwaethaf", + "preferences_quality_dash_option_360p": "360p", + "published": "dyddiad cyhoeddi", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "preferences_comments_label": "Ffynhonnell sylwadau: ", + "preferences_captions_label": "Isdeitlau rhagosodedig: ", + "youtube": "YouTube", + "reddit": "Reddit", + "Fallback captions: ": "Isdeitlau amgen: ", + "preferences_related_videos_label": "Dangos fideos perthnasol: ", + "dark": "tywyll", + "preferences_dark_mode_label": "Thema: ", + "light": "golau", + "preferences_sort_label": "Trefnu fideo yn ôl: ", + "Import/export data": "Mewnforio/allforio data", + "Delete account": "Dileu eich cyfrif", + "preferences_category_admin": "Hoffterau gweinyddu", + "playlist_button_add_items": "Ychwanegu fideos", + "Delete playlist": "Dileu'r rhestr chwarae", + "Create playlist": "Creu rhestr chwarae", + "Show less": "Dangos llai", + "Show more": "Dangos rhagor", + "Watch on YouTube": "Gwylio ar YouTube", + "search_message_no_results": "Dim canlyniadau.", + "search_message_change_filters_or_query": "Ceisiwch ehangu eich chwiliad ac/neu newid yr hidlyddion.", + "License: ": "Trwydded: ", + "Standard YouTube license": "Trwydded safonol YouTube", + "Family friendly? ": "Addas i bawb? ", + "Wilson score: ": "Sgôr Wilson: ", + "Show replies": "Dangos ymatebion", + "Music in this video": "Cerddoriaeth yn y fideo hwn", + "Artist: ": "Artist: ", + "Erroneous CAPTCHA": "CAPTCHA anghywir", + "This channel does not exist.": "Dyw'r sianel hon ddim yn bodoli.", + "Not a playlist.": "Ddim yn rhestr chwarae.", + "Could not fetch comments": "Wedi methu llwytho sylwadau", + "Playlist does not exist.": "Dyw'r rhestr chwarae ddim yn bodoli.", + "Erroneous challenge": "Her annilys", + "channel_tab_podcasts_label": "Podlediadau", + "channel_tab_playlists_label": "Rhestrau chwarae", + "channel_tab_streams_label": "Fideos byw", + "crash_page_read_the_faq": "darllen y <a href=\"`x`\">cwestiynau cyffredin</a>", + "crash_page_switch_instance": "ceisio <a href=\"`x`\">defnyddio gweinydd arall</a>", + "crash_page_refresh": "ceisio <a href=\"`x`\">ail-lwytho'r dudalen</a>", + "search_filters_features_option_four_k": "4K", + "search_filters_features_label": "Nodweddion", + "search_filters_duration_option_medium": "Canolig (4 - 20 munud)", + "search_filters_features_option_live": "Yn fyw", + "search_filters_duration_option_long": "Hir (> 20 munud)", + "search_filters_date_option_year": "Eleni", + "search_filters_type_label": "Math", + "search_filters_date_option_month": "Y mis hwn", + "generic_views_count_0": "{{count}} o wyliadau", + "generic_views_count_1": "{{count}} gwyliad", + "generic_views_count_2": "{{count}} wyliad", + "generic_views_count_3": "{{count}} o wyliadau", + "generic_views_count_4": "{{count}} o wyliadau", + "generic_views_count_5": "{{count}} o wyliadau", + "Answer": "Ateb", + "Add to playlist: ": "Ychwanegu at y rhestr chwarae: ", + "Add to playlist": "Ychwanegu at y rhestr chwarae", + "generic_button_cancel": "Diddymu", + "generic_button_rss": "RSS", + "LIVE": "YN FYW", + "Import YouTube watch history (.json)": "Mewnforio hanes gwylio YouTube (.json)", + "generic_videos_count_0": "{{count}} fideo", + "generic_videos_count_1": "{{count}} fideo", + "generic_videos_count_2": "{{count}} fideo", + "generic_videos_count_3": "{{count}} fideo", + "generic_videos_count_4": "{{count}} fideo", + "generic_videos_count_5": "{{count}} fideo", + "generic_subscribers_count_0": "{{count}} tanysgrifiwr", + "generic_subscribers_count_1": "{{count}} tanysgrifiwr", + "generic_subscribers_count_2": "{{count}} danysgrifiwr", + "generic_subscribers_count_3": "{{count}} thanysgrifiwr", + "generic_subscribers_count_4": "{{count}} o danysgrifwyr", + "generic_subscribers_count_5": "{{count}} o danysgrifwyr", + "Authorize token?": "Awdurdodi'r tocyn?", + "Authorize token for `x`?": "Awdurdodi'r tocyn ar gyfer `x`?", + "English": "Saesneg", + "English (United Kingdom)": "Saesneg (Y Deyrnas Unedig)", + "English (United States)": "Saesneg (Yr Unol Daleithiau)", + "Afrikaans": "Affricaneg", + "English (auto-generated)": "Saesneg (awtomatig)", + "Amharic": "Amhareg", + "Albanian": "Albaneg", + "Arabic": "Arabeg", + "crash_page_report_issue": "Os nad yw'r awgrymiadau uchod wedi helpu, <a href=\"`x`\">codwch 'issue' newydd ar Github </a> (yn Saesneg, gorau oll) a chynnwys y testun canlynol yn eich neges (peidiwch â chyfieithu'r testun hwn):", + "Search for videos": "Chwilio am fideos", + "The Popular feed has been disabled by the administrator.": "Mae'r ffrwd fideos poblogaidd wedi ei hanalluogi gan y gweinyddwr.", + "generic_channels_count_0": "{{count}} sianel", + "generic_channels_count_1": "{{count}} sianel", + "generic_channels_count_2": "{{count}} sianel", + "generic_channels_count_3": "{{count}} sianel", + "generic_channels_count_4": "{{count}} sianel", + "generic_channels_count_5": "{{count}} sianel", + "generic_button_delete": "Dileu", + "generic_button_edit": "Golygu", + "generic_button_save": "Cadw", + "Shared `x` ago": "Rhannwyd `x` yn ôl", + "Unsubscribe": "Dad-danysgrifio", + "Subscribe": "Tanysgrifio", + "View channel on YouTube": "Gweld y sianel ar YouTube", + "View playlist on YouTube": "Gweld y rhestr chwarae ar YouTube", + "newest": "diweddaraf", + "oldest": "hynaf", + "popular": "poblogaidd", + "Next page": "Tudalen nesaf", + "Previous page": "Tudalen flaenorol", + "Clear watch history?": "Clirio'ch hanes gwylio?", + "New password": "Cyfrinair newydd", + "Import and Export Data": "Mewnforio ac allforio data", + "Import": "Mewnforio", + "Import Invidious data": "Mewnforio data JSON Invidious", + "Import YouTube subscriptions": "Mewnforio tanysgrifiadau YouTube ar fformat CSV neu OPML", + "Import YouTube playlist (.csv)": "Mewnforio rhestr chwarae YouTube (.csv)", + "Export": "Allforio", + "Export data as JSON": "Allforio data Invidious ar fformat JSON", + "Delete account?": "Ydych chi'n siŵr yr hoffech chi ddileu eich cyfrif?", + "History": "Hanes", + "JavaScript license information": "Gwybodaeth am y drwydded JavaScript", + "generic_subscriptions_count_0": "{{count}} tanysgrifiad", + "generic_subscriptions_count_1": "{{count}} tanysgrifiad", + "generic_subscriptions_count_2": "{{count}} danysgrifiad", + "generic_subscriptions_count_3": "{{count}} thanysgrifiad", + "generic_subscriptions_count_4": "{{count}} o danysgrifiadau", + "generic_subscriptions_count_5": "{{count}} o danysgrifiadau", + "Yes": "Iawn", + "No": "Na", + "Import FreeTube subscriptions (.db)": "Mewnforio tanysgrifiadau FreeTube (.db)", + "Import NewPipe subscriptions (.json)": "Mewnforio tanysgrifiadau NewPipe (.json)", + "Import NewPipe data (.zip)": "Mewnforio data NewPipe (.zip)", + "An alternative front-end to YouTube": "Pen blaen amgen i YouTube", + "source": "ffynhonnell", + "Log in": "Mewngofnodi", + "Log in/register": "Mewngofnodi/Cofrestru", + "User ID": "Enw defnyddiwr", + "preferences_quality_option_dash": "DASH (ansawdd addasol)", + "Sign In": "Mewngofnodi", + "Register": "Cofrestru", + "E-mail": "Ebost", + "Preferences": "Hoffterau", + "preferences_category_player": "Hoffterau'r chwaraeydd", + "preferences_autoplay_label": "Chwarae'n awtomatig: ", + "preferences_local_label": "Llwytho fideos drwy ddirprwy weinydd: ", + "preferences_watch_history_label": "Galluogi hanes gwylio: ", + "preferences_speed_label": "Cyflymder rhagosodedig: ", + "preferences_quality_label": "Ansawdd fideos: ", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Canolig", + "preferences_quality_option_small": "Bach", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "invidious": "Invidious", + "Text CAPTCHA": "CAPTCHA testun", + "Image CAPTCHA": "CAPTCHA delwedd", + "preferences_continue_label": "Chwarae'r fideo nesaf fel rhagosodiad: ", + "preferences_continue_autoplay_label": "Chwarae'r fideo nesaf yn awtomatig: ", + "preferences_listen_label": "Sain yn unig: ", + "preferences_quality_dash_label": "Ansawdd fideos DASH a ffefrir: ", + "preferences_volume_label": "Uchder sain y chwaraeydd: ", + "preferences_category_visual": "Hoffterau'r wefan", + "preferences_region_label": "Gwlad y cynnwys: ", + "preferences_player_style_label": "Arddull y chwaraeydd: ", + "Dark mode: ": "Modd tywyll: ", + "preferences_thin_mode_label": "Modd tenau: ", + "preferences_category_misc": "Hoffterau amrywiol", + "preferences_category_subscription": "Hoffterau tanysgrifio", + "preferences_max_results_label": "Nifer o fideos a ddangosir yn eich ffrwd: ", + "alphabetically": "yr wyddor", + "alphabetically - reverse": "yr wyddor - am yn ôl", + "published - reverse": "dyddiad cyhoeddi - am yn ôl", + "channel name": "enw'r sianel", + "channel name - reverse": "enw'r sianel - am yn ôl", + "Only show latest video from channel: ": "Dangos fideo diweddaraf y sianeli rydych chi'n tanysgrifio iddynt: ", + "Only show latest unwatched video from channel: ": "Dangos fideo heb ei wylio diweddaraf y sianeli rydych chi'n tanysgrifio iddynt: ", + "Enable web notifications": "Galluogi hysbysiadau gwe", + "`x` uploaded a video": "uwchlwythodd `x` fideo", + "`x` is live": "mae `x` yn darlledu'n fyw", + "preferences_category_data": "Hoffterau data", + "Clear watch history": "Clirio'ch hanes gwylio", + "Change password": "Newid eich cyfrinair", + "Manage subscriptions": "Rheoli tanysgrifiadau", + "Manage tokens": "Rheoli tocynnau", + "Watch history": "Hanes gwylio", + "preferences_default_home_label": "Hafan ragosodedig: ", + "preferences_show_nick_label": "Dangos eich enw defnyddiwr ar frig y dudalen: ", + "preferences_annotations_label": "Dangos nodiadau fel rhagosodiad: ", + "preferences_unseen_only_label": "Dangos fideos heb eu gwylio yn unig: ", + "preferences_notifications_only_label": "Dangos hysbysiadau yn unig (os oes unrhyw rai): ", + "Token manager": "Rheolydd tocynnau", + "Token": "Tocyn", + "unsubscribe": "dad-danysgrifio", + "Subscriptions": "Tanysgrifiadau", + "Import/export": "Mewngofnodi/allgofnodi", + "search": "chwilio", + "Log out": "Allgofnodi", + "View privacy policy.": "Polisi preifatrwydd", + "Trending": "Pynciau llosg", + "Public": "Cyhoeddus", + "Private": "Preifat", + "Updated `x` ago": "Diweddarwyd `x` yn ôl", + "Delete playlist `x`?": "Ydych chi'n siŵr yr hoffech chi ddileu'r rhestr chwarae `x`?", + "Title": "Teitl", + "Playlist privacy": "Preifatrwydd y rhestr chwarae", + "search_message_use_another_instance": " Gallwch hefyd <a href=\"`x`\">chwilio ar weinydd arall</a>.", + "Popular enabled: ": "Tudalen fideos poblogaidd wedi'i galluogi: ", + "CAPTCHA enabled: ": "CAPTCHA wedi'i alluogi: ", + "Registration enabled: ": "Cofrestru wedi'i alluogi: ", + "Save preferences": "Cadw'r hoffterau", + "Subscription manager": "Rheolydd tanysgrifio", + "revoke": "tynnu", + "subscriptions_unseen_notifs_count_0": "{{count}} hysbysiad heb ei weld", + "subscriptions_unseen_notifs_count_1": "{{count}} hysbysiad heb ei weld", + "subscriptions_unseen_notifs_count_2": "{{count}} hysbysiad heb eu gweld", + "subscriptions_unseen_notifs_count_3": "{{count}} hysbysiad heb eu gweld", + "subscriptions_unseen_notifs_count_4": "{{count}} hysbysiad heb eu gweld", + "subscriptions_unseen_notifs_count_5": "{{count}} hysbysiad heb eu gweld", + "Released under the AGPLv3 on Github.": "Cyhoeddwyd dan drwydded AGPLv3 ar GitHub", + "Unlisted": "Heb ei restru", + "Switch Invidious Instance": "Newid gweinydd Invidious", + "Report statistics: ": "Galluogi ystadegau'r gweinydd: ", + "View all playlists": "Gweld pob rhestr chwarae", + "Editing playlist `x`": "Yn golygu'r rhestr chwarae `x`", + "Whitelisted regions: ": "Rhanbarthau a ganiateir: ", + "Blacklisted regions: ": "Rhanbarthau a rwystrir: ", + "Song: ": "Cân: ", + "Album: ": "Albwm: ", + "Shared `x`": "Rhannwyd `x`", + "View YouTube comments": "Dangos sylwadau YouTube", + "View more comments on Reddit": "Dangos rhagor o sylwadau ar Reddit", + "View Reddit comments": "Dangos sylwadau Reddit", + "Hide replies": "Cuddio ymatebion", + "Incorrect password": "Cyfrinair anghywir", + "Wrong answer": "Ateb anghywir", + "CAPTCHA is a required field": "Rhaid rhoi'r CAPTCHA", + "User ID is a required field": "Rhaid rhoi enw defnyddiwr", + "Password is a required field": "Rhaid rhoi cyfrinair", + "Wrong username or password": "Enw defnyddiwr neu gyfrinair anghywir", + "Password cannot be empty": "All y cyfrinair ddim bod yn wag", + "Password cannot be longer than 55 characters": "All y cyfrinair ddim bod yn hirach na 55 nod", + "Please log in": "Mewngofnodwch", + "channel:`x`": "sianel: `x`", + "Deleted or invalid channel": "Sianel wedi'i dileu neu'n annilys", + "Could not get channel info.": "Wedi methu llwytho gwybodaeth y sianel.", + "`x` ago": "`x` yn ôl", + "Load more": "Llwytho rhagor", + "Empty playlist": "Rhestr chwarae wag", + "Hide annotations": "Cuddio nodiadau", + "Show annotations": "Dangos nodiadau", + "Premieres in `x`": "Yn dechrau mewn `x`", + "Premieres `x`": "Yn dechrau `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Helo! Mae'n ymddangos eich bod wedi diffodd JavaScript. Cliciwch yma i weld sylwadau, ond cofiwch y gall gymryd mwy o amser i'w llwytho.", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Gweld `x` sylw", + "": "Gweld `x` sylw" + }, + "Could not create mix.": "Wedi methu creu'r cymysgiad hwn.", + "Erroneous token": "Tocyn annilys", + "No such user": "Dyw'r defnyddiwr hwn ddim yn bodoli", + "Token is expired, please try again": "Mae'r tocyn hwn wedi dod i ben, ceisiwch eto", + "Bangla": "Bangleg", + "Basque": "Basgeg", + "Bulgarian": "Bwlgareg", + "Catalan": "Catalaneg", + "Chinese": "Tsieineeg", + "Chinese (China)": "Tsieineeg (Tsieina)", + "Chinese (Hong Kong)": "Tsieineeg (Hong Kong)", + "Chinese (Taiwan)": "Tsieineeg (Taiwan)", + "Danish": "Daneg", + "Dutch": "Iseldireg", + "Esperanto": "Esperanteg", + "Finnish": "Ffinneg", + "French": "Ffrangeg", + "German": "Almaeneg", + "Greek": "Groeg", + "Could not pull trending pages.": "Wedi methu llwytho tudalennau pynciau llosg.", + "Hidden field \"challenge\" is a required field": "Mae'r maes cudd \"her\" yn ofynnol", + "Hidden field \"token\" is a required field": "Mae'r maes cudd \"tocyn\" yn ofynnol", + "Hebrew": "Hebraeg", + "Hungarian": "Hwngareg", + "Irish": "Gwyddeleg", + "Italian": "Eidaleg", + "Welsh": "Cymraeg", + "generic_count_hours_0": "{{count}} awr", + "generic_count_hours_1": "{{count}} awr", + "generic_count_hours_2": "{{count}} awr", + "generic_count_hours_3": "{{count}} awr", + "generic_count_hours_4": "{{count}} awr", + "generic_count_hours_5": "{{count}} awr", + "generic_count_minutes_0": "{{count}} munud", + "generic_count_minutes_1": "{{count}} munud", + "generic_count_minutes_2": "{{count}} funud", + "generic_count_minutes_3": "{{count}} munud", + "generic_count_minutes_4": "{{count}} o funudau", + "generic_count_minutes_5": "{{count}} o funudau", + "generic_count_weeks_0": "{{count}} wythnos", + "generic_count_weeks_1": "{{count}} wythnos", + "generic_count_weeks_2": "{{count}} wythnos", + "generic_count_weeks_3": "{{count}} wythnos", + "generic_count_weeks_4": "{{count}} wythnos", + "generic_count_weeks_5": "{{count}} wythnos", + "generic_count_seconds_0": "{{count}} eiliad", + "generic_count_seconds_1": "{{count}} eiliad", + "generic_count_seconds_2": "{{count}} eiliad", + "generic_count_seconds_3": "{{count}} eiliad", + "generic_count_seconds_4": "{{count}} o eiliadau", + "generic_count_seconds_5": "{{count}} o eiliadau", + "Fallback comments: ": "Sylwadau amgen: ", + "Popular": "Poblogaidd", + "preferences_locale_label": "Iaith: ", + "About": "Ynghylch", + "Search": "Chwilio", + "search_filters_features_option_c_commons": "Comin Creu", + "search_filters_features_option_subtitles": "Isdeitlau (CC)", + "search_filters_features_option_hd": "HD", + "permalink": "dolen barhaol", + "search_filters_duration_option_short": "Byr (< 4 munud)", + "search_filters_duration_option_none": "Unrhyw hyd", + "search_filters_duration_label": "Hyd", + "search_filters_type_option_show": "Rhaglen", + "search_filters_type_option_movie": "Ffilm", + "search_filters_type_option_playlist": "Rhestr chwarae", + "search_filters_type_option_channel": "Sianel", + "search_filters_type_option_video": "Fideo", + "search_filters_type_option_all": "Unrhyw fath", + "search_filters_date_option_week": "Yr wythnos hon", + "search_filters_date_option_today": "Heddiw", + "search_filters_date_option_hour": "Yr awr ddiwethaf", + "search_filters_date_option_none": "Unrhyw ddyddiad", + "search_filters_date_label": "Dyddiad uwchlwytho", + "search_filters_title": "Hidlyddion", + "Playlists": "Rhestrau chwarae", + "Video mode": "Modd fideo", + "Audio mode": "Modd sain", + "Channel Sponsor": "Noddwr y sianel", + "(edited)": "(golygwyd)", + "Download": "Islwytho", + "Movies": "Ffilmiau", + "News": "Newyddion", + "Gaming": "Gemau", + "Music": "Cerddoriaeth", + "Download is disabled": "Mae islwytho wedi'i analluogi", + "Download as: ": "Islwytho fel: ", + "View as playlist": "Gweld fel rhestr chwarae", + "Default": "Rhagosodiad", + "YouTube comment permalink": "Dolen barhaol i'r sylw ar YouTube", + "crash_page_before_reporting": "Cyn adrodd nam, sicrhewch eich bod wedi:", + "crash_page_search_issue": "<a href=\"`x`\">chwilio am y nam ar GitHub</a>", + "videoinfo_watch_on_youTube": "Gwylio ar YouTube", + "videoinfo_started_streaming_x_ago": "Yn ffrydio'n fyw ers `x` o funudau", + "videoinfo_invidious_embed_link": "Dolen mewnblannu", + "footer_documentation": "Dogfennaeth", + "footer_donate_page": "Rhoddi", + "Current version: ": "Fersiwn gyfredol: ", + "search_filters_apply_button": "Rhoi'r hidlyddion ar waith", + "search_filters_sort_option_date": "Dyddiad uwchlwytho", + "search_filters_sort_option_relevance": "Perthnasedd", + "search_filters_sort_label": "Trefnu yn ôl", + "search_filters_features_option_location": "Lleoliad", + "search_filters_features_option_hdr": "HDR", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_vr180": "VR180", + "search_filters_features_option_three_sixty": "360°", + "videoinfo_youTube_embed_link": "Mewnblannu", + "download_subtitles": "Isdeitlau - `x` (.vtt)", + "user_created_playlists": "`x` rhestr chwarae wedi'u creu", + "user_saved_playlists": "`x` rhestr chwarae wedi'u cadw", + "Video unavailable": "Fideo ddim ar gael", + "crash_page_you_found_a_bug": "Mae'n debyg eich bod wedi dod o hyd i nam yn Invidious!", + "channel_tab_channels_label": "Sianeli", + "channel_tab_community_label": "Cymuned", + "channel_tab_shorts_label": "Fideos byrion", + "channel_tab_videos_label": "Fideos" +} diff --git a/locales/da.json b/locales/da.json index 0962524b..9cbb446a 100644 --- a/locales/da.json +++ b/locales/da.json @@ -1,17 +1,5 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnenter.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` abonnenter." - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videoer.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` videoer." - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` afspilningslister.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` afspilningslister." - }, - "LIVE": "DIREKTE", + "LIVE": "LIVE", "Shared `x` ago": "Delt for `x` siden", "Unsubscribe": "Opsig abonnement", "Subscribe": "Abonner", @@ -26,30 +14,28 @@ "Clear watch history?": "Ryd afspilningshistorik?", "New password": "Nyt kodeord", "New passwords must match": "Nye kodeord skal matche", - "Cannot change password for Google accounts": "Kan ikke skifte kodeord til Google-konti", "Authorize token?": "Godkend token?", "Authorize token for `x`?": "Godkend token til `x`?", "Yes": "Ja", "No": "Nej", "Import and Export Data": "Importer og Eksporter Data", "Import": "Importer", - "Import Invidious data": "Importer Invidious data", - "Import YouTube subscriptions": "Importer YouTube abonnementer", + "Import Invidious data": "Importer Invidious JSON-data", + "Import YouTube subscriptions": "Importer YouTube/OPML-abonnementer", "Import FreeTube subscriptions (.db)": "Importer FreeTube abonnementer (.db)", "Import NewPipe subscriptions (.json)": "Importer NewPipe abonnementer (.json)", "Import NewPipe data (.zip)": "Importer NewPipe data (.zip)", "Export": "Exporter", "Export subscriptions as OPML": "Exporter abonnementer som OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter abonnementer som OPML (til NewPipe & FreeTube)", - "Export data as JSON": "Exporter data som JSON", + "Export data as JSON": "Eksporter Invidious-data som JSON", "Delete account?": "Slet konto?", "History": "Historik", - "An alternative front-end to YouTube": "En alternativ forside til YouTube", + "An alternative front-end to YouTube": "Et alternativt front-end til YouTube", "JavaScript license information": "JavaScript licens information", "source": "kilde", "Log in": "Log på", "Log in/register": "Log på/registrer", - "Log in with Google": "Log på med Google", "User ID": "Bruger ID", "Password": "Kodeord", "Time (h:mm:ss):": "Tid (t:mm:ss):", @@ -58,38 +44,38 @@ "Sign In": "Log ind", "Register": "Registrer", "E-mail": "E-mail", - "Google verification code": "Google-verifikationskode", "Preferences": "Præferencer", - "Player preferences": "Afspillerindstillinger", - "Always loop: ": "Altid gentag: ", - "Autoplay: ": "Auto afspil: ", - "Play next by default: ": "Afspil næste som standard: ", - "Autoplay next video: ": "Auto afspil næste video: ", - "Listen by default: ": "Lyt som standard: ", - "Proxy videos: ": "Proxy videoer: ", - "Default speed: ": "Standard hastighed: ", - "Preferred video quality: ": "Foretrukken video kvalitet: ", - "Player volume: ": "Lydstyrke: ", - "Default comments: ": "Standard kommentarer: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "Standard undertekster: ", + "preferences_category_player": "Afspillerindstillinger", + "preferences_video_loop_label": "Altid gentag: ", + "preferences_autoplay_label": "Auto afspil: ", + "preferences_continue_label": "Afspil næste som standard: ", + "preferences_continue_autoplay_label": "Auto afspil næste video: ", + "preferences_listen_label": "Lyt som standard: ", + "preferences_local_label": "Proxy videoer: ", + "preferences_speed_label": "Standard hastighed: ", + "preferences_quality_label": "Foretrukken video kvalitet: ", + "preferences_volume_label": "Lydstyrke: ", + "preferences_comments_label": "Standard kommentarer: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Standard undertekster: ", "Fallback captions: ": "Alternative undertekster: ", - "Show related videos: ": "Vis relaterede videoer: ", - "Show annotations by default: ": "Vis annotationer som standard: ", - "Automatically extend video description: ": "", - "Visual preferences": "Visuelle præferencer", - "Player style: ": "Afspiller stil: ", + "preferences_related_videos_label": "Vis relaterede videoer: ", + "preferences_annotations_label": "Vis annotationer som standard: ", + "preferences_extend_desc_label": "Automatisk udvid videoens beskrivelse: ", + "preferences_vr_mode_label": "Interaktive 360 graders videoer (kræver WebGL): ", + "preferences_category_visual": "Visuelle præferencer", + "preferences_player_style_label": "Afspiller stil: ", "Dark mode: ": "Mørk tilstand: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "mørk", "light": "lys", - "Thin mode: ": "Tynd tilstand: ", - "Subscription preferences": "Abonnements præferencer", - "Show annotations by default for subscribed channels: ": "Vis annotationer som standard for abonnerede kanaler: ", + "preferences_thin_mode_label": "Tynd tilstand: ", + "preferences_category_subscription": "Abonnements præferencer", + "preferences_annotations_subscribed_label": "Vis annotationer som standard for abonnerede kanaler: ", "Redirect homepage to feed: ": "Omdiriger startside til feed: ", - "Number of videos shown in feed: ": "Antal videoer vist i feed: ", - "Sort videos by: ": "Sorter videoer efter: ", + "preferences_max_results_label": "Antal videoer vist i feed: ", + "preferences_sort_label": "Sorter videoer efter: ", "published": "offentliggjort", "published - reverse": "offentliggjort - omvendt", "alphabetically": "alfabetisk", @@ -98,12 +84,12 @@ "channel name - reverse": "kanalnavn - omvendt", "Only show latest video from channel: ": "Vis kun seneste video fra kanal: ", "Only show latest unwatched video from channel: ": "Vis kun seneste usete video fra kanal: ", - "Only show unwatched: ": "Vis kun usete: ", - "Only show notifications (if there are any): ": "Vis kun notifikationer (hvis der er nogle): ", + "preferences_unseen_only_label": "Vis kun usete: ", + "preferences_notifications_only_label": "Vis kun notifikationer (hvis der er nogle): ", "Enable web notifications": "Aktiver webnotifikationer", "`x` uploaded a video": "`x` uploadede en video", "`x` is live": "`x` er live", - "Data preferences": "Data præferencer", + "preferences_category_data": "Data præferencer", "Clear watch history": "Ryd afspilningshistorik", "Import/export data": "Importer/exporter data", "Change password": "Skift adgangskode", @@ -111,9 +97,9 @@ "Manage tokens": "Administrer tokens", "Watch history": "Afspilningshistorik", "Delete account": "Slet konto", - "Administrator preferences": "Administrator præferencer", - "Default homepage: ": "Standard startside: ", - "Feed menu: ": "Feed menu: ", + "preferences_category_admin": "Administrator præferencer", + "preferences_default_home_label": "Standard startside: ", + "preferences_feed_menu_label": "Feed menu: ", "Top enabled: ": "Top aktiveret: ", "CAPTCHA enabled: ": "CAPTCHA aktiveret: ", "Login enabled: ": "Login aktiveret: ", @@ -123,25 +109,12 @@ "Subscription manager": "Abonnementsmanager", "Token manager": "Tokenmanager", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnementer.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x`" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` tokens." - }, "Import/export": "Importer/eksporter", "unsubscribe": "opsig abonnement", "revoke": "tilbagekald", "Subscriptions": "Abonnementer", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` usete notifikationer.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` usete notifikationer." - }, "search": "søg", "Log out": "Log ud", - "Released under the AGPLv3 by Omar Roth.": "Offentliggjort under AGPLv3 af Omar Roth.", "Source available here.": "Kilde tilgængelig her.", "View JavaScript license information.": "Vis JavaScriptlicensinformation.", "View privacy policy.": "Vis privatpolitik.", @@ -150,15 +123,15 @@ "Unlisted": "Skjult", "Private": "Privat", "View all playlists": "Vis alle afspilningslister", - "Updated `x` ago": "", - "Delete playlist `x`?": "Opdateret `x` siden", + "Updated `x` ago": "Opdateret for `x` siden", + "Delete playlist `x`?": "Fjern spilleliste `x`?", "Delete playlist": "Slet afspilningsliste", "Create playlist": "Opret afspilningsliste", "Title": "Titel", "Playlist privacy": "Privatlivsindstillinger for afspilningsliste", "Editing playlist `x`": "Redigerer afspilningsliste `x`", - "Show more": "", - "Show less": "", + "Show more": "Vis mere", + "Show less": "Vis mindre", "Watch on YouTube": "Se på YouTube", "Hide annotations": "Skjul annotationer", "Show annotations": "Vis annotationer", @@ -170,249 +143,349 @@ "Whitelisted regions: ": "Whitelistede regioner: ", "Blacklisted regions: ": "Blacklistede regioner: ", "Shared `x`": "Delt `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visninger.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` visninger" - }, "Premieres in `x`": "Har premiere om `x`", "Premieres `x`": "Har premiere om `x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hej! Det ser ud til at du har JavaScript slået fra. Klik her for at se kommentarer, vær opmærksom på at de kan tage længere om at loade.", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hej! Det ser ud til at du har JavaScript slået fra. Klik her for at se kommentarer, vær opmærksom på at de kan tage længere om at indlæse.", "View YouTube comments": "Vis YouTube kommentarer", "View more comments on Reddit": "Se flere kommentarer på Reddit", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` kommentarer.([^.,0-9]|^)1([^.,0-9]|$)", - "": "Vis `x` kommentarer." + "": "Vis `x` kommentarer" }, "View Reddit comments": "Vis Reddit kommentarer", "Hide replies": "Skjul svar", "Show replies": "Vis svar", "Incorrect password": "Forkert adgangskode", - "Quota exceeded, try again in a few hours": "Kvota overskredet, prøv igen om et par timer", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Login fejlet, tjek at totrinsbekræftelse (Authenticator eller SMS) er slået til.", - "Invalid TFA code": "", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "", "Wrong answer": "Forkert svar", "Erroneous CAPTCHA": "Fejlagtig CAPTCHA", - "CAPTCHA is a required field": "", - "User ID is a required field": "", - "Password is a required field": "", + "CAPTCHA is a required field": "CAPTCHA er et obligatorisk felt", + "User ID is a required field": "Bruger ID er et krævet felt", + "Password is a required field": "Adgangskode er et obligatorisk felt", "Wrong username or password": "Forkert brugernavn eller adgangskode", - "Please sign in using 'Log in with Google'": "", - "Password cannot be empty": "", + "Password cannot be empty": "Adgangskoden må ikke være tom", "Password cannot be longer than 55 characters": "Adgangskoden må ikke være længere end 55 tegn", - "Please log in": "", - "Invidious Private Feed for `x`": "", - "channel:`x`": "", - "Deleted or invalid channel": "", - "This channel does not exist.": "", - "Could not get channel info.": "", - "Could not fetch comments": "", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` ago": "", - "Load more": "", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Could not create mix.": "", - "Empty playlist": "", - "Not a playlist.": "", - "Playlist does not exist.": "", - "Could not pull trending pages.": "", - "Hidden field \"challenge\" is a required field": "", - "Hidden field \"token\" is a required field": "", - "Erroneous challenge": "", - "Erroneous token": "", - "No such user": "", - "Token is expired, please try again": "", - "English": "", - "English (auto-generated)": "", - "Afrikaans": "", - "Albanian": "", - "Amharic": "", - "Arabic": "", - "Armenian": "", - "Azerbaijani": "", - "Bangla": "", - "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", - "Hungarian": "", - "Icelandic": "", - "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian Bokmål": "", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", - "Russian": "", - "Samoan": "", - "Scottish Gaelic": "", - "Serbian": "", - "Shona": "", - "Sindhi": "", - "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", - "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", - "Swedish": "", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", - "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Fallback comments: ": "", - "Popular": "", - "Search": "", - "Top": "", - "About": "", - "Rating: ": "", - "Language: ": "", - "View as playlist": "", - "Default": "", - "Music": "", - "Gaming": "", - "News": "", - "Movies": "", - "Download": "", - "Download as: ": "", - "%A %B %-d, %Y": "", - "(edited)": "", - "YouTube comment permalink": "", - "permalink": "", - "`x` marked it with a ❤": "", - "Audio mode": "", - "Video mode": "", - "Videos": "", - "Playlists": "", - "Community": "", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "" -}
\ No newline at end of file + "Please log in": "Venligst log ind", + "channel:`x`": "kanal: `x`", + "Deleted or invalid channel": "Slettet eller invalid kanal", + "This channel does not exist.": "Denne kanal eksisterer ikke.", + "Could not get channel info.": "Kunne ikke hente kanal info.", + "Could not fetch comments": "Kunne ikke hente kommentarer", + "`x` ago": "`x` siden", + "Load more": "Hent flere", + "Could not create mix.": "Kunne ikke skabe blanding.", + "Empty playlist": "Tom playliste", + "Not a playlist.": "Ikke en playliste.", + "Playlist does not exist.": "Playlist eksisterer ikke.", + "Esperanto": "Esperanto", + "Czech": "Tjekkisk", + "Danish": "Dansk", + "channel_tab_community_label": "Samfund", + "Afrikaans": "Afrikansk", + "Portuguese": "Portugisisk", + "Ukrainian": "Ukrainsk", + "Fallback comments: ": "Fallback kommentarer: ", + "Popular": "Populær", + "footer_donate_page": "Doner", + "Gujarati": "Gujarati", + "Punjabi": "Punjabi", + "Sundanese": "Sundanesisk", + "Urdu": "Urdu", + "preferences_region_label": "Indhold land: ", + "Hidden field \"challenge\" is a required field": "Det skjulte felt \"challenge\" er et påkrævet felt", + "Albanian": "Albansk", + "preferences_quality_dash_label": "Fortrukket DASH video kvalitet: ", + "search_filters_features_option_live": "Direkte", + "Lao": "Lao-tse", + "Filipino": "Filippinsk", + "Greek": "Græsk", + "Kurdish": "Kurdisk", + "Malay": "Malaysisk", + "Romanian": "Rumænsk", + "Somali": "Somalisk", + "preferences_locale_label": "Sprog: ", + "News": "Nyheder", + "permalink": "permalink", + "search_filters_sort_option_date": "Upload dato", + "search_filters_features_label": "Funktioner", + "Khmer": "Khmer", + "Finnish": "Finsk", + "search_filters_date_option_week": "Denne uge", + "Korean": "Koreansk", + "Telugu": "Telugu", + "Malayalam": "Malayalam", + "View as playlist": "Se som spilleliste", + "Hungarian": "Ungarsk", + "Welsh": "Walisisk", + "search_filters_features_option_subtitles": "Undertekster/CC", + "Bosnian": "Bosnisk", + "Yiddish": "Jiddisch", + "Belarusian": "Belarussisk", + "search_filters_date_option_today": "I dag", + "Shona": "Shona", + "Slovenian": "Slovensk", + "Gaming": "Gaming", + "Bangla": "Bengali", + "Swahili": "Swahili", + "`x` marked it with a ❤": "`x`markeret med et ❤", + "Kyrgyz": "Kirgisisk", + "Turkish": "Tyrkisk", + "adminprefs_modified_source_code_url_label": "URL-adresse til modificeret kildekodelager", + "Switch Invidious Instance": "Skift Invidious instans", + "Samoan": "Samoansk", + "Spanish": "Spansk", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "footer_documentation": "Dokumentation", + "Pashto": "Pashto", + "footer_modfied_source_code": "Modificeret Kildekode", + "Released under the AGPLv3 on Github.": "Udgivet under AGPLv3 på GitHub.", + "Tajik": "Tadsjikisk", + "search_filters_date_option_month": "Denne måned", + "Hebrew": "Hebraisk", + "Kannada": "Kannada", + "Current version: ": "Nuværende version: ", + "Amharic": "Amharisk", + "Swedish": "Svensk", + "Corsican": "Korsikansk", + "search_filters_type_option_movie": "Film", + "Could not pull trending pages.": "Kunne ikke hente trending sider.", + "English": "Engelsk", + "search_filters_features_option_hd": "HD", + "Hausa": "Islandsk", + "search_filters_date_option_year": "Dette år", + "Japanese": "Japansk", + "search_filters_type_label": "Type", + "Icelandic": "Islandsk", + "Basque": "Baskisk", + "search_filters_sort_option_rating": "Bedømmelse", + "Yoruba": "Yoruba", + "Erroneous token": "Fejlagtig token", + "channel_tab_videos_label": "Videoer", + "search_filters_type_option_show": "Vis", + "Luxembourgish": "Luxemboursk", + "Vietnamese": "Vietnamesisk", + "Latvian": "Lettisk", + "Indonesian": "Indonesisk", + "search_filters_duration_label": "Varighed", + "footer_original_source_code": "Original kildekode", + "Search": "Søg", + "Serbian": "Serbisk", + "Armenian": "Armensk", + "Bulgarian": "Bulgarsk", + "French": "Fransk", + "Burmese": "Burmesisk", + "Macedonian": "Makedonsk", + "Southern Sotho": "Sydlige Sotho", + "About": "Omkring", + "Malagasy": "Madagaskiske", + "Rating: ": "Bedømmelse: ", + "Movies": "Film", + "YouTube comment permalink": "Youtube kommentarer permalink", + "search_filters_features_option_location": "Lokation", + "search_filters_features_option_hdr": "HDR", + "Cebuano": "Cebuano (Sugbuanon)", + "Nyanja": "Nyanja", + "Chinese (Simplified)": "Kinesisk (forenklet)", + "Chinese (Traditional)": "Kinesisk (traditionelt)", + "Dutch": "Hollandsk", + "Estonian": "Estisk", + "preferences_automatic_instance_redirect_label": "Automatisk eksempel omdirigering (Fallback til redirect.invidious.io): ", + "Nepali": "Nepalesisk", + "Norwegian Bokmål": "Norsk Bokmål", + "(edited)": "(ændret)", + "preferences_show_nick_label": "Vis kælenavn på toppen: ", + "Galician": "Galisisk", + "German": "Tysk", + "Maori": "Maori", + "Slovak": "Slovakisk", + "search_filters_sort_option_relevance": "Relevans", + "search_filters_date_option_hour": "Sidste time", + "search_filters_type_option_playlist": "Spilleliste", + "search_filters_duration_option_long": "Lang (> 20 minutter)", + "search_filters_features_option_c_commons": "Creative Commons", + "Marathi": "Marathi", + "Sindhi": "Sindhi", + "preferences_category_misc": "Diverse indstillinger", + "Erroneous challenge": "Fejlagtig udfordring", + "Hindi": "Hindi", + "Igbo": "Igbo", + "Javanese": "Javanesisk", + "Kazakh": "Kasabhisk", + "Latin": "Latinsk", + "Lithuanian": "Lituaisk", + "Mongolian": "Mongolsk", + "Spanish (Latin America)": "Spansk (Latinamerika)", + "Uzbek": "Usbekisk", + "Western Frisian": "Vestfrisisk", + "Top": "Top", + "Music": "Musik", + "search_filters_sort_option_views": "Antal visninger", + "search_filters_sort_label": "Sorter efter", + "Zulu": "Zulu", + "Invidious Private Feed for `x`": "Invidious Privat Feed til `x`", + "English (auto-generated)": "Engelsk (autogenereret)", + "Arabic": "Arabisk", + "Croatian": "Kroatisk", + "Hawaiian": "Hawaiiansk", + "Maltese": "Maltesisk", + "Polish": "Polsk", + "Russian": "Russisk", + "Download": "Hent", + "Download as: ": "Hent som: ", + "Playlists": "Spillelister", + "next_steps_error_message_refresh": "Opdater", + "next_steps_error_message_go_to_youtube": "Gå til Youtube", + "footer_source_code": "Kildekode", + "Tamil": "Tamil", + "Xhosa": "Xhosa", + "next_steps_error_message": "Efter det burde du prøve at: ", + "Sinhala": "Singalesisk (Sinhala)", + "Thai": "Thai", + "No such user": "Brugeren findes ikke", + "Token is expired, please try again": "Token er udløbet, prøv igen", + "Catalan": "Catalansk", + "Haitian Creole": "Haitiansk", + "Irish": "Irsk", + "Persian": "Persisk", + "Scottish Gaelic": "Skotsk Gælisk", + "Default": "Standard", + "Video mode": "Videotilstand", + "search_filters_duration_option_short": "Kort (< 4 minutter)", + "Hidden field \"token\" is a required field": "Det skjulte felt \"token\" er et påkrævet felt", + "Azerbaijani": "Aserbajdsjansk", + "Georgian": "Georgisk", + "Italian": "Italiensk", + "Audio mode": "Lydtilstand", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Kanal", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_four_k": "4K", + "Hmong": "Hmong", + "preferences_quality_option_medium": "Medium", + "preferences_quality_option_small": "Lille", + "preferences_quality_dash_option_best": "Bedste", + "preferences_quality_dash_option_worst": "Værste", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "search_filters_features_option_purchased": "Købt", + "search_filters_features_option_three_sixty": "360°", + "none": "ingen", + "videoinfo_started_streaming_x_ago": "Streamen blev startet for `x`siden", + "videoinfo_watch_on_youTube": "Se på YouTube", + "videoinfo_youTube_embed_link": "Integrer", + "videoinfo_invidious_embed_link": "Integrer Link", + "download_subtitles": "Undertekster - `x`(.vtt)", + "user_created_playlists": "`x`opretede spillelister", + "user_saved_playlists": "´x`gemte spillelister", + "Video unavailable": "Video ikke tilgængelig", + "preferences_save_player_pos_label": "Gem afspilningsposition: ", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_option_dash": "DASH (adaptiv kvalitet)", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_240p": "240p", + "subscriptions_unseen_notifs_count": "{{count}} uset notifikation", + "subscriptions_unseen_notifs_count_plural": "{{count}} usete notifikationer", + "comments_view_x_replies": "Vis {{count}} svar", + "comments_view_x_replies_plural": "Vis {{count}} svar", + "comments_points_count": "{{count}} point", + "comments_points_count_plural": "{{count}} point", + "generic_count_years": "{{count}} år", + "generic_count_years_plural": "{{count}} år", + "generic_count_months": "{{count}} måned", + "generic_count_months_plural": "{{count}} måneder", + "generic_count_days": "{{count}} dag", + "generic_count_days_plural": "{{count}} dage", + "generic_count_minutes": "{{count}} minut", + "generic_count_minutes_plural": "{{count}} minutter", + "generic_count_seconds": "{{count}} sekund", + "generic_count_seconds_plural": "{{count}} sekunder", + "generic_subscribers_count": "{{count}} abonnent", + "generic_subscribers_count_plural": "{{count}} abonnenter", + "generic_subscriptions_count": "{{count}} abonnement", + "generic_subscriptions_count_plural": "{{count}} abonnementer", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videoer", + "English (United States)": "Engelsk (USA)", + "French (auto-generated)": "Fransk (autogenereret)", + "Spanish (auto-generated)": "Spansk (autogenereret)", + "crash_page_before_reporting": "Før du rapporterer en fejl, skal du sikre dig, at du har:", + "crash_page_refresh": "forsøgte at <a href=\"`x`\">opdatere siden</a>", + "generic_playlists_count": "{{count}} spilleliste", + "generic_playlists_count_plural": "{{count}} spillelister", + "preferences_watch_history_label": "Aktiver afspilningshistorik: ", + "tokens_count": "{{count}} token", + "tokens_count_plural": "{{count}} tokens", + "Cantonese (Hong Kong)": "Kantonesisk (Hongkong)", + "Chinese": "Kinesisk", + "Chinese (China)": "Kinesisk (Kina)", + "Chinese (Hong Kong)": "Kinesisk (Hongkong)", + "Chinese (Taiwan)": "Kinesisk (Taiwan)", + "Dutch (auto-generated)": "Hollandsk (autogenereret)", + "Indonesian (auto-generated)": "Indonesisk (autogenereret)", + "Interlingue": "Interlingue", + "Japanese (auto-generated)": "Japansk (autogenereret)", + "Korean (auto-generated)": "Koreansk (autogenereret)", + "Russian (auto-generated)": "Russisk (autogenereret)", + "Turkish (auto-generated)": "Tyrkisk (autogenereret)", + "Vietnamese (auto-generated)": "Vietnamesisk (autogenereret)", + "crash_page_report_issue": "Hvis intet af ovenstående hjalp, bedes du <a href=\"`x`\">åbne et nyt problem på GitHub</a> (helst på engelsk) og inkludere følgende tekst i din besked (oversæt IKKE denne tekst):", + "English (United Kingdom)": "Engelsk (Storbritannien)", + "Italian (auto-generated)": "Italiensk (autogenereret)", + "Portuguese (auto-generated)": "Portugisisk (autogenereret)", + "Portuguese (Brazil)": "Portugisisk (Brasilien)", + "generic_views_count": "{{count}} visning", + "generic_views_count_plural": "{{count}} visninger", + "generic_count_hours": "{{count}} time", + "generic_count_hours_plural": "{{count}} timer", + "Spanish (Spain)": "Spansk (Spanien)", + "crash_page_switch_instance": "forsøgte at <a href=\"`x`\">bruge en anden instans</a>", + "German (auto-generated)": "Tysk (autogenereret)", + "Spanish (Mexico)": "Spansk (Mexico)", + "generic_count_weeks": "{{count}} uge", + "generic_count_weeks_plural": "{{count}} uger", + "crash_page_you_found_a_bug": "Det ser ud til, at du har fundet en fejl i Invidious!", + "crash_page_read_the_faq": "læs <a href=\"`x`\">Ofte stillede spørgsmål (FAQ)</a>", + "crash_page_search_issue": "søgte efter <a href=\"`x`\">eksisterende problemer på GitHub</a>", + "search_filters_title": "Filter", + "playlist_button_add_items": "Tilføj videoer", + "search_message_no_results": "Ingen resultater fundet.", + "Import YouTube watch history (.json)": "Importer YouTube afspilningshistorik (.json)", + "search_message_change_filters_or_query": "Prøv at udvide din søgeforspørgsel og/eller ændre filtrene.", + "search_message_use_another_instance": " Du kan også <a href=\"`x`\">søge på en anden instans</a>.", + "Music in this video": "Musik i denne video", + "search_filters_date_option_none": "Enhver dato", + "search_filters_type_option_all": "Enhver type", + "search_filters_duration_option_none": "Enhver varighed", + "search_filters_duration_option_medium": "Medium (4 - 20 minutter)", + "search_filters_features_option_vr180": "VR180", + "generic_channels_count": "{{count}} kanal", + "generic_channels_count_plural": "{{count}} kanaler", + "Import YouTube playlist (.csv)": "Importer YouTube playliste (.csv)", + "Standard YouTube license": "Standard Youtube-licens", + "Album: ": "Album: ", + "Channel Sponsor": "Kanal-sponsor", + "Song: ": "Sang: ", + "channel_tab_playlists_label": "Playlister", + "channel_tab_channels_label": "Kanaler", + "Artist: ": "Kunstner: ", + "search_filters_date_label": "Uploaddato", + "generic_button_delete": "Slet", + "generic_button_edit": "Rediger", + "generic_button_save": "Gem", + "generic_button_cancel": "Afbryd", + "generic_button_rss": "RSS", + "Popular enabled: ": "Populær aktiveret: ", + "search_filters_apply_button": "Anvend udvalgte filtre", + "channel_tab_shorts_label": "Shorts", + "channel_tab_streams_label": "Livestreams", + "channel_tab_podcasts_label": "Podcasts", + "channel_tab_releases_label": "Udgivelser", + "Download is disabled": "Download er slået fra", + "error_video_not_in_playlist": "Den ønskede video findes ikke i denne playliste. <a href=\"`x`\">Klik her for playlistens startside.</a>" +} diff --git a/locales/de.json b/locales/de.json index 17350211..151f2abe 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1,47 +1,34 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Abonnenten", - "": "`x` Abonnenten" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Videos", - "": "`x` Videos" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Wiedergabelisten", - "": "`x` Wiedergabelisten" - }, "LIVE": "LIVE", "Shared `x` ago": "Vor `x` geteilt", - "Unsubscribe": "Abbestellen", + "Unsubscribe": "Abo beenden", "Subscribe": "Abonnieren", "View channel on YouTube": "Kanal auf YouTube anzeigen", "View playlist on YouTube": "Wiedergabeliste auf YouTube anzeigen", "newest": "neueste", "oldest": "älteste", - "popular": "beliebt", - "last": "letzte", + "popular": "beliebteste", + "last": "neueste", "Next page": "Nächste Seite", "Previous page": "Vorherige Seite", "Clear watch history?": "Verlauf löschen?", "New password": "Neues Passwort", - "New passwords must match": "Neue Passwörter müssen gleich sein", - "Cannot change password for Google accounts": "Ich kann das Passwort deines Google Kontos nicht ändern", + "New passwords must match": "Neue Passwörter müssen übereinstimmen", "Authorize token?": "Token autorisieren?", "Authorize token for `x`?": "Token für `x` autorisieren?", "Yes": "Ja", "No": "Nein", "Import and Export Data": "Daten importieren und exportieren", "Import": "Importieren", - "Import Invidious data": "Invidious Daten importieren", - "Import YouTube subscriptions": "YouTube Abonnements importieren", + "Import Invidious data": "Invidious-JSON-Daten importieren", + "Import YouTube subscriptions": "YouTube-CSV/OPML-Abonnements importieren", "Import FreeTube subscriptions (.db)": "FreeTube Abonnements importieren (.db)", "Import NewPipe subscriptions (.json)": "NewPipe Abonnements importieren (.json)", "Import NewPipe data (.zip)": "NewPipe Daten importieren (.zip)", "Export": "Exportieren", "Export subscriptions as OPML": "Abonnements als OPML exportieren", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonnements als OPML exportieren (für NewPipe & FreeTube)", - "Export data as JSON": "Daten als JSON exportieren", + "Export data as JSON": "Invidious-Daten als JSON exportieren", "Delete account?": "Konto löschen?", "History": "Verlauf", "An alternative front-end to YouTube": "Eine alternative Oberfläche für YouTube", @@ -49,8 +36,7 @@ "source": "Quelle", "Log in": "Anmelden", "Log in/register": "Anmelden/registrieren", - "Log in with Google": "Mit Google anmelden", - "User ID": "Benutzer ID", + "User ID": "Benutzer-ID", "Password": "Passwort", "Time (h:mm:ss):": "Zeit (h:mm:ss):", "Text CAPTCHA": "Text CAPTCHA", @@ -58,38 +44,41 @@ "Sign In": "Anmelden", "Register": "Registrieren", "E-mail": "E-Mail", - "Google verification code": "Google-Bestätigungscode", "Preferences": "Einstellungen", - "Player preferences": "Wiedergabeeinstellungen", - "Always loop: ": "Immer wiederholen: ", - "Autoplay: ": "Automatisch abspielen: ", - "Play next by default: ": "Immer automatisch nächstes Video spielen: ", - "Autoplay next video: ": "nächstes Video automatisch abspielen: ", - "Listen by default: ": "Nur Ton als Standard: ", - "Proxy videos: ": "Proxy-Videos: ", - "Default speed: ": "Standardgeschwindigkeit: ", - "Preferred video quality: ": "Bevorzugte Videoqualität: ", - "Player volume: ": "Wiedergabelautstärke: ", - "Default comments: ": "Standardkommentare: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "Standarduntertitel: ", + "preferences_category_player": "Wiedergabeeinstellungen", + "preferences_video_loop_label": "Immer wiederholen: ", + "preferences_preload_label": "Videodaten vorladen: ", + "preferences_autoplay_label": "Automatisch abspielen: ", + "preferences_continue_label": "Immer automatisch nächstes Video abspielen: ", + "preferences_continue_autoplay_label": "Nächstes Video automatisch abspielen: ", + "preferences_listen_label": "Nur Ton als Standard: ", + "preferences_local_label": "Videos durch Proxy leiten: ", + "preferences_speed_label": "Standardgeschwindigkeit: ", + "preferences_quality_label": "Bevorzugte Videoqualität: ", + "preferences_volume_label": "Wiedergabelautstärke: ", + "preferences_comments_label": "Standardkommentare: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Standarduntertitel: ", "Fallback captions: ": "Ersatzuntertitel: ", - "Show related videos: ": "Ähnliche Videos anzeigen? ", - "Show annotations by default: ": "Standardmäßig Anmerkungen anzeigen? ", - "Automatically extend video description: ": "", - "Visual preferences": "Anzeigeeinstellungen", - "Player style: ": "Abspielgeräterstil: ", + "preferences_related_videos_label": "Ähnliche Videos anzeigen: ", + "preferences_annotations_label": "Anmerkungen standardmäßig anzeigen: ", + "preferences_extend_desc_label": "Videobeschreibung automatisch erweitern: ", + "preferences_vr_mode_label": "Interaktive 360-Grad-Videos (erfordert WebGL): ", + "preferences_category_visual": "Anzeigeeinstellungen", + "preferences_player_style_label": "Player-Stil: ", "Dark mode: ": "Nachtmodus: ", - "Theme: ": "Modus: ", + "preferences_dark_mode_label": "Modus: ", "dark": "Nachtmodus", - "light": "klarer Modus", - "Thin mode: ": "Schlanker Modus: ", - "Subscription preferences": "Abonnementeinstellungen", - "Show annotations by default for subscribed channels: ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ", + "light": "hell", + "preferences_thin_mode_label": "Schlanker Modus: ", + "preferences_category_misc": "Sonstige Einstellungen", + "preferences_automatic_instance_redirect_label": "Automatische Instanzweiterleitung (über redirect.invidious.io): ", + "preferences_category_subscription": "Abonnementeinstellungen", + "preferences_annotations_subscribed_label": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ", "Redirect homepage to feed: ": "Startseite zu Feed umleiten: ", - "Number of videos shown in feed: ": "Anzahl von Videos die im Feed angezeigt werden: ", - "Sort videos by: ": "Videos sortieren nach: ", + "preferences_max_results_label": "Anzahl von Videos die im Feed angezeigt werden: ", + "preferences_sort_label": "Videos sortieren nach: ", "published": "veröffentlicht", "published - reverse": "veröffentlicht - invertiert", "alphabetically": "alphabetisch", @@ -98,22 +87,23 @@ "channel name - reverse": "Kanalname - invertiert", "Only show latest video from channel: ": "Nur neueste Videos des Kanals anzeigen: ", "Only show latest unwatched video from channel: ": "Nur neueste ungesehene Videos des Kanals anzeigen: ", - "Only show unwatched: ": "Nur ungesehene anzeigen: ", - "Only show notifications (if there are any): ": "Nur Benachrichtigungen anzeigen (wenn es welche gibt): ", + "preferences_unseen_only_label": "Nur ungesehene anzeigen: ", + "preferences_notifications_only_label": "Nur Benachrichtigungen anzeigen (wenn es welche gibt): ", "Enable web notifications": "Webbenachrichtigungen aktivieren", "`x` uploaded a video": "`x` hat ein Video hochgeladen", "`x` is live": "`x` ist live", - "Data preferences": "Dateneinstellungen", + "preferences_category_data": "Dateneinstellungen", "Clear watch history": "Verlauf löschen", - "Import/export data": "Daten im-/exportieren", + "Import/export data": "Daten importieren/exportieren", "Change password": "Passwort ändern", "Manage subscriptions": "Abonnements verwalten", "Manage tokens": "Tokens verwalten", - "Watch history": "Verlauf", + "Watch history": "Wiedergabeverlauf", "Delete account": "Account löschen", - "Administrator preferences": "Administrator-Einstellungen", - "Default homepage: ": "Standard-Startseite: ", - "Feed menu: ": "Feed-Menü: ", + "preferences_category_admin": "Administrator-Einstellungen", + "preferences_default_home_label": "Standard-Startseite: ", + "preferences_feed_menu_label": "Feed-Menü: ", + "preferences_show_nick_label": "Nutzernamen oben anzeigen: ", "Top enabled: ": "Top aktiviert? ", "CAPTCHA enabled: ": "CAPTCHA aktiviert? ", "Login enabled: ": "Anmeldung aktiviert: ", @@ -123,29 +113,17 @@ "Subscription manager": "Abonnementverwaltung", "Token manager": "Tokenverwalter", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Abonnements", - "": "`x` Abonnements" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Tokens", - "": "`x` Tokens" - }, "Import/export": "Importieren/Exportieren", "unsubscribe": "abbestellen", "revoke": "widerrufen", "Subscriptions": "Abonnements", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ungesehene Benachrichtigungen", - "": "`x` ungesehene Benachrichtigungen" - }, "search": "Suchen", "Log out": "Abmelden", - "Released under the AGPLv3 by Omar Roth.": "Veröffentlicht unter AGPLv3 von Omar Roth.", + "Released under the AGPLv3 on Github.": "Auf GitHub unter der AGPLv3 Lizenz veröffentlicht.", "Source available here.": "Quellcode verfügbar hier.", "View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.", "View privacy policy.": "Datenschutzerklärung einsehen.", - "Trending": "Trending", + "Trending": "Angesagt", "Public": "Öffentlich", "Unlisted": "Nicht aufgeführt", "Private": "Privat", @@ -157,9 +135,10 @@ "Title": "Titel", "Playlist privacy": "Vertrauliche Wiedergabeliste", "Editing playlist `x`": "Wiedergabeliste bearbeiten `x`", - "Show more": "", - "Show less": "", + "Show more": "Mehr anzeigen", + "Show less": "Weniger anzeigen", "Watch on YouTube": "Video auf YouTube ansehen", + "Switch Invidious Instance": "Invidious Instanz wechseln", "Hide annotations": "Anmerkungen ausblenden", "Show annotations": "Anmerkungen anzeigen", "Genre: ": "Genre: ", @@ -170,11 +149,7 @@ "Whitelisted regions: ": "Erlaubte Regionen: ", "Blacklisted regions: ": "Unerlaubte Regionen: ", "Shared `x`": "Geteilt `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Aufrufe", - "": "`x` Aufrufe" - }, - "Premieres in `x`": "Zuerst gesehen in `x`", + "Premieres in `x`": "Premiere in `x`", "Premieres `x`": "Erster Start `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hallo! Anscheinend haben Sie JavaScript deaktiviert. Klicken Sie hier um Kommentare anzuzeigen, beachten sie dass es etwas länger dauern kann um sie zu laden.", "View YouTube comments": "YouTube Kommentare anzeigen", @@ -187,17 +162,12 @@ "Hide replies": "Antworten verstecken", "Show replies": "Antworten anzeigen", "Incorrect password": "Falsches Passwort", - "Quota exceeded, try again in a few hours": "Kontingent überschritten, versuche es in ein paar Stunden erneut", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Anmeldung nicht möglich, stellen Sie sicher, dass die Zwei-Faktor-Authentisierung (Authenticator oder SMS) aktiviert ist.", - "Invalid TFA code": "Ungültiger TFA Code", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Die Anmeldung ist fehlgeschlagen. Dies kann daran liegen, dass die Zwei-Faktor-Authentisierung für Ihr Konto nicht aktiviert ist.", "Wrong answer": "Ungültige Antwort", "Erroneous CAPTCHA": "Ungültiges CAPTCHA", "CAPTCHA is a required field": "CAPTCHA ist eine erforderliche Eingabe", "User ID is a required field": "Benutzer ID ist eine erforderliche Eingabe", "Password is a required field": "Passwort ist eine erforderliche Eingabe", "Wrong username or password": "Ungültiger Benutzername oder Passwort", - "Please sign in using 'Log in with Google'": "Bitte melden Sie sich mit „Mit Google anmelden“ an", "Password cannot be empty": "Passwort darf nicht leer sein", "Password cannot be longer than 55 characters": "Passwort darf nicht länger als 55 Zeichen sein", "Please log in": "Bitte anmelden", @@ -207,21 +177,13 @@ "This channel does not exist.": "Dieser Kanal existiert nicht.", "Could not get channel info.": "Kanalinformationen konnten nicht geladen werden.", "Could not fetch comments": "Kommentare konnten nicht geladen werden", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Zeige `x` Antworten", - "": "Zeige `x` Antworten" - }, "`x` ago": "vor `x`", "Load more": "Mehr laden", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Punkte", - "": "`x` Punkte" - }, "Could not create mix.": "Mix konnte nicht erstellt werden.", - "Empty playlist": "Playlist ist leer", - "Not a playlist.": "Ungültige Playlist.", - "Playlist does not exist.": "Playlist existiert nicht.", - "Could not pull trending pages.": "Trending Seiten konnten nicht geladen werden.", + "Empty playlist": "Wiedergabeliste ist leer", + "Not a playlist.": "Ungültige Wiedergabeliste.", + "Playlist does not exist.": "Wiedergabeliste existiert nicht.", + "Could not pull trending pages.": "Trendenz-Seiten konnten nicht geladen werden.", "Hidden field \"challenge\" is a required field": "Verstecktes Feld „challenge“ ist eine erforderliche Eingabe", "Hidden field \"token\" is a required field": "Verstecktes Feld „token“ ist eine erforderliche Eingabe", "Erroneous challenge": "Ungültiger Test", @@ -334,41 +296,13 @@ "Yiddish": "Jiddisch", "Yoruba": "Joruba", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Jahre", - "": "`x` Jahre" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Monate", - "": "`x` Monate" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Wochen", - "": "`x` Wochen" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Tage", - "": "`x` Tage" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Stunden", - "": "`x` Stunden" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Minuten", - "": "`x` Minuten" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Sekunden", - "": "`x` Sekunden" - }, "Fallback comments: ": "Alternative Kommentare: ", "Popular": "Populär", - "Search": "", + "Search": "Suchen", "Top": "Top", "About": "Über", "Rating: ": "Bewertung: ", - "Language: ": "Sprache: ", + "preferences_locale_label": "Sprache: ", "View as playlist": "Als Wiedergabeliste anzeigen", "Default": "Standard", "Music": "Musik", @@ -384,35 +318,184 @@ "`x` marked it with a ❤": "`x` markierte es mit einem ❤", "Audio mode": "Audiomodus", "Video mode": "Videomodus", - "Videos": "Videos", + "channel_tab_videos_label": "Videos", "Playlists": "Wiedergabelisten", - "Community": "Gemeinschaft", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Aktuelle Version: " -}
\ No newline at end of file + "channel_tab_community_label": "Gemeinschaft", + "search_filters_sort_option_relevance": "Relevanz", + "search_filters_sort_option_rating": "Bewertung", + "search_filters_sort_option_date": "Hochladedatum", + "search_filters_sort_option_views": "Aufrufe", + "search_filters_type_label": "Inhaltstyp", + "search_filters_duration_label": "Dauer", + "search_filters_features_label": "Eigenschaften", + "search_filters_sort_label": "sortieren", + "search_filters_date_option_hour": "Letzte Stunde", + "search_filters_date_option_today": "Heute", + "search_filters_date_option_week": "Diese Woche", + "search_filters_date_option_month": "Diesen Monat", + "search_filters_date_option_year": "Dieses Jahr", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Kanal", + "search_filters_type_option_playlist": "Wiedergabeliste", + "search_filters_type_option_movie": "Film", + "search_filters_type_option_show": "Anzeigen", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Untertitel / CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Live", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Standort", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Aktuelle Version: ", + "next_steps_error_message": "Danach folgendes versuchen: ", + "next_steps_error_message_refresh": "Aktualisieren", + "next_steps_error_message_go_to_youtube": "Zu YouTube gehen", + "footer_donate_page": "Spende", + "search_filters_duration_option_long": "Lang (> 20 Minuten)", + "footer_original_source_code": "Original Quellcode", + "footer_modfied_source_code": "Modifizierter Quellcode", + "footer_documentation": "Dokumentation", + "footer_source_code": "Quellcode", + "adminprefs_modified_source_code_url_label": "URL zum Repositorie des modifizierten Quellcodes", + "search_filters_duration_option_short": "Kurz (< 4 Minuten)", + "preferences_region_label": "Land der Inhalte: ", + "preferences_quality_option_dash": "DASH (adaptive Qualität)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Mittel", + "preferences_quality_option_small": "Niedrig", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "videoinfo_invidious_embed_link": "Link zum Einbetten", + "download_subtitles": "Untertitel - `x` (.vtt)", + "Video unavailable": "Video nicht verfügbar", + "user_created_playlists": "`x` Wiedergabelisten erstellt", + "user_saved_playlists": "`x` Wiedergabelisten gespeichert", + "preferences_save_player_pos_label": "Wiedergabeposition speichern: ", + "search_filters_features_option_three_sixty": "360°", + "preferences_quality_dash_option_best": "Höchste", + "preferences_quality_dash_option_worst": "Niedrigste", + "preferences_quality_dash_option_1440p": "1440p", + "videoinfo_youTube_embed_link": "Eingebettet", + "search_filters_features_option_purchased": "Gekauft", + "none": "keine", + "videoinfo_started_streaming_x_ago": "Stream begann vor `x`", + "videoinfo_watch_on_youTube": "Auf YouTube ansehen", + "preferences_quality_dash_label": "Bevorzugte DASH-Videoqualität: ", + "generic_subscribers_count": "{{count}} Abonnent", + "generic_subscribers_count_plural": "{{count}} Abonnenten", + "generic_videos_count": "{{count}} Video", + "generic_videos_count_plural": "{{count}} Videos", + "subscriptions_unseen_notifs_count": "{{count}} ungesehene Benachrichtung", + "subscriptions_unseen_notifs_count_plural": "{{count}} ungesehene Benachrichtungen", + "crash_page_refresh": "Versucht haben, <a href=\"`x`\">die Seite neu zu laden</a>", + "comments_view_x_replies": "{{count}} Antwort anzeigen", + "comments_view_x_replies_plural": "{{count}} Antworten anzeigen", + "generic_count_years": "{{count}} Jahr", + "generic_count_years_plural": "{{count}} Jahre", + "generic_count_weeks": "{{count}} Woche", + "generic_count_weeks_plural": "{{count}} Wochen", + "generic_count_days": "{{count}} Tag", + "generic_count_days_plural": "{{count}} Tage", + "crash_page_before_reporting": "Bevor Sie einen Bug melden, stellen Sie sicher, dass Sie:", + "crash_page_switch_instance": "Eine <a href=\"`x`\">andere Instanz</a> versucht haben", + "generic_count_hours": "{{count}} Stunde", + "generic_count_hours_plural": "{{count}} Stunden", + "generic_count_minutes": "{{count}} Minute", + "generic_count_minutes_plural": "{{count}} Minuten", + "crash_page_read_the_faq": "Das <a href=\"`x`\">FAQ</a> gelesen haben", + "crash_page_search_issue": "Nach <a href=\"`x`\">bereits gemeldeten Bugs auf GitHub</a> gesucht haben", + "crash_page_report_issue": "Wenn all dies nicht geholfen hat, <a href=\"`x`\">öffnen Sie bitte ein neues Problem (issue) auf Github</a> (vorzugsweise auf Englisch) und fügen Sie den folgenden Text in Ihre Nachricht ein (bitte übersetzen Sie diesen Text NICHT):", + "generic_views_count": "{{count}} Aufruf", + "generic_views_count_plural": "{{count}} Aufrufe", + "generic_count_seconds": "{{count}} Sekunde", + "generic_count_seconds_plural": "{{count}} Sekunden", + "generic_subscriptions_count": "{{count}} Abo", + "generic_subscriptions_count_plural": "{{count}} Abos", + "tokens_count": "{{count}} Token", + "tokens_count_plural": "{{count}} Tokens", + "comments_points_count": "{{count}} Punkt", + "comments_points_count_plural": "{{count}} Punkte", + "crash_page_you_found_a_bug": "Anscheinend haben Sie einen Fehler in Invidious gefunden!", + "generic_count_months": "{{count}} Monat", + "generic_count_months_plural": "{{count}} Monaten", + "Cantonese (Hong Kong)": "Kantonesisch (Hong Kong)", + "Chinese (Hong Kong)": "Chinesisch (Hong Kong)", + "generic_playlists_count": "{{count}} Wiedergabeliste", + "generic_playlists_count_plural": "{{count}} Wiedergabelisten", + "preferences_watch_history_label": "Wiedergabeverlauf aktivieren: ", + "English (United Kingdom)": "Englisch (Vereinigtes Königreich)", + "English (United States)": "Englisch (Vereinigte Staaten)", + "Dutch (auto-generated)": "Niederländisch (automatisch generiert)", + "French (auto-generated)": "Französisch (automatisch generiert)", + "German (auto-generated)": "Deutsch (automatisch generiert)", + "Indonesian (auto-generated)": "Indonesisch (automatisch generiert)", + "Interlingue": "Interlingue", + "Italian (auto-generated)": "Italienisch (automatisch generiert)", + "Japanese (auto-generated)": "Japanisch (automatisch generiert)", + "Spanish (Mexico)": "Spanisch (Mexiko)", + "Spanish (Spain)": "Spanisch (Spanien)", + "Vietnamese (auto-generated)": "Vietnamesisch (automatisch generiert)", + "Russian (auto-generated)": "Russisch (automatisch generiert)", + "Chinese": "Chinesisch", + "Portuguese (Brazil)": "Portugiesisch (Brasilien)", + "Spanish (auto-generated)": "Spanisch (automatisch generiert)", + "Turkish (auto-generated)": "Türkisch (automatisch generiert)", + "Chinese (China)": "Chinesisch (China)", + "Chinese (Taiwan)": "Chinesisch (Taiwan)", + "Korean (auto-generated)": "Koreanisch (automatisch generiert)", + "Portuguese (auto-generated)": "Portugiesisch (automatisch generiert)", + "search_filters_title": "Filtern", + "search_message_change_filters_or_query": "Versuchen Sie, Ihre Suchanfrage zu erweitern und/oder die Filter zu ändern.", + "search_message_use_another_instance": "Sie können auch <a href=\"`x`\">auf einer anderen Instanz suchen</a>.", + "Popular enabled: ": "„Beliebt“-Seite aktiviert: ", + "search_message_no_results": "Keine Ergebnisse gefunden.", + "search_filters_duration_option_medium": "Mittel (4 - 20 Minuten)", + "search_filters_features_option_vr180": "VR180", + "search_filters_type_option_all": "Beliebiger Typ", + "search_filters_apply_button": "Ausgewählte Filter anwenden", + "search_filters_duration_option_none": "Beliebige Länge", + "search_filters_date_label": "Upload-Datum", + "search_filters_date_option_none": "Beliebiges Datum", + "error_video_not_in_playlist": "Das angeforderte Video existiert nicht in dieser Wiedergabeliste. <a href=\"`x`\">Klicken Sie hier, um zur Startseite der Wiedergabeliste zu gelangen.</a>", + "channel_tab_shorts_label": "Shorts", + "channel_tab_streams_label": "Livestreams", + "Music in this video": "Musik in diesem Video", + "Artist: ": "Künstler: ", + "Album: ": "Album: ", + "channel_tab_playlists_label": "Wiedergabelisten", + "channel_tab_channels_label": "Kanäle", + "Channel Sponsor": "Kanalsponsor", + "Standard YouTube license": "Standard YouTube-Lizenz", + "Song: ": "Musik: ", + "Download is disabled": "Herunterladen ist deaktiviert", + "Import YouTube playlist (.csv)": "YouTube Wiedergabeliste importieren (.csv)", + "generic_button_delete": "Löschen", + "generic_button_edit": "Bearbeiten", + "generic_button_save": "Speichern", + "generic_button_cancel": "Abbrechen", + "generic_button_rss": "RSS", + "playlist_button_add_items": "Videos hinzufügen", + "channel_tab_podcasts_label": "Podcasts", + "channel_tab_releases_label": "Veröffentlichungen", + "generic_channels_count": "{{count}} Kanal", + "generic_channels_count_plural": "{{count}} Kanäle", + "Import YouTube watch history (.json)": "YouTube Wiedergabeverlauf importieren (.json)", + "Answer": "Antwort", + "The Popular feed has been disabled by the administrator.": "Der Angesagt-Feed wurde vom Administrator deaktiviert.", + "Add to playlist": "Einer Wiedergabeliste hinzufügen", + "Search for videos": "Nach Videos suchen", + "toggle_theme": "Thema wechseln", + "Add to playlist: ": "Einer Wiedergabeliste hinzufügen: ", + "carousel_go_to": "Zu Folie `x` gehen", + "carousel_slide": "Folie {{current}} von {{total}}", + "carousel_skip": "Karussell überspringen" +} diff --git a/locales/el.json b/locales/el.json index 5eb4a304..38550458 100644 --- a/locales/el.json +++ b/locales/el.json @@ -1,16 +1,4 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` συνδρομητές", - "": "`x` συνδρομητές" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` βίντεο", - "": "`x` βίντεο" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` λίστες αναπαραγωγής", - "": "`x` λίστες αναπαραγωγής" - }, "LIVE": "ΖΩΝΤΑΝΑ", "Shared `x` ago": "Μοιράστηκε πριν από `x`", "Unsubscribe": "Απεγγραφή", @@ -26,22 +14,21 @@ "Clear watch history?": "Διαγραφή ιστορικού προβολής;", "New password": "Νέος κωδικός πρόσβασης", "New passwords must match": "Οι νέοι κωδικοί πρόσβασης πρέπει να ταιριάζουν", - "Cannot change password for Google accounts": "Δεν επιτρέπεται η αλλαγή κωδικού πρόσβασης λογαριασμών Google", "Authorize token?": "Εξουσιοδότηση διασύνδεσης;", "Authorize token for `x`?": "Εξουσιοδότηση διασύνδεσης με `x`;", "Yes": "Ναι", "No": "Όχι", "Import and Export Data": "Εισαγωγή και Εξαγωγή Δεδομένων", "Import": "Εισαγωγή", - "Import Invidious data": "Εισαγωγή δεδομένων Invidious", - "Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube", + "Import Invidious data": "Εsαγωγή δεδομένων Invidious JSON", + "Import YouTube subscriptions": "Εισαγωγή συνδρομών YouTube/OPML", "Import FreeTube subscriptions (.db)": "Εισαγωγή συνδρομών FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Εισαγωγή συνδρομών NewPipe (.json)", "Import NewPipe data (.zip)": "Εισαγωγή δεδομένων NewPipe (.zip)", "Export": "Εξαγωγή", "Export subscriptions as OPML": "Εξαγωγή συνδρομών ως OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Εξαγωγή συνδρομών ως OPML (για NewPipe & FreeTube)", - "Export data as JSON": "Εξαγωγή δεδομένων ως JSON", + "Export data as JSON": "Εξαγωγή δεδομένων Invidious ως JSON", "Delete account?": "Διαγραφή λογαριασμού;", "History": "Ιστορικό", "An alternative front-end to YouTube": "Μία εναλλακτική πλατφόρμα για το YouTube", @@ -49,47 +36,44 @@ "source": "πηγή", "Log in": "Σύνδεση", "Log in/register": "Σύνδεση/εγγραφή", - "Log in with Google": "Σύνδεση με Google", "User ID": "Ταυτότητα χρήστη", "Password": "Κωδικός πρόσβασης", "Time (h:mm:ss):": "Ώρα (ω:λλ:δδ):", "Text CAPTCHA": "Κείμενο CAPTCHA", "Image CAPTCHA": "Εικόνα CAPTCHA", - "Sign In": "Σύνδεση", + "Sign In": "Εγγραφή", "Register": "Εγγραφή", "E-mail": "Ηλεκτρονικό ταχυδρομείο", - "Google verification code": "Κωδικός επαλήθευσης Google", "Preferences": "Προτιμήσεις", - "Player preferences": "Προτιμήσεις αναπαραγωγής", - "Always loop: ": "Αυτόματη επανάληψη: ", - "Autoplay: ": "Αυτόματη αναπαραγωγή: ", - "Play next by default: ": "Αναπαραγωγή επόμενου: ", - "Autoplay next video: ": "Αυτόματη αναπαραγωγή επόμενου: ", - "Listen by default: ": "Φόρτωση μόνο ήχου: ", - "Proxy videos: ": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ", - "Default speed: ": "Προεπιλεγμένη ταχύτητα: ", - "Preferred video quality: ": "Προτιμώμενη ανάλυση: ", - "Player volume: ": "Ένταση αναπαραγωγής: ", - "Default comments: ": "Προεπιλεγμένα σχόλια: ", + "preferences_category_player": "Προτιμήσεις αναπαραγωγής", + "preferences_video_loop_label": "Αυτόματη επανάληψη: ", + "preferences_autoplay_label": "Αυτόματη αναπαραγωγή: ", + "preferences_continue_label": "Αναπαραγωγή επόμενου: ", + "preferences_continue_autoplay_label": "Αυτόματη αναπαραγωγή επόμενου: ", + "preferences_listen_label": "Φόρτωση μόνο ήχου: ", + "preferences_local_label": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ", + "preferences_speed_label": "Προεπιλεγμένη ταχύτητα: ", + "preferences_quality_label": "Προτιμώμενη ανάλυση: ", + "preferences_volume_label": "Ένταση αναπαραγωγής: ", + "preferences_comments_label": "Προεπιλεγμένα σχόλια: ", "youtube": "YouTube", - "reddit": "reddit", - "Default captions: ": "Προεπιλεγμένοι υπότιτλοι: ", + "reddit": "Reddit", + "preferences_captions_label": "Προεπιλεγμένοι υπότιτλοι: ", "Fallback captions: ": "Εναλλακτικοί υπότιτλοι: ", - "Show related videos: ": "Προβολή σχετικών βίντεο; ", - "Show annotations by default: ": "Αυτόματη προβολή σημειώσεων: ", - "Automatically extend video description: ": "", - "Visual preferences": "Προτιμήσεις εμφάνισης", - "Player style: ": "Τεχνοτροπία της συσκευής αναπαραγωγης: ", + "preferences_related_videos_label": "Προβολή σχετικών βίντεο; ", + "preferences_annotations_label": "Αυτόματη προβολή σημειώσεων: ", + "preferences_category_visual": "Προτιμήσεις εμφάνισης", + "preferences_player_style_label": "Τεχνοτροπία της συσκευής αναπαραγωγης: ", "Dark mode: ": "Σκοτεινή λειτουργία: ", - "Theme: ": "Θέμα: ", + "preferences_dark_mode_label": "Θέμα: ", "dark": "σκοτεινό", "light": "φωτεινό", - "Thin mode: ": "Ελαφριά λειτουργία: ", - "Subscription preferences": "Προτιμήσεις συνδρομών", - "Show annotations by default for subscribed channels: ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ", + "preferences_thin_mode_label": "Ελαφριά λειτουργία: ", + "preferences_category_subscription": "Προτιμήσεις συνδρομών", + "preferences_annotations_subscribed_label": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ", "Redirect homepage to feed: ": "Ανακατεύθυνση αρχικής στη ροή συνδρομών: ", - "Number of videos shown in feed: ": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ", - "Sort videos by: ": "Ταξινόμηση ανά: ", + "preferences_max_results_label": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ", + "preferences_sort_label": "Ταξινόμηση ανά: ", "published": "ημερομηνία δημοσίευσης", "published - reverse": "ημερομηνία δημοσίευσης - ανάποδα", "alphabetically": "αλφαβητικά", @@ -98,12 +82,12 @@ "channel name - reverse": "όνομα καναλιού - ανάποδα", "Only show latest video from channel: ": "Προβολή μόνο του τελευταίου βίντεο του καναλιού: ", "Only show latest unwatched video from channel: ": "Προβολή μόνο του τελευταίου μη-προβεβλημένου βίντεο του καναλιού: ", - "Only show unwatched: ": "Προβολή μόνο μη-προβεβλημένων: ", - "Only show notifications (if there are any): ": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ", + "preferences_unseen_only_label": "Προβολή μόνο μη-προβεβλημένων: ", + "preferences_notifications_only_label": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ", "Enable web notifications": "Ενεργοποίηση ειδοποιήσεων δικτύου", "`x` uploaded a video": "`x` κοινοποίησε ένα βίντεο", "`x` is live": "`x` κάνει live", - "Data preferences": "Προτιμήσεις δεδομένων", + "preferences_category_data": "Προτιμήσεις δεδομένων", "Clear watch history": "Εκκαθάριση ιστορικού προβολής", "Import/export data": "Εισαγωγή/εξαγωγή δεδομένων", "Change password": "Αλλαγή κωδικού πρόσβασης", @@ -111,9 +95,9 @@ "Manage tokens": "Διαχείριση διασυνδέσεων", "Watch history": "Ιστορικό προβολής", "Delete account": "Διαγραφή λογαριασμού", - "Administrator preferences": "Προτιμήσεις διαχειριστή", - "Default homepage: ": "Προεπιλεγμένη αρχική: ", - "Feed menu: ": "Μενού ροής συνδρομών: ", + "preferences_category_admin": "Προτιμήσεις διαχειριστή", + "preferences_default_home_label": "Προεπιλεγμένη αρχική: ", + "preferences_feed_menu_label": "Μενού ροής συνδρομών: ", "Top enabled: ": "Ενεργοποίηση κορυφαίων; ", "CAPTCHA enabled: ": "Ενεργοποίηση CAPTCHA; ", "Login enabled: ": "Ενεργοποίηση σύνδεσης; ", @@ -123,25 +107,12 @@ "Subscription manager": "Διαχειριστής συνδρομών", "Token manager": "Διαχειριστής διασυνδέσεων", "Token": "Διασύνδεση", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` συνδρομή", - "": "`x` συνδρομές" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` διασύνδεση", - "": "`x` διασυνδέσεις" - }, "Import/export": "Εισαγωγή/εξαγωγή", "unsubscribe": "κατάργηση συνδρομής", "revoke": "ανάκληση", "Subscriptions": "Συνδρομές", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` καινούρια ειδοποίηση", - "": "`x` καινούριες ειδοποιήσεις" - }, "search": "αναζήτηση", "Log out": "Αποσύνδεση", - "Released under the AGPLv3 by Omar Roth.": "Κυκλοφορεί υπό την άδεια AGPLv3 από τον Omar Roth.", "Source available here.": "Προβολή πηγαίου κώδικα εδώ.", "View JavaScript license information.": "Προβολή πληροφοριών άδειας JavaScript.", "View privacy policy.": "Προβολή πολιτικής απορρήτου.", @@ -157,8 +128,6 @@ "Title": "Τίτλος", "Playlist privacy": "Ιδιωτικότητα καταλόγων αναπαραγωγής", "Editing playlist `x`": "Επεξεργασία `x` καταλόγου αναπαραγωγής", - "Show more": "", - "Show less": "", "Watch on YouTube": "Προβολή στο YouTube", "Hide annotations": "Απόκρυψη σημειώσεων", "Show annotations": "Προβολή σημειώσεων", @@ -170,34 +139,25 @@ "Whitelisted regions: ": "Επιτρεπτές περιοχές: ", "Blacklisted regions: ": "Μη-επιτρεπτές περιοχές: ", "Shared `x`": "Μοιράστηκε το `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` προβολή", - "": "`x` προβολές" - }, "Premieres in `x`": "Πρώτη προβολή σε `x`", "Premieres `x`": "Επίσημη πρώτη παράσταση του `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Γεια! Φαίνεται πως έχετε απενεργοποιήσει το JavaScript. Πατήστε εδώ για προβολή σχολίων, αλλά έχετε υπ'όψιν σας πως ίσως φορτώσουν πιο αργά.", "View YouTube comments": "Προβολή σχολίων από το YouTube", "View more comments on Reddit": "Προβολή περισσότερων σχολίων στο Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Προβολή `x` σχολίων", + "([^.,0-9]|^)1([^.,0-9]|$)": "Προβολή `x` σχολίου", "": "Προβολή `x` σχολίων" }, "View Reddit comments": "Προβολή σχολίων από το Reddit", "Hide replies": "Απόκρυψη απαντήσεων", "Show replies": "Προβολή απαντήσεων", "Incorrect password": "Λανθασμένος κωδικός πρόσβασης", - "Quota exceeded, try again in a few hours": "Έχετε υπερβεί το όριο προσπαθειών, δοκιμάστε ξανα σε λίγες ώρες", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Αδυναμία σύνδεσης, βεβαιωθείτε πως ο έλεγχος ταυτότητας δύο παραγόντων (με Authenticator ή SMS) είναι ενεργοποιημένος.", - "Invalid TFA code": "Μη έγκυρος κωδικός ελέγχου ταυτότητας δύο παραγόντων", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Αποτυχία σύνδεσης. Ίσως ευθύνεται η έλλειψη ελέγχου ταυτότητας δύο παραγόντων για το λογαριασμό σας.", "Wrong answer": "Λανθασμένη απάντηση", "Erroneous CAPTCHA": "Λανθασμένο CAPTCHA", "CAPTCHA is a required field": "Το CAPTCHA είναι απαιτούμενο πεδίο", "User ID is a required field": "Η ταυτότητα χρήστη είναι απαιτούμενο πεδίο", "Password is a required field": "Ο κωδικός πρόσβασης είναι απαιτούμενο πεδίο", "Wrong username or password": "Λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης", - "Please sign in using 'Log in with Google'": "Συνδεθείτε με την επιλογή 'Σύνδεση με Google'", "Password cannot be empty": "Ο κωδικός πρόσβασης δεν γίνεται να είναι κενός", "Password cannot be longer than 55 characters": "Ο κωδικός πρόσβασης δεν γίνεται να υπερβαίνει τους 55 χαρακτήρες", "Please log in": "Συνδεθείτε", @@ -207,16 +167,8 @@ "This channel does not exist.": "Αυτό το κανάλι δεν υπάρχει.", "Could not get channel info.": "Αδύναμια εύρεσης πληροφοριών καναλιού.", "Could not fetch comments": "Αδυναμία λήψης σχολίων", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Προβολή `x` απάντησης", - "": "Προβολή `x` απαντήσεων" - }, "`x` ago": "Πριν `x`", "Load more": "Φόρτωση περισσότερων", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` βαθμός", - "": "`x` βαθμοί" - }, "Could not create mix.": "Αδυναμία δημιουργίας μίξης.", "Empty playlist": "Κενή λίστα αναπαραγωγής", "Not a playlist.": "Μη έγκυρη λίστα αναπαραγωγής.", @@ -334,41 +286,12 @@ "Yiddish": "Γίντις", "Yoruba": "Γιορούμπα", "Zulu": "Ζουλού", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` χρόνο", - "": "`x` χρόνια" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` μήνα", - "": "`x` μήνες" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` εβδομάδα", - "": "`x` εβδομάδες" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ημέρα", - "": "`x` ημέρες" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ώρα", - "": "`x` ώρες" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` λεπτό", - "": "`x` λεπτά" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` δευτερόλεπτο", - "": "`x` δευτερόλεπτα" - }, "Fallback comments: ": "Εναλλακτικά σχόλια: ", "Popular": "Δημοφιλή", - "Search": "", "Top": "Κορυφαία", "About": "Σχετικά", "Rating: ": "Aξιολόγηση: ", - "Language: ": "Γλώσσα: ", + "preferences_locale_label": "Γλώσσα: ", "View as playlist": "Προβολή ως λίστα αναπαραγωγής", "Default": "Προεπιλογή", "Music": "Μουσική", @@ -384,35 +307,192 @@ "`x` marked it with a ❤": "Ο χρηστης `x` έβαλε ❤", "Audio mode": "Λειτουργία ήχου", "Video mode": "Λειτουργία βίντεο", - "Videos": "Βίντεο", + "channel_tab_videos_label": "Βίντεο", "Playlists": "Λίστες Αναπαραγωγής", - "Community": "Κοινότητα", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Τρέχουσα έκδοση: " -}
\ No newline at end of file + "channel_tab_community_label": "Κοινότητα", + "Current version: ": "Τρέχουσα έκδοση: ", + "generic_playlists_count": "{{count}} λίστα αναπαραγωγής", + "generic_playlists_count_plural": "{{count}} λίστες αναπαραγωγής", + "preferences_quality_dash_option_worst": "Χειρότερη", + "preferences_quality_dash_option_2160p": "2160 p", + "Video unavailable": "Το βίντεο δεν είναι διαθέσιμο", + "preferences_quality_dash_option_auto": "Αυτόματη", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "comments_view_x_replies": "Προβολή {{count}} απάντησης", + "comments_view_x_replies_plural": "Προβολή {{count}} απαντήσεων", + "crash_page_report_issue": "Εάν κανένα από τα παραπάνω δεν βοήθησε, παρακαλούμε <a href=\"`x`\">ανοίξτε ένα νέο θέμα στο GitHub</a> (κατά προτίμηση στα αγγλικά) και συμπεριλάβετε το ακόλουθο κείμενο στο μήνυμά σας (ΜΗΝ μεταφράζετε αυτό το κείμενο):", + "generic_count_hours": "{{count}} ώρα", + "generic_count_hours_plural": "{{count}} ώρες", + "generic_count_minutes": "{{count}} λεπτό", + "generic_count_minutes_plural": "{{count}} λεπτά", + "generic_count_seconds": "{{count}} δευτερόλεπτο", + "generic_count_seconds_plural": "{{count}} δευτερόλεπτα", + "preferences_quality_dash_label": "Προτιμώμενη ποιότητα βίντεο DASH: ", + "preferences_quality_dash_option_best": "Καλύτερη", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "generic_subscribers_count": "{{count}} συνδρομητής", + "generic_subscribers_count_plural": "{{count}} συνδρομητές", + "generic_subscriptions_count": "{{count}} συνδρομή", + "generic_subscriptions_count_plural": "{{count}} συνδρομές", + "generic_count_years": "{{count}} έτος", + "generic_count_years_plural": "{{count}} έτη", + "generic_count_months": "{{count}} μήνας", + "generic_count_months_plural": "{{count}} μήνες", + "generic_count_weeks": "{{count}} εβδομάδα", + "generic_count_weeks_plural": "{{count}} εβδομάδες", + "generic_count_days": "{{count}} ημέρα", + "generic_count_days_plural": "{{count}} ημέρες", + "crash_page_you_found_a_bug": "Φαίνεται ότι βρήκατε ένα σφάλμα στο Invidious!", + "crash_page_before_reporting": "Πριν αναφέρετε ένα σφάλμα, βεβαιωθείτε ότι έχετε:", + "crash_page_refresh": "προσπαθήσει να <a href=\"`x`\">ανανεώσετε τη σελίδα</a>", + "crash_page_read_the_faq": "διαβάστε τις <a href=\"`x`\">Συχνές Ερωτήσεις (ΣΕ)</a>", + "crash_page_search_issue": "αναζητήσει για <a href=\"`x`\">υπάρχοντα θέματα στο GitHub</a>", + "generic_views_count": "{{count}} προβολή", + "generic_views_count_plural": "{{count}} προβολές", + "generic_videos_count": "{{count}} βίντεο", + "generic_videos_count_plural": "{{count}} βίντεο", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Μεσαία", + "preferences_quality_option_small": "Μικρό", + "preferences_quality_option_dash": "DASH (προσαρμόσιμη ποιότητα)", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_720p": "720p", + "invidious": "Invidious", + "preferences_region_label": "Χώρα περιεχομένου: ", + "preferences_category_misc": "Διάφορες προτιμήσεις", + "Show more": "Εμφάνιση περισσότερων", + "search_filters_date_option_today": "Σήμερα", + "search_filters_features_option_three_sixty": "360°", + "videoinfo_started_streaming_x_ago": "Ξεκίνησε η ροή `x` πριν από", + "videoinfo_watch_on_youTube": "Παρακολουθήστε στο YouTube", + "download_subtitles": "Υπότιτλοι - `x` (.vtt)", + "user_created_playlists": "`x` δημιουργημένες λίστες αναπαραγωγής", + "user_saved_playlists": "`x` αποθηκευμένες λίστες αναπαραγωγής", + "search_filters_sort_option_rating": "Αξιολόγηση", + "search_filters_sort_option_relevance": "Συνάφεια", + "search_filters_features_option_purchased": "Αγορασμένο", + "search_filters_sort_option_date": "Ημερομηνία μεταφόρτωσης", + "search_filters_type_label": "Τύπος", + "search_filters_duration_label": "Διάρκεια", + "search_filters_date_option_week": "Αυτή την εβδομάδα", + "search_filters_date_option_year": "Φέτος", + "search_filters_type_option_channel": "Κανάλι", + "search_filters_type_option_playlist": "Λίστα αναπαραγωγής", + "search_filters_duration_option_long": "Μεγάλο (> 20 λεπτά)", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_location": "Τοποθεσία", + "search_filters_features_option_three_d": "3D", + "next_steps_error_message": "Μετά από αυτό θα πρέπει να προσπαθήσετε να: ", + "next_steps_error_message_go_to_youtube": "Μεταβείτε στο YouTube", + "footer_donate_page": "Δωρεά", + "footer_original_source_code": "Πρωτότυπος πηγαίος κώδικας", + "preferences_show_nick_label": "Εμφάνιση ψευδώνυμου στην κορυφή: ", + "search_filters_date_option_hour": "Τελευταία ώρα", + "adminprefs_modified_source_code_url_label": "URL σε αποθετήριο τροποποιημένου πηγαίου κώδικα", + "search_filters_features_option_subtitles": "Υπότιτλοι/CC", + "search_filters_date_option_month": "Αυτόν τον μήνα", + "Released under the AGPLv3 on Github.": "Κυκλοφορεί υπό την AGPLv3 στο GitHub.", + "search_filters_sort_label": "Ταξινόμηση κατά", + "search_filters_type_option_movie": "Ταινία", + "footer_modfied_source_code": "Τροποποιημένος πηγαίος κώδικας", + "search_filters_features_label": "Χαρακτηριστικά", + "search_filters_features_option_four_k": "4K", + "footer_documentation": "Τεκμηρίωση", + "search_filters_duration_option_short": "Σύντομο (< 4 λεπτά)", + "next_steps_error_message_refresh": "Ανανέωση", + "search_filters_type_option_video": "Βίντεο", + "search_filters_features_option_live": "Ζωντανά", + "search_filters_features_option_c_commons": "Creative Commons", + "Search": "Αναζήτηση", + "search_filters_features_option_hdr": "HDR", + "preferences_extend_desc_label": "Αυτόματη επέκταση της περιγραφής του βίντεο: ", + "preferences_vr_mode_label": "Διαδραστικά βίντεο 360 μοιρών (απαιτεί WebGL): ", + "Show less": "Εμφάνιση λιγότερων", + "footer_source_code": "Πηγαίος κώδικας", + "Chinese (Taiwan)": "Κινέζικα (Ταϊβάν)", + "Portuguese (Brazil)": "Πορτογαλικά (Βραζιλία)", + "German (auto-generated)": "Γερμανικά (αυτόματη παραγωγή)", + "Korean (auto-generated)": "Κορεάτικα (αυτόματη παραγωγή)", + "Russian (auto-generated)": "Ρωσικά (αυτόματη παραγωγή)", + "Spanish (auto-generated)": "Ισπανικά (αυτόματη παραγωγή)", + "Vietnamese (auto-generated)": "Βιετναμέζικα (αυτόματη παραγωγή)", + "English (United Kingdom)": "Αγγλικά (Ηνωμένο Βασίλειο)", + "English (United States)": "Αγγλικά (Ηνωμένων Πολιτειών)", + "Cantonese (Hong Kong)": "Καντονέζικα (Χονγκ Κονγκ)", + "Chinese": "Κινεζικά", + "Chinese (China)": "Κινέζικα (Κίνα)", + "Chinese (Hong Kong)": "Κινεζικά (Χονγκ Κονγκ)", + "Dutch (auto-generated)": "Ολαμδικά (αυτόματη παραγωγή)", + "French (auto-generated)": "Γαλλικά (αυτόματη παραγωγή)", + "Interlingue": "Ιντερλίνγκουα", + "Indonesian (auto-generated)": "Ινδονησιακά (αυτόματη παραγωγή)", + "Italian (auto-generated)": "Ιταλικά (αυτόματη παραγωγή)", + "Japanese (auto-generated)": "Ιαπωνικά (αυτόματη παραγωγή)", + "Portuguese (auto-generated)": "Πορτογαλικά (αυτόματη παραγωγή)", + "Spanish (Mexico)": "Ισπανικά (Μεξικό)", + "Spanish (Spain)": "Ισπανικά (Ισπανία)", + "Turkish (auto-generated)": "Τούρκικα (αυτόματη παραγωγή)", + "none": "κανένα", + "videoinfo_youTube_embed_link": "Ενσωμάτωση", + "videoinfo_invidious_embed_link": "Σύνδεσμος Ενσωμάτωσης", + "search_filters_type_option_show": "Μπάρα προόδου διαβάσματος", + "preferences_watch_history_label": "Ενεργοποίηση ιστορικού παρακολούθησης: ", + "search_filters_title": "Φίλτρο", + "search_message_no_results": "Δε βρέθηκαν αποτελέσματα.", + "channel_tab_podcasts_label": "Podcast", + "preferences_save_player_pos_label": "Αποθήκευση σημείου αναπαραγωγής: ", + "search_filters_apply_button": "Εφαρμογή επιλεγμένων φίλτρων", + "Download is disabled": "Είναι απενεργοποιημένη η λήψη", + "comments_points_count": "{{count}} βαθμός", + "comments_points_count_plural": "{{count}} βαθμοί", + "search_filters_sort_option_views": "Προβολές", + "search_message_change_filters_or_query": "Προσπαθήστε να διευρύνετε το ερώτημα αναζήτησης ή/και να αλλάξετε τα φίλτρα.", + "Channel Sponsor": "Χορηγός Καναλιού", + "channel_tab_streams_label": "Ζωντανή μετάδοση", + "playlist_button_add_items": "Προσθήκη βίντεο", + "Artist: ": "Καλλιτέχνης: ", + "search_message_use_another_instance": " Μπορείτε επίσης <a href=\"`x`\">να αναζητήσετε σε άλλο instance</a>.", + "generic_button_save": "Αποθήκευση", + "generic_button_cancel": "Ακύρωση", + "subscriptions_unseen_notifs_count": "{{count}} μη αναγνωσμένη ειδοποίηση", + "subscriptions_unseen_notifs_count_plural": "{{count}} μη αναγνωσμένες ειδοποιήσεις", + "Album: ": "Δίσκος: ", + "tokens_count": "{{count}} σύμβολο", + "tokens_count_plural": "{{count}} σύμβολα", + "channel_tab_shorts_label": "Short", + "channel_tab_releases_label": "Κυκλοφορίες", + "Song: ": "Τραγούδι: ", + "generic_channels_count": "{{count}} κανάλι", + "generic_channels_count_plural": "{{count}} κανάλια", + "Popular enabled: ": "Ενεργοποιημένα Δημοφιλή: ", + "channel_tab_playlists_label": "Λίστες αναπαραγωγής", + "generic_button_edit": "Επεξεργασία", + "search_filters_date_option_none": "Οποιαδήποτε ημερομηνία", + "crash_page_switch_instance": "προσπάθεια <a href=\"`x`\">χρήσης άλλου instance</a>", + "Music in this video": "Μουσική σε αυτό το βίντεο", + "generic_button_rss": "RSS", + "channel_tab_channels_label": "Κανάλια", + "search_filters_type_option_all": "Οποιοσδήποτε τύπος", + "search_filters_features_option_vr180": "VR180", + "error_video_not_in_playlist": "Το αιτούμενο βίντεο δεν υπάρχει στη δεδομένη λίστα αναπαραγωγής. <a href=\"`x`\">Πατήστε εδώ για επιστροφή στη κεντρική σελίδα λιστών αναπαραγωγής.</a>", + "search_filters_duration_option_none": "Οποιαδήποτε διάρκεια", + "preferences_automatic_instance_redirect_label": "Αυτόματη ανακατεύθυνση instance (εναλλακτική σε redirect.invidious.io): ", + "generic_button_delete": "Διαγραφή", + "Import YouTube playlist (.csv)": "Εισαγωγή λίστας αναπαραγωγής YouTube (.csv)", + "Switch Invidious Instance": "Αλλαγή Instance Invidious", + "Standard YouTube license": "Τυπική άδεια YouTube", + "search_filters_duration_option_medium": "Μεσαία (4 - 20 λεπτά)", + "search_filters_date_label": "Ημερομηνία αναφόρτωσης", + "Search for videos": "Αναζήτηση βίντεο", + "The Popular feed has been disabled by the administrator.": "Η δημοφιλής ροή έχει απενεργοποιηθεί από τον διαχειριστή.", + "Answer": "Απάντηση", + "Add to playlist": "Λίιστα αναπαραγωγής", + "Add to playlist: ": "Λίστα αναπαραγωγής: ", + "carousel_slide": "Εικόνα {{current}}απο {{total}}", + "carousel_go_to": "Πήγαινε στην εικόνα`x`", + "toggle_theme": "Αλλαγή θέματος" +} diff --git a/locales/en-US.json b/locales/en-US.json index 71485826..c23f6bc3 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -1,16 +1,26 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscriber", - "": "`x` subscribers" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", - "": "`x` videos" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist", - "": "`x` playlists" - }, + "Add to playlist": "Add to playlist", + "Add to playlist: ": "Add to playlist: ", + "Answer": "Answer", + "Search for videos": "Search for videos", + "The Popular feed has been disabled by the administrator.": "The Popular feed has been disabled by the administrator.", + "generic_channels_count": "{{count}} channel", + "generic_channels_count_plural": "{{count}} channels", + "generic_views_count": "{{count}} view", + "generic_views_count_plural": "{{count}} views", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videos", + "generic_playlists_count": "{{count}} playlist", + "generic_playlists_count_plural": "{{count}} playlists", + "generic_subscribers_count": "{{count}} subscriber", + "generic_subscribers_count_plural": "{{count}} subscribers", + "generic_subscriptions_count": "{{count}} subscription", + "generic_subscriptions_count_plural": "{{count}} subscriptions", + "generic_button_delete": "Delete", + "generic_button_edit": "Edit", + "generic_button_save": "Save", + "generic_button_cancel": "Cancel", + "generic_button_rss": "RSS", "LIVE": "LIVE", "Shared `x` ago": "Shared `x` ago", "Unsubscribe": "Unsubscribe", @@ -26,22 +36,23 @@ "Clear watch history?": "Clear watch history?", "New password": "New password", "New passwords must match": "New passwords must match", - "Cannot change password for Google accounts": "Cannot change password for Google accounts", "Authorize token?": "Authorize token?", "Authorize token for `x`?": "Authorize token for `x`?", "Yes": "Yes", "No": "No", "Import and Export Data": "Import and Export Data", "Import": "Import", - "Import Invidious data": "Import Invidious data", - "Import YouTube subscriptions": "Import YouTube subscriptions", + "Import Invidious data": "Import Invidious JSON data", + "Import YouTube subscriptions": "Import YouTube CSV or OPML subscriptions", + "Import YouTube playlist (.csv)": "Import YouTube playlist (.csv)", + "Import YouTube watch history (.json)": "Import YouTube watch history (.json)", "Import FreeTube subscriptions (.db)": "Import FreeTube subscriptions (.db)", "Import NewPipe subscriptions (.json)": "Import NewPipe subscriptions (.json)", "Import NewPipe data (.zip)": "Import NewPipe data (.zip)", "Export": "Export", "Export subscriptions as OPML": "Export subscriptions as OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Export subscriptions as OPML (for NewPipe & FreeTube)", - "Export data as JSON": "Export data as JSON", + "Export data as JSON": "Export Invidious data as JSON", "Delete account?": "Delete account?", "History": "History", "An alternative front-end to YouTube": "An alternative front-end to YouTube", @@ -49,7 +60,6 @@ "source": "source", "Log in": "Log in", "Log in/register": "Log in/register", - "Log in with Google": "Log in with Google", "User ID": "User ID", "Password": "Password", "Time (h:mm:ss):": "Time (h:mm:ss):", @@ -58,38 +68,61 @@ "Sign In": "Sign In", "Register": "Register", "E-mail": "E-mail", - "Google verification code": "Google verification code", "Preferences": "Preferences", - "Player preferences": "Player preferences", - "Always loop: ": "Always loop: ", - "Autoplay: ": "Autoplay: ", - "Play next by default: ": "Play next by default: ", - "Autoplay next video: ": "Autoplay next video: ", - "Listen by default: ": "Listen by default: ", - "Proxy videos: ": "Proxy videos: ", - "Default speed: ": "Default speed: ", - "Preferred video quality: ": "Preferred video quality: ", - "Player volume: ": "Player volume: ", - "Default comments: ": "Default comments: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "Default captions: ", + "preferences_category_player": "Player preferences", + "preferences_video_loop_label": "Always loop: ", + "preferences_preload_label": "Preload video data: ", + "preferences_autoplay_label": "Autoplay: ", + "preferences_continue_label": "Play next by default: ", + "preferences_continue_autoplay_label": "Autoplay next video: ", + "preferences_listen_label": "Listen by default: ", + "preferences_local_label": "Proxy videos: ", + "preferences_watch_history_label": "Enable watch history: ", + "preferences_speed_label": "Default speed: ", + "preferences_quality_label": "Preferred video quality: ", + "preferences_quality_option_dash": "DASH (adaptive quality)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Medium", + "preferences_quality_option_small": "Small", + "preferences_quality_dash_label": "Preferred DASH video quality: ", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_best": "Best", + "preferences_quality_dash_option_worst": "Worst", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "preferences_volume_label": "Player volume: ", + "preferences_comments_label": "Default comments: ", + "youtube": "YouTube", + "reddit": "Reddit", + "invidious": "Invidious", + "preferences_captions_label": "Default captions: ", "Fallback captions: ": "Fallback captions: ", - "Show related videos: ": "Show related videos: ", - "Show annotations by default: ": "Show annotations by default: ", - "Automatically extend video description: ": "Automatically extend video description: ", - "Visual preferences": "Visual preferences", - "Player style: ": "Player style: ", + "preferences_related_videos_label": "Show related videos: ", + "preferences_annotations_label": "Show annotations by default: ", + "preferences_extend_desc_label": "Automatically extend video description: ", + "preferences_vr_mode_label": "Interactive 360 degree videos (requires WebGL): ", + "preferences_category_visual": "Visual preferences", + "preferences_region_label": "Content country: ", + "preferences_player_style_label": "Player style: ", "Dark mode: ": "Dark mode: ", - "Theme: ": "Theme: ", + "preferences_dark_mode_label": "Theme: ", "dark": "dark", "light": "light", - "Thin mode: ": "Thin mode: ", - "Subscription preferences": "Subscription preferences", - "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ", + "preferences_thin_mode_label": "Thin mode: ", + "preferences_category_misc": "Miscellaneous preferences", + "preferences_automatic_instance_redirect_label": "Automatic instance redirection (fallback to redirect.invidious.io): ", + "preferences_category_subscription": "Subscription preferences", + "preferences_annotations_subscribed_label": "Show annotations by default for subscribed channels? ", "Redirect homepage to feed: ": "Redirect homepage to feed: ", - "Number of videos shown in feed: ": "Number of videos shown in feed: ", - "Sort videos by: ": "Sort videos by: ", + "preferences_max_results_label": "Number of videos shown in feed: ", + "preferences_sort_label": "Sort videos by: ", "published": "published", "published - reverse": "published - reverse", "alphabetically": "alphabetically", @@ -98,12 +131,12 @@ "channel name - reverse": "channel name - reverse", "Only show latest video from channel: ": "Only show latest video from channel: ", "Only show latest unwatched video from channel: ": "Only show latest unwatched video from channel: ", - "Only show unwatched: ": "Only show unwatched: ", - "Only show notifications (if there are any): ": "Only show notifications (if there are any): ", + "preferences_unseen_only_label": "Only show unwatched: ", + "preferences_notifications_only_label": "Only show notifications (if there are any): ", "Enable web notifications": "Enable web notifications", "`x` uploaded a video": "`x` uploaded a video", "`x` is live": "`x` is live", - "Data preferences": "Data preferences", + "preferences_category_data": "Data preferences", "Clear watch history": "Clear watch history", "Import/export data": "Import/export data", "Change password": "Change password", @@ -111,9 +144,11 @@ "Manage tokens": "Manage tokens", "Watch history": "Watch history", "Delete account": "Delete account", - "Administrator preferences": "Administrator preferences", - "Default homepage: ": "Default homepage: ", - "Feed menu: ": "Feed menu: ", + "preferences_category_admin": "Administrator preferences", + "preferences_default_home_label": "Default homepage: ", + "preferences_feed_menu_label": "Feed menu: ", + "preferences_show_nick_label": "Show nickname on top: ", + "Popular enabled: ": "Popular enabled: ", "Top enabled: ": "Top enabled: ", "CAPTCHA enabled: ": "CAPTCHA enabled: ", "Login enabled: ": "Login enabled: ", @@ -123,25 +158,17 @@ "Subscription manager": "Subscription manager", "Token manager": "Token manager", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscription", - "": "`x` subscriptions" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token", - "": "`x` tokens" - }, + "tokens_count": "{{count}} token", + "tokens_count_plural": "{{count}} tokens", "Import/export": "Import/export", "unsubscribe": "unsubscribe", "revoke": "revoke", "Subscriptions": "Subscriptions", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` unseen notification", - "": "`x` unseen notifications" - }, + "subscriptions_unseen_notifs_count": "{{count}} unseen notification", + "subscriptions_unseen_notifs_count_plural": "{{count}} unseen notifications", "search": "search", "Log out": "Log out", - "Released under the AGPLv3 by Omar Roth.": "Released under the AGPLv3 by Omar Roth.", + "Released under the AGPLv3 on Github.": "Released under the AGPLv3 on GitHub.", "Source available here.": "Source available here.", "View JavaScript license information.": "View JavaScript license information.", "View privacy policy.": "View privacy policy.", @@ -157,23 +184,29 @@ "Title": "Title", "Playlist privacy": "Playlist privacy", "Editing playlist `x`": "Editing playlist `x`", + "playlist_button_add_items": "Add videos", "Show more": "Show more", "Show less": "Show less", "Watch on YouTube": "Watch on YouTube", + "Switch Invidious Instance": "Switch Invidious Instance", + "search_message_no_results": "No results found.", + "search_message_change_filters_or_query": "Try widening your search query and/or changing the filters.", + "search_message_use_another_instance": "You can also <a href=\"`x`\">search on another instance</a>.", "Hide annotations": "Hide annotations", "Show annotations": "Show annotations", "Genre: ": "Genre: ", "License: ": "License: ", + "Standard YouTube license": "Standard YouTube license", "Family friendly? ": "Family friendly? ", "Wilson score: ": "Wilson score: ", "Engagement: ": "Engagement: ", "Whitelisted regions: ": "Whitelisted regions: ", "Blacklisted regions: ": "Blacklisted regions: ", + "Music in this video": "Music in this video", + "Artist: ": "Artist: ", + "Song: ": "Song: ", + "Album: ": "Album: ", "Shared `x`": "Shared `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` view", - "": "`x` views" - }, "Premieres in `x`": "Premieres in `x`", "Premieres `x`": "Premieres `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.", @@ -187,17 +220,12 @@ "Hide replies": "Hide replies", "Show replies": "Show replies", "Incorrect password": "Incorrect password", - "Quota exceeded, try again in a few hours": "Quota exceeded, try again in a few hours", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.", - "Invalid TFA code": "Invalid TFA code", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Login failed. This may be because two-factor authentication is not turned on for your account.", "Wrong answer": "Wrong answer", "Erroneous CAPTCHA": "Erroneous CAPTCHA", "CAPTCHA is a required field": "CAPTCHA is a required field", "User ID is a required field": "User ID is a required field", "Password is a required field": "Password is a required field", "Wrong username or password": "Wrong username or password", - "Please sign in using 'Log in with Google'": "Please sign in using 'Log in with Google'", "Password cannot be empty": "Password cannot be empty", "Password cannot be longer than 55 characters": "Password cannot be longer than 55 characters", "Please log in": "Please log in", @@ -207,16 +235,12 @@ "This channel does not exist.": "This channel does not exist.", "Could not get channel info.": "Could not get channel info.", "Could not fetch comments": "Could not fetch comments", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "View `x` reply", - "": "View `x` replies" - }, + "comments_view_x_replies": "View {{count}} reply", + "comments_view_x_replies_plural": "View {{count}} replies", "`x` ago": "`x` ago", "Load more": "Load more", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` point", - "": "`x` points" - }, + "comments_points_count": "{{count}} point", + "comments_points_count_plural": "{{count}} points", "Could not create mix.": "Could not create mix.", "Empty playlist": "Empty playlist", "Not a playlist.": "Not a playlist.", @@ -229,6 +253,8 @@ "No such user": "No such user", "Token is expired, please try again": "Token is expired, please try again", "English": "English", + "English (United Kingdom)": "English (United Kingdom)", + "English (United States)": "English (United States)", "English (auto-generated)": "English (auto-generated)", "Afrikaans": "Afrikaans", "Albanian": "Albanian", @@ -242,23 +268,32 @@ "Bosnian": "Bosnian", "Bulgarian": "Bulgarian", "Burmese": "Burmese", + "Cantonese (Hong Kong)": "Cantonese (Hong Kong)", "Catalan": "Catalan", "Cebuano": "Cebuano", + "Chinese": "Chinese", + "Chinese (China)": "Chinese (China)", + "Chinese (Hong Kong)": "Chinese (Hong Kong)", "Chinese (Simplified)": "Chinese (Simplified)", + "Chinese (Taiwan)": "Chinese (Taiwan)", "Chinese (Traditional)": "Chinese (Traditional)", "Corsican": "Corsican", "Croatian": "Croatian", "Czech": "Czech", "Danish": "Danish", "Dutch": "Dutch", + "Dutch (auto-generated)": "Dutch (auto-generated)", "Esperanto": "Esperanto", "Estonian": "Estonian", "Filipino": "Filipino", + "Filipino (auto-generated)": "Filipino (auto-generated)", "Finnish": "Finnish", "French": "French", + "French (auto-generated)": "French (auto-generated)", "Galician": "Galician", "Georgian": "Georgian", "German": "German", + "German (auto-generated)": "German (auto-generated)", "Greek": "Greek", "Gujarati": "Gujarati", "Haitian Creole": "Haitian Creole", @@ -271,14 +306,19 @@ "Icelandic": "Icelandic", "Igbo": "Igbo", "Indonesian": "Indonesian", + "Indonesian (auto-generated)": "Indonesian (auto-generated)", + "Interlingue": "Interlingue", "Irish": "Irish", "Italian": "Italian", + "Italian (auto-generated)": "Italian (auto-generated)", "Japanese": "Japanese", + "Japanese (auto-generated)": "Japanese (auto-generated)", "Javanese": "Javanese", "Kannada": "Kannada", "Kazakh": "Kazakh", "Khmer": "Khmer", "Korean": "Korean", + "Korean (auto-generated)": "Korean (auto-generated)", "Kurdish": "Kurdish", "Kyrgyz": "Kyrgyz", "Lao": "Lao", @@ -301,9 +341,12 @@ "Persian": "Persian", "Polish": "Polish", "Portuguese": "Portuguese", + "Portuguese (auto-generated)": "Portuguese (auto-generated)", + "Portuguese (Brazil)": "Portuguese (Brazil)", "Punjabi": "Punjabi", "Romanian": "Romanian", "Russian": "Russian", + "Russian (auto-generated)": "Russian (auto-generated)", "Samoan": "Samoan", "Scottish Gaelic": "Scottish Gaelic", "Serbian": "Serbian", @@ -315,7 +358,10 @@ "Somali": "Somali", "Southern Sotho": "Southern Sotho", "Spanish": "Spanish", + "Spanish (auto-generated)": "Spanish (auto-generated)", "Spanish (Latin America)": "Spanish (Latin America)", + "Spanish (Mexico)": "Spanish (Mexico)", + "Spanish (Spain)": "Spanish (Spain)", "Sundanese": "Sundanese", "Swahili": "Swahili", "Swedish": "Swedish", @@ -324,51 +370,39 @@ "Telugu": "Telugu", "Thai": "Thai", "Turkish": "Turkish", + "Turkish (auto-generated)": "Turkish (auto-generated)", "Ukrainian": "Ukrainian", "Urdu": "Urdu", "Uzbek": "Uzbek", "Vietnamese": "Vietnamese", + "Vietnamese (auto-generated)": "Vietnamese (auto-generated)", "Welsh": "Welsh", "Western Frisian": "Western Frisian", "Xhosa": "Xhosa", "Yiddish": "Yiddish", "Yoruba": "Yoruba", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` year", - "": "`x` years" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` month", - "": "`x` months" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` week", - "": "`x` weeks" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` day", - "": "`x` days" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hour", - "": "`x` hours" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minute", - "": "`x` minutes" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` second", - "": "`x` seconds" - }, + "generic_count_years": "{{count}} year", + "generic_count_years_plural": "{{count}} years", + "generic_count_months": "{{count}} month", + "generic_count_months_plural": "{{count}} months", + "generic_count_weeks": "{{count}} week", + "generic_count_weeks_plural": "{{count}} weeks", + "generic_count_days": "{{count}} day", + "generic_count_days_plural": "{{count}} days", + "generic_count_hours": "{{count}} hour", + "generic_count_hours_plural": "{{count}} hours", + "generic_count_minutes": "{{count}} minute", + "generic_count_minutes_plural": "{{count}} minutes", + "generic_count_seconds": "{{count}} second", + "generic_count_seconds_plural": "{{count}} seconds", "Fallback comments: ": "Fallback comments: ", "Popular": "Popular", "Search": "Search", "Top": "Top", "About": "About", "Rating: ": "Rating: ", - "Language: ": "Language: ", + "preferences_locale_label": "Language: ", "View as playlist": "View as playlist", "Default": "Default", "Music": "Music", @@ -377,42 +411,92 @@ "Movies": "Movies", "Download": "Download", "Download as: ": "Download as: ", + "Download is disabled": "Download is disabled", "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(edited)", "YouTube comment permalink": "YouTube comment permalink", "permalink": "permalink", "`x` marked it with a ❤": "`x` marked it with a ❤", + "Channel Sponsor": "Channel Sponsor", "Audio mode": "Audio mode", "Video mode": "Video mode", - "Videos": "Videos", "Playlists": "Playlists", - "Community": "Community", - "relevance": "Relevance", - "rating": "Rating", - "date": "Upload date", - "views": "View count", - "content_type": "Type", - "duration": "Duration", - "features": "Features", - "sort": "Sort By", - "hour": "Last Hour", - "today": "Today", - "week": "This week", - "month": "This month", - "year": "This year", - "video": "Video", - "channel": "Channel", - "playlist": "Playlist", - "movie": "Movie", - "show": "Show", - "hd": "HD", - "subtitles": "Subtitles/CC", - "creative_commons": "Creative Commons", - "3d": "3D", - "live": "Live", - "4k": "4K", - "location": "Location", - "hdr": "HDR", - "filter": "Filter", - "Current version: ": "Current version: " + "search_filters_title": "Filters", + "search_filters_date_label": "Upload date", + "search_filters_date_option_none": "Any date", + "search_filters_date_option_hour": "Last hour", + "search_filters_date_option_today": "Today", + "search_filters_date_option_week": "This week", + "search_filters_date_option_month": "This month", + "search_filters_date_option_year": "This year", + "search_filters_type_label": "Type", + "search_filters_type_option_all": "Any type", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Channel", + "search_filters_type_option_playlist": "Playlist", + "search_filters_type_option_movie": "Movie", + "search_filters_type_option_show": "Show", + "search_filters_duration_label": "Duration", + "search_filters_duration_option_none": "Any duration", + "search_filters_duration_option_short": "Short (< 4 minutes)", + "search_filters_duration_option_medium": "Medium (4 - 20 minutes)", + "search_filters_duration_option_long": "Long (> 20 minutes)", + "search_filters_features_label": "Features", + "search_filters_features_option_live": "Live", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Subtitles/CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_vr180": "VR180", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_hdr": "HDR", + "search_filters_features_option_location": "Location", + "search_filters_features_option_purchased": "Purchased", + "search_filters_sort_label": "Sort By", + "search_filters_sort_option_relevance": "Relevance", + "search_filters_sort_option_rating": "Rating", + "search_filters_sort_option_date": "Upload date", + "search_filters_sort_option_views": "View count", + "search_filters_apply_button": "Apply selected filters", + "Current version: ": "Current version: ", + "next_steps_error_message": "After which you should try to: ", + "next_steps_error_message_refresh": "Refresh", + "next_steps_error_message_go_to_youtube": "Go to YouTube", + "footer_donate_page": "Donate", + "footer_documentation": "Documentation", + "footer_source_code": "Source code", + "footer_original_source_code": "Original source code", + "footer_modfied_source_code": "Modified source code", + "adminprefs_modified_source_code_url_label": "URL to modified source code repository", + "none": "none", + "videoinfo_started_streaming_x_ago": "Started streaming `x` ago", + "videoinfo_watch_on_youTube": "Watch on YouTube", + "videoinfo_youTube_embed_link": "Embed", + "videoinfo_invidious_embed_link": "Embed Link", + "download_subtitles": "Subtitles - `x` (.vtt)", + "user_created_playlists": "`x` created playlists", + "user_saved_playlists": "`x` saved playlists", + "Video unavailable": "Video unavailable", + "preferences_save_player_pos_label": "Save playback position: ", + "crash_page_you_found_a_bug": "It looks like you found a bug in Invidious!", + "crash_page_before_reporting": "Before reporting a bug, make sure that you have:", + "crash_page_refresh": "tried to <a href=\"`x`\">refresh the page</a>", + "crash_page_switch_instance": "tried to <a href=\"`x`\">use another instance</a>", + "crash_page_read_the_faq": "read the <a href=\"`x`\">Frequently Asked Questions (FAQ)</a>", + "crash_page_search_issue": "searched for <a href=\"`x`\">existing issues on GitHub</a>", + "crash_page_report_issue": "If none of the above helped, please <a href=\"`x`\">open a new issue on GitHub</a> (preferably in English) and include the following text in your message (do NOT translate that text):", + "error_video_not_in_playlist": "The requested video doesn't exist in this playlist. <a href=\"`x`\">Click here for the playlist home page.</a>", + "channel_tab_videos_label": "Videos", + "channel_tab_shorts_label": "Shorts", + "channel_tab_streams_label": "Livestreams", + "channel_tab_podcasts_label": "Podcasts", + "channel_tab_releases_label": "Releases", + "channel_tab_playlists_label": "Playlists", + "channel_tab_community_label": "Community", + "channel_tab_channels_label": "Channels", + "toggle_theme": "Toggle Theme", + "carousel_slide": "Slide {{current}} of {{total}}", + "carousel_skip": "Skip the Carousel", + "carousel_go_to": "Go to slide `x`" } diff --git a/locales/eo.json b/locales/eo.json index e98ebb59..7276c890 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -1,24 +1,12 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonantoj.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` abonantoj." - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` filmetoj.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` filmetoj." - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ludlistoj.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` ludlistoj." - }, "LIVE": "NUNA", "Shared `x` ago": "Konigita antaŭ `x`", "Unsubscribe": "Malabonu", "Subscribe": "Abonu", "View channel on YouTube": "Vidu kanalon en JuTubo", "View playlist on YouTube": "Vidu ludliston en JuTubo", - "newest": "pli novaj", - "oldest": "pli malnovaj", + "newest": "plej novaj", + "oldest": "plej malnovaj", "popular": "popularaj", "last": "lasta", "Next page": "Sekva paĝo", @@ -26,22 +14,21 @@ "Clear watch history?": "Ĉu forigi vidohistorion?", "New password": "Nova pasvorto", "New passwords must match": "Novaj pasvortoj devas kongrui", - "Cannot change password for Google accounts": "Ne eblas ŝanĝi pasvorton por kontoj de Google", "Authorize token?": "Ĉu rajtigi ĵetonon?", "Authorize token for `x`?": "Ĉu rajtigi ĵetonon por `x`?", "Yes": "Jes", "No": "Ne", "Import and Export Data": "Importi kaj Eksporti Datumojn", "Import": "Importi", - "Import Invidious data": "Importi datumojn de Invidious", - "Import YouTube subscriptions": "Importi abonojn de JuTubo", + "Import Invidious data": "Importi JSON-datumojn de Invidious", + "Import YouTube subscriptions": "Importi abonojn de YouTube/OPML", "Import FreeTube subscriptions (.db)": "Importi abonojn de FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importi abonojn de NewPipe (.json)", "Import NewPipe data (.zip)": "Importi datumojn de NewPipe (.zip)", "Export": "Eksporti", "Export subscriptions as OPML": "Eksporti abonojn kiel OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksporti abonojn kiel OPML (por NewPipe kaj FreeTube)", - "Export data as JSON": "Eksporti datumojn kiel JSON", + "Export data as JSON": "Eksporti Invidious-datumojn kiel JSON", "Delete account?": "Ĉu forigi konton?", "History": "Historio", "An alternative front-end to YouTube": "Alternativa fasado al JuTubo", @@ -49,7 +36,6 @@ "source": "fonto", "Log in": "Ensaluti", "Log in/register": "Ensaluti/Registriĝi", - "Log in with Google": "Ensaluti al Google", "User ID": "Uzula identigilo", "Password": "Pasvorto", "Time (h:mm:ss):": "Horo (h:mm:ss):", @@ -58,38 +44,40 @@ "Sign In": "Ensaluti", "Register": "Registriĝi", "E-mail": "Retpoŝto", - "Google verification code": "Kontrolkodo de Google", "Preferences": "Agordoj", - "Player preferences": "Spektilaj agordoj", - "Always loop: ": "Ĉiam ripeti: ", - "Autoplay: ": "Aŭtomate ludi: ", - "Play next by default: ": "Ludi sekvan defaŭlte: ", - "Autoplay next video: ": "Aŭtomate ludi sekvan filmeton: ", - "Listen by default: ": "Aŭskulti defaŭlte: ", - "Proxy videos: ": "Ĉu uzi prokuran servilon por filmetojn? ", - "Default speed: ": "Defaŭlta rapido: ", - "Preferred video quality: ": "Preferita filmetkvalito: ", - "Player volume: ": "Ludila sonforteco: ", - "Default comments: ": "Defaŭltaj komentoj: ", + "preferences_category_player": "Spektilaj agordoj", + "preferences_video_loop_label": "Ĉiam ripeti: ", + "preferences_autoplay_label": "Aŭtomate ludi: ", + "preferences_continue_label": "Ludi sekvan defaŭlte: ", + "preferences_continue_autoplay_label": "Aŭtomate ludi sekvan filmeton: ", + "preferences_listen_label": "Aŭskulti defaŭlte: ", + "preferences_local_label": "Ĉu uzi prokuran servilon por filmetojn? ", + "preferences_speed_label": "Defaŭlta rapido: ", + "preferences_quality_label": "Preferita filmetkvalito: ", + "preferences_volume_label": "Ludila sonforteco: ", + "preferences_comments_label": "Defaŭltaj komentoj: ", "youtube": "JuTubo", "reddit": "Reddit", - "Default captions: ": "Defaŭltaj subtekstoj: ", + "preferences_captions_label": "Defaŭltaj subtekstoj: ", "Fallback captions: ": "Retrodefaŭltaj subtekstoj: ", - "Show related videos: ": "Ĉu montri rilatajn filmetojn? ", - "Show annotations by default: ": "Ĉu montri prinotojn defaŭlte? ", - "Automatically extend video description: ": "", - "Visual preferences": "Vidaj preferoj", - "Player style: ": "Ludila stilo: ", + "preferences_related_videos_label": "Ĉu montri rilatajn filmetojn? ", + "preferences_annotations_label": "Ĉu montri prinotojn defaŭlte? ", + "preferences_extend_desc_label": "Aŭtomate etendi priskribon de filmeto: ", + "preferences_vr_mode_label": "Interagaj 360-gradaj filmoj (postulas WebGL-n): ", + "preferences_category_visual": "Vidaj preferoj", + "preferences_player_style_label": "Ludila stilo: ", "Dark mode: ": "Malhela reĝimo: ", - "Theme: ": "Etoso: ", + "preferences_dark_mode_label": "Etoso: ", "dark": "malhela", "light": "hela", - "Thin mode: ": "Maldika reĝimo: ", - "Subscription preferences": "Abonaj agordoj", - "Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", + "preferences_thin_mode_label": "Maldika reĝimo: ", + "preferences_category_misc": "Aliaj agordoj", + "preferences_automatic_instance_redirect_label": "Aŭtomata alidirektado de nodo (retropaŝo al redirect.invidious.io): ", + "preferences_category_subscription": "Abonaj agordoj", + "preferences_annotations_subscribed_label": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", "Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ", - "Number of videos shown in feed: ": "Nombro da filmetoj montritaj en fluo: ", - "Sort videos by: ": "Ordi filmetojn per: ", + "preferences_max_results_label": "Nombro da filmetoj montritaj en fluo: ", + "preferences_sort_label": "Ordi filmetojn per: ", "published": "publikigo", "published - reverse": "publitigo - renverse", "alphabetically": "alfabete", @@ -98,12 +86,12 @@ "channel name - reverse": "kanala nombro - renverse", "Only show latest video from channel: ": "Nur montri pli novan filmeton el kanalo: ", "Only show latest unwatched video from channel: ": "Nur montri pli novan malviditan filmeton el kanalo: ", - "Only show unwatched: ": "Nur montri malviditajn: ", - "Only show notifications (if there are any): ": "Nur montri sciigojn (se estas): ", + "preferences_unseen_only_label": "Nur montri malviditajn: ", + "preferences_notifications_only_label": "Nur montri sciigojn (se estas): ", "Enable web notifications": "Ebligi retejajn sciigojn", "`x` uploaded a video": "`x` alŝutis filmeton", "`x` is live": "`x` estas nuna", - "Data preferences": "Datumagordoj", + "preferences_category_data": "Datumagordoj", "Clear watch history": "Forigi vidohistorion", "Import/export data": "Importi/Eksporti datumojn", "Change password": "Ŝanĝi pasvorton", @@ -111,9 +99,10 @@ "Manage tokens": "Administri ĵetonojn", "Watch history": "Vidohistorio", "Delete account": "Forigi konton", - "Administrator preferences": "Agordoj de administranto", - "Default homepage: ": "Defaŭlta hejmpaĝo: ", - "Feed menu: ": "Flua menuo: ", + "preferences_category_admin": "Agordoj de administranto", + "preferences_default_home_label": "Defaŭlta hejmpaĝo: ", + "preferences_feed_menu_label": "Flua menuo: ", + "preferences_show_nick_label": "Montri kromnomon supre: ", "Top enabled: ": "Ĉu pli bonaj ŝaltitaj? ", "CAPTCHA enabled: ": "Ĉu CAPTCHA ŝaltita? ", "Login enabled: ": "Ĉu ensaluto aktivita? ", @@ -123,25 +112,13 @@ "Subscription manager": "Administrilo de abonoj", "Token manager": "Ĵetona administrilo", "Token": "Ĵetono", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonoj", - "": "`x` abonoj." - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ĵetonoj", - "": "`x` ĵetonoj." - }, "Import/export": "Importi/Eksporti", "unsubscribe": "malabonu", "revoke": "senvalidigi", "Subscriptions": "Abonoj", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` neviditaj sciigoj", - "": "`x` neviditaj sciigoj." - }, "search": "serĉi", "Log out": "Elsaluti", - "Released under the AGPLv3 by Omar Roth.": "Eldonita sub la AGPLv3 de Omar Roth.", + "Released under the AGPLv3 on Github.": "Eldonita sub la AGPLv3 en GitHub.", "Source available here.": "Fonto havebla ĉi tie.", "View JavaScript license information.": "Vidi Ĝavoskriptan licencan informon.", "View privacy policy.": "Vidi regularon pri privateco.", @@ -157,9 +134,10 @@ "Title": "Titolo", "Playlist privacy": "Privateco de ludlisto", "Editing playlist `x`": "Redaktante ludlisto `x`", - "Show more": "", - "Show less": "", + "Show more": "Montri pli", + "Show less": "Montri malpli", "Watch on YouTube": "Vidi filmeton en JuTubo", + "Switch Invidious Instance": "Ŝanĝi nodon de Indivious", "Hide annotations": "Kaŝi prinotojn", "Show annotations": "Montri prinotojn", "Genre: ": "Ĝenro: ", @@ -170,34 +148,25 @@ "Whitelisted regions: ": "Regionoj listigitaj en blanka listo: ", "Blacklisted regions: ": "Regionoj listigitaj en nigra listo: ", "Shared `x`": "Konigita `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` spektaĵoj", - "": "`x` spektaĵoj." - }, "Premieres in `x`": "Premieras en `x`", "Premieres `x`": "Premieras `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Saluton! Ŝajnas, ke vi havas Ĝavoskripton malebligitan. Klaku ĉi tie por vidi komentojn, memoru, ke la ŝargado povus daŭri iom pli.", "View YouTube comments": "Vidi komentojn de JuTubo", "View more comments on Reddit": "Vidi pli komentoj en Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Vidi `x` komentojn", - "": "Vidi `x` komentojn." + "([^.,0-9]|^)1([^.,0-9]|$)": "Vidi `x` komenton", + "": "Vidi `x` komentojn" }, "View Reddit comments": "Vidi komentojn de Reddit", "Hide replies": "Kaŝi respondojn", "Show replies": "Montri respondojn", "Incorrect password": "Malbona pasvorto", - "Quota exceeded, try again in a few hours": "Kvoto transpasita, provu denove post iuj horoj", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Ne povas ensaluti, certigu, ke dufaktora aŭtentigo (Authenticator aŭ SMS) estas ebligita.", - "Invalid TFA code": "Nevalida TFA-kodo", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Ensalutado fiaskis. Eble ĉar la dufaktora aŭtentigo estas malebligita en via konto.", "Wrong answer": "Nevalida respondo", "Erroneous CAPTCHA": "Nevalida CAPTCHA", "CAPTCHA is a required field": "CAPTCHA estas deviga kampo", "User ID is a required field": "Uzula identigilo estas deviga kampo", "Password is a required field": "Pasvorto estas deviga kampo", "Wrong username or password": "Nevalida uzantnomo aŭ pasvorto", - "Please sign in using 'Log in with Google'": "Bonvolu ensaluti per 'Ensaluti per Google'", "Password cannot be empty": "Pasvorto ne povas esti malplena", "Password cannot be longer than 55 characters": "Pasvorto ne povas esti pli longa ol 55 signoj", "Please log in": "Bonvolu ensaluti", @@ -207,16 +176,8 @@ "This channel does not exist.": "Ĉi tiu kanalo ne ekzistas.", "Could not get channel info.": "Ne povis havigi kanalan informon.", "Could not fetch comments": "Ne povis venigi komentojn", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Vidi `x` respondojn", - "": "Vidi `x` respondojn." - }, "`x` ago": "antaŭ `x`", "Load more": "Ŝarĝi pli", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` poentoj", - "": "`x` poentoj." - }, "Could not create mix.": "Ne povis krei mikson.", "Empty playlist": "Ludlisto estas malplena", "Not a playlist.": "Nevalida ludlisto.", @@ -334,41 +295,13 @@ "Yiddish": "Jida", "Yoruba": "Joruba", "Zulu": "Zulua", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jaroj", - "": "`x` jaroj." - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` monatoj", - "": "`x` monatoj." - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semajnoj", - "": "`x` semajnoj." - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tagoj", - "": "`x` tagoj." - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horoj", - "": "`x` horoj." - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutoj", - "": "`x` minutoj." - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekundoj", - "": "`x` sekundoj." - }, "Fallback comments: ": "Retrodefaŭltaj komentoj: ", "Popular": "Popularaj", "Search": "Serĉi", "Top": "Supraj", "About": "Pri", "Rating: ": "Takso: ", - "Language: ": "Lingvo: ", + "preferences_locale_label": "Lingvo: ", "View as playlist": "Vidi kiel ludlisto", "Default": "Defaŭlte", "Music": "Muziko", @@ -384,35 +317,174 @@ "`x` marked it with a ❤": "`x` markis ĝin per ❤", "Audio mode": "Aŭda reĝimo", "Video mode": "Videa reĝimo", - "Videos": "Filmetoj", + "channel_tab_videos_label": "Videoj", "Playlists": "Ludlistoj", - "Community": "Komunumo", - "relevance": "rilateco", - "rating": "takso", - "date": "dato", - "views": "vidoj", - "content_type": "enhavtipo", - "duration": "daŭro", - "features": "trajtoj", - "sort": "ordigi", - "hour": "horo", - "today": "hodiaŭ", - "week": "semajno", - "month": "monato", - "year": "jaro", - "video": "filmeto", - "channel": "kanalo", - "playlist": "ludlisto", - "movie": "filmo", - "show": "spektaĵo", - "hd": "altdistingiva", - "subtitles": "subtekstoj", - "creative_commons": "Krea Komunaĵo", - "3d": "3D", - "live": "nuna", - "4k": "4k", - "location": "loko", - "hdr": "granddinamikgama", - "filter": "filtri", - "Current version: ": "Nuna versio: " -}
\ No newline at end of file + "channel_tab_community_label": "Komunumo", + "search_filters_sort_option_relevance": "rilateco", + "search_filters_sort_option_rating": "takso", + "search_filters_sort_option_date": "dato", + "search_filters_sort_option_views": "vidoj", + "search_filters_type_label": "enhavtipo", + "search_filters_duration_label": "daŭro", + "search_filters_features_label": "trajtoj", + "search_filters_sort_label": "ordigi", + "search_filters_date_option_hour": "horo", + "search_filters_date_option_today": "hodiaŭ", + "search_filters_date_option_week": "semajno", + "search_filters_date_option_month": "monato", + "search_filters_date_option_year": "jaro", + "search_filters_type_option_video": "filmeto", + "search_filters_type_option_channel": "kanalo", + "search_filters_type_option_playlist": "ludlisto", + "search_filters_type_option_movie": "filmo", + "search_filters_type_option_show": "spektaĵo", + "search_filters_features_option_hd": "altdistingiva", + "search_filters_features_option_subtitles": "subtekstoj", + "search_filters_features_option_c_commons": "Krea Komunaĵo", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "nuna", + "search_filters_features_option_four_k": "4k", + "search_filters_features_option_location": "loko", + "search_filters_features_option_hdr": "granddinamikgama", + "Current version: ": "Nuna versio: ", + "next_steps_error_message": "Poste, vi provu: ", + "next_steps_error_message_refresh": "Reŝargi", + "next_steps_error_message_go_to_youtube": "Iri al JuTubo", + "search_filters_duration_option_long": "Longa (> 20 minutos)", + "search_filters_duration_option_short": "Mallonga (< 4 minutos)", + "footer_documentation": "Dokumentaro", + "footer_source_code": "Fontkodo", + "adminprefs_modified_source_code_url_label": "URL al modifita deponejo de fontkodo", + "footer_modfied_source_code": "Modifita Fontkodo", + "footer_original_source_code": "Originala fontkodo", + "footer_donate_page": "Donaci", + "preferences_region_label": "Lando de la enhavo: ", + "preferences_quality_dash_label": "Preferata DASH-a videkvalito: ", + "search_filters_title": "Filtri", + "preferences_quality_dash_option_best": "Plej bona", + "preferences_quality_dash_option_worst": "Malplej bona", + "Popular enabled: ": "Populara sekcio ebligita: ", + "search_message_no_results": "Neniu rezulto trovita.", + "search_message_use_another_instance": " Vi ankaŭ povas <a href=\"`x`\">serĉi en alia nodo</a>.", + "tokens_count": "{{count}} ĵetono", + "tokens_count_plural": "{{count}} ĵetonoj", + "subscriptions_unseen_notifs_count": "{{count}} nevidita sciigo", + "subscriptions_unseen_notifs_count_plural": "{{count}} neviditaj sciigoj", + "Indonesian (auto-generated)": "Indonezia (aŭtomate generita)", + "Interlingue": "Interlingvo", + "Italian (auto-generated)": "Itala (aŭtomate generita)", + "Korean (auto-generated)": "Korea (aŭtomate generita)", + "Portuguese (Brazil)": "Portugala (Brazilo)", + "Portuguese (auto-generated)": "Portugala (aŭtomate generita)", + "Russian (auto-generated)": "Rusa (aŭtomate generita)", + "Spanish (Spain)": "Hispana (Hispanio)", + "generic_count_years": "{{count}} jaro", + "generic_count_years_plural": "{{count}} jaroj", + "Turkish (auto-generated)": "Turka (aŭtomate generita)", + "Vietnamese (auto-generated)": "Vjetnama (aŭtomate generita)", + "generic_count_hours": "{{count}} horo", + "generic_count_hours_plural": "{{count}} horoj", + "generic_count_minutes": "{{count}} minuto", + "generic_count_minutes_plural": "{{count}} minutoj", + "search_filters_date_label": "Alŝutdato", + "search_filters_date_option_none": "Ajna dato", + "search_filters_duration_option_medium": "Meza (4 - 20 minutoj)", + "search_filters_features_option_three_sixty": "360º", + "search_filters_features_option_vr180": "VR180", + "user_created_playlists": "`x`kreitaj ludlistoj", + "user_saved_playlists": "`x`konservitaj ludlistoj", + "crash_page_switch_instance": "klopodis <a href=\"`x`\">uzi alian nodon</a>", + "crash_page_read_the_faq": "legis la <a href=\"`x`\">oftajn demandojn</a>", + "error_video_not_in_playlist": "La petita video ne ekzistas en ĉi tiu ludlisto. <a href=\"`x`\">Alklaku ĉi tie por iri al la ludlista hejmpaĝo.</a>", + "crash_page_search_issue": "serĉis por <a href=\"`x`\">ekzistantaj problemoj en GitHub</a>", + "generic_count_seconds": "{{count}} sekundo", + "generic_count_seconds_plural": "{{count}} sekundoj", + "preferences_quality_dash_option_144p": "144p", + "comments_view_x_replies": "Vidi {{count}} respondon", + "comments_view_x_replies_plural": "Vidi {{count}} respondojn", + "preferences_quality_dash_option_360p": "360p", + "invidious": "Invidious", + "Chinese (Taiwan)": "Ĉina (Tajvano)", + "English (United Kingdom)": "Angla (Britio)", + "search_filters_features_option_purchased": "Aĉetita", + "Japanese (auto-generated)": "Japana (aŭtomate generita)", + "search_message_change_filters_or_query": "Provu vastigi vian serĉpeton kaj/aŭ ŝanĝi la filtrilojn.", + "preferences_quality_dash_option_1080p": "1080p", + "generic_count_weeks": "{{count}} semajno", + "generic_count_weeks_plural": "{{count}} semajnoj", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_auto": "Aŭtomate", + "preferences_quality_dash_option_2160p": "2160p", + "English (United States)": "Angla (Usono)", + "Chinese": "Ĉina", + "videoinfo_watch_on_youTube": "Vidi en YouTube", + "crash_page_you_found_a_bug": "Ŝajnas, ke vi trovis eraron en Invidious!", + "comments_points_count": "{{count}} poento", + "comments_points_count_plural": "{{count}} poentoj", + "Cantonese (Hong Kong)": "Kantona (Honkongo)", + "preferences_watch_history_label": "Ebligi vidohistorion: ", + "preferences_quality_option_small": "Eta", + "generic_playlists_count": "{{count}} ludlisto", + "generic_playlists_count_plural": "{{count}} ludlistoj", + "videoinfo_youTube_embed_link": "Enigi", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Meza", + "generic_subscriptions_count": "{{count}} abono", + "generic_subscriptions_count_plural": "{{count}} abonoj", + "videoinfo_started_streaming_x_ago": "Komercis elsendi antaŭ `x`", + "download_subtitles": "Subtitoloj - `x` (.vtt)", + "videoinfo_invidious_embed_link": "Enigi Ligilon", + "crash_page_report_issue": "Se neniu el la antaŭaj agoj helpis, bonvolu <a href=\"`x`\">estigi novan problemon en GitHub</a> (prefere angle) kaj inkludi la jenan tekston en via mesaĝo (NE traduku tiun tekston):", + "preferences_quality_option_dash": "DASH (adapta kvalito)", + "Chinese (Hong Kong)": "Ĉina (Honkongo)", + "Chinese (China)": "Ĉina (Ĉinio)", + "Dutch (auto-generated)": "Nederlanda (aŭtomate generita)", + "German (auto-generated)": "Germana (aŭtomate generita)", + "French (auto-generated)": "Franca (aŭtomate generita)", + "Spanish (Mexico)": "Hispana (Meksiko)", + "Spanish (auto-generated)": "Hispana (aŭtomate generita)", + "generic_count_days": "{{count}} tago", + "generic_count_days_plural": "{{count}} tagoj", + "search_filters_type_option_all": "Ajna speco", + "search_filters_duration_option_none": "Ajna daŭro", + "search_filters_apply_button": "Uzi elektitajn filtrilojn", + "none": "neniu", + "Video unavailable": "Nedisponebla video", + "crash_page_before_reporting": "Antaŭ ol informi pri eraro certigu, ke vi:", + "crash_page_refresh": "klopodis <a href=\"`x`\">reŝarĝi la paĝon</a>", + "generic_views_count": "{{count}} spekto", + "generic_views_count_plural": "{{count}} spektoj", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videoj", + "generic_subscribers_count": "{{count}} abonanto", + "generic_subscribers_count_plural": "{{count}} abonantoj", + "generic_count_months": "{{count}} monato", + "generic_count_months_plural": "{{count}} monatoj", + "preferences_save_player_pos_label": "Konservi ludadan pozicion: ", + "channel_tab_streams_label": "Tujelsendoj", + "channel_tab_playlists_label": "Ludlistoj", + "channel_tab_channels_label": "Kanaloj", + "channel_tab_shorts_label": "Mallongaj", + "Music in this video": "Muziko en ĉi tiu video", + "Artist: ": "Artisto: ", + "Album: ": "Albumo: ", + "Channel Sponsor": "Kanala sponsoro", + "Song: ": "Muzikaĵo: ", + "Standard YouTube license": "Implicita YouTube-licenco", + "Download is disabled": "Elŝuto estas malebligita", + "Import YouTube playlist (.csv)": "Importi YouTube-ludliston (.csv)", + "generic_button_edit": "Redakti", + "playlist_button_add_items": "Aldoni videojn", + "generic_button_rss": "RSS", + "generic_button_delete": "Forigi", + "channel_tab_podcasts_label": "Podkastoj", + "generic_button_cancel": "Nuligi", + "channel_tab_releases_label": "Eldonoj", + "generic_button_save": "Konservi", + "generic_channels_count": "{{count}} kanalo", + "generic_channels_count_plural": "{{count}} kanaloj" +} diff --git a/locales/es.json b/locales/es.json index 1b50aea1..fda29198 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1,16 +1,4 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` suscriptores", - "": "`x` suscriptores." - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vídeos", - "": "`x` vídeos." - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reproducción", - "": "`x` listas de reproducción." - }, "LIVE": "DIRECTO", "Shared `x` ago": "Compartido hace `x`", "Unsubscribe": "Desuscribirse", @@ -26,22 +14,21 @@ "Clear watch history?": "¿Quiere borrar el historial de reproducción?", "New password": "Nueva contraseña", "New passwords must match": "Las nuevas contraseñas deben coincidir", - "Cannot change password for Google accounts": "No se puede cambiar la contraseña de la cuenta de Google", "Authorize token?": "¿Autorizar el token?", "Authorize token for `x`?": "¿Autorizar el token para `x`?", "Yes": "Sí", "No": "No", "Import and Export Data": "Importación y exportación de datos", "Import": "Importar", - "Import Invidious data": "Importar datos de Invidious", - "Import YouTube subscriptions": "Importar suscripciones de YouTube", + "Import Invidious data": "Importar datos JSON de Invidious", + "Import YouTube subscriptions": "Importar suscripciones CSV u OPML de YouTube", "Import FreeTube subscriptions (.db)": "Importar suscripciones de FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importar suscripciones de NewPipe (.json)", "Import NewPipe data (.zip)": "Importar datos de NewPipe (.zip)", "Export": "Exportar", "Export subscriptions as OPML": "Exportar suscripciones como OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar suscripciones como OPML (para NewPipe y FreeTube)", - "Export data as JSON": "Exportar datos como JSON", + "Export data as JSON": "Exportar datos de Invidious como JSON", "Delete account?": "¿Quiere borrar la cuenta?", "History": "Historial", "An alternative front-end to YouTube": "Una interfaz alternativa para YouTube", @@ -49,7 +36,6 @@ "source": "código fuente", "Log in": "Iniciar sesión", "Log in/register": "Iniciar sesión/Registrarse", - "Log in with Google": "Iniciar sesión en Google", "User ID": "Nombre", "Password": "Contraseña", "Time (h:mm:ss):": "Hora (h:mm:ss):", @@ -58,52 +44,54 @@ "Sign In": "Iniciar sesión", "Register": "Registrarse", "E-mail": "Correo", - "Google verification code": "Código de verificación de Google", "Preferences": "Preferencias", - "Player preferences": "Preferencias del reproductor", - "Always loop: ": "Repetir siempre: ", - "Autoplay: ": "Reproducción automática: ", - "Play next by default: ": "Reproducir siguiente por defecto: ", - "Autoplay next video: ": "Reproducir automáticamente el vídeo siguiente: ", - "Listen by default: ": "Activar el sonido por defecto: ", - "Proxy videos: ": "¿Usar un proxy para los vídeos? ", - "Default speed: ": "Velocidad por defecto: ", - "Preferred video quality: ": "Calidad de vídeo preferida: ", - "Player volume: ": "Volumen del reproductor: ", - "Default comments: ": "Comentarios por defecto: ", + "preferences_category_player": "Preferencias del reproductor", + "preferences_video_loop_label": "Repetir siempre: ", + "preferences_autoplay_label": "Reproducción automática: ", + "preferences_continue_label": "Reproducir siguiente por defecto: ", + "preferences_continue_autoplay_label": "Reproducir automáticamente el video siguiente: ", + "preferences_listen_label": "Activar el sonido por defecto: ", + "preferences_local_label": "¿Usar un proxy para los videos? ", + "preferences_speed_label": "Velocidad por defecto: ", + "preferences_quality_label": "Calidad de video preferida: ", + "preferences_volume_label": "Volumen del reproductor: ", + "preferences_comments_label": "Comentarios por defecto: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Subtítulos por defecto: ", + "preferences_captions_label": "Subtítulos por defecto: ", "Fallback captions: ": "Subtítulos alternativos: ", - "Show related videos: ": "¿Mostrar vídeos relacionados? ", - "Show annotations by default: ": "¿Mostrar anotaciones por defecto? ", - "Automatically extend video description: ": "", - "Visual preferences": "Preferencias visuales", - "Player style: ": "Estilo de reproductor: ", + "preferences_related_videos_label": "¿Mostrar videos relacionados? ", + "preferences_annotations_label": "¿Mostrar anotaciones por defecto? ", + "preferences_extend_desc_label": "Extender automáticamente la descripción del video: ", + "preferences_vr_mode_label": "Videos interactivos de 360 grados (necesita WebGL): ", + "preferences_category_visual": "Preferencias visuales", + "preferences_player_style_label": "Estilo de reproductor: ", "Dark mode: ": "Modo oscuro: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "oscuro", "light": "claro", - "Thin mode: ": "Modo compacto: ", - "Subscription preferences": "Preferencias de la suscripción", - "Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ", + "preferences_thin_mode_label": "Modo compacto: ", + "preferences_category_misc": "Preferencias misceláneas", + "preferences_automatic_instance_redirect_label": "Redirección automática de instancia (segunda opción a redirect.invidious.io): ", + "preferences_category_subscription": "Preferencias de la suscripción", + "preferences_annotations_subscribed_label": "¿Mostrar anotaciones por defecto para los canales suscritos? ", "Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ", - "Number of videos shown in feed: ": "Número de vídeos mostrados en la fuente: ", - "Sort videos by: ": "Ordenar los vídeos por: ", + "preferences_max_results_label": "Número de videos mostrados en la fuente: ", + "preferences_sort_label": "Ordenar los videos por: ", "published": "fecha de publicación", "published - reverse": "fecha de publicación: orden inverso", "alphabetically": "alfabéticamente", "alphabetically - reverse": "alfabéticamente: orden inverso", "channel name": "nombre del canal", "channel name - reverse": "nombre del canal: orden inverso", - "Only show latest video from channel: ": "Mostrar solo el último vídeo del canal: ", - "Only show latest unwatched video from channel: ": "Mostrar solo el último vídeo sin ver del canal: ", - "Only show unwatched: ": "Mostrar solo los no vistos: ", - "Only show notifications (if there are any): ": "Mostrar solo notificaciones (si hay alguna): ", + "Only show latest video from channel: ": "Mostrar solo el último video del canal: ", + "Only show latest unwatched video from channel: ": "Mostrar solo el último video sin ver del canal: ", + "preferences_unseen_only_label": "Mostrar solo los no vistos: ", + "preferences_notifications_only_label": "Mostrar solo notificaciones (si hay alguna): ", "Enable web notifications": "Habilitar notificaciones web", "`x` uploaded a video": "`x` subió un video", - "`x` is live": "`x` esta en vivo", - "Data preferences": "Preferencias de los datos", + "`x` is live": "`x` está en directo", + "preferences_category_data": "Preferencias de los datos", "Clear watch history": "Borrar el historial de reproducción", "Import/export data": "Importar/Exportar datos", "Change password": "Cambiar contraseña", @@ -111,9 +99,10 @@ "Manage tokens": "Gestionar tokens", "Watch history": "Historial de reproducción", "Delete account": "Borrar cuenta", - "Administrator preferences": "Preferencias de administrador", - "Default homepage: ": "Página de inicio por defecto: ", - "Feed menu: ": "Menú de fuentes: ", + "preferences_category_admin": "Preferencias de administrador", + "preferences_default_home_label": "Página de inicio por defecto: ", + "preferences_feed_menu_label": "Menú de fuentes: ", + "preferences_show_nick_label": "Mostrar nombre de usuario encima: ", "Top enabled: ": "¿Habilitar los destacados? ", "CAPTCHA enabled: ": "¿Habilitar los CAPTCHA? ", "Login enabled: ": "¿Habilitar el inicio de sesión? ", @@ -122,26 +111,14 @@ "Save preferences": "Guardar las preferencias", "Subscription manager": "Gestor de suscripciones", "Token manager": "Gestor de tokens", - "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` suscripciones", - "": "`x` suscripciones." - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens", - "": "`x` tokens." - }, + "Token": "Ficha", "Import/export": "Importar/Exportar", - "unsubscribe": "Desuscribirse", + "unsubscribe": "desuscribirse", "revoke": "revocar", "Subscriptions": "Suscripciones", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificaciones sin ver", - "": "`x` notificaciones sin ver." - }, "search": "buscar", "Log out": "Cerrar la sesión", - "Released under the AGPLv3 by Omar Roth.": "Publicado bajo licencia AGPLv3 por Omar Roth.", + "Released under the AGPLv3 on Github.": "Publicado bajo la AGPLv3 en GitHub.", "Source available here.": "Código fuente disponible aquí.", "View JavaScript license information.": "Ver información de licencia de JavaScript.", "View privacy policy.": "Ver la política de privacidad.", @@ -156,50 +133,42 @@ "Create playlist": "Crear lista de reproducción", "Title": "Título", "Playlist privacy": "Privacidad de la lista de reproducción", - "Editing playlist `x`": "Editando la lista de reproducción 'x'", - "Show more": "", - "Show less": "", - "Watch on YouTube": "Ver el vídeo en Youtube", + "Editing playlist `x`": "Editando la lista de reproducción `x`", + "Show more": "Mostrar más", + "Show less": "Mostrar menos", + "Watch on YouTube": "Ver en YouTube", + "Switch Invidious Instance": "Cambiar Instancia de Invidious", "Hide annotations": "Ocultar anotaciones", "Show annotations": "Mostrar anotaciones", "Genre: ": "Género: ", "License: ": "Licencia: ", "Family friendly? ": "¿Filtrar contenidos? ", "Wilson score: ": "Puntuación Wilson: ", - "Engagement: ": "Compromiso: ", + "Engagement: ": "Retención: ", "Whitelisted regions: ": "Regiones permitidas: ", "Blacklisted regions: ": "Regiones bloqueadas: ", "Shared `x`": "Compartido `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizaciones", - "": "`x` visualizaciones." - }, "Premieres in `x`": "Se estrena en `x`", "Premieres `x`": "Estrenos `x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "¡Hola! Parece que tiene JavaScript desactivado. Haga clic aquí para ver los comentarios, pero tenga en cuenta que pueden tardar un poco más en cargarse.", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "¡Hola! Parece que tienes JavaScript desactivado. Haz clic aquí para ver los comentarios, ten en cuenta que pueden tardar un poco más en cargar.", "View YouTube comments": "Ver los comentarios de YouTube", "View more comments on Reddit": "Ver más comentarios en Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentarios", - "": "Ver `x` comentarios." + "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentario", + "": "Ver `x` comentarios" }, "View Reddit comments": "Ver los comentarios de Reddit", "Hide replies": "Ocultar las respuestas", "Show replies": "Mostrar las respuestas", "Incorrect password": "Contraseña incorrecta", - "Quota exceeded, try again in a few hours": "Cuota excedida, pruebe otra vez en unas horas", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "No se puede iniciar sesión, asegúrese de que la autentificación de dos factores (autentificador o SMS) esté habilitada.", - "Invalid TFA code": "Código TFA no válido", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Error de inicio de sesion. Puede deberse a que la autentificación de dos factores no está habilitada en su cuenta.", "Wrong answer": "Respuesta no válida", "Erroneous CAPTCHA": "CAPTCHA no válido", "CAPTCHA is a required field": "El CAPTCHA es un campo obligatorio", "User ID is a required field": "El nombre es un campo obligatorio", "Password is a required field": "La contraseña es un campo obligatorio", "Wrong username or password": "Nombre o contraseña incorrecto", - "Please sign in using 'Log in with Google'": "Inicie sesión con «Iniciar sesión con Google»", "Password cannot be empty": "La contraseña no puede estar en blanco", - "Password cannot be longer than 55 characters": "La contraseña no puede tener más de 55 caracteres", + "Password cannot be longer than 55 characters": "La contraseña no debe tener más de 55 caracteres", "Please log in": "Inicie sesión, por favor", "Invidious Private Feed for `x`": "Fuente privada de Invidious para `x`", "channel:`x`": "canal: `x`", @@ -207,16 +176,8 @@ "This channel does not exist.": "El canal no existe.", "Could not get channel info.": "No se ha podido obtener información del canal.", "Could not fetch comments": "No se han podido recuperar los comentarios", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respuestas", - "": "Ver `x` respuestas." - }, "`x` ago": "hace `x`", "Load more": "Cargar más", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` puntos", - "": "`x` puntos." - }, "Could not create mix.": "No se ha podido crear la mezcla.", "Empty playlist": "La lista de reproducción está vacía", "Not a playlist.": "Lista de reproducción no válida.", @@ -226,10 +187,10 @@ "Hidden field \"token\" is a required field": "El campo oculto «símbolo» es un campo obligatorio", "Erroneous challenge": "Desafío no válido", "Erroneous token": "Símbolo no válido", - "No such user": "Usuario no válido", + "No such user": "Usuario no existe", "Token is expired, please try again": "El símbolo ha caducado, inténtelo de nuevo", "English": "Inglés", - "English (auto-generated)": "Inglés (autogenerado)", + "English (auto-generated)": "Inglés (generado automáticamente)", "Afrikaans": "Afrikáans", "Albanian": "Albanés", "Amharic": "Amárico", @@ -334,41 +295,13 @@ "Yiddish": "Yidis", "Yoruba": "Yoruba", "Zulu": "Zulú", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` años", - "": "`x` años." - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses", - "": "`x` meses." - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas", - "": "`x` semanas." - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` días", - "": "`x` días." - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas", - "": "`x` horas." - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos", - "": "`x` minutos." - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos", - "": "`x` segundos." - }, "Fallback comments: ": "Comentarios alternativos: ", "Popular": "Populares", "Search": "Buscar", "Top": "Destacados", "About": "Acerca de", "Rating: ": "Valoración: ", - "Language: ": "Idioma: ", + "preferences_locale_label": "Idioma: ", "View as playlist": "Ver como lista de reproducción", "Default": "Por defecto", "Music": "Música", @@ -379,40 +312,206 @@ "Download as: ": "Descargar como: ", "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(editado)", - "YouTube comment permalink": "Enlace permanente de YouTube del comentario", - "permalink": "permalink", + "YouTube comment permalink": "Enlace permanente de comentario de YouTube", + "permalink": "enlace permanente", "`x` marked it with a ❤": "`x` lo ha marcado con un ❤", "Audio mode": "Modo de audio", - "Video mode": "Modo de vídeo", - "Videos": "Vídeos", + "Video mode": "Modo de video", + "channel_tab_videos_label": "Videos", "Playlists": "Listas de reproducción", - "Community": "Comunidad", - "relevance": "relevancia", - "rating": "valoración", - "date": "fecha", - "views": "visualizaciones", - "content_type": "content_type", - "duration": "duración", - "features": "funcionalidades", - "sort": "ordenar", - "hour": "hora", - "today": "hoy", - "week": "semana", - "month": "mes", - "year": "año", - "video": "vídeo", - "channel": "canal", - "playlist": "lista de reproducción", - "movie": "película", - "show": "programa", - "hd": "hd", - "subtitles": "subtítulos", - "creative_commons": "creative_commons", - "3d": "3d", - "live": "directo", - "4k": "4k", - "location": "ubicación", - "hdr": "hdr", - "filter": "filtro", - "Current version: ": "Versión actual: " -}
\ No newline at end of file + "channel_tab_community_label": "Comunidad", + "search_filters_sort_option_relevance": "Relevancia", + "search_filters_sort_option_rating": "Valoración", + "search_filters_sort_option_date": "Fecha de subida", + "search_filters_sort_option_views": "Visualizaciones", + "search_filters_type_label": "Tipo de contenido", + "search_filters_duration_label": "Duración", + "search_filters_features_label": "Funcionalidades", + "search_filters_sort_label": "Ordenar", + "search_filters_date_option_hour": "Última hora", + "search_filters_date_option_today": "Hoy", + "search_filters_date_option_week": "Esta semana", + "search_filters_date_option_month": "Este mes", + "search_filters_date_option_year": "Este año", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Canal", + "search_filters_type_option_playlist": "Lista de reproducción", + "search_filters_type_option_movie": "Película", + "search_filters_type_option_show": "Programa", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Subtítulos", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "En directo", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Ubicación", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Versión actual: ", + "next_steps_error_message": "Después de lo cual debes intentar: ", + "next_steps_error_message_refresh": "Recargar la página", + "next_steps_error_message_go_to_youtube": "Ir a YouTube", + "search_filters_duration_option_short": "Menos de 4 minutos", + "search_filters_duration_option_medium": "De 4 a 20 minutos", + "search_filters_duration_option_long": "Más de 20 minutos", + "footer_documentation": "Documentación", + "footer_original_source_code": "Código fuente original", + "adminprefs_modified_source_code_url_label": "Enlace al repositorio de código fuente modificado", + "footer_source_code": "Código fuente", + "footer_modfied_source_code": "Código fuente modificado", + "footer_donate_page": "Donar", + "preferences_region_label": "País del contenido: ", + "preferences_quality_dash_label": "Calidad de video DASH preferida: ", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Intermedia", + "preferences_quality_dash_option_auto": "Automática", + "none": "ninguno", + "videoinfo_started_streaming_x_ago": "Comenzó difusión hace `x`", + "download_subtitles": "Subtítulos- `x` (.vtt)", + "user_created_playlists": "`x` listas de reproducción creadas", + "user_saved_playlists": "`x` listas de reproducción guardadas", + "Video unavailable": "Video no disponible", + "videoinfo_youTube_embed_link": "Insertar", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_4320p": "4320p", + "invidious": "Invidious", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_option_dash": "DASH (calidad adaptativa)", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "preferences_quality_option_small": "Pequeña", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_best": "La mejor", + "preferences_quality_dash_option_worst": "La peor", + "videoinfo_invidious_embed_link": "Enlace para Insertar", + "preferences_quality_dash_option_1080p": "1080p", + "search_filters_features_option_purchased": "Comprado", + "search_filters_features_option_three_sixty": "360°", + "videoinfo_watch_on_youTube": "Ver en YouTube", + "preferences_save_player_pos_label": "Guardar posición de reproducción: ", + "generic_views_count_0": "{{count}} visualización", + "generic_views_count_1": "{{count}} visualizaciones", + "generic_views_count_2": "{{count}} visualizaciones", + "generic_subscribers_count_0": "{{count}} suscriptor", + "generic_subscribers_count_1": "{{count}} suscriptores", + "generic_subscribers_count_2": "{{count}} suscriptores", + "generic_subscriptions_count_0": "{{count}} suscripción", + "generic_subscriptions_count_1": "{{count}} suscripciones", + "generic_subscriptions_count_2": "{{count}} suscripciones", + "subscriptions_unseen_notifs_count_0": "{{count}} notificación sin ver", + "subscriptions_unseen_notifs_count_1": "{{count}} notificaciones sin ver", + "subscriptions_unseen_notifs_count_2": "{{count}} notificaciones sin ver", + "generic_count_days_0": "{{count}} día", + "generic_count_days_1": "{{count}} días", + "generic_count_days_2": "{{count}} días", + "comments_view_x_replies_0": "Ver {{count}} respuesta", + "comments_view_x_replies_1": "Ver {{count}} respuestas", + "comments_view_x_replies_2": "Ver {{count}} respuestas", + "generic_count_weeks_0": "{{count}} semana", + "generic_count_weeks_1": "{{count}} semanas", + "generic_count_weeks_2": "{{count}} semanas", + "generic_playlists_count_0": "{{count}} lista de reproducción", + "generic_playlists_count_1": "{{count}} listas de reproducciones", + "generic_playlists_count_2": "{{count}} listas de reproducciones", + "generic_videos_count_0": "{{count}} video", + "generic_videos_count_1": "{{count}} videos", + "generic_videos_count_2": "{{count}} videos", + "generic_count_months_0": "{{count}} mes", + "generic_count_months_1": "{{count}} meses", + "generic_count_months_2": "{{count}} meses", + "comments_points_count_0": "{{count}} punto", + "comments_points_count_1": "{{count}} puntos", + "comments_points_count_2": "{{count}} puntos", + "generic_count_years_0": "{{count}} año", + "generic_count_years_1": "{{count}} años", + "generic_count_years_2": "{{count}} años", + "generic_count_hours_0": "{{count}} hora", + "generic_count_hours_1": "{{count}} horas", + "generic_count_hours_2": "{{count}} horas", + "generic_count_minutes_0": "{{count}} minuto", + "generic_count_minutes_1": "{{count}} minutos", + "generic_count_minutes_2": "{{count}} minutos", + "generic_count_seconds_0": "{{count}} segundo", + "generic_count_seconds_1": "{{count}} segundos", + "generic_count_seconds_2": "{{count}} segundos", + "crash_page_before_reporting": "Antes de notificar un error asegúrate de que has:", + "crash_page_switch_instance": "probado a <a href=\"`x`\">usar otra instancia</a>", + "crash_page_read_the_faq": "leído las <a href=\"`x`\">Preguntas Frecuentes</a>", + "crash_page_search_issue": "buscado <a href=\"`x`\">problemas existentes en GitHub</a>", + "crash_page_you_found_a_bug": "¡Parece que has encontrado un error en Invidious!", + "crash_page_refresh": "probado a <a href=\"`x`\">recargar la página</a>", + "crash_page_report_issue": "Si nada de lo anterior ha sido de ayuda, por favor, <a href=\"`x`\">abre una nueva incidencia en GitHub</a> (preferiblemente en inglés) e incluye el siguiente texto en tu mensaje (NO traduzcas este texto):", + "English (United States)": "Inglés (Estados Unidos)", + "Cantonese (Hong Kong)": "Cantonés (Hong Kong)", + "Dutch (auto-generated)": "Neerlandés (generados automáticamente)", + "French (auto-generated)": "Francés (generados automáticamente)", + "Interlingue": "Occidental", + "Japanese (auto-generated)": "Japonés (generados automáticamente)", + "Russian (auto-generated)": "Ruso (generados automáticamente)", + "Spanish (Spain)": "Español (España)", + "Vietnamese (auto-generated)": "Vietnamita (generados automáticamente)", + "English (United Kingdom)": "Inglés (Reino Unido)", + "Chinese (Taiwan)": "Chino (Taiwán)", + "German (auto-generated)": "Alemán (generados automáticamente)", + "Italian (auto-generated)": "Italiano (generados automáticamente)", + "Turkish (auto-generated)": "Turco (generados automáticamente)", + "Portuguese (Brazil)": "Portugués (Brasil)", + "Indonesian (auto-generated)": "Indonesio (generados automáticamente)", + "Portuguese (auto-generated)": "Portugués (generados automáticamente)", + "Chinese": "Chino", + "Chinese (Hong Kong)": "Chino (Hong Kong)", + "Chinese (China)": "Chino (China)", + "Korean (auto-generated)": "Coreano (generados automáticamente)", + "Spanish (Mexico)": "Español (Méjico)", + "Spanish (auto-generated)": "Español (generados automáticamente)", + "preferences_watch_history_label": "Habilitar historial de reproducciones: ", + "search_message_no_results": "No se han encontrado resultados.", + "search_message_change_filters_or_query": "Pruebe ampliar la consulta de búsqueda y/o a cambiar los filtros.", + "search_filters_title": "Filtros", + "search_filters_date_label": "Fecha de subida", + "search_filters_date_option_none": "Cualquier fecha", + "search_filters_type_option_all": "Cualquier tipo", + "search_filters_duration_option_none": "Cualquier duración", + "search_filters_features_option_vr180": "VR180", + "search_filters_apply_button": "Aplicar filtros", + "tokens_count_0": "{{count}} token", + "tokens_count_1": "{{count}} tokens", + "tokens_count_2": "{{count}} tokens", + "search_message_use_another_instance": "También puedes <a href=\"`x`\">buscar en otra instancia</a>.", + "Popular enabled: ": "¿Habilitar la sección popular? ", + "error_video_not_in_playlist": "El video que solicitaste no existe en esta lista de reproducción. <a href=\"`x`\">Haz clic aquí para acceder a la página de inicio de la lista de reproducción.</a>", + "channel_tab_streams_label": "Directos", + "channel_tab_channels_label": "Canales", + "channel_tab_shorts_label": "Cortos", + "channel_tab_playlists_label": "Listas de reproducción", + "Music in this video": "Música en este video", + "Artist: ": "Artista: ", + "Album: ": "Álbum: ", + "Song: ": "Canción: ", + "Channel Sponsor": "Patrocinador del canal", + "Standard YouTube license": "Licencia de YouTube estándar", + "Download is disabled": "La descarga está deshabilitada", + "Import YouTube playlist (.csv)": "Importar lista de reproducción de YouTube (.csv)", + "playlist_button_add_items": "Añadir vídeos", + "generic_button_edit": "Editar", + "generic_button_save": "Guardar", + "generic_button_delete": "Borrar", + "generic_button_cancel": "Cancelar", + "generic_button_rss": "RSS", + "channel_tab_podcasts_label": "Podcasts", + "channel_tab_releases_label": "Publicaciones", + "generic_channels_count_0": "{{count}} canal", + "generic_channels_count_1": "{{count}} canales", + "generic_channels_count_2": "{{count}} canales", + "Import YouTube watch history (.json)": "Importar el historial de las visualizaciones de YouTube (.json)", + "toggle_theme": "Alternar tema", + "Add to playlist: ": "Añadir a la lista de reproducción: ", + "Add to playlist": "Añadir a la lista de reproducción", + "Answer": "Respuesta", + "Search for videos": "Buscar por vídeos", + "The Popular feed has been disabled by the administrator.": "El feed Popular ha sido desactivado por el administrador.", + "carousel_slide": "Diapositiva {{current}} de {{total}}", + "carousel_skip": "Saltar el carrusel", + "carousel_go_to": "Ir a la diapositiva `x`" +} diff --git a/locales/et.json b/locales/et.json new file mode 100644 index 00000000..7f652810 --- /dev/null +++ b/locales/et.json @@ -0,0 +1,332 @@ +{ + "generic_playlists_count": "{{count}} esitusloend", + "generic_playlists_count_plural": "{{count}} esindusloendit", + "LIVE": "OTSEÜLEKANNE", + "View channel on YouTube": "Vaata kanalit YouTube'is", + "Log in": "Logi sisse", + "Log in/register": "Logi sisse/registreeru", + "Dark mode: ": "Tume režiim: ", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videot", + "generic_subscribers_count": "{{count}} tellija", + "generic_subscribers_count_plural": "{{count}} tellijat", + "generic_subscriptions_count": "{{count}} tellimus", + "generic_subscriptions_count_plural": "{{count}} tellimust", + "Shared `x` ago": "Jagatud `x` tagasi", + "Unsubscribe": "Loobu tellimusest", + "Subscribe": "Telli", + "View playlist on YouTube": "Vaata esitusloendit YouTube'is", + "newest": "uusimad", + "oldest": "vanimad", + "popular": "populaarsed", + "last": "viimane", + "Next page": "Järgmine leht", + "Previous page": "Eelmine leht", + "Clear watch history?": "Kustuta vaatamiste ajalugu?", + "New password": "Uus salasõna", + "New passwords must match": "Uued salasõnad peavad ühtima", + "Import and Export Data": "Impordi ja ekspordi andmed", + "Import": "Impordi", + "Import YouTube subscriptions": "Impordi tellimused Youtube'ist/OPML-ist", + "Import FreeTube subscriptions (.db)": "Impordi tellimused FreeTube'ist (.db)", + "Import NewPipe data (.zip)": "Impordi NewPipe'i andmed (.zip)", + "Export": "Ekspordi", + "Export subscriptions as OPML": "Ekspordi tellimused OPML-ina", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Ekspordi tellimused OPML-ina (NewPipe'i ja FreeTube'i jaoks)", + "Delete account?": "Kustuta kasutaja?", + "History": "Ajalugu", + "JavaScript license information": "JavaScripti litsentsi info", + "source": "allikas", + "User ID": "Kasutada ID", + "Password": "Salasõna", + "Time (h:mm:ss):": "Aeg (h:mm:ss):", + "Text CAPTCHA": "CAPTCHA-tekst", + "Image CAPTCHA": "CAPTCHA-foto", + "Sign In": "Logi sisse", + "Register": "Registreeru", + "E-mail": "E-post", + "Preferences": "Eelistused", + "preferences_category_player": "Mängija eelistused", + "preferences_continue_autoplay_label": "Mängi järgmine video automaatselt: ", + "preferences_quality_label": "Eelistatud videokvaliteet: ", + "preferences_quality_option_dash": "DASH (kohanduv kvaliteet)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Keskmine", + "preferences_quality_option_small": "Väike", + "preferences_quality_dash_label": "Eelistatav DASH-video kvaliteet: ", + "preferences_quality_dash_option_auto": "Automaatne", + "preferences_quality_dash_option_best": "Parim", + "preferences_quality_dash_option_worst": "Halvim", + "preferences_volume_label": "Video helitugevus: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_related_videos_label": "Näita sarnaseid videosid: ", + "preferences_vr_mode_label": "Interaktiivne 360-kraadine video (vajalik WebGL): ", + "preferences_dark_mode_label": "Teema: ", + "dark": "tume", + "light": "hele", + "preferences_category_subscription": "Tellimuse seaded", + "preferences_max_results_label": "Avalehel näidatavate videote arv: ", + "preferences_sort_label": "Sorteeri: ", + "published": "avaldatud", + "alphabetically": "tähestikulises järjekorras", + "alphabetically - reverse": "vastupidi tähestikulises järjekorras", + "channel name": "kanali nimi", + "preferences_unseen_only_label": "Näita ainult vaatamata videosid: ", + "Only show latest video from channel: ": "Näita ainult viimast videot: ", + "preferences_notifications_only_label": "Näita ainult teavitusi (kui neid on): ", + "Enable web notifications": "Luba veebiteavitused", + "`x` uploaded a video": "`x` laadis video üles", + "`x` is live": "`x` teeb otseülekannet", + "preferences_category_data": "Andme-eelistused", + "Clear watch history": "Puhasta vaatamisajalugu", + "Import/export data": "Impordi/ekspordi andmed", + "Change password": "Muuda salasõna", + "Watch history": "Vaatamisajalugu", + "Delete account": "Kustuta kasutaja", + "Save preferences": "Salvesta eelistused", + "Token": "Token", + "Import/export": "Imprort/eksport", + "unsubscribe": "loobu tellimusest", + "Subscriptions": "Tellimused", + "search": "otsi", + "Source available here.": "Allikas on kättesaadaval siin.", + "View privacy policy.": "Vaata privaatsuspoliitikat.", + "Public": "Avalik", + "Private": "Privaatne", + "View all playlists": "Vaata kõiki esitusloendeid", + "Updated `x` ago": "Uuendas `x` tagasi", + "Delete playlist `x`?": "Kustuta esitusloend `x`?", + "Delete playlist": "Kustuta esitusloend", + "Create playlist": "Loo esitlusloend", + "Title": "Pealkiri", + "Playlist privacy": "Esitusloendi privaatsus", + "Show more": "Näita rohkem", + "Show less": "Näita vähem", + "Watch on YouTube": "Vaata YouTube'is", + "search_message_no_results": "Tulemusi ei leitud.", + "search_message_change_filters_or_query": "Proovi otsingut laiendada või filtreid muuta.", + "Genre: ": "Žanr: ", + "License: ": "Litsents: ", + "Family friendly? ": "Peresõbralik? ", + "Shared `x`": "Jagas `x`", + "Premieres in `x`": "Esilinastub `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Tundub, et oled JavaScripti välja lülitanud. Vajuta siia, et kommentaare vaadata; nende laadimine võib võtta natukene rohkem aega.", + "View Reddit comments": "Vaata Redditi kommentaare", + "Hide replies": "Peida vastused", + "Show replies": "Näita vastuseid", + "Incorrect password": "Vale salasõna", + "Wrong answer": "Vale vastus", + "User ID is a required field": "Kasutaja ID on kohustuslik väli", + "Password is a required field": "Salasõna on kohustuslik väli", + "Wrong username or password": "Vale kasutajanimi või salasõna", + "Password cannot be longer than 55 characters": "Salasõna ei tohi olla pikem kui 55 tähemärki", + "Password cannot be empty": "Salasõna ei tohi olla tühi", + "Please log in": "Palun logige sisse", + "channel:`x`": "kanal:`x`", + "Deleted or invalid channel": "Kanal on kustutatud või seda ei leitud", + "This channel does not exist.": "Sellist kanalit pole olemas.", + "comments_view_x_replies": "{{count}} vastus", + "comments_view_x_replies_plural": "{{count}} vastust", + "`x` ago": "`x` tagasi", + "Load more": "Laadi rohkem", + "Empty playlist": "Tühi esitusloend", + "Not a playlist.": "Tegu pole esitusloendiga.", + "Playlist does not exist.": "Seda esitusloendit pole olemas.", + "No such user": "Sellist kasutajat pole", + "English": "Inglise", + "English (United Kingdom)": "Inglise (Suurbritannia)", + "English (United States)": "Inglise (USA)", + "English (auto-generated)": "Inglise (automaatselt koostatud)", + "Afrikaans": "Afrikaani", + "Albanian": "Albaania", + "Arabic": "Araabia", + "Armenian": "Armeenia", + "Bangla": "Bengali", + "Basque": "Baski", + "Belarusian": "Valgevene", + "Bulgarian": "Bulgaaria", + "Burmese": "Birma", + "Cantonese (Hong Kong)": "Kantoni (Hong Konk)", + "Chinese (China)": "Hiina (Hiina)", + "Chinese (Hong Kong)": "Hiina (Hong Kong)", + "Chinese (Simplified)": "Hiina (lihtsustatud)", + "Chinese (Taiwan)": "Hiina (Taiwan)", + "Croatian": "Horvaatia", + "Czech": "Tšehhi", + "Danish": "Taani", + "Dutch": "Hollandi", + "Esperanto": "Esperanto", + "Estonian": "Eesti", + "Filipino": "Filipiini", + "Finnish": "Soome", + "French": "Prantsuse", + "French (auto-generated)": "Prantsuse (automaatne)", + "Dutch (auto-generated)": "Hollandi (automaatne)", + "Galician": "Kaliitsia", + "Georgian": "Gruusia", + "Haitian Creole": "Haiti kreool", + "Hausa": "Hausa", + "Hawaiian": "Havaii", + "Hebrew": "Heebrea", + "Hindi": "Hindi", + "Hungarian": "Ungari", + "Icelandic": "Islandi", + "Indonesian": "Indoneesia", + "Japanese (auto-generated)": "Jaapani (automaatne)", + "Kannada": "Kannada", + "Kazakh": "Kasahhi", + "Luxembourgish": "Luksemburgi", + "Macedonian": "Makedoonia", + "Malay": "Malai", + "Maltese": "Malta", + "Maori": "Maori", + "Marathi": "Marathi", + "Mongolian": "Mongoli", + "Nepali": "Nepaali", + "Norwegian Bokmål": "Norra (Bokmål)", + "Persian": "Pärsia", + "Polish": "Poola", + "Portuguese": "Portugali", + "Portuguese (auto-generated)": "Portugali (automaatne)", + "Portuguese (Brazil)": "Portugali (Brasiilia)", + "Romanian": "Rumeenia", + "Russian": "Vene", + "Russian (auto-generated)": "Vene (automaatne)", + "Scottish Gaelic": "Šoti (Gaeli)", + "Serbian": "Serbia", + "Slovak": "Slovaki", + "Slovenian": "Sloveeni", + "Somali": "Somaali", + "Spanish": "Hispaania", + "Spanish (auto-generated)": "Hispaania (automaatne)", + "Spanish (Latin America)": "Hispaania (Ladina-Ameerika)", + "Spanish (Mexico)": "Hispaania (Mehhiko)", + "Spanish (Spain)": "Hispaania (Hispaania)", + "Swahili": "Suahili", + "Swedish": "Rootsi", + "Tajik": "Tadžiki", + "Tamil": "Tamiili", + "Thai": "Tai", + "Turkish": "Türgi", + "Turkish (auto-generated)": "Türgi (automaatne)", + "Ukrainian": "Ukraina", + "Uzbek": "Usbeki", + "Vietnamese": "Vietnami", + "Vietnamese (auto-generated)": "Vietnami (automaatne)", + "generic_count_years": "{{count}} aasta", + "generic_count_years_plural": "{{count}} aastat", + "generic_count_months": "{{count}} kuu", + "generic_count_months_plural": "{{count}} kuud", + "generic_count_weeks": "{{count}} nädal", + "generic_count_weeks_plural": "{{count}} nädalat", + "generic_count_days": "{{count}} päev", + "generic_count_days_plural": "{{count}} päeva", + "generic_count_hours": "{{count}} tund", + "generic_count_hours_plural": "{{count}} tundi", + "generic_count_minutes": "{{count}} minut", + "generic_count_minutes_plural": "{{count}} minutit", + "Popular": "Populaarne", + "Search": "Otsi", + "Top": "Top", + "About": "Leheküljest", + "preferences_locale_label": "Keel: ", + "View as playlist": "Vaata esitusloendina", + "Movies": "Filmid", + "Download as: ": "Laadi kui: ", + "(edited)": "(muudetud)", + "`x` marked it with a ❤": "`x` märkis ❤", + "Audio mode": "Audiorežiim", + "Video mode": "Videorežiim", + "search_filters_date_label": "Üleslaadimise kuupäev", + "search_filters_date_option_none": "Ükskõik mis kuupäev", + "search_filters_date_option_today": "Täna", + "search_filters_date_option_week": "Sel nädalal", + "search_filters_date_option_hour": "Viimasel tunnil", + "search_filters_date_option_month": "Sel kuul", + "search_filters_date_option_year": "Sel aastal", + "search_filters_type_label": "Tüüp", + "search_filters_type_option_all": "Ükskõik mis tüüp", + "search_filters_duration_label": "Kestus", + "search_filters_type_option_show": "Näita", + "search_filters_duration_option_none": "Ükskõik mis kestus", + "search_filters_duration_option_short": "Lühike (alla 4 minuti)", + "search_filters_duration_option_medium": "Keskmine (4 - 20 minutit)", + "search_filters_duration_option_long": "Pikk (üle 20 minuti)", + "search_filters_features_option_live": "Otseülekanne", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Subtiitrid", + "search_filters_features_option_location": "Asukoht", + "search_filters_sort_label": "Sorteeri", + "search_filters_sort_option_views": "Vaatamiste arv", + "next_steps_error_message": "Pärast mida võiksite proovida: ", + "videoinfo_started_streaming_x_ago": "Alustas otseülekannet `x` tagasi", + "Yes": "Jah", + "generic_views_count": "{{count}} vaatamine", + "generic_views_count_plural": "{{count}} vaatamist", + "Import NewPipe subscriptions (.json)": "Impordi tellimused NewPipe'ist (.json)", + "No": "Ei", + "preferences_region_label": "Riik: ", + "View YouTube comments": "Vaata YouTube'i kommentaare", + "preferences_extend_desc_label": "Ava video kirjeldus automaatselt: ", + "German (auto-generated)": "Saksa (automaatne)", + "Italian": "Itaalia", + "preferences_player_style_label": "Mängija stiil: ", + "subscriptions_unseen_notifs_count": "{{count}} lugemata teavitus", + "subscriptions_unseen_notifs_count_plural": "{{count}} lugemata teavitust", + "View more comments on Reddit": "Vaata teisi kommentaare Redditis", + "Only show latest unwatched video from channel: ": "Näita ainult viimast vaatamata videot: ", + "tokens_count": "{{count}} token", + "tokens_count_plural": "{{count}} tokenit", + "Log out": "Logi välja", + "Premieres `x`": "Linastub`x`", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Vaata `x` kommentaari", + "": "Vaata `x` kommentaare" + }, + "Khmer": "Khmeeri", + "Bosnian": "Bosnia", + "Corsican": "Korsika", + "Javanese": "Jaava", + "Lithuanian": "Leedu", + "channel_tab_videos_label": "Videod", + "channel_tab_community_label": "Kogukond", + "CAPTCHA is a required field": "CAPTCHA on kohustuslik väli", + "comments_points_count": "{{count}} punkt", + "comments_points_count_plural": "{{count}} punkti", + "Chinese": "Hiina", + "German": "Saksa", + "Indonesian (auto-generated)": "Indoneesia (automaatne)", + "Italian (auto-generated)": "Itaalia (automaatne)", + "Kyrgyz": "Kirkiisi", + "Latin": "Ladina", + "generic_count_seconds": "{{count}} sekund", + "generic_count_seconds_plural": "{{count}} sekundit", + "Catalan": "Katalaani", + "Chinese (Traditional)": "Hiina (traditsiooniline)", + "Greek": "Kreeka", + "Kurdish": "Kurdi", + "Latvian": "Läti", + "Irish": "Iiri", + "Korean": "Korea", + "Japanese": "Jaapani", + "Korean (auto-generated)": "Korea (automaatne)", + "Music": "Muusika", + "Playlists": "Esitusloendid", + "search_filters_type_option_video": "Video", + "search_filters_sort_option_date": "Üleslaadimise kuupäev", + "Current version: ": "Praegune versioon: ", + "footer_documentation": "Dokumentatsioon", + "Gaming": "Mängud", + "News": "Uudised", + "Download": "Laadi alla", + "search_filters_title": "Filtrid", + "search_filters_type_option_channel": "Kanal", + "search_filters_type_option_playlist": "Esitusloend", + "search_filters_type_option_movie": "Film", + "next_steps_error_message_go_to_youtube": "Minna YouTube'i", + "next_steps_error_message_refresh": "Laadida uuesti", + "footer_donate_page": "Anneta", + "videoinfo_watch_on_youTube": "Vaata YouTube'is" +} diff --git a/locales/eu.json b/locales/eu.json index d5021c25..fbca537b 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -1,7 +1,4 @@ { - "`x` subscribers": "`x` harpidedun", - "`x` videos": "`x` bideo", - "`x` playlists": "`x` erreprodukzio-zerrenda", "LIVE": "ZUZENEAN", "Shared `x` ago": "Duela `x` partekatua", "Unsubscribe": "Harpidetza kendu", @@ -17,22 +14,20 @@ "Clear watch history?": "Garbitu ikusitakoen historia?", "New password": "Pasahitz berria", "New passwords must match": "Pasahitza berriek bat egin behar dute", - "Cannot change password for Google accounts": "Ezin da pasahitza aldatu Google kontuetan", "Authorize token?": "Baimendu tokena?", - "Authorize token for `x`?": "", "Yes": "Bai", "No": "Ez", "Import and Export Data": "Datuak inportatu eta esportatu", "Import": "Inportatu", - "Import Invidious data": "Inportatu Invidiouseko datuak", - "Import YouTube subscriptions": "Inportatu YouTubeko harpidetzak", + "Import Invidious data": "Inportatu Invidiouseko JSON datuak", + "Import YouTube subscriptions": "Inportatu YouTubeko/OPML harpidetzak", "Import FreeTube subscriptions (.db)": "Inportatu FreeTubeko harpidetzak (.db)", "Import NewPipe subscriptions (.json)": "Inportatu NewPipeko harpidetzak (.json)", "Import NewPipe data (.zip)": "Inportatu NewPipeko datuak (.zip)", "Export": "Esportatu", "Export subscriptions as OPML": "Esportatu harpidetzak OPML bezala", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Esportatu harpidetzak OPML bezala (NewPipe eta FreeTuberako)", - "Export data as JSON": "Esportatu datuak JSON bezala", + "Export data as JSON": "Esportatu Invidious datuak JSON gisa", "Delete account?": "Kontua ezabatu?", "History": "Historia", "An alternative front-end to YouTube": "YouTuberako interfaze alternatibo bat", @@ -40,7 +35,6 @@ "source": "iturburua", "Log in": "Saioa hasi", "Log in/register": "Hasi saioa / Eman izena", - "Log in with Google": "Hasi saioa Googlekin", "User ID": "Erabiltzaile IDa", "Password": "Pasahitza", "Time (h:mm:ss):": "Denbora (h:mm:ss):", @@ -49,292 +43,227 @@ "Sign In": "Hasi saioa", "Register": "Eman izena", "E-mail": "E-posta", - "Google verification code": "", "Preferences": "Hobespenak", - "Player preferences": "Erreproduzigailuaren hobespenak", - "Always loop: ": "", - "Autoplay: ": "Automatikoki erreproduzitu: ", - "Play next by default: ": "", - "Autoplay next video: ": "Erreproduzitu automatikoki hurrengo bideoa: ", - "Listen by default: ": "", - "Proxy videos: ": "", - "Default speed: ": "", - "Preferred video quality: ": "Hobetsitako bideoaren kalitatea: ", - "Player volume: ": "Erreproduzigailuaren bolumena: ", - "Default comments: ": "Lehenetsitako iruzkinak: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "Lehenetsitako azpitituluak: ", - "Fallback captions: ": "", - "Show related videos: ": "Erakutsi erlazionatutako bideoak: ", - "Show annotations by default: ": "Erakutsi oharrak modu lehenetsian: ", - "Automatically extend video description: ": "", - "Visual preferences": "Hobespen bisualak", - "Player style: ": "Erreproduzigailu mota: ", + "preferences_category_player": "Erreproduzigailuaren hobespenak", + "preferences_autoplay_label": "Automatikoki erreproduzitu: ", + "preferences_continue_autoplay_label": "Erreproduzitu automatikoki hurrengo bideoa: ", + "preferences_quality_label": "Hobetsitako bideoaren kalitatea: ", + "preferences_volume_label": "Erreproduzigailuaren bolumena: ", + "preferences_comments_label": "Lehenetsitako iruzkinak: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Lehenetsitako azpitituluak: ", + "preferences_related_videos_label": "Erakutsi erlazionatutako bideoak: ", + "preferences_annotations_label": "Erakutsi oharrak modu lehenetsian: ", + "preferences_category_visual": "Hobespen bisualak", + "preferences_player_style_label": "Erreproduzigailu mota: ", "Dark mode: ": "Gai iluna: ", - "Theme: ": "Gaia: ", + "preferences_dark_mode_label": "Gaia: ", "dark": "iluna", "light": "argia", - "Thin mode: ": "", - "Subscription preferences": "Harpidetzen hobespenak", - "Show annotations by default for subscribed channels: ": "", - "Redirect homepage to feed: ": "", - "Number of videos shown in feed: ": "", - "Sort videos by: ": "", - "published": "", - "published - reverse": "", - "alphabetically": "", - "alphabetically - reverse": "", - "channel name": "", - "channel name - reverse": "", - "Only show latest video from channel: ": "", - "Only show latest unwatched video from channel: ": "", - "Only show unwatched: ": "", - "Only show notifications (if there are any): ": "", - "Enable web notifications": "", - "`x` uploaded a video": "", - "`x` is live": "", - "Data preferences": "", - "Clear watch history": "", - "Import/export data": "", - "Change password": "", - "Manage subscriptions": "", - "Manage tokens": "", - "Watch history": "", - "Delete account": "", - "Administrator preferences": "", - "Default homepage: ": "", - "Feed menu: ": "", - "Top enabled: ": "", - "CAPTCHA enabled: ": "", - "Login enabled: ": "", - "Registration enabled: ": "", - "Report statistics: ": "", - "Save preferences": "", - "Subscription manager": "", - "Token manager": "", - "Token": "", - "`x` subscriptions": "", - "`x` tokens": "", - "Import/export": "", - "unsubscribe": "", - "revoke": "", - "Subscriptions": "", - "`x` unseen notifications": "", - "search": "", - "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", - "Source available here.": "", - "View JavaScript license information.": "", - "View privacy policy.": "", - "Trending": "", - "Public": "", - "Unlisted": "", - "Private": "", - "View all playlists": "", - "Updated `x` ago": "", - "Delete playlist `x`?": "", - "Delete playlist": "", - "Create playlist": "", - "Title": "", - "Playlist privacy": "", - "Editing playlist `x`": "", - "Show more": "", - "Show less": "", - "Watch on YouTube": "", - "Hide annotations": "", - "Show annotations": "", - "Genre: ": "", - "License: ": "", - "Family friendly? ": "", - "Wilson score: ": "", - "Engagement: ": "", - "Whitelisted regions: ": "", - "Blacklisted regions: ": "", - "Shared `x`": "", - "`x` views": "", - "Premieres in `x`": "", - "Premieres `x`": "", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", - "View YouTube comments": "", - "View more comments on Reddit": "", - "View `x` comments": "", - "View Reddit comments": "", - "Hide replies": "", - "Show replies": "", - "Incorrect password": "", - "Quota exceeded, try again in a few hours": "", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", - "Invalid TFA code": "", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "", - "Wrong answer": "", - "Erroneous CAPTCHA": "", - "CAPTCHA is a required field": "", - "User ID is a required field": "", - "Password is a required field": "", - "Wrong username or password": "", - "Please sign in using 'Log in with Google'": "", - "Password cannot be empty": "", - "Password cannot be longer than 55 characters": "", - "Please log in": "", - "Invidious Private Feed for `x`": "", - "channel:`x`": "", - "Deleted or invalid channel": "", - "This channel does not exist.": "", - "Could not get channel info.": "", - "Could not fetch comments": "", - "View `x` replies": "", - "`x` ago": "", - "Load more": "", - "`x` points": "", - "Could not create mix.": "", - "Empty playlist": "", - "Not a playlist.": "", - "Playlist does not exist.": "", - "Could not pull trending pages.": "", - "Hidden field \"challenge\" is a required field": "", - "Hidden field \"token\" is a required field": "", - "Erroneous challenge": "", - "Erroneous token": "", - "No such user": "", - "Token is expired, please try again": "", - "English": "", - "English (auto-generated)": "", - "Afrikaans": "", - "Albanian": "", - "Amharic": "", - "Arabic": "", - "Armenian": "", - "Azerbaijani": "", - "Bangla": "", - "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", - "Hungarian": "", - "Icelandic": "", - "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian Bokmål": "", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", - "Russian": "", - "Samoan": "", - "Scottish Gaelic": "", - "Serbian": "", - "Shona": "", - "Sindhi": "", - "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", - "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", - "Swedish": "", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", - "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", - "`x` years": "", - "`x` months": "", - "`x` weeks": "", - "`x` days": "", - "`x` hours": "", - "`x` minutes": "", - "`x` seconds": "", - "Fallback comments: ": "", - "Popular": "", - "Search": "", - "Top": "", - "About": "", - "Rating: ": "", - "Language: ": "", - "View as playlist": "", - "Default": "", - "Music": "", - "Gaming": "", - "News": "", - "Movies": "", - "Download": "", - "Download as: ": "", - "%A %B %-d, %Y": "", - "(edited)": "", - "YouTube comment permalink": "", - "permalink": "", - "`x` marked it with a ❤": "", - "Audio mode": "", - "Video mode": "", - "Videos": "", - "Playlists": "", - "Community": "", - "Current version: ": "" -}
\ No newline at end of file + "generic_subscriptions_count": "{{count}} harpidetza", + "generic_subscriptions_count_plural": "{{count}} harpidetzak", + "tokens_count": "{{count}} tokena", + "tokens_count_plural": "{{count}} tokenak", + "comments_points_count": "{{count}} puntua", + "comments_points_count_plural": "{{count}} puntuak", + "View more comments on Reddit": "Iruzkin gehiago Redditen", + "Fallback captions: ": "Ordezko azpitituluak: ", + "generic_subscribers_count": "{{count}} harpidedun", + "generic_subscribers_count_plural": "{{count}} harpidedunak", + "preferences_quality_option_dash": "DASH (kalitate egokitua)", + "preferences_listen_label": "Lehenetsiz jo: ", + "preferences_speed_label": "Abiadura lehenetsia: ", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_144p": "144p", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_worst": "Txarrena", + "preferences_quality_dash_option_best": "Hoberena", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_240p": "240p", + "preferences_extend_desc_label": "Bideoaren azalpena automatikoki zabaldu: ", + "preferences_annotations_subscribed_label": "Harpidetutako kanalen oharrak erakutsi lehenetsiz? ", + "Redirect homepage to feed: ": "Hasierako orrira bidali jarraitzeko: ", + "channel name - reverse": "kanalaren izena - alderantziz", + "preferences_notifications_only_label": "Jakinarazpenak soilik erakutsi (baldin badago): ", + "Top enabled: ": "Goikoa gaitu: ", + "Import/export data": "Inportatu/exportatu data", + "Create playlist": "Zerrenda sortu", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Aditu! JavaScript itzalita dakazula ematen du. Hemen sakatu iruzkinak ikusteko. Denbora luza leikeela kontuan hartu.", + "generic_views_count": "{{count}}ikusia", + "generic_views_count_plural": "{{count}}ikusiak", + "generic_playlists_count": "{{count}}zerrenda", + "generic_playlists_count_plural": "{{count}}zerrendak", + "Could not fetch comments": "Iruzkinei ezin heldu", + "Erroneous token": "Token okerra", + "Albanian": "Albaniarra", + "Azerbaijani": "Azerbaitarra", + "No such user": "Ez dago erabiltzailerik", + "Bulgarian": "Bulgariarra", + "Filipino": "Filipinera", + "French": "Frantsesa", + "French (auto-generated)": "Frantsesa (auto-sortua)", + "Show more": "Erakutsi gehiago", + "Show less": "Erakutsi gutxiago", + "Delete playlist": "Zerrenda ezabatu", + "Delete account": "Kontua ezabatu", + "User ID is a required field": "Erabiltzailearen IDa beharrezkoa da", + "English (United Kingdom)": "Ingelesa (Britania Handia", + "preferences_vr_mode_label": "360 graduko bideo interaktiboak (WebGL beharko): ", + "English (United States)": "Estatu batuarra (AEB)", + "English (auto-generated)": "Ingelesa (autosortua)", + "Arabic": "Arabiarra", + "Armenian": "Armeniarra", + "Bangla": "Banglera", + "Belarusian": "Bielorrusiara", + "Burmese": "Burmesera", + "Chinese (Simplified)": "Txinera (sinplifikatua)", + "preferences_watch_history_label": "Baimendu historia ikusi ", + "generic_videos_count": "{{count}}bideo", + "generic_videos_count_plural": "{{count}}bideoak", + "View privacy policy.": "Pribatutasun politika ikusi.", + "Cantonese (Hong Kong)": "Kantoniera (Hong Kong)", + "subscriptions_unseen_notifs_count": "{{count}} ezikusitako oharra", + "subscriptions_unseen_notifs_count_plural": "{{count}} ezikusitako oharrak", + "Trending": "Joera", + "Playlist privacy": "Zerrendaren privatutasuna", + "Switch Invidious Instance": "Invidious adibidea aldatu", + "Genre: ": "Genero: ", + "License: ": "Lizentzia: ", + "Family friendly? ": "Adeikorra familiarekin? ", + "Wilson score: ": "Wilsonen puntuazioa: ", + "comments_view_x_replies": "{{count}} erantzuna ikusi", + "comments_view_x_replies_plural": "{{count}} erantzunak ikusi", + "Catalan": "Katalaniera", + "Chinese": "Txinera", + "Chinese (China)": "Txinatarra", + "Chinese (Hong Kong)": "Hongkondarra", + "Chinese (Taiwan)": "Taiwandarra", + "Corsican": "Korsikera", + "Dutch (auto-generated)": "Alemaniera (auto-sortua)", + "Estonian": "Estoniera", + "Finnish": "Finlandiera", + "Galician": "Galizera", + "German (auto-generated)": "Alemaiera (auto-sortua)", + "Greek": "Greziera", + "crash_page_report_issue": "Aurreko ezerk ez badizu lagundu, arren <a href=\"`x`\"> GitHuben gai berri bat zabaldu </a> (ingelesez ahal bada) eta zure mezuan hurrengo testua sartu (testuari EZ itzulpena egin):", + "crash_page_search_issue": "GitHuben dauden gaiak <a href=\"`x`\"> buruz</a>", + "preferences_quality_option_medium": "Erdixka", + "preferences_quality_option_small": "Txikia", + "preferences_quality_dash_label": "DASH bideo kalitate lehenetsia: ", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_360p": "360p", + "invidious": "Invidious", + "Source available here.": "Iturburua hemen eskura.", + "View JavaScript license information.": "JavaScriptaren lizentzi adierazpena ikusi.", + "Blacklisted regions: ": "zerrenda beltzaren zonaldeak: ", + "Premieres `x`": "`x` estrenaldiak", + "Wrong answer": "Erantzun ez zuzena", + "Password is a required field": "Pasahitza beharrezkoa da", + "Wrong username or password": "Pasahitza edo ezizena gaizki", + "Password cannot be longer than 55 characters": "Pasahitza 55 karaktere baino luzeagoa ezin da izan", + "This channel does not exist.": "Kanal hau ez dago.", + "`x` ago": "duela `x`", + "Czech": "Txekiera", + "preferences_region_label": "Herrialdeko edukiera: ", + "preferences_sort_label": "Bideoak ordenatu: ", + "published": "argitaratuta", + "Only show latest video from channel: ": "Kanalaren azken bideoa soilik erakutsi ", + "preferences_category_admin": "Administratzailearen lehentasunak", + "Registration enabled: ": "Harpidetza gaituta: ", + "Save preferences": "Baloreak gorde", + "Token manager": "Token kudeatzailea", + "unsubscribe": "Baja eman", + "search": "Bilatu", + "Log out": "Irten", + "English": "Ingelesa", + "Afrikaans": "Afrikarra", + "Amharic": "Amharerra", + "Basque": "Euskera", + "Bosnian": "Bosniarra", + "Cebuano": "Zebuera", + "Chinese (Traditional)": "Txinera (Tradizionala)", + "Croatian": "Croaziera", + "Danish": "Daniera", + "Dutch": "Alemaniera", + "Esperanto": "Esperanto", + "Erroneous challenge": "Erronka okerra", + "View all playlists": "Zerrenda guztiak ikusi", + "Show annotations": "Oharrak erakutsi", + "Empty playlist": "Zerrenda hutsik", + "Please log in": "Sartu, mesedez", + "CAPTCHA is a required field": "CAPTCHA beharrezko eremua da", + "preferences_category_data": "Dataren lehentasunak", + "preferences_default_home_label": "Homepage lehenetsia: ", + "preferences_automatic_instance_redirect_label": "berbideratze adibide automatikoa (atzera egin berbideratzeko: invidious.io) ", + "`x` uploaded a video": "' x'(e)k bideo bat igo du", + "published - reverse": "argitaratuta - alderantziz", + "Could not get channel info.": "Kanalaren adierazpena ezin lortu.", + "alphabetically - reverse": "alfabetikoki - alderantziz", + "Public": "Orokorra", + "Unlisted": "Ez zerrendatua", + "Subscription manager": "Harpidetzen kudeatzailea", + "Updated `x` ago": "Duela `x` eguneratua", + "Hide replies": "Erantzunak izkutatu", + "preferences_thin_mode_label": "Urri eran: ", + "Show replies": "Erantzunak erakutsi", + "Watch on YouTube": "YouTuben ikusi", + "Premieres in `x`": "`x`eko estrenaldiak", + "Delete playlist `x`?": "`x` zerrenda ezabatu nahi?", + "Token is expired, please try again": "Token kadukatua, saiatu berriro", + "CAPTCHA enabled: ": "CAPTCHA gaitu: ", + "Released under the AGPLv3 on Github.": "GitHubeko AGPLv3pean argitaratuta.", + "channel:`x`": "Kanal: `x`", + "Georgian": "Georgiera", + "Incorrect password": "Pasahitza gaizki", + "Playlist does not exist.": "Zerrenda ez da existitzen.", + "preferences_category_misc": "Askotariko lehentasunak", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iruzkina ikusi", + "": "`x` iruzkinak ikusi" + }, + "Report statistics: ": "Estatistikak adierazi: ", + "preferences_max_results_label": "Jotzeko bideo zerrendaren luzera: ", + "Subscriptions": "Harpidetzak", + "Load more": "Gehiago atera", + "Change password": "Pasahitza aldatu", + "preferences_show_nick_label": "Erakutsi ezizena goian: ", + "View Reddit comments": "Redditeko iruzkinak ikusi", + "preferences_category_subscription": "Harpidetzaren lehentasunak", + "Hidden field \"challenge\" is a required field": "\"challenge\" eremu ezkutua beharrezkoa da", + "German": "Alemaniarra", + "View YouTube comments": "YouTubeko iruzkinak ikusi", + "`x` is live": "`x` bizirik darrai", + "Password cannot be empty": "Pasahitza ezin da hutsik utzi", + "preferences_video_loop_label": "Beti begiztatu: ", + "Only show latest unwatched video from channel: ": "kanalaren azken bideo ezikusia erakutsi soilik ", + "Enable web notifications": "Webaren jakinarazpenak baimendu", + "revoke": "ukatu", + "preferences_continue_label": "Hurrengo lehenetsia jo: ", + "Whitelisted regions: ": "Zuri zerrendaren zonaldeak: ", + "Erroneous CAPTCHA": "CAPTCHA gaizki", + "Deleted or invalid channel": "Ezgai edota ezabatutako kanala", + "Could not create mix.": "Nahastea ezin sortu.", + "Not a playlist.": "Ez da zerrenda.", + "Hidden field \"token\" is a required field": "\"token\" eremu ezkutua beharrezkoa da", + "Import/export": "Inportatu/esportatu", + "alphabetically": "alfabetikoki", + "preferences_unseen_only_label": "Ezikusiak besterik ez erakutsi: ", + "Clear watch history": "Historia ezabatu", + "Manage subscriptions": "Harpidetzak kudeatu", + "Manage tokens": "Fitxak kudeatu", + "Watch history": "Historia ikusi", + "Login enabled: ": "Login gaitu: ", + "Hide annotations": "Oharrak izkutatu", + "Title": "Titulua", + "channel name": "Kanalaren izena", + "Authorize token for `x`?": "Baimendu tokena `x`tzako?", + "Private": "Pribatua", + "Editing playlist `x`": "`x` zerrenda editatu", + "Could not pull trending pages.": "Ezin ekarri orri arrakastatsuak.", + "crash_page_read_the_faq": "Bide <a href=\"`x`\"> (FAQ) ohiko galderak</a>" +} diff --git a/locales/fa.json b/locales/fa.json index b6114292..b146385e 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -1,95 +1,93 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` مشترکان", - "": "`x` مشترکان" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ویدیو ها", - "": "`x` ویدیو ها" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` لیست های پخش", - "": "`x` لیست های پخش" - }, + "generic_views_count": "{{count}} بازدید", + "generic_views_count_plural": "{{count}} بازدید", + "generic_videos_count": "{{count}} ویدئو", + "generic_videos_count_plural": "{{count}} ویدئو", + "generic_playlists_count": "{{count}} فهرست پخش", + "generic_playlists_count_plural": "{{count}} فهرست پخش", + "generic_subscribers_count": "{{count}} دنبال کننده", + "generic_subscribers_count_plural": "{{count}} دنبال کننده", + "generic_subscriptions_count": "{{count}} اشتراک", + "generic_subscriptions_count_plural": "{{count}} اشتراک", "LIVE": "زنده", - "Shared `x` ago": "به اشتراک گذاشته شده `x` پیش", + "Shared `x` ago": "`x` پیش به اشتراک گذاشته شده", "Unsubscribe": "لغو اشتراک", "Subscribe": "مشترک شدن", - "View channel on YouTube": "نمایش کانال در یوتیوب", - "View playlist on YouTube": "نمایش لیست پخش در یوتیوب", - "newest": "جدید تر", - "oldest": "قدیمی تر", - "popular": "محبوب", + "View channel on YouTube": "دیدن کانال در یوتیوب", + "View playlist on YouTube": "دیدن فهرست پخش در یوتیوب", + "newest": "تازهترین", + "oldest": "کهنهترین", + "popular": "پرطرفدار", "last": "آخرین", "Next page": "صفحه بعد", "Previous page": "صفحه قبل", "Clear watch history?": "پاک کردن تاریخچه نمایش؟", - "New password": "گذرواژه جدید", - "New passwords must match": "گذارواژه های جدید باید باهم همخوانی داشته باشند", - "Cannot change password for Google accounts": "نمیتوان گذرواژه را برای حساب های کاربری گوگل تغییر داد", + "New password": "گذرواژه تازه", + "New passwords must match": "گذارواژه های تازه باید باهم همخوانی داشته باشند", "Authorize token?": "توکن دسترسی؟", "Authorize token for `x`?": "توکن دسترسی برای `x`؟", "Yes": "بله", "No": "خیر", - "Import and Export Data": "وارد کردن و خارج کردن داده ها", - "Import": "وارد کردن", - "Import Invidious data": "وارد کردن داده Invidious", - "Import YouTube subscriptions": "وارد کردن اشتراک های یوتیوب", - "Import FreeTube subscriptions (.db)": "وارد کردن اشتراک های فری توب (.db)", - "Import NewPipe subscriptions (.json)": "وارد کردن اشتراک های نیو پایپ (.json)", - "Import NewPipe data (.zip)": "وارد کردن داده نیو پایپ (.zip)", - "Export": "خارج کردن", - "Export subscriptions as OPML": "خارج کردن اشتراک ها به عنوان OPML", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "خارج کردن اشتراک ها به عنوان OPML (برای فری توب و نیو پایپ)", - "Export data as JSON": "خارج کردن داده ها به عنوان JSON", + "Import and Export Data": "درونبرد و برونبرد داده", + "Import": "درونبرد", + "Import Invidious data": "وارد کردن داده JSON اینویدیوس", + "Import YouTube subscriptions": "وارد کردن فایل CSV یا OPML سابسکرایب های یوتیوب", + "Import FreeTube subscriptions (.db)": "درونبرد اشتراکهای فریتیوب (.db)", + "Import NewPipe subscriptions (.json)": "درونبرد اشتراکهای نیوپایپ (.json)", + "Import NewPipe data (.zip)": "درونبرد داده نیوپایپ (.zip)", + "Export": "برونبرد", + "Export subscriptions as OPML": "برونبرد اشتراکها در قالب OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "برونبرد اشتراکها در قالب OPML (برای نیوپایپ و فریتیوب)", + "Export data as JSON": "گرفتن(خارج کردن) اطلاعات اینویدیوس با فرمت JSON", "Delete account?": "حذف حساب کاربری؟", "History": "تاریخچه", - "An alternative front-end to YouTube": "یک فرانت-اند جایگذین برای یوتیوب", - "JavaScript license information": "اطلاعات مجوز جاوا اسکریپت", + "An alternative front-end to YouTube": "یک پیشانه جایگزین برای یوتیوب", + "JavaScript license information": "اطلاعات پروانه جاوااسکریپت", "source": "منبع", "Log in": "ورود", "Log in/register": "ورود/ثبت نام", - "Log in with Google": "ورود با گوگل", "User ID": "شناسه کاربری", "Password": "گذرواژه", "Time (h:mm:ss):": "زمان (h:mm:ss):", - "Text CAPTCHA": "متن CAPTCHA", - "Image CAPTCHA": "تصویر CAPTCHA", + "Text CAPTCHA": "کپچای متنی", + "Image CAPTCHA": "کپچای تصویری", "Sign In": "ورود", "Register": "ثبت نام", "E-mail": "ایمیل", - "Google verification code": "کد تایید گوگل", "Preferences": "ترجیحات", - "Player preferences": "ترجیحات نمایشدهنده", - "Always loop: ": "همیشه تکرار شنوده: ", - "Autoplay: ": "نمایش خودکار: ", - "Play next by default: ": "پخش بعدی به طور پیشفرض: ", - "Autoplay next video: ": "پخش خودکار ویدیو بعدی: ", - "Listen by default: ": "گوش کردن به طور پیشفرض: ", - "Proxy videos: ": "پروکسی ویدیو ها: ", - "Default speed: ": "سرعت پیشفرض: ", - "Preferred video quality: ": "کیفیت ویدیوی ترجیحی: ", - "Player volume: ": "صدای پخش کننده: ", - "Default comments: ": "نظرات پیشفرض: ", + "preferences_category_player": "ترجیحات نمایشدهنده", + "preferences_video_loop_label": "همواره ویدئو را بازپخش کن ", + "preferences_autoplay_label": "نمایش خودکار: ", + "preferences_continue_label": "پخش بعدی به طور پیشفرض: ", + "preferences_continue_autoplay_label": "پخش خودکار ویدیو بعدی: ", + "preferences_listen_label": "گوش کردن به طور پیشفرض: ", + "preferences_local_label": "پروکسی ویدیو ها: ", + "preferences_speed_label": "سرعت پیشفرض: ", + "preferences_quality_label": "کیفیت ویدیوی ترجیحی: ", + "preferences_volume_label": "صدای پخش کننده: ", + "preferences_comments_label": "نظرات پیشفرض: ", "youtube": "یوتیوب", "reddit": "ردیت", - "Default captions: ": "زیرنویس های پیشفرض: ", + "preferences_captions_label": "زیرنویس های پیشفرض: ", "Fallback captions: ": "عقب گرد زیرنویس ها: ", - "Show related videos: ": "نمایش ویدیو های مرتبط: ", - "Show annotations by default: ": "نمایش حاشیه نویسی ها به طور پیشفرض: ", - "Automatically extend video description: ": "", - "Visual preferences": "ترجیحات بصری", - "Player style: ": "حالت پخش کننده: ", + "preferences_related_videos_label": "نمایش ویدیو های مرتبط: ", + "preferences_annotations_label": "نمایش حاشیه نویسی ها به طور پیشفرض: ", + "preferences_extend_desc_label": "گسترش خودکار توضیحات ویدئو: ", + "preferences_vr_mode_label": "ویدئوها ۳۶۰ درجه تعاملی(نیازمند WebGL): ", + "preferences_category_visual": "ترجیحات بصری", + "preferences_player_style_label": "حالت پخش کننده: ", "Dark mode: ": "حالت تاریک: ", - "Theme: ": "تم: ", + "preferences_dark_mode_label": "تم: ", "dark": "تاریک", "light": "روشن", - "Thin mode: ": "حالت نازک: ", - "Subscription preferences": "ترجیحات اشتراک", - "Show annotations by default for subscribed channels: ": "نمایش حاشیه نویسی ها به طور پیشفرض برای کانال های مشترک شده: ", + "preferences_thin_mode_label": "حالت نازک: ", + "preferences_category_misc": "ترجیحات متفرقه", + "preferences_automatic_instance_redirect_label": "هدایت خودکار نمونه (انتقال به redirect.invidious.io): ", + "preferences_category_subscription": "ترجیحات اشتراک", + "preferences_annotations_subscribed_label": "نمایش حاشیه نویسی ها به طور پیشفرض برای کانال های مشترک شده: ", "Redirect homepage to feed: ": "تغییر مسیر صفحه خانه به خوراک: ", - "Number of videos shown in feed: ": "تعداد ویدیو های نمایش داده شده در خوراک: ", - "Sort videos by: ": "مرتب سازی ویدیو ها بر اساس: ", + "preferences_max_results_label": "تعداد ویدیو های نمایش داده شده در خوراک: ", + "preferences_sort_label": "مرتب سازی ویدیو ها بر اساس: ", "published": "منتشر شده", "published - reverse": "منتشر شده - معکوس", "alphabetically": "بر اساس حروف الفبا", @@ -98,12 +96,12 @@ "channel name - reverse": "نام کانال - معکوس", "Only show latest video from channel: ": "تنها نمایش آخرین ویدیو های کانال: ", "Only show latest unwatched video from channel: ": "تنها نمایش آخرین ویدیو های تماشا نشده از کانال: ", - "Only show unwatched: ": "تنها نمایش ویدیو های تماشا نشده: ", - "Only show notifications (if there are any): ": "تنها نمایش اعلان ها (اگر وجود داشته باشد) ", + "preferences_unseen_only_label": "تنها نمایش ویدیو های تماشا نشده: ", + "preferences_notifications_only_label": "تنها نمایش اعلان ها (اگر وجود داشته باشد) ", "Enable web notifications": "فعال کردن اعلان های وب", "`x` uploaded a video": "`x` یک ویدیو بارگذاری کرد", "`x` is live": "`x` زنده است", - "Data preferences": "ترجیحات داده", + "preferences_category_data": "ترجیحات داده", "Clear watch history": "پاککردن تاریخچه تماشا", "Import/export data": "وارد کردن/خارج کردن داده", "Change password": "تغییر گذرواژه", @@ -111,9 +109,10 @@ "Manage tokens": "مدیریت توکن ها", "Watch history": "تاریخچه تماشا", "Delete account": "حذف حساب کاربری", - "Administrator preferences": "ترجیحات مدیریت", - "Default homepage: ": "صفحه خانه پیشفرض ", - "Feed menu: ": "منو خوراک: ", + "preferences_category_admin": "ترجیحات مدیریت", + "preferences_default_home_label": "صفحه خانه پیشفرض ", + "preferences_feed_menu_label": "منو خوراک: ", + "preferences_show_nick_label": "نمایش نام مستعار در بالا: ", "Top enabled: ": "بالا فعال شده: ", "CAPTCHA enabled: ": "CAPTCHA فعال شده: ", "Login enabled: ": "ورود فعال شده: ", @@ -123,25 +122,17 @@ "Subscription manager": "مدیریت اشتراک", "Token manager": "مدیر توکن", "Token": "توکن", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` اشتراک ها", - "": "`x` اشتراک ها" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` توکن ها", - "": "`x` توکن ها" - }, + "tokens_count": "{{count}} توکن", + "tokens_count_plural": "{{count}} توکن", "Import/export": "وارد کردن/خارج کردن", "unsubscribe": "لغو اشتراک", "revoke": "ابطال", "Subscriptions": "اشتراک ها", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` اعلان نادیده", - "": "`x` اعلان نادیده" - }, - "search": "جستجو", + "subscriptions_unseen_notifs_count": "{{count}} اعلان نادیده", + "subscriptions_unseen_notifs_count_plural": "{{count}} اعلان نادیده", + "search": "جست و جو", "Log out": "خروج", - "Released under the AGPLv3 by Omar Roth.": "منتشر شده تحت مجوز AGPLv3 توسط Omar Roth.", + "Released under the AGPLv3 on Github.": "منتشر شده تحت پروانه AGPLv3 روی گیتهاب.", "Source available here.": "منبع اینجا دردسترس است.", "View JavaScript license information.": "نمایش اطلاعات مجوز جاوا اسکریپت.", "View privacy policy.": "نمایش سیاست حفظ حریم خصوصی.", @@ -149,17 +140,18 @@ "Public": "عمومی", "Unlisted": "لیست نشده", "Private": "خصوصی", - "View all playlists": "نمایش همه لیست پخش", + "View all playlists": "نمایش همه سیاهههای پخش", "Updated `x` ago": "بروز شده `x` پیش", - "Delete playlist `x`?": "حذف لیست پخش `x`؟", - "Delete playlist": "حذف لیست پخش", - "Create playlist": "ایجاد لیست پخش", + "Delete playlist `x`?": "حذف سیاههٔ پخش `x`؟", + "Delete playlist": "حذف سیاههٔ پخش", + "Create playlist": "ایجاد سیاههٔ پخش", "Title": "عنوان", - "Playlist privacy": "حریم خصوصی لیست پخش", - "Editing playlist `x`": "تغییر لیست پخش `x`", - "Show more": "", - "Show less": "", + "Playlist privacy": "حریم خصوصی سیاههٔ پخش", + "Editing playlist `x`": "تغییر سیاههٔ پخش `x`", + "Show more": "نمایش بیشتر", + "Show less": "نمایش کمتر", "Watch on YouTube": "تماشا در یوتیوب", + "Switch Invidious Instance": "تعویض نمونه اینویدیوس", "Hide annotations": "مخفی کردن حاشیه نویسی ها", "Show annotations": "نمایش حاشیه نویسی ها", "Genre: ": "ژانر: ", @@ -169,11 +161,7 @@ "Engagement: ": "نامزدی: ", "Whitelisted regions: ": "مناطق لیست سفید: ", "Blacklisted regions: ": "مناطق لیست سیاه: ", - "Shared `x`": "به اشتراک گذاشته شده `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` بازدید", - "": "`x` بازدید" - }, + "Shared `x`": "`x` به اشتراک گذاشته شد", "Premieres in `x`": "برای اولین بار در `x`", "Premieres `x`": "برای اولین بار `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "سلام! مثل اینکه تو جاوا اسکریپت رو خاموش کرده ای. اینجا کلیک کن تا نظرات را ببینی، این رو یادت باشه که ممکنه بارگذاری اونها کمی طول بکشه.", @@ -187,17 +175,12 @@ "Hide replies": "مخفی کردن پاسخ ها", "Show replies": "نمایش پاسخ ها", "Incorrect password": "گذرواژه نا درست", - "Quota exceeded, try again in a few hours": "سهمیه بیشتر شده است، چند ساعت بعد دوباره تلاش کنید", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "قادر به ورود نیستید، مطمئن شوید احراز تایید-دومرحله (Authenticator یا پیامکوتاه) خاموش باشد.", - "Invalid TFA code": "کد TFA نادرست است", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "ورود با خطا مواجه شد. این ممکن است به خاطر احراز تایید-دومرحله باشد که برای حساب کاربری شما فعال نشده است.", "Wrong answer": "پاسخ غلط", "Erroneous CAPTCHA": "CAPTCHA نا درست", "CAPTCHA is a required field": "CAPTCHA یک فیلد ضروری است", "User ID is a required field": "شناسه کاربری یک فیلد ضروری است", "Password is a required field": "گذرواژه یک فیلد ضروری است", "Wrong username or password": "نام کاربری یا گذرواژه غلط است", - "Please sign in using 'Log in with Google'": "لطفا با استفاده از 'ورود توسط گوگل' وارد شوید", "Password cannot be empty": "گذرواژه نمیتواند خالی باشد", "Password cannot be longer than 55 characters": "گذر واژه نمیتواند از ۵۵ کاراکتر بیشتر باشد", "Please log in": "لطفا وارد شوید", @@ -207,20 +190,16 @@ "This channel does not exist.": "این کانال وجود ندارد.", "Could not get channel info.": "نمیتوان اطلاعات کانال را دریافت کرد.", "Could not fetch comments": "نمیتوان نظرات را دریافت کرد", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "نمایش `x` پاسخ ها", - "": "نمایش `x` پاسخ ها" - }, + "comments_view_x_replies": "نمایش {{count}} پاسخ", + "comments_view_x_replies_plural": "نمایش {{count}} پاسخ", "`x` ago": "`x` پیش", "Load more": "بارگذاری بیشتر", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` نقطه ها", - "": "`x` نقطه ها" - }, + "comments_points_count": "{{count}} نقطه", + "comments_points_count_plural": "{{count}} نقطه", "Could not create mix.": "نمیتوان میکس ساخت.", - "Empty playlist": "لیست پخش خالی", - "Not a playlist.": "یک لیست پخش نیست.", - "Playlist does not exist.": "لیست پخش وجود ندارد.", + "Empty playlist": "سیاههٔ پخش خالی", + "Not a playlist.": "یک سیاههٔ پخش نیست.", + "Playlist does not exist.": "سیاههٔ پخش وجود ندارد.", "Could not pull trending pages.": "نمیتوان صفحه های پر طرفدار را بکشد.", "Hidden field \"challenge\" is a required field": "فیلد مخفی \"چالش\" یک فیلد ضروری است", "Hidden field \"token\" is a required field": "فیلد مخفی \"توکن\" یک فیلد ضروری است", @@ -334,42 +313,28 @@ "Yiddish": "ییدیش", "Yoruba": "یوروبایی", "Zulu": "زولو", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` سال", - "": "`x` سال" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ماه", - "": "`x` ماه" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` هفته", - "": "`x` هفته" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` روز", - "": "`x` روز" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ساعت", - "": "`x` ساعت" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` دقیقه", - "": "`x` دقیقه" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ثانیه", - "": "`x` ثانیه" - }, + "generic_count_years": "{{count}} سال", + "generic_count_years_plural": "{{count}} سال", + "generic_count_months": "{{count}} ماه", + "generic_count_months_plural": "{{count}} ماه", + "generic_count_weeks": "{{count}} هفته", + "generic_count_weeks_plural": "{{count}} هفته", + "generic_count_days": "{{count}} روز", + "generic_count_days_plural": "{{count}} روز", + "generic_count_hours": "{{count}} ساعت", + "generic_count_hours_plural": "{{count}} ساعت", + "generic_count_minutes": "{{count}} دقیقه", + "generic_count_minutes_plural": "{{count}} دقیقه", + "generic_count_seconds": "{{count}} ثانیه", + "generic_count_seconds_plural": "{{count}} ثانیه", "Fallback comments: ": "نظرات عقب گرد: ", - "Popular": "محبوب", - "Search": "", + "Popular": "پربیننده", + "Search": "جست و جو", "Top": "بالا", "About": "درباره", "Rating: ": "رتبه دهی: ", - "Language: ": "زبان: ", - "View as playlist": "نمایش به عنوان لیست پخش", + "preferences_locale_label": "زبان: ", + "View as playlist": "نمایش به عنوان سیاههٔ پخش", "Default": "پیشفرض", "Music": "موسیقی", "Gaming": "بازی", @@ -384,35 +349,152 @@ "`x` marked it with a ❤": "`x` نشان گذاری شده با یک ❤", "Audio mode": "حالت صدا", "Video mode": "حالت ویدیو", - "Videos": "ویدیو ها", - "Playlists": "لیست های پخش", - "Community": "اجتماع", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "نسخه فعلی: " -}
\ No newline at end of file + "channel_tab_videos_label": "ویدیو ها", + "Playlists": "سیاهههای پخش", + "channel_tab_community_label": "اجتماع", + "search_filters_sort_option_relevance": "مرتبط بودن", + "search_filters_sort_option_rating": "امتیاز", + "search_filters_sort_option_date": "تاریخ بارگذاری", + "search_filters_sort_option_views": "تعداد بازدید", + "search_filters_type_label": "نوع", + "search_filters_duration_label": "مدت", + "search_filters_features_label": "ویژگیها", + "search_filters_sort_label": "به ترتیب", + "search_filters_date_option_hour": "ساعت گذشته", + "search_filters_date_option_today": "امروز", + "search_filters_date_option_week": "این هفته", + "search_filters_date_option_month": "این ماه", + "search_filters_date_option_year": "امسال", + "search_filters_type_option_video": "ویدئو", + "search_filters_type_option_channel": "کانال", + "search_filters_type_option_playlist": "سیاههٔ پخش", + "search_filters_type_option_movie": "فیلم", + "search_filters_type_option_show": "نمایش", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "زیرنویس", + "search_filters_features_option_c_commons": "کریتیو کامونز", + "search_filters_features_option_three_d": "سهبعدی", + "search_filters_features_option_live": "زنده", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "مکان", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "نسخه فعلی: ", + "next_steps_error_message": "اکنون بایستی یکی از این موارد را امتحان کنید: ", + "next_steps_error_message_refresh": "تازهسازی", + "next_steps_error_message_go_to_youtube": "رفتن به یوتیوب", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_dash": "DASH (کیفیت تطبیفی)", + "preferences_quality_option_medium": "میانه", + "preferences_quality_option_small": "پایین", + "preferences_quality_dash_option_auto": "خودکار", + "preferences_quality_dash_option_best": "بهترین", + "preferences_quality_dash_option_worst": "بدترین", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "اینویدیوس", + "search_filters_features_option_three_sixty": "360°", + "footer_donate_page": "کمک مالی", + "footer_source_code": "کد منبع", + "footer_modfied_source_code": "کد منبع ویرایش شده", + "none": "هیچکدام", + "videoinfo_started_streaming_x_ago": "پخش جریانی `x` پیش آغاز شد", + "videoinfo_watch_on_youTube": "تماشا در یوتیوب", + "videoinfo_youTube_embed_link": "توکار", + "videoinfo_invidious_embed_link": "پیوند توکار", + "download_subtitles": "زیرنویسها - `x` (.vtt)", + "Video unavailable": "ویدئو دردسترس نیست", + "preferences_save_player_pos_label": "ذخیره زمان کنونی ویدئو: ", + "search_filters_features_option_purchased": "خریداری شده", + "preferences_quality_dash_label": "کیفیت ترجیحی ویدئو DASH: ", + "preferences_region_label": "کشور محتوا: ", + "footer_documentation": "مستندات", + "footer_original_source_code": "کد منبع اصلی", + "search_filters_duration_option_long": "بلند (> ۲۰ دقیقه)", + "adminprefs_modified_source_code_url_label": "URL مخزن کد منبع ویریش شده", + "search_filters_duration_option_short": "کوتاه (< ۴ دقیقه)", + "search_filters_title": "پالایه", + "Chinese (Hong Kong)": "چینی (هنگکنگ)", + "Dutch (auto-generated)": "هلندی (تولید خودکار)", + "preferences_watch_history_label": "فعالسازی تاریخچهی پخش ", + "Indonesian (auto-generated)": "اندونزیایی (تولید خودکار)", + "English (United States)": "انگلیسی (ایالات متحده)", + "Chinese": "چینی", + "Chinese (Taiwan)": "چینی (تایوان)", + "French (auto-generated)": "فرانسوی (تولید خودکار)", + "English (United Kingdom)": "انگلیسی (ایالات بریتانیا)", + "search_message_no_results": "نتیجهای یافت نشد.", + "search_message_change_filters_or_query": "سعی کنید جستوجوی خود را وسیعتر کنید و/یا فیلترها را تغییر دهید.", + "Chinese (China)": "چینی (چین)", + "German (auto-generated)": "آلمانی (تولید خودکار)", + "Japanese (auto-generated)": "ژاپنی (تولید خودکار)", + "Korean (auto-generated)": "کرهای (تولید خودکار)", + "Portuguese (Brazil)": "پرتغالی (برزیل)", + "search_filters_apply_button": "اعمال فیلترهای انتخاب شده", + "Italian (auto-generated)": "ایتالیایی (تولید خودکار)", + "Vietnamese (auto-generated)": "ویتنامی (تولید خودکار)", + "search_filters_type_option_all": "هر نوعی", + "search_filters_duration_option_none": "هر مدت زمانی", + "search_filters_date_label": "تاریخ بارگذاری", + "search_filters_date_option_none": "هر تاریخی", + "user_created_playlists": "`x` فهرست پخش ایجاد شد", + "Interlingue": "سرخپوستی", + "Russian (auto-generated)": "روسی (تولید خودکار)", + "Spanish (auto-generated)": "اسپانیایی (تولید خودکار)", + "search_filters_duration_option_medium": "متوسط (۴ تا ۲۰ دقیقه)", + "Portuguese (auto-generated)": "پرتغالی (تولید خودکار)", + "Cantonese (Hong Kong)": "کانتونی (هنگ کنگ)", + "Spanish (Spain)": "اسپانیایی (اسپانیا)", + "Turkish (auto-generated)": "ترکی (تولید خودکار)", + "search_filters_features_option_vr180": "VR180", + "Spanish (Mexico)": "اسپانیایی (مکزیک)", + "Popular enabled: ": "محبوب ها فعال شد: ", + "Music in this video": "آهنگ در این ویدیو", + "Artist: ": "هنرمند: ", + "Album: ": "آلبوم: ", + "Song: ": "آهنگ: ", + "Channel Sponsor": "اسپانسر کانال", + "Standard YouTube license": "پروانه استاندارد YouTube", + "search_message_use_another_instance": "همچنین میتوانید <a href=\"`x`\">در نمونهای دیگر هم جستوجو کنید</a>.", + "Download is disabled": "دریافت غیرفعال است", + "crash_page_before_reporting": "پیش از گزارش ایراد، مطمئنید شوید که:", + "playlist_button_add_items": "افزودن ویدیو", + "user_saved_playlists": "فهرستهای پخش ذخیره شده", + "crash_page_refresh": "که صفحه را <a href=\"`x`\">بازنشانی</a> کردهاید", + "generic_button_save": "ذخیره", + "generic_button_cancel": "لغو", + "generic_channels_count": "{{count}} کانال", + "generic_channels_count_plural": "{{count}} کانال", + "generic_button_edit": "ویرایش", + "crash_page_switch_instance": "که تلاش کردهاید <a href=\"`x`\">از یک نمونهٔ دیگر</a> استفاده کنید", + "generic_button_rss": "خوراک RSS", + "crash_page_read_the_faq": "که <a href=\"`x`\">سوالات بیشتر پرسیده شده (FAQ)</a> را خواندهاید", + "generic_button_delete": "حذف", + "Import YouTube playlist (.csv)": "واردکردن فهرستپخش YouTube (.csv)", + "Import YouTube watch history (.json)": "وارد کردن فهرست پخش YouTube (.json)", + "crash_page_you_found_a_bug": "به نظر میرسد که ایرادی در Invidious پیدا کردهاید!", + "channel_tab_podcasts_label": "پادکستها", + "channel_tab_streams_label": "پخش زندهها", + "channel_tab_shorts_label": "Shortها", + "channel_tab_playlists_label": "فهرستهای پخش", + "channel_tab_channels_label": "کانالها", + "error_video_not_in_playlist": "ویدیوی درخواستی معلق به این فهرست پخش نیست. <a href=\"`x`\">کلیک کنید تا به صفحهٔ اصلی فهرست پخش بروید.</a>", + "Add to playlist": "به لیست پخش افزوده شود", + "Answer": "پاسخ", + "Search for videos": "جست و جو برای ویدیوها", + "Add to playlist: ": "افزودن به لیست پخش ", + "The Popular feed has been disabled by the administrator.": "بخش ویدیوهای پرطرفدار توسط مدیر غیرفعال شده است.", + "carousel_slide": "اسلاید {{current}} از {{total}}", + "carousel_skip": "رد شدن از گرداننده", + "carousel_go_to": "به اسلاید `x` برو", + "crash_page_search_issue": "دنبال <a href=\"`x`\"> گشتیم بین مشکلات در گیت هاب </a>", + "crash_page_report_issue": "اگر هیچ یک از روش های بالا کمکی نکردند لطفا <a href=\"`x`\"> (ترجیحا به انگلیسی) یک سوال جدید در گیت هاب بپرسید و </a> طوری که سوالتون شامل متن زیر باشه:", + "channel_tab_releases_label": "آثار", + "toggle_theme": "تغییر وضعیت تم" +} diff --git a/locales/fi.json b/locales/fi.json index 5adeec05..b0df1e46 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -1,16 +1,4 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tilaaja", - "": "`x` tilaajaa" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", - "": "`x` videota" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` soittolista", - "": "`x` soittolistaa" - }, "LIVE": "SUORA", "Shared `x` ago": "Jaettu `x` sitten", "Unsubscribe": "Peruuta tilaus", @@ -26,70 +14,70 @@ "Clear watch history?": "Tyhjennä katseluhistoria?", "New password": "Uusi salasana", "New passwords must match": "Uusien salasanojen täytyy täsmätä", - "Cannot change password for Google accounts": "Google-tilien salasanaa ei voi vaihtaa", - "Authorize token?": "Valuutetaanko tunnus?", + "Authorize token?": "Valtuutetaanko tunnus?", "Authorize token for `x`?": "Valtuutetaanko tunnus `x`:lle?", "Yes": "Kyllä", "No": "Ei", "Import and Export Data": "Tuo ja vie tietoja", "Import": "Tuo", - "Import Invidious data": "Vie Invidious-tietoja", - "Import YouTube subscriptions": "Tuo YouTube-tilaukset", + "Import Invidious data": "Tuo Invidiousin JSON-tietoja", + "Import YouTube subscriptions": "Tuo YouTube/OPML-tilaukset", "Import FreeTube subscriptions (.db)": "Tuo FreeTube-tilaukset (.db)", "Import NewPipe subscriptions (.json)": "Tuo NewPipe-tilaukset (.json)", - "Import NewPipe data (.zip)": "Tuo NewPipe data (.zip)", + "Import NewPipe data (.zip)": "Tuo NewPipe-tietoja (.zip)", "Export": "Vie", - "Export subscriptions as OPML": "Vie tilaukset muodossa OPML", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Vie tilaukset muodossa OPML (NewPipe ja FreeTube)", - "Export data as JSON": "Vie data muodossa JSON", + "Export subscriptions as OPML": "Vie tilaukset OPML-muodossa", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Vie tilaukset OPML-muodossa (NewPipe & FreeTube)", + "Export data as JSON": "Vie Invidiousin tiedot JSON-muodossa", "Delete account?": "Poista tili?", "History": "Historia", - "An alternative front-end to YouTube": "Vaihtoehtoinen käyttöliittymä YouTubelle", - "JavaScript license information": "JavaScript-käyttöoikeustiedot", + "An alternative front-end to YouTube": "Vaihtoehtoinen front-end YouTubelle", + "JavaScript license information": "JavaScript-lisenssitiedot", "source": "lähde", - "Log in": "Kirjaudu", - "Log in/register": "Kirjaudu sisään / Rekisteröidy", - "Log in with Google": "Kirjaudu sisään Googlella", + "Log in": "Kirjaudu sisään", + "Log in/register": "Kirjaudu sisään/rekisteröidy", "User ID": "Käyttäjätunnus", "Password": "Salasana", "Time (h:mm:ss):": "Aika (h:mm:ss):", - "Text CAPTCHA": "Teksti CAPTCHA", - "Image CAPTCHA": "Kuva CAPTCHA", + "Text CAPTCHA": "Teksti-CAPTCHA", + "Image CAPTCHA": "Kuva-CAPTCHA", "Sign In": "Kirjaudu sisään", "Register": "Rekisteröidy", "E-mail": "Sähköposti", - "Google verification code": "Google-vahvistuskoodi", "Preferences": "Asetukset", - "Player preferences": "Soittimen asetukset", - "Always loop: ": "Aina silmukka: ", - "Autoplay: ": "Automaattinen toisto: ", - "Play next by default: ": "Toista seuraava oletuksena: ", - "Autoplay next video: ": "Toista seuraava video automaattisesti: ", - "Listen by default: ": "Kuuntele oletuksena: ", - "Proxy videos: ": "Proxy videot: ", - "Default speed: ": "Oletusnopeus: ", - "Preferred video quality: ": "Ensisijainen videon laatu: ", - "Player volume: ": "Soittimen äänenvoimakkuus: ", - "Default comments: ": "Oletuskommentit: ", + "preferences_category_player": "Soittimen asetukset", + "preferences_video_loop_label": "Toista aina uudelleen: ", + "preferences_autoplay_label": "Automaattinen toiston aloitus: ", + "preferences_continue_label": "Toista seuraava oletuksena: ", + "preferences_continue_autoplay_label": "Aloita seuraava video automaattisesti: ", + "preferences_listen_label": "Kuuntele oletuksena: ", + "preferences_local_label": "Videot välityspalvelimen kautta: ", + "preferences_speed_label": "Oletusnopeus: ", + "preferences_quality_label": "Ensisijainen videon laatu: ", + "preferences_volume_label": "Soittimen äänenvoimakkuus: ", + "preferences_comments_label": "Oletuskommentit: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Tekstitykset: ", + "preferences_captions_label": "Oletustekstitykset: ", "Fallback captions: ": "Toissijaiset tekstitykset: ", - "Show related videos: ": "Näytä aiheeseen liittyviä videoita: ", - "Show annotations by default: ": "Näytä huomautukset oletuksena: ", - "Automatically extend video description: ": "", - "Visual preferences": "Visuaaliset asetukset", - "Player style: ": "Soittimen tyyli: ", + "preferences_related_videos_label": "Näytä aiheeseen liittyviä videoita: ", + "preferences_annotations_label": "Näytä huomautukset oletuksena: ", + "preferences_extend_desc_label": "Laajenna automaattisesti videon kuvausta: ", + "preferences_vr_mode_label": "Interaktiiviset 360-videot (vaatii WebGL:n): ", + "preferences_category_visual": "Visuaaliset asetukset", + "preferences_player_style_label": "Soittimen tyyli: ", "Dark mode: ": "Tumma tila: ", - "Theme: ": "Teema: ", + "preferences_dark_mode_label": "Teema: ", "dark": "tumma", "light": "vaalea", - "Thin mode: ": "Kapea tila ", - "Subscription preferences": "Tilausten asetukset", - "Show annotations by default for subscribed channels: ": "Näytä oletuksena tilattujen kanavien huomautukset: ", + "preferences_thin_mode_label": "Kapea tila: ", + "preferences_category_misc": "Sekalaiset asetukset", + "preferences_automatic_instance_redirect_label": "Automaattinen instanssin uudelleenohjaus (perääntyminen sivulle redirect.invidious.io): ", + "preferences_category_subscription": "Tilausten asetukset", + "preferences_annotations_subscribed_label": "Näytä oletuksena tilattujen kanavien huomautukset: ", "Redirect homepage to feed: ": "Uudelleenohjaa kotisivu syötteeseen: ", - "Number of videos shown in feed: ": "Syötteessä näytettävien videoiden määrä: ", - "Sort videos by: ": "Videoiden lajitteluperuste: ", + "preferences_max_results_label": "Syötteessä näytettävien videoiden määrä: ", + "preferences_sort_label": "Videoiden lajitteluperuste: ", "published": "julkaistu", "published - reverse": "julkaistu - käänteinen", "alphabetically": "aakkosjärjestys", @@ -98,12 +86,12 @@ "channel name - reverse": "kanavan nimi - käänteinen", "Only show latest video from channel: ": "Näytä vain uusin video kanavalta: ", "Only show latest unwatched video from channel: ": "Näytä vain uusin katsomaton video kanavalta: ", - "Only show unwatched: ": "Näytä vain katsomattomat: ", - "Only show notifications (if there are any): ": "Näytä vain ilmoitukset (jos niitä on): ", + "preferences_unseen_only_label": "Näytä vain katsomattomat: ", + "preferences_notifications_only_label": "Näytä vain ilmoitukset (jos niitä on): ", "Enable web notifications": "Näytä verkkoilmoitukset", "`x` uploaded a video": "`x` latasi videon", "`x` is live": "`x` lähettää suorana", - "Data preferences": "Tietojen asetukset", + "preferences_category_data": "Tietojen asetukset", "Clear watch history": "Tyhjennä katseluhistoria", "Import/export data": "Tuo/vie tiedot", "Change password": "Vaihda salasana", @@ -111,9 +99,10 @@ "Manage tokens": "Hallinnoi tunnuksia", "Watch history": "Katseluhistoria", "Delete account": "Poista tili", - "Administrator preferences": "Järjestelmänvalvojan asetukset", - "Default homepage: ": "Oletuskotisivu: ", - "Feed menu: ": "Syötevalikko: ", + "preferences_category_admin": "Järjestelmänvalvojan asetukset", + "preferences_default_home_label": "Oletuskotisivu: ", + "preferences_feed_menu_label": "Syötevalikko: ", + "preferences_show_nick_label": "Näytä nimimerkki ylimpänä: ", "Top enabled: ": "Yläosa käytössä: ", "CAPTCHA enabled: ": "CAPTCHA käytössä: ", "Login enabled: ": "Kirjautuminen käytössä: ", @@ -123,27 +112,14 @@ "Subscription manager": "Tilausten hallinnoija", "Token manager": "Tunnusten hallinnoija", "Token": "Tunnus", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tilausta", - "": "`x` tilausta" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tunnistetta", - "": "`x` tunnistetta" - }, "Import/export": "Tuo/vie", "unsubscribe": "peru tilaus", "revoke": "kumoa", "Subscriptions": "Tilaukset", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` näkemätöntä ilmoitusta", - "": "`x` näkemätöntä ilmoitusta" - }, "search": "haku", "Log out": "Kirjaudu ulos", - "Released under the AGPLv3 by Omar Roth.": "Julkaissut AGPLv3-lisenssillä: Omar Roth.", "Source available here.": "Lähdekoodi on saatavilla täällä.", - "View JavaScript license information.": "JavaScript-koodin lisenssit.", + "View JavaScript license information.": "Katso JavaScript-koodin lisenssitiedot.", "View privacy policy.": "Katso tietosuojaseloste.", "Trending": "Nousussa", "Public": "Julkinen", @@ -151,83 +127,67 @@ "Private": "Yksityinen", "View all playlists": "Kaikki soittolistat", "Updated `x` ago": "Päivitetty `x` sitten", - "Delete playlist `x`?": "Poistetaanko soittolista `x`?", + "Delete playlist `x`?": "Poista soittolista `x`?", "Delete playlist": "Poista soittolista", "Create playlist": "Luo soittolista", "Title": "Nimi", "Playlist privacy": "Soittolistan yksityisyys", "Editing playlist `x`": "Muokataan soittolistaa `x`", - "Show more": "", - "Show less": "", + "Show more": "Näytä enemmän", + "Show less": "Näytä vähemmän", "Watch on YouTube": "Katso YouTubessa", - "Hide annotations": "Piilota merkkaukset", - "Show annotations": "Näytä merkkaukset", - "Genre: ": "Genre: ", + "Switch Invidious Instance": "Vaihda Invidious-instanssia", + "Hide annotations": "Piilota huomautukset", + "Show annotations": "Näytä huomautukset", + "Genre: ": "Tyylilaji: ", "License: ": "Lisenssi: ", "Family friendly? ": "Kaiken ikäisille sopiva? ", "Wilson score: ": "Wilson-pistemäärä: ", "Engagement: ": "Huomio: ", - "Whitelisted regions: ": "valkolistatut alueet: ", - "Blacklisted regions: ": "mustalla listalla olevat alueet: ", + "Whitelisted regions: ": "Sallitut alueet: ", + "Blacklisted regions: ": "Estetyt alueet: ", "Shared `x`": "Jaettu `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` katselukertaa", - "": "`x` katselukertaa" - }, "Premieres in `x`": "Ensiesitykseen aikaa `x`", "Premieres `x`": "Ensiesitykseen `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hei! Vaikuttaa siltä, että sinulla on JavaScript pois käytöstä. Klikkaa tästä nähdäksesi kommentit, huomioi että lataamisessa voi kestää melko kauan.", "View YouTube comments": "Näytä YouTube-kommentit", "View more comments on Reddit": "Katso lisää kommentteja Redditissä", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Näytä `x` komenttia", - "": "Näytä `x` kommenttia" + "([^.,0-9]|^)1([^.,0-9]|$)": "Katso `x` kommentti", + "": "Katso `x` kommenttia" }, - "View Reddit comments": "Näytä Reddit-kommentit", + "View Reddit comments": "Katso Reddit-kommentit", "Hide replies": "Piilota vastaukset", "Show replies": "Näytä vastaukset", "Incorrect password": "Väärä salasana", - "Quota exceeded, try again in a few hours": "Kiintiö ylitetty, yritä parin tunnin kuluttua uudestaan", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Sisäänkirjautuminen epäonnistui. Varmista, että kaksivaiheinen tunnistautuminen (Authenticator tai tekstiviesti) on käytössä.", - "Invalid TFA code": "Virheellinen turvakoodi", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Sisäänkirjautuminen epäonnistui. Tämä voi johtua siitä, että kaksivaiheinen tunnistautuminen on pois käytöstä tunnuksellasi.", "Wrong answer": "Väärä vastaus", "Erroneous CAPTCHA": "Virheellinen CAPTCHA", "CAPTCHA is a required field": "CAPTCHA-kenttä vaaditaan", "User ID is a required field": "Käyttäjätunnus vaaditaan", "Password is a required field": "Salasana vaaditaan", "Wrong username or password": "Väärä käyttäjänimi tai salasana", - "Please sign in using 'Log in with Google'": "Ole hyvä ja kirjaudu sisään Google-tunnuksella", "Password cannot be empty": "Salasana ei voi olla tyhjä", "Password cannot be longer than 55 characters": "Salasana ei voi olla yli 55 merkkiä pitkä", - "Please log in": "Kirjaudu sisään, ole hyvä", - "Invidious Private Feed for `x`": "Invidousin yksityinen syöte `x`:lle", + "Please log in": "Kirjaudu sisään", + "Invidious Private Feed for `x`": "Invidiousin yksityinen syöte `x`:lle", "channel:`x`": "kanava:`x`", "Deleted or invalid channel": "Poistettu tai virheellinen kanava", "This channel does not exist.": "Tätä kanavaa ei ole olemassa.", "Could not get channel info.": "Kanavatietoa ei saatu ladattua.", "Could not fetch comments": "Kommenttien nouto epäonnistui", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Näytä `x` vastausta", - "": "Näytä `x` vastausta" - }, "`x` ago": "`x` sitten", "Load more": "Lataa lisää", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pistettä", - "": "`x` pistettä" - }, "Could not create mix.": "Sekoituksen luominen epäonnistui.", - "Empty playlist": "Tyhjennä soittolista", + "Empty playlist": "Tyhjä soittolista", "Not a playlist.": "Ei ole soittolista.", "Playlist does not exist.": "Soittolistaa ei ole olemassa.", - "Could not pull trending pages.": "Nousussa olevien sivujen lataus epäonnitui.", - "Hidden field \"challenge\" is a required field": "Piilotettu kenttä \"challenge\" on vaaditaan", - "Hidden field \"token\" is a required field": "Piilotettu kenttä \"tunniste\" vaaditaan", + "Could not pull trending pages.": "Nousussa olevien sivujen lataus epäonnistui.", + "Hidden field \"challenge\" is a required field": "Piilotettu kenttä \"challenge\" vaaditaan", + "Hidden field \"token\" is a required field": "Piilotettu kenttä \"tunnus\" vaaditaan", "Erroneous challenge": "Virheellinen haaste", - "Erroneous token": "Virheellinen tunniste", + "Erroneous token": "Virheellinen tunnus", "No such user": "Käyttäjää ei ole olemassa", - "Token is expired, please try again": "Tunniste on vanhentunut, yritä uudestaan", + "Token is expired, please try again": "Tunnus on vanhentunut, yritä uudestaan", "English": "englanti", "English (auto-generated)": "englanti (automaattisesti luotu)", "Afrikaans": "afrikaans", @@ -252,15 +212,15 @@ "Danish": "tanska", "Dutch": "hollanti", "Esperanto": "esperanto", - "Estonian": "eesti", + "Estonian": "viro", "Filipino": "filipino", "Finnish": "suomi", "French": "ranska", - "Galician": "galego", + "Galician": "galicia", "Georgian": "georgia", "German": "saksa", "Greek": "kreikka", - "Gujarati": "gujarati", + "Gujarati": "gudžarati", "Haitian Creole": "haitinkreoli", "Hausa": "hausa", "Hawaiian": "havaiji", @@ -334,42 +294,14 @@ "Yiddish": "jiddiš", "Yoruba": "joruba", "Zulu": "zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vuotta", - "": "`x` vuotta" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` kuukautta", - "": "`x` kuukautta" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` viikkoa", - "": "`x` viikkoa" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` päivää", - "": "`x` päivää" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tuntia", - "": "`x` tuntia" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuuttia", - "": "`x` minuuttia" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekuntia", - "": "`x` sekuntia" - }, - "Fallback comments: ": "varakommentit: ", + "Fallback comments: ": "Varakommentit: ", "Popular": "Suosittu", - "Search": "", + "Search": "Etsi", "Top": "Ylin", "About": "Tietoa", "Rating: ": "Arvosana: ", - "Language: ": "Kieli: ", - "View as playlist": "Näytä soittolistana", + "preferences_locale_label": "Kieli: ", + "View as playlist": "Katso soittolistana", "Default": "Oletus", "Music": "Musiikki", "Gaming": "Videopelit", @@ -381,38 +313,188 @@ "(edited)": "(muokattu)", "YouTube comment permalink": "Pysyvä linkki YouTube-kommenttiin", "permalink": "pysyvä linkki", - "`x` marked it with a ❤": "`x` merkattu ❤:llä", + "`x` marked it with a ❤": "`x` merkkasi ❤:llä", "Audio mode": "Äänitila", "Video mode": "Videotila", - "Videos": "Videot", + "channel_tab_videos_label": "Videot", "Playlists": "Soittolistat", - "Community": "Yhteisö", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Tämänhetkinen versio: " -}
\ No newline at end of file + "channel_tab_community_label": "Yhteisö", + "search_filters_sort_option_relevance": "Osuvuus", + "search_filters_sort_option_rating": "Arvostelu", + "search_filters_sort_option_date": "Latauspäivämäärä", + "search_filters_sort_option_views": "Katselukerrat", + "search_filters_type_label": "Tyyppi", + "search_filters_duration_label": "Kesto", + "search_filters_features_label": "Ominaisuudet", + "search_filters_sort_label": "Luokittele", + "search_filters_date_option_hour": "Tunnin sisään", + "search_filters_date_option_today": "Tänään", + "search_filters_date_option_week": "Tällä viikolla", + "search_filters_date_option_month": "Tässä kuussa", + "search_filters_date_option_year": "Tänä vuonna", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Kanava", + "search_filters_type_option_playlist": "Soittolista", + "search_filters_type_option_movie": "Elokuva", + "search_filters_type_option_show": "Ohjelma", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Tekstitys/CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Suora lähetys", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Sijainti", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Tämänhetkinen versio: ", + "next_steps_error_message": "Kokeile seuraavia: ", + "next_steps_error_message_refresh": "Päivitä", + "next_steps_error_message_go_to_youtube": "Siirry YouTubeen", + "generic_count_hours": "{{count}} tunti", + "generic_count_hours_plural": "{{count}} tuntia", + "download_subtitles": "Tekstitykset - `x` (.vtt)", + "user_created_playlists": "`x` luotua soittolistaa", + "Video unavailable": "Video ei ole saatavilla", + "videoinfo_youTube_embed_link": "Upota", + "tokens_count": "{{count}} tunnus", + "tokens_count_plural": "{{count}} tunnusta", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videota", + "generic_playlists_count": "{{count}} soittolista", + "generic_playlists_count_plural": "{{count}} soittolistaa", + "generic_subscriptions_count": "{{count}} tilaus", + "generic_subscriptions_count_plural": "{{count}} tilausta", + "comments_view_x_replies": "Katso {{count}} vastaus", + "comments_view_x_replies_plural": "Katso {{count}} vastausta", + "generic_count_months": "{{count}} kuukausi", + "generic_count_months_plural": "{{count}} kuukautta", + "generic_count_weeks": "{{count}} viikko", + "generic_count_weeks_plural": "{{count}} viikkoa", + "generic_count_days": "{{count}} päivä", + "generic_count_days_plural": "{{count}} päivää", + "generic_count_minutes": "{{count}} minuutti", + "generic_count_minutes_plural": "{{count}} minuuttia", + "comments_points_count": "{{count}} piste", + "comments_points_count_plural": "{{count}} pistettä", + "generic_count_seconds": "{{count}} sekunti", + "generic_count_seconds_plural": "{{count}} sekuntia", + "crash_page_before_reporting": "Varmista ennen bugin ilmoittamista, että sinä olet:", + "crash_page_refresh": "yrittänyt <a href=\"`x`\">päivittää sivun</a>", + "crash_page_read_the_faq": "lukenut <a href=\"`x`\">Usein kysytyt kysymykset (FAQ)</a>", + "crash_page_search_issue": "etsinyt <a href=\"`x`\">olemassa olevia issueita GitHubissa</a>", + "generic_views_count": "{{count}} katselu", + "generic_views_count_plural": "{{count}} katselua", + "preferences_quality_dash_option_720p": "720p", + "generic_subscribers_count": "{{count}} tilaaja", + "generic_subscribers_count_plural": "{{count}} tilaajaa", + "preferences_quality_dash_option_1440p": "1440p", + "crash_page_you_found_a_bug": "Vaikuttaa siltä, että löysit bugin Invidiousista!", + "subscriptions_unseen_notifs_count": "{{count}} näkemätön ilmoitus", + "subscriptions_unseen_notifs_count_plural": "{{count}} näkemätöntä ilmoitusta", + "crash_page_switch_instance": "yrittänyt <a href=\"`x`\">käyttää toista instassia</a>", + "videoinfo_invidious_embed_link": "Upotettava linkki", + "user_saved_playlists": "`x` tallennetua soittolistaa", + "crash_page_report_issue": "Jos mikään näistä ei auttanut, <a href=\"`x`\">avaathan uuden issuen GitHubissa</a> (mieluiten englanniksi) ja sisällytät seuraavan tekstin viestissäsi (ÄLÄ käännä tätä tekstiä):", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_worst": "Huonoin", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "preferences_region_label": "Sisältömaa: ", + "preferences_quality_option_medium": "Keskitaso", + "preferences_quality_option_small": "Pieni", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_best": "Paras", + "preferences_quality_option_dash": "DASH (mukautuva laatu)", + "preferences_quality_dash_label": "Ensisijainen DASH-videolaatu: ", + "generic_count_years": "{{count}} vuosi", + "generic_count_years_plural": "{{count}} vuotta", + "search_filters_features_option_purchased": "Ostettu", + "search_filters_features_option_three_sixty": "360°", + "videoinfo_watch_on_youTube": "Katso YouTubessa", + "none": "ei mikään", + "videoinfo_started_streaming_x_ago": "Striimaaminen aloitettu `x` sitten", + "preferences_save_player_pos_label": "Tallenna toistokohta: ", + "footer_donate_page": "Lahjoita", + "footer_source_code": "Lähdekoodi", + "adminprefs_modified_source_code_url_label": "URL muokatun lähdekoodin repositorioon", + "Released under the AGPLv3 on Github.": "Julkaistu AGPLv3-lisenssillä GitHubissa.", + "search_filters_duration_option_short": "Lyhyt (< 4 minuuttia)", + "search_filters_duration_option_long": "Pitkä (> 20 minuuttia)", + "footer_documentation": "Dokumentaatio", + "footer_original_source_code": "Alkuperäinen lähdekoodi", + "footer_modfied_source_code": "Muokattu lähdekoodi", + "Japanese (auto-generated)": "japani (automaattisesti luotu)", + "German (auto-generated)": "saksa (automaattisesti luotu)", + "Portuguese (auto-generated)": "portugali (automaattisesti luotu)", + "Russian (auto-generated)": "Venäjä (automaattisesti luotu)", + "preferences_watch_history_label": "Ota katseluhistoria käyttöön: ", + "English (United Kingdom)": "englanti (Iso-Britannia)", + "English (United States)": "englanti (Yhdysvallat)", + "Cantonese (Hong Kong)": "kantoninkiina (Hongkong)", + "Chinese": "kiina", + "Chinese (China)": "kiina (Kiina)", + "Chinese (Hong Kong)": "kiina (Hongkong)", + "Chinese (Taiwan)": "kiina (Taiwan)", + "Dutch (auto-generated)": "hollanti (automaattisesti luotu)", + "French (auto-generated)": "ranska (automaattisesti luotu)", + "Indonesian (auto-generated)": "indonesia (automaattisesti luotu)", + "Interlingue": "interlingue", + "Italian (auto-generated)": "Italia (automaattisesti luotu)", + "Korean (auto-generated)": "korea (automaattisesti luotu)", + "Portuguese (Brazil)": "portugali (Brasilia)", + "Spanish (auto-generated)": "espanja (automaattisesti luotu)", + "Spanish (Mexico)": "espanja (Meksiko)", + "Spanish (Spain)": "espanja (Espanja)", + "Turkish (auto-generated)": "turkki (automaattisesti luotu)", + "Vietnamese (auto-generated)": "vietnam (automaattisesti luotu)", + "search_filters_title": "Suodattimet", + "search_message_no_results": "Tuloksia ei löytynyt.", + "search_message_change_filters_or_query": "Yritä hakukyselysi laajentamista ja/tai suodattimien muuttamista.", + "search_filters_duration_option_none": "Mikä tahansa kesto", + "search_filters_features_option_vr180": "VR180", + "search_filters_apply_button": "Ota valitut suodattimet käyttöön", + "search_filters_date_label": "Latausaika", + "search_filters_duration_option_medium": "Keskipituinen (4 - 20 minuuttia)", + "search_message_use_another_instance": " Voit myös <a href=\"`x`\">hakea toisella instanssilla</a>.", + "search_filters_date_option_none": "Milloin tahansa", + "search_filters_type_option_all": "Mikä tahansa tyyppi", + "Popular enabled: ": "Suosittu käytössä: ", + "error_video_not_in_playlist": "Pyydettyä videota ei ole tässä soittolistassa. <a href=\"`x`\">Klikkaa tästä päästäksesi soittolistan kotisivulle.</a>", + "Import YouTube playlist (.csv)": "Tuo YouTube-soittolista (.csv)", + "Music in this video": "Musiikki tässä videossa", + "Add to playlist": "Lisää soittolistaan", + "Add to playlist: ": "Lisää soittolistaan: ", + "Search for videos": "Etsi videoita", + "generic_button_rss": "RSS", + "Answer": "Vastaus", + "Standard YouTube license": "Vakio YouTube-lisenssi", + "Song: ": "Kappale: ", + "Album: ": "Albumi: ", + "Download is disabled": "Lataus on poistettu käytöstä", + "Channel Sponsor": "Kanavan sponsori", + "channel_tab_podcasts_label": "Podcastit", + "channel_tab_releases_label": "Julkaisut", + "channel_tab_shorts_label": "Shorts-videot", + "carousel_slide": "Dia {{current}}/{{total}}", + "carousel_skip": "Ohita karuselli", + "carousel_go_to": "Siirry diaan `x`", + "channel_tab_playlists_label": "Soittolistat", + "channel_tab_channels_label": "Kanavat", + "generic_button_delete": "Poista", + "generic_button_edit": "Muokkaa", + "generic_button_save": "Tallenna", + "generic_button_cancel": "Peru", + "playlist_button_add_items": "Lisää videoita", + "Artist: ": "Esittäjä: ", + "channel_tab_streams_label": "Suoratoistot", + "generic_channels_count": "{{count}} kanava", + "generic_channels_count_plural": "{{count}} kanavaa", + "The Popular feed has been disabled by the administrator.": "Järjestelmänvalvoja on poistanut Suositut-syötteen.", + "Import YouTube watch history (.json)": "Tuo Youtube-katseluhistoria (.json)", + "toggle_theme": "Vaihda teemaa" +} diff --git a/locales/fr.json b/locales/fr.json index f1055657..6147a159 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1,16 +1,27 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonné", - "": "`x` abonnés" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vidéo", - "": "`x` vidéos" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` liste de lecture", - "": "`x` listes de lecture" - }, + "generic_channels_count_0": "{{count}} chaîne", + "generic_channels_count_1": "{{count}} de chaînes", + "generic_channels_count_2": "{{count}} chaînes", + "generic_views_count_0": "{{count}} vue", + "generic_views_count_1": "{{count}} de vues", + "generic_views_count_2": "{{count}} vues", + "generic_videos_count_0": "{{count}} vidéo", + "generic_videos_count_1": "{{count}} de vidéos", + "generic_videos_count_2": "{{count}} vidéos", + "generic_playlists_count_0": "{{count}} liste de lecture", + "generic_playlists_count_1": "{{count}} listes de lecture", + "generic_playlists_count_2": "{{count}} listes de lecture", + "generic_subscribers_count_0": "{{count}} abonné", + "generic_subscribers_count_1": "{{count}} d'abonnés", + "generic_subscribers_count_2": "{{count}} abonnés", + "generic_subscriptions_count_0": "{{count}} abonnement", + "generic_subscriptions_count_1": "{{count}} d'abonnements", + "generic_subscriptions_count_2": "{{count}} abonnements", + "generic_button_delete": "Supprimer", + "generic_button_edit": "Modifier", + "generic_button_save": "Enregistrer", + "generic_button_cancel": "Annuler", + "generic_button_rss": "RSS", "LIVE": "EN DIRECT", "Shared `x` ago": "Ajoutée il y a `x`", "Unsubscribe": "Se désabonner", @@ -26,84 +37,84 @@ "Clear watch history?": "Êtes-vous sûr de vouloir supprimer l'historique des vidéos regardées ?", "New password": "Nouveau mot de passe", "New passwords must match": "Les nouveaux mots de passe doivent correspondre", - "Cannot change password for Google accounts": "Le mot de passe d'un compte Google ne peut pas être changé depuis Invidious", - "Authorize token?": "Autoriser le token ?", + "Authorize token?": "Autoriser le token ?", "Authorize token for `x`?": "Autoriser le token pour `x` ?", "Yes": "Oui", "No": "Non", "Import and Export Data": "Importer et exporter des données", "Import": "Importer", - "Import Invidious data": "Importer des données Invidious", - "Import YouTube subscriptions": "Importer des abonnements YouTube", + "Import Invidious data": "Importer des données Invidious au format JSON", + "Import YouTube subscriptions": "Importer des abonnements YouTube aux formats OPML/CSV", "Import FreeTube subscriptions (.db)": "Importer des abonnements FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importer des abonnements NewPipe (.json)", "Import NewPipe data (.zip)": "Importer des données NewPipe (.zip)", "Export": "Exporter", "Export subscriptions as OPML": "Exporter les abonnements au format OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exporter les abonnements au format OPML (pour NewPipe & FreeTube)", - "Export data as JSON": "Exporter les données au format JSON", + "Export data as JSON": "Exporter les données Invidious au format JSON", "Delete account?": "Êtes-vous sûr de vouloir supprimer votre compte ?", "History": "Historique", "An alternative front-end to YouTube": "Un front-end alternatif à YouTube", "JavaScript license information": "Informations sur les licences JavaScript", "source": "source", "Log in": "Se connecter", - "Log in/register": "Se connecter/Créer un compte", - "Log in with Google": "Se connecter avec Google", + "Log in/register": "Se connecter/S'inscrire", "User ID": "Identifiant utilisateur", "Password": "Mot de passe", "Time (h:mm:ss):": "Heure (h:mm:ss) :", - "Text CAPTCHA": "CAPTCHA Texte", - "Image CAPTCHA": "CAPTCHA Image", - "Sign In": "Se connecter", + "Text CAPTCHA": "CAPTCHA textuel", + "Image CAPTCHA": "CAPTCHA pictural", + "Sign In": "S'identifier", "Register": "S'inscrire", - "E-mail": "E-mail", - "Google verification code": "Code de vérification Google", + "E-mail": "Courriel", "Preferences": "Préférences", - "Player preferences": "Préférences du lecteur", - "Always loop: ": "Lire en boucle : ", - "Autoplay: ": "Lancer la lecture automatiquement : ", - "Play next by default: ": "Lire les vidéos suivantes par défaut : ", - "Autoplay next video: ": "Lancer la lecture automatiquement pour la vidéo suivant la vidéo regardée : ", - "Listen by default: ": "Audio uniquement : ", - "Proxy videos: ": "Charger les vidéos à travers un proxy : ", - "Default speed: ": "Vitesse par défaut : ", - "Preferred video quality: ": "Qualité vidéo souhaitée : ", - "Player volume: ": "Volume du lecteur : ", - "Default comments: ": "Source des commentaires : ", + "preferences_category_player": "Préférences du lecteur", + "preferences_video_loop_label": "Lire en boucle : ", + "preferences_autoplay_label": "Lancer la lecture automatiquement : ", + "preferences_continue_label": "Lire les vidéos suivantes par défaut : ", + "preferences_continue_autoplay_label": "Lire automatiquement la vidéo suivante : ", + "preferences_listen_label": "Audio uniquement : ", + "preferences_local_label": "Charger les vidéos à travers un proxy : ", + "preferences_speed_label": "Vitesse par défaut : ", + "preferences_quality_label": "Qualité vidéo souhaitée : ", + "preferences_volume_label": "Volume du lecteur : ", + "preferences_comments_label": "Source des commentaires : ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Sous-titres par défaut : ", + "preferences_captions_label": "Sous-titres par défaut : ", "Fallback captions: ": "Sous-titres alternatifs : ", - "Show related videos: ": "Voir les vidéos liées : ", - "Show annotations by default: ": "Afficher les annotations par défaut : ", - "Automatically extend video description: ": "", - "Visual preferences": "Préférences du site", - "Player style: ": "Style du lecteur : ", + "preferences_related_videos_label": "Voir les vidéos liées : ", + "preferences_annotations_label": "Afficher les annotations par défaut : ", + "preferences_extend_desc_label": "Etendre automatiquement la description : ", + "preferences_vr_mode_label": "Vidéos interactives à 360° (nécessite WebGL) : ", + "preferences_category_visual": "Préférences du site", + "preferences_player_style_label": "Style du lecteur : ", "Dark mode: ": "Mode sombre : ", - "Theme: ": "Thème : ", + "preferences_dark_mode_label": "Thème : ", "dark": "sombre", "light": "clair", - "Thin mode: ": "Mode léger : ", - "Subscription preferences": "Préférences de la page d'abonnements", - "Show annotations by default for subscribed channels: ": "Afficher les annotations par défaut sur les chaînes auxquelles vous êtes abonnés : ", + "preferences_thin_mode_label": "Mode léger : ", + "preferences_category_misc": "Paramètres divers", + "preferences_automatic_instance_redirect_label": "Redirection automatique vers une autre instance (via redirect.invidious.io) : ", + "preferences_category_subscription": "Préférences des abonnements", + "preferences_annotations_subscribed_label": "Afficher les annotations par défaut sur les chaînes auxquelles vous êtes abonnés : ", "Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ", - "Number of videos shown in feed: ": "Nombre de vidéos affichées dans la page d'abonnements : ", - "Sort videos by: ": "Trier les vidéos par : ", + "preferences_max_results_label": "Nombre de vidéos affichées dans la page d'abonnements : ", + "preferences_sort_label": "Trier les vidéos par : ", "published": "date de publication", "published - reverse": "date de publication - inversé", - "alphabetically": "alphabétiquement", - "alphabetically - reverse": "alphabétiquement - inversé", + "alphabetically": "ordre alphabétique", + "alphabetically - reverse": "ordre alphabétique - inversé", "channel name": "nom de la chaîne", "channel name - reverse": "nom de la chaîne - inversé", "Only show latest video from channel: ": "Afficher uniquement la dernière vidéo des chaînes auxquelles vous êtes abonnés : ", "Only show latest unwatched video from channel: ": "Afficher uniquement la dernière vidéo des chaînes auxquelles vous êtes abonnés qui n'a pas été regardée : ", - "Only show unwatched: ": "Afficher uniquement les vidéos qui n'ont pas été regardées : ", - "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ", + "preferences_unseen_only_label": "Afficher uniquement les vidéos qui n'ont pas été regardées : ", + "preferences_notifications_only_label": "Afficher uniquement les notifications (s'il y en a) : ", "Enable web notifications": "Activer les notifications web", "`x` uploaded a video": "`x` a partagé une vidéo", "`x` is live": "`x` est en direct", - "Data preferences": "Préférences liées aux données", + "preferences_category_data": "Préférences liées aux données", "Clear watch history": "Supprimer l'historique des vidéos regardées", "Import/export data": "Importer/exporter les données", "Change password": "Modifier le mot de passe", @@ -111,37 +122,33 @@ "Manage tokens": "Gérer les tokens", "Watch history": "Historique de visionnage", "Delete account": "Supprimer votre compte", - "Administrator preferences": "Préferences d'Administration", - "Default homepage: ": "Page d'accueil par défaut : ", - "Feed menu: ": "Préferences des abonnements : ", + "preferences_category_admin": "Préferences d'Administration", + "preferences_default_home_label": "Page d'accueil par défaut : ", + "preferences_feed_menu_label": "Préferences des abonnements : ", + "preferences_show_nick_label": "Afficher le nom d'utilisateur en haut à droite : ", + "Popular enabled: ": "Page \"populaire\" activée : ", "Top enabled: ": "Top activé : ", "CAPTCHA enabled: ": "CAPTCHA activé : ", - "Login enabled: ": "Connexion activée : ", - "Registration enabled: ": "Inscription activée : ", - "Report statistics: ": "Télémétrie activé : ", + "Login enabled: ": "Autoriser l'ouverture de sessions utilisateur : ", + "Registration enabled: ": "Autoriser la création de comptes utilisateur : ", + "Report statistics: ": "Activer les statistiques d'instance : ", "Save preferences": "Enregistrer les préférences", "Subscription manager": "Gestionnaire d'abonnement", - "Token manager": "Gestionnaire de tokens", + "Token manager": "Gestionnaire de token", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnements", - "": "`x` abonnements" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token", - "": "`x` tokens" - }, + "tokens_count_0": "{{count}} jeton", + "tokens_count_1": "{{count}} de jetons", + "tokens_count_2": "{{count}} jetons", "Import/export": "Importer/Exporter", "unsubscribe": "se désabonner", "revoke": "révoquer", "Subscriptions": "Abonnements", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notification non vue", - "": "`x` notifications non vues" - }, + "subscriptions_unseen_notifs_count_0": "{{count}} notification non vue", + "subscriptions_unseen_notifs_count_1": "{{count}} de notifications non vues", + "subscriptions_unseen_notifs_count_2": "{{count}} notifications non vues", "search": "rechercher", - "Log out": "Déconnexion", - "Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.", + "Log out": "Se déconnecter", + "Released under the AGPLv3 on Github.": "Publié sous licence AGPLv3 sur GitHub.", "Source available here.": "Code source disponible ici.", "View JavaScript license information.": "Informations des licences JavaScript.", "View privacy policy.": "Politique de confidentialité.", @@ -156,10 +163,12 @@ "Create playlist": "Créer une liste de lecture", "Title": "Titre", "Playlist privacy": "Paramètres de confidentialité de la liste de lecture", - "Editing playlist `x`": "Liste de lecture modifier le `x`", - "Show more": "", - "Show less": "", + "Editing playlist `x`": "Modifier la liste de lecture `x`", + "playlist_button_add_items": "Ajouter des vidéos", + "Show more": "Afficher plus", + "Show less": "Afficher moins", "Watch on YouTube": "Voir la vidéo sur Youtube", + "Switch Invidious Instance": "Changer d'instance", "Hide annotations": "Masquer les annotations", "Show annotations": "Afficher les annotations", "Genre: ": "Genre : ", @@ -170,10 +179,6 @@ "Whitelisted regions: ": "Régions sur liste blanche : ", "Blacklisted regions: ": "Régions sur liste noire : ", "Shared `x`": "Ajoutée le `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vues", - "": "`x` vues" - }, "Premieres in `x`": "Première dans `x`", "Premieres `x`": "Première le `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Il semblerait que JavaScript soit désactivé. Cliquez ici pour voir les commentaires, mais gardez à l'esprit que le chargement peut prendre plus de temps.", @@ -187,17 +192,12 @@ "Hide replies": "Masquer les réponses", "Show replies": "Afficher les réponses", "Incorrect password": "Mot de passe incorrect", - "Quota exceeded, try again in a few hours": "Nombre de tentatives de connexion dépassé, réessayez dans quelques heures", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Impossible de se connecter, si après plusieurs tentative vous ne parvenez toujours pas à vous connecter, assurez-vous que l'authentification à deux facteurs (Authenticator ou SMS) est activée.", - "Invalid TFA code": "Code d'authentification à deux facteurs invalide", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "La connexion a échoué. Cela peut être dû au fait que l'authentification à deux facteurs n'est pas activée sur votre compte.", "Wrong answer": "Réponse invalide", "Erroneous CAPTCHA": "CAPTCHA invalide", "CAPTCHA is a required field": "Veuillez entrer un CAPTCHA", "User ID is a required field": "Veuillez entrer un Identifiant Utilisateur", "Password is a required field": "Veuillez entrer un Mot de passe", "Wrong username or password": "Nom d'utilisateur ou mot de passe invalide", - "Please sign in using 'Log in with Google'": "Veuillez vous connecter en utilisant \"Se connecter avec Google\"", "Password cannot be empty": "Le mot de passe ne peut pas être vide", "Password cannot be longer than 55 characters": "Le mot de passe ne doit pas comporter plus de 55 caractères", "Please log in": "Veuillez vous connecter", @@ -207,16 +207,14 @@ "This channel does not exist.": "Cette chaine n'existe pas.", "Could not get channel info.": "Impossible de charger les informations de cette chaîne.", "Could not fetch comments": "Impossible de charger les commentaires", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Voir `x` réponse", - "": "Voir `x` réponses" - }, + "comments_view_x_replies_0": "Voir {{count}} réponse", + "comments_view_x_replies_1": "Voir {{count}} de réponses", + "comments_view_x_replies_2": "Voir {{count}} réponses", "`x` ago": "il y a `x`", "Load more": "Voir plus", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` point", - "": "`x` points" - }, + "comments_points_count_0": "{{count}} point", + "comments_points_count_1": "{{count}} de points", + "comments_points_count_2": "{{count}} points", "Could not create mix.": "Impossible de charger cette liste de lecture.", "Empty playlist": "La liste de lecture est vide", "Not a playlist.": "La liste de lecture est invalide.", @@ -334,41 +332,34 @@ "Yiddish": "Yiddish", "Yoruba": "Yoruba", "Zulu": "Zoulou", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` an", - "": "`x` ans" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mois", - "": "`x` mois" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semaine", - "": "`x` semaines" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jour", - "": "`x` jours" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` heure", - "": "`x` heures" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minute", - "": "`x` minutes" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` seconde", - "": "`x` secondes" - }, + "generic_count_years_0": "{{count}} an", + "generic_count_years_1": "{{count}} ans", + "generic_count_years_2": "{{count}} ans", + "generic_count_months_0": "{{count}} mois", + "generic_count_months_1": "{{count}} mois", + "generic_count_months_2": "{{count}} mois", + "generic_count_weeks_0": "{{count}} semaine", + "generic_count_weeks_1": "{{count}} semaines", + "generic_count_weeks_2": "{{count}} semaines", + "generic_count_days_0": "{{count}} jour", + "generic_count_days_1": "{{count}} jours", + "generic_count_days_2": "{{count}} jours", + "generic_count_hours_0": "{{count}} heure", + "generic_count_hours_1": "{{count}} heures", + "generic_count_hours_2": "{{count}} heures", + "generic_count_minutes_0": "{{count}} minute", + "generic_count_minutes_1": "{{count}} minutes", + "generic_count_minutes_2": "{{count}} minutes", + "generic_count_seconds_0": "{{count}} seconde", + "generic_count_seconds_1": "{{count}} secondes", + "generic_count_seconds_2": "{{count}} secondes", "Fallback comments: ": "Commentaires alternatifs : ", "Popular": "Populaire", "Search": "Rechercher", "Top": "Top", "About": "À propos", "Rating: ": "Évaluation : ", - "Language: ": "Langue : ", + "preferences_locale_label": "Langue : ", "View as playlist": "Voir en tant que liste de lecture", "Default": "Défaut", "Music": "Musique", @@ -384,35 +375,143 @@ "`x` marked it with a ❤": "`x` l'a marqué d'un ❤", "Audio mode": "Mode audio", "Video mode": "Mode vidéo", - "Videos": "Vidéos", + "channel_tab_videos_label": "Vidéos", "Playlists": "Listes de lecture", - "Community": "Communauté", - "relevance": "pertinence", - "rating": "évaluation", - "date": "date", - "views": "nombre de vues", - "content_type": "type de contenu", - "duration": "durée", - "features": "fonctionnalités", - "sort": "Trier par", - "hour": "heure", - "today": "aujourd'hui", - "week": "semaine", - "month": "mois", - "year": "année", - "video": "vidéo", - "channel": "chaîne", - "playlist": "liste de lecture", - "movie": "film", - "show": "affichage", - "hd": "HD", - "subtitles": "sous-titres / CC", - "creative_commons": "Creative Commons", - "3d": "3D", - "live": "en direct", - "4k": "4K", - "location": "emplacement", - "hdr": "HDR", - "filter": "filtrer", - "Current version: ": "Version actuelle : " -}
\ No newline at end of file + "channel_tab_community_label": "Communauté", + "search_filters_sort_option_relevance": "Pertinence", + "search_filters_sort_option_rating": "Notation", + "search_filters_sort_option_date": "Date d'ajout", + "search_filters_sort_option_views": "Nombre de vues", + "search_filters_type_label": "Type de contenu", + "search_filters_duration_label": "Durée", + "search_filters_features_label": "Fonctionnalités", + "search_filters_sort_label": "Trier par", + "search_filters_date_option_hour": "Dernière heure", + "search_filters_date_option_today": "Aujourd'hui", + "search_filters_date_option_week": "Cette semaine", + "search_filters_date_option_month": "Ce mois-ci", + "search_filters_date_option_year": "Cette année", + "search_filters_type_option_video": "Vidéo", + "search_filters_type_option_channel": "Chaîne", + "search_filters_type_option_playlist": "Liste de lecture", + "search_filters_type_option_movie": "Film", + "search_filters_type_option_show": "Émission", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Sous-titres (CC)", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "En direct", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "emplacement", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Version actuelle : ", + "next_steps_error_message": "Vous pouvez essayer de : ", + "next_steps_error_message_refresh": "Rafraîchir la page", + "next_steps_error_message_go_to_youtube": "Aller sur YouTube", + "preferences_quality_dash_label": "Qualité vidéo DASH préférée : ", + "footer_source_code": "Code source", + "preferences_region_label": "Pays du contenu : ", + "footer_donate_page": "Faire un don", + "footer_modfied_source_code": "Code source modifié", + "search_filters_duration_option_short": "Courte (< 4 minutes)", + "search_filters_duration_option_long": "Longue (> 20 minutes)", + "adminprefs_modified_source_code_url_label": "URL du dépôt du code source modifié", + "footer_documentation": "Documentation", + "footer_original_source_code": "Code source original", + "preferences_quality_option_medium": "Moyenne", + "preferences_quality_option_small": "Petite", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_best": "La plus haute", + "preferences_quality_dash_option_worst": "La plus basse", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "search_filters_features_option_three_sixty": "360°", + "none": "aucun", + "videoinfo_started_streaming_x_ago": "En stream depuis `x`", + "videoinfo_watch_on_youTube": "Regarder sur YouTube", + "videoinfo_youTube_embed_link": "Intégrer", + "search_filters_features_option_purchased": "Acheté", + "videoinfo_invidious_embed_link": "Lien intégré", + "download_subtitles": "Sous-titres - `x` (.vtt)", + "user_saved_playlists": "`x` listes de lecture sauvegardées", + "Video unavailable": "Vidéo non disponible", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_dash": "DASH (qualité adaptative)", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "user_created_playlists": "`x` listes de lecture créées", + "preferences_save_player_pos_label": "Sauvegarder la position du lecteur : ", + "crash_page_you_found_a_bug": "Il semblerait que vous ayez trouvé un bug dans Invidious !", + "crash_page_refresh": "tenté de <a href=\"`x`\">rafraîchir la page</a>", + "crash_page_switch_instance": "essayé d'<a href=\"`x`\">utiliser une autre instance</a>", + "crash_page_read_the_faq": "lu la <a href=\"`x`\">Foire Aux Questions (FAQ)</a>", + "crash_page_search_issue": "<a href=\"`x`\">cherché ce bug sur GitHub</a>", + "crash_page_before_reporting": "Avant de signaler un bug, veuillez vous assurez que vous avez :", + "crash_page_report_issue": "Si aucune des solutions proposées ci-dessus ne vous a aidé, veuillez <a href=\"`x`\">ouvrir une \"issue\" sur GitHub</a> (de préférence en anglais) et d'y inclure le message suivant (ne PAS traduire le texte) :", + "English (United States)": "Anglais (Etats-Unis)", + "Chinese (China)": "Chinois (Chine)", + "Chinese (Hong Kong)": "Chinois (Hong Kong)", + "Dutch (auto-generated)": "Danoi (auto-généré)", + "French (auto-generated)": "Français (auto-généré)", + "German (auto-generated)": "Allemand (auto-généré)", + "Japanese (auto-generated)": "Japonais (auto-généré)", + "Korean (auto-generated)": "Coréen (auto-généré)", + "Indonesian (auto-generated)": "Indonésien (auto-généré)", + "Portuguese (auto-generated)": "Portuguais (auto-généré)", + "Portuguese (Brazil)": "Portugais (Brésil)", + "Spanish (auto-generated)": "Espagnol (auto-généré)", + "Spanish (Mexico)": "Espagnol (Mexique)", + "Turkish (auto-generated)": "Turque (auto-généré)", + "Chinese": "Chinois", + "English (United Kingdom)": "Anglais (Royaume-Uni)", + "Chinese (Taiwan)": "Chinois (Taiwan)", + "Cantonese (Hong Kong)": "Cantonais (Hong Kong)", + "Interlingue": "Occidental", + "Italian (auto-generated)": "Italien (auto-généré)", + "Vietnamese (auto-generated)": "Vietnamien (auto-généré)", + "Russian (auto-generated)": "Russe (auto-généré)", + "Spanish (Spain)": "Espagnol (Espagne)", + "preferences_watch_history_label": "Activer l'historique de visionnage : ", + "search_filters_title": "Filtres", + "search_message_change_filters_or_query": "Essayez d'élargir votre recherche et/ou de changer les filtres.", + "search_filters_date_option_none": "Toutes les dates", + "search_filters_duration_option_medium": "Moyenne (de 4 à 20 minutes)", + "search_filters_apply_button": "Appliquer les filtres", + "search_message_no_results": "Aucun résultat.", + "search_message_use_another_instance": "Vous pouvez également <a href=\"`x`\">effectuer votre recherche sur une autre instance</a>.", + "search_filters_type_option_all": "Tous les types", + "search_filters_date_label": "Date d'ajout", + "search_filters_features_option_vr180": "VR180", + "search_filters_duration_option_none": "Toutes les durées", + "error_video_not_in_playlist": "La vidéo demandée n'existe pas dans cette liste de lecture. <a href=\"`x`\">Cliquez ici pour retourner à la liste de lecture.</a>", + "channel_tab_shorts_label": "Vidéos courtes", + "channel_tab_streams_label": "Vidéos en direct", + "channel_tab_playlists_label": "Listes de lecture", + "channel_tab_channels_label": "Chaînes", + "Song: ": "Chanson : ", + "Artist: ": "Artiste : ", + "Album: ": "Album : ", + "Standard YouTube license": "Licence YouTube Standard", + "Music in this video": "Musique dans cette vidéo", + "Channel Sponsor": "Soutien de la chaîne", + "Download is disabled": "Le téléchargement est désactivé", + "Import YouTube playlist (.csv)": "Importer des listes de lecture de Youtube (.csv)", + "channel_tab_releases_label": "Parutions", + "channel_tab_podcasts_label": "Émissions audio", + "Import YouTube watch history (.json)": "Importer l'historique de visionnement YouTube (.json)", + "Add to playlist: ": "Ajouter à la playlist : ", + "Add to playlist": "Ajouter à la playlist", + "Answer": "Répondre", + "Search for videos": "Rechercher des vidéos", + "The Popular feed has been disabled by the administrator.": "Le flux populaire a été désactivé par l'administrateur.", + "carousel_skip": "Passez le carrousel", + "carousel_slide": "Diapositive {{current}} sur {{total}}", + "carousel_go_to": "Aller à la diapositive `x`", + "toggle_theme": "Changer le Thème" +} diff --git a/locales/he.json b/locales/he.json index bf34c085..6fee93b2 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1,16 +1,4 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` רשומים.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` רשומים." - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` סרטונים.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` סרטונים." - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` פלייליסטים.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` פלייליסטים." - }, "LIVE": "שידור חי", "Shared `x` ago": "שותף לפני `x`", "Unsubscribe": "ביטול מינוי", @@ -26,7 +14,6 @@ "Clear watch history?": "לנקות את היסטוריית הצפייה?", "New password": "סיסמה חדשה", "New passwords must match": "על הסיסמאות החדשות להתאים", - "Cannot change password for Google accounts": "לא ניתן לשנות את הסיסמה לחשבונות Google", "Authorize token?": "לאשר את האסימון?", "Authorize token for `x`?": "האם לאשר את האסימון עבור `x`?", "Yes": "כן", @@ -49,7 +36,6 @@ "source": "source", "Log in": "כניסה", "Log in/register": "כניסה/הרשמה", - "Log in with Google": "כניסה עם Google", "User ID": "שם משתמש", "Password": "סיסמה", "Time (h:mm:ss):": "זמן (h:mm:ss):", @@ -58,52 +44,44 @@ "Sign In": "התחברות", "Register": "הרשמה", "E-mail": "דוא״ל", - "Google verification code": "קוד האימות של Google", "Preferences": "העדפות", - "Player preferences": "העדפות הנגן", - "Always loop: ": "", - "Autoplay: ": "ניגון אוטומטי: ", - "Play next by default: ": "ניגון הסרטון הבא כברירת מחדל: ", - "Autoplay next video: ": "ניגון הסרטון הבא באופן אוטומטי: ", - "Listen by default: ": "שמע כברירת מחדל: ", - "Proxy videos: ": "", - "Default speed: ": "מהירות ברירת המחדל: ", - "Preferred video quality: ": "איכות הווידאו המועדפת: ", - "Player volume: ": "עצמת השמע של הנגן: ", - "Default comments: ": "תגובות ברירת מחדל ", + "preferences_category_player": "העדפות הנגן", + "preferences_autoplay_label": "ניגון אוטומטי: ", + "preferences_continue_label": "ניגון הסרטון הבא כברירת מחדל: ", + "preferences_continue_autoplay_label": "ניגון הסרטון הבא באופן אוטומטי: ", + "preferences_listen_label": "שמע כברירת מחדל: ", + "preferences_speed_label": "מהירות ברירת המחדל: ", + "preferences_quality_label": "איכות הווידאו המועדפת: ", + "preferences_volume_label": "עצמת השמע של הנגן: ", + "preferences_comments_label": "תגובות ברירת מחדל ", "youtube": "יוטיוב", "reddit": "reddit", - "Default captions: ": "כתוביות ברירת מחדל ", + "preferences_captions_label": "כתוביות ברירת מחדל ", "Fallback captions: ": "כתוביות גיבוי ", - "Show related videos: ": "הצגת סרטונים קשורים: ", - "Show annotations by default: ": "הצגת הערות כברירת מחדל: ", - "Automatically extend video description: ": "", - "Visual preferences": "העדפות חזותיות", - "Player style: ": "סגנון הנגן: ", + "preferences_related_videos_label": "הצגת סרטונים קשורים: ", + "preferences_annotations_label": "הצגת הערות כברירת מחדל: ", + "preferences_category_visual": "העדפות חזותיות", + "preferences_player_style_label": "סגנון הנגן: ", "Dark mode: ": "מצב כהה: ", - "Theme: ": "ערכת נושא: ", + "preferences_dark_mode_label": "ערכת נושא: ", "dark": "כהה", "light": "בהיר", - "Thin mode: ": "", - "Subscription preferences": "העדפות מינויים", - "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ", - "Redirect homepage to feed: ": "", - "Number of videos shown in feed: ": "מספר הסרטונים שמוצגים בהזנה: ", - "Sort videos by: ": "מיון הסרטונים לפי: ", + "preferences_category_subscription": "העדפות מינויים", + "preferences_annotations_subscribed_label": "Show annotations by default for subscribed channels? ", + "preferences_max_results_label": "מספר הסרטונים שמוצגים בהזנה: ", + "preferences_sort_label": "מיון הסרטונים לפי: ", "published": "פורסם", - "published - reverse": "", "alphabetically": "בסדר אלפביתי", "alphabetically - reverse": "בסדר אלפביתי - הפוך", "channel name": "שם הערוץ", "channel name - reverse": "שם הערוץ - הפוך", "Only show latest video from channel: ": "הצגת הסרטון האחרון מהערוץ בלבד: ", "Only show latest unwatched video from channel: ": "הצגת הסרטון האחרון שלא נצפה מהערוץ בלבד: ", - "Only show unwatched: ": "הצגת סרטונים שלא נצפו בלבד: ", - "Only show notifications (if there are any): ": "הצגת התראות בלבד (אם ישנן): ", - "Enable web notifications": "", + "preferences_unseen_only_label": "הצגת סרטונים שלא נצפו בלבד: ", + "preferences_notifications_only_label": "הצגת התראות בלבד (אם ישנן): ", "`x` uploaded a video": "סרטון הועלה על ידי `x`", "`x` is live": "`x` בשידור חי", - "Data preferences": "העדפות נתונים", + "preferences_category_data": "העדפות נתונים", "Clear watch history": "ניקוי היסטוריית הצפייה", "Import/export data": "ייבוא/ייצוא נתונים", "Change password": "שינוי הסיסמה", @@ -111,39 +89,19 @@ "Manage tokens": "ניהול אסימונים", "Watch history": "היסטוריית צפייה", "Delete account": "מחיקת החשבון", - "Administrator preferences": "הגדרות ניהול מערכת", - "Default homepage: ": "Default homepage: ", - "Feed menu: ": "תפריט ההזנה: ", - "Top enabled: ": "", - "CAPTCHA enabled: ": "", - "Login enabled: ": "", - "Registration enabled: ": "", - "Report statistics: ": "", + "preferences_category_admin": "הגדרות ניהול מערכת", + "preferences_default_home_label": "Default homepage: ", + "preferences_feed_menu_label": "תפריט ההזנה: ", "Save preferences": "שמירת ההעדפות", "Subscription manager": "מנהל המינויים", "Token manager": "Token manager", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` מינויים.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` מינויים." - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, "Import/export": "ייבוא/ייצוא", "unsubscribe": "ביטול מנוי", - "revoke": "", "Subscriptions": "מינויים", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` הודעות שלא נראו.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` הודעות שלא נראו." - }, "search": "חיפוש", "Log out": "יציאה", - "Released under the AGPLv3 by Omar Roth.": "מופץ תחת רישיון AGPLv3 על ידי עמר רות׳ (Omar Roth).", "Source available here.": "קוד המקור זמין כאן.", - "View JavaScript license information.": "", "View privacy policy.": "להצגת מדיניות הפרטיות.", "Trending": "הסרטונים החמים", "Public": "ציבורי", @@ -154,78 +112,40 @@ "Delete playlist `x`?": "למחוק את פלייליסט `x`?", "Delete playlist": "מחיקת פלייליסט", "Create playlist": "יצירת פלייליסט", - "Title": "", "Playlist privacy": "Playlist privacy", - "Editing playlist `x`": "", - "Show more": "", - "Show less": "", "Watch on YouTube": "צפייה ב־YouTube", - "Hide annotations": "", - "Show annotations": "", "Genre: ": "Genre: ", "License: ": "רישיון: ", "Family friendly? ": "לכל המשפחה? ", "Wilson score: ": "ציון וילסון: ", - "Engagement: ": "", - "Whitelisted regions: ": "", - "Blacklisted regions: ": "", - "Shared `x`": "", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` צפיות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` צפיות." - }, - "Premieres in `x`": "", - "Premieres `x`": "", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "שלום! נראה ש־JavaScript כבוי. יש ללחוץ כאן להצגת התגובות, נא לקחת בחשבון שהטעינה תיקח קצת יותר זמן.", "View YouTube comments": "הצגת התגובות מ־YouTube", "View more comments on Reddit": "להצגת תגובות נוספות ב־Reddit", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "הצגת `x` תגובות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "הצגת `x` תגובות." + "": "הצגת `x` תגובות" }, "View Reddit comments": "להצגת התגובות ב־Reddit", "Hide replies": "הסתרת תגובות", "Show replies": "הצגת תגובות", "Incorrect password": "סיסמה שגויה", - "Quota exceeded, try again in a few hours": "", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", - "Invalid TFA code": "", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "", "Wrong answer": "תשובה שגויה", - "Erroneous CAPTCHA": "", "CAPTCHA is a required field": "שדה CAPTCHA הוא שדה חובה", "User ID is a required field": "חובה למלא את שדה שם המשתמש", "Password is a required field": "חובה למלא את שדה הסיסמה", "Wrong username or password": "שם משתמש שגוי או סיסמה שגויה", - "Please sign in using 'Log in with Google'": "נא להתחבר בעזרת \"התחברות עם Google\"", - "Password cannot be empty": "", "Password cannot be longer than 55 characters": "על אורך הסיסמה להיות 55 תווים לכל היותר", "Please log in": "נא להתחבר", - "Invidious Private Feed for `x`": "", "channel:`x`": "ערוץ:`x`", "Deleted or invalid channel": "הערוץ נמחק או שאינו תקין", "This channel does not exist.": "הערוץ הזה אינו קיים.", "Could not get channel info.": "לא היה ניתן לקבל מידע על הערוץ.", "Could not fetch comments": "לא היה ניתן למשוך את התגובות", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "הצגת `x` תגובות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "הצגת `x` תגובות." - }, "`x` ago": "לפני `x`", "Load more": "לטעון עוד", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Could not create mix.": "", "Empty playlist": "פלייליסט ריק", "Not a playlist.": "לא פלייליסט.", "Playlist does not exist.": "הפלייליסט אינו קיים.", - "Could not pull trending pages.": "", - "Hidden field \"challenge\" is a required field": "", - "Hidden field \"token\" is a required field": "", - "Erroneous challenge": "", - "Erroneous token": "", "No such user": "אין משתמש כזה", "Token is expired, please try again": "תוקף האסימון פג, נא לנסות שוב", "English": "אנגלית", @@ -296,7 +216,6 @@ "Mongolian": "מונגולית", "Nepali": "נפאלית", "Norwegian Bokmål": "Norwegian Bokmål", - "Nyanja": "", "Pashto": "פשטו", "Persian": "פרסית", "Polish": "פולנית", @@ -307,16 +226,13 @@ "Samoan": "סמואית", "Scottish Gaelic": "גאלית סקוטית", "Serbian": "Serbian", - "Shona": "", "Sindhi": "סינדהי", "Sinhala": "סינהלית", "Slovak": "Slovak", "Slovenian": "Slovenian", "Somali": "סומלית", - "Southern Sotho": "", "Spanish": "ספרדית", "Spanish (Latin America)": "ספרדית (אמריקה הלטינית)", - "Sundanese": "", "Swahili": "סווהילי", "Swedish": "שוודית", "Tajik": "טג׳יקית", @@ -329,46 +245,15 @@ "Uzbek": "אוזבקית", "Vietnamese": "וייטנאמית", "Welsh": "ולשית", - "Western Frisian": "", "Xhosa": "קוסה", "Yiddish": "יידיש", "Yoruba": "יורובה", "Zulu": "זולו", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שנים.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` שנים." - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` חודשים.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` חודשים." - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שבועות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` שבועות." - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ימים.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` ימים." - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שעות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` שעות." - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` דקות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` דקות." - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` שניות.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` שניות." - }, - "Fallback comments: ": "", "Popular": "סרטונים פופולריים", - "Search": "", "Top": "Top", "About": "על אודות", "Rating: ": "דירוג: ", - "Language: ": "שפה: ", + "preferences_locale_label": "שפה: ", "View as playlist": "הצגה כפלייליסט", "Default": "ברירת מחדל", "Music": "מוזיקה", @@ -379,40 +264,38 @@ "Download as: ": "הורדה בתור: ", "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(לאחר עריכה)", - "YouTube comment permalink": "", - "permalink": "", "`x` marked it with a ❤": "סומנה ב־❤ על ידי `x`", "Audio mode": "Audio mode", "Video mode": "Video mode", - "Videos": "סרטונים", + "channel_tab_videos_label": "סרטונים", "Playlists": "פלייליסטים", - "Community": "קהילה", - "relevance": "רלוונטיות", - "rating": "דירוג", - "date": "תאריך העלאה", - "views": "מספר צפיות", - "content_type": "סוג", - "duration": "משך זמן", - "features": "תכונות", - "sort": "מיון לפי", - "hour": "השעה האחרונה", - "today": "היום", - "week": "השבוע", - "month": "החודש", - "year": "השנה", - "video": "סרטון", - "channel": "ערוץ", - "playlist": "פלייליסט", - "movie": "סרט", - "show": "תכנית טלוויזיה", - "hd": "HD", - "subtitles": "כתוביות", - "creative_commons": "Creative Commons", - "3d": "3D", - "live": "Live", - "4k": "4K", - "location": "מיקום", - "hdr": "HDR", - "filter": "סינון", - "Current version: ": "הגרסה הנוכחית: " -}
\ No newline at end of file + "channel_tab_community_label": "קהילה", + "search_filters_sort_option_relevance": "רלוונטיות", + "search_filters_sort_option_rating": "דירוג", + "search_filters_sort_option_date": "תאריך העלאה", + "search_filters_sort_option_views": "מספר צפיות", + "search_filters_type_label": "סוג", + "search_filters_duration_label": "משך זמן", + "search_filters_features_label": "תכונות", + "search_filters_sort_label": "מיון לפי", + "search_filters_date_option_hour": "השעה האחרונה", + "search_filters_date_option_today": "היום", + "search_filters_date_option_week": "השבוע", + "search_filters_date_option_month": "החודש", + "search_filters_date_option_year": "השנה", + "search_filters_type_option_video": "סרטון", + "search_filters_type_option_channel": "ערוץ", + "search_filters_type_option_playlist": "פלייליסט", + "search_filters_type_option_movie": "סרט", + "search_filters_type_option_show": "תכנית טלוויזיה", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "כתוביות", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Live", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "מיקום", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "הגרסה הנוכחית: ", + "search_filters_title": "סינון" +} diff --git a/locales/hi.json b/locales/hi.json new file mode 100644 index 00000000..0a1c09dd --- /dev/null +++ b/locales/hi.json @@ -0,0 +1,500 @@ +{ + "last": "आखिरी", + "Yes": "हाँ", + "No": "नहीं", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML के रूप में सदस्यताएँ निर्यात करें (NewPipe और FreeTube के लिए)", + "Log in/register": "लॉग-इन/पंजीकृत करें", + "preferences_autoplay_label": "अपने आप चलाने की सुविधा: ", + "preferences_dark_mode_label": "थीम: ", + "preferences_default_home_label": "डिफ़ॉल्ट मुखपृष्ठ: ", + "Could not fetch comments": "टिप्पणियाँ प्राप्त न की जा सकीं", + "comments_points_count": "{{count}} पॉइंट", + "comments_points_count_plural": "{{count}} पॉइंट्स", + "Subscription manager": "सदस्यता प्रबंधन", + "License: ": "लाइसेंस: ", + "Wilson score: ": "Wilson स्कोर: ", + "Wrong answer": "गलत जवाब", + "Erroneous CAPTCHA": "गलत CAPTCHA", + "Please log in": "कृपया लॉग-इन करें", + "Bosnian": "बोस्नियाई", + "Bulgarian": "बुल्गारियाई", + "Burmese": "बर्मी", + "Chinese (Traditional)": "चीनी (पारंपरिक)", + "Kurdish": "कुर्द", + "Punjabi": "पंजाबी", + "Sinhala": "सिंहली", + "Slovak": "स्लोवाक", + "generic_count_days": "{{count}} दिन", + "generic_count_days_plural": "{{count}} दिन", + "generic_count_hours": "{{count}} घंटे", + "generic_count_hours_plural": "{{count}} घंटे", + "generic_count_minutes": "{{count}} मिनट", + "generic_count_minutes_plural": "{{count}} मिनट", + "generic_count_seconds": "{{count}} सेकंड", + "generic_count_seconds_plural": "{{count}} सेकंड", + "generic_playlists_count": "{{count}} प्लेलिस्ट", + "generic_playlists_count_plural": "{{count}} प्लेलिस्ट्स", + "crash_page_report_issue": "अगर इनमें से कुछ भी काम नहीं करता, कृपया <a href=\"`x`\">GitHub पर एक नया मुद्दा खोल दें</a> (अंग्रेज़ी में) और अपने संदेश में यह टेक्स्ट दर्ज करें (इसे अनुवादित न करें!):", + "generic_views_count": "{{count}} बार देखा गया", + "generic_views_count_plural": "{{count}} बार देखा गया", + "generic_videos_count": "{{count}} वीडियो", + "generic_videos_count_plural": "{{count}} वीडियो", + "generic_subscribers_count": "{{count}} सदस्य", + "generic_subscribers_count_plural": "{{count}} सदस्य", + "generic_subscriptions_count": "{{count}} सदस्यता", + "generic_subscriptions_count_plural": "{{count}} सदस्यताएँ", + "LIVE": "लाइव", + "Shared `x` ago": "`x` पहले बाँटा गया", + "Unsubscribe": "सदस्यता छोड़ें", + "Subscribe": "सदस्यता लें", + "View channel on YouTube": "चैनल YouTube पर देखें", + "View playlist on YouTube": "प्लेलिस्ट YouTube पर देखें", + "newest": "सबसे नया", + "oldest": "सबसे पुराना", + "popular": "सर्वाधिक लोकप्रिय", + "Next page": "अगला पृष्ठ", + "Previous page": "पिछला पृष्ठ", + "Clear watch history?": "देखने का इतिहास मिटाएँ?", + "New password": "नया पासवर्ड", + "New passwords must match": "पासवर्ड्स को मेल खाना होगा", + "Authorize token?": "टोकन को प्रमाणित करें?", + "Authorize token for `x`?": "`x` के लिए टोकन को प्रमाणित करें?", + "Import and Export Data": "डेटा को आयात और निर्यात करें", + "Import": "आयात करें", + "Import Invidious data": "Invidious JSON डेटा आयात करें", + "Import YouTube subscriptions": "YouTube CSV या OPML सदस्यताएँ आयात करें", + "Import FreeTube subscriptions (.db)": "FreeTube सदस्यताएँ आयात करें (.db)", + "Import NewPipe subscriptions (.json)": "NewPipe सदस्यताएँ आयात करें (.json)", + "Import NewPipe data (.zip)": "NewPipe डेटा आयात करें (.zip)", + "Export": "निर्यात करें", + "Export subscriptions as OPML": "OPML के रूप में सदस्यताएँ निर्यात करें", + "Export data as JSON": "Invidious डेटा को JSON के रूप में निर्यात करें", + "Delete account?": "खाता हटाएँ?", + "History": "देखे गए वीडियो", + "An alternative front-end to YouTube": "YouTube का एक वैकल्पिक फ्रंट-एंड", + "JavaScript license information": "जावास्क्रिप्ट लाइसेंस की जानकारी", + "source": "स्रोत", + "Log in": "लॉग-इन करें", + "User ID": "सदस्य ID", + "Password": "पासवर्ड", + "Register": "पंजीकृत करें", + "E-mail": "ईमेल", + "Time (h:mm:ss):": "समय (घं:मिमि:सेसे):", + "Text CAPTCHA": "टेक्स्ट CAPTCHA", + "Image CAPTCHA": "चित्र CAPTCHA", + "Sign In": "साइन इन करें", + "Preferences": "प्राथमिकताएँ", + "preferences_category_player": "प्लेयर की प्राथमिकताएँ", + "preferences_video_loop_label": "हमेशा लूप करें: ", + "preferences_continue_label": "डिफ़ॉल्ट से अगला चलाएँ: ", + "preferences_continue_autoplay_label": "अगला वीडियो अपने आप चलाएँ: ", + "preferences_listen_label": "डिफ़ॉल्ट से सुनें: ", + "preferences_local_label": "प्रॉक्सी वीडियो: ", + "preferences_watch_history_label": "देखने का इतिहास सक्षम करें: ", + "preferences_speed_label": "वीडियो चलाने की डिफ़ॉल्ट रफ़्तार: ", + "preferences_quality_label": "वीडियो की प्राथमिक क्वालिटी: ", + "preferences_quality_option_dash": "DASH (अनुकूली गुणवत्ता)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "मध्यम", + "preferences_quality_option_small": "छोटा", + "preferences_quality_dash_label": "प्राथमिक DASH वीडियो क्वालिटी: ", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_auto": "अपने-आप", + "preferences_quality_dash_option_best": "सबसे अच्छा", + "preferences_quality_dash_option_worst": "सबसे खराब", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "preferences_comments_label": "डिफ़ॉल्ट टिप्पणियाँ: ", + "preferences_volume_label": "प्लेयर का वॉल्यूम: ", + "youtube": "YouTube", + "reddit": "Reddit", + "invidious": "Invidious", + "preferences_captions_label": "डिफ़ॉल्ट कैप्शन: ", + "Fallback captions: ": "वैकल्पिक कैप्शन: ", + "preferences_related_videos_label": "संबंधित वीडियो दिखाएँ: ", + "preferences_annotations_label": "डिफ़ॉल्ट से टिप्पणियाँ दिखाएँ: ", + "preferences_extend_desc_label": "अपने आप वीडियो के विवरण का विस्तार करें: ", + "preferences_vr_mode_label": "उत्तरदायी 360 डिग्री वीडियो (WebGL की ज़रूरत है): ", + "preferences_category_visual": "यथादृश्य प्राथमिकताएँ", + "preferences_region_label": "सामग्री का राष्ट्र: ", + "preferences_player_style_label": "प्लेयर का स्टाइल: ", + "Dark mode: ": "डार्क मोड: ", + "dark": "डार्क", + "light": "लाइट", + "preferences_thin_mode_label": "हल्का मोड: ", + "preferences_category_misc": "विविध प्राथमिकताएँ", + "preferences_automatic_instance_redirect_label": "अपने आप अनुप्रेषित करें (redirect.invidious.io पर फ़ॉलबैक करें): ", + "preferences_category_subscription": "सदस्यताओं की प्राथमिकताएँ", + "preferences_annotations_subscribed_label": "सदस्यता लिए गए चैनलों पर डिफ़ॉल्ट से टिप्पणियाँ दिखाएँ? ", + "Redirect homepage to feed: ": "फ़ीड पर मुखपृष्ठ को अनुप्रेषित करें: ", + "preferences_max_results_label": "फ़ीड में दिखाए जाने वाले वीडियों की संख्या: ", + "preferences_sort_label": "वीडियों को इस मानदंड पर छाँटें: ", + "published": "प्रकाशित", + "published - reverse": "प्रकाशित - उल्टा", + "Only show latest video from channel: ": "चैनल से सिर्फ नवीनतम वीडियो ही दिखाएँ: ", + "alphabetically": "वर्णक्रमानुसार", + "Only show latest unwatched video from channel: ": "चैनल से सिर्फ न देखा गया नवीनतम वीडियो ही दिखाएँ: ", + "alphabetically - reverse": "वर्णक्रमानुसार - उल्टा", + "channel name": "चैनल का नाम", + "channel name - reverse": "चैनल का नाम - उल्टा", + "preferences_unseen_only_label": "सिर्फ न देखे गए वीडियो ही दिखाएँ: ", + "preferences_notifications_only_label": "सिर्फ सूचनाएँ दिखाएँ (अगर हो तो): ", + "Enable web notifications": "वेब सूचनाएँ सक्षम करें", + "`x` uploaded a video": "`x` ने वीडियो अपलोड किया", + "`x` is live": "`x` लाइव हैं", + "preferences_category_data": "डेटा की प्राथमिकताएँ", + "Clear watch history": "देखने का इतिहास साफ़ करें", + "Import/export data": "डेटा को आयात/निर्यात करें", + "Change password": "पासवर्ड बदलें", + "Manage subscriptions": "सदस्यताएँ प्रबंधित करें", + "Manage tokens": "टोकन प्रबंधित करें", + "Watch history": "देखने का इतिहास", + "Delete account": "खाता हटाएँ", + "preferences_category_admin": "प्रबंधक प्राथमिकताएँ", + "preferences_feed_menu_label": "फ़ीड मेन्यू: ", + "preferences_show_nick_label": "ऊपर उपनाम दिखाएँ: ", + "Top enabled: ": "ऊपर का हिस्सा सक्षम है: ", + "CAPTCHA enabled: ": "CAPTCHA सक्षम है: ", + "Login enabled: ": "लॉग-इन सक्षम है: ", + "Registration enabled: ": "पंजीकरण सक्षम है: ", + "Report statistics: ": "सांख्यिकी रिपोर्ट करें: ", + "Released under the AGPLv3 on Github.": "GitHub पर AGPLv3 के अंतर्गत प्रकाशित।", + "Save preferences": "प्राथमिकताएँ सहेजें", + "Token manager": "टोकन प्रबंधन", + "Token": "टोकन", + "tokens_count": "{{count}} टोकन", + "tokens_count_plural": "{{count}} टोकन", + "Import/export": "आयात/निर्यात करें", + "unsubscribe": "सदस्यता छोड़ें", + "revoke": "हटाएँ", + "Subscriptions": "सदस्यताएँ", + "subscriptions_unseen_notifs_count": "{{count}} अपठित सूचना", + "subscriptions_unseen_notifs_count_plural": "{{count}} अपठित सूचना", + "search": "खोजें", + "Log out": "लॉग-आउट करें", + "Source available here.": "स्रोत यहाँ उपलब्ध है।", + "View JavaScript license information.": "जावास्क्रिप्ट लाइसेंस की जानकारी देखें।", + "View privacy policy.": "निजता नीति देखें।", + "Trending": "रुझान में", + "Public": "सार्वजनिक", + "Unlisted": "सबके लिए उपलब्ध नहीं", + "Private": "निजी", + "View all playlists": "सभी प्लेलिस्ट देखें", + "Create playlist": "प्लेलिस्ट बनाएँ", + "Updated `x` ago": "`x` पहले अपडेट किया गया", + "Delete playlist `x`?": "प्लेलिस्ट `x` हटाएँ?", + "Delete playlist": "प्लेलिस्ट हटाएँ", + "Title": "शीर्षक", + "Playlist privacy": "प्लेलिस्ट की निजता", + "Editing playlist `x`": "प्लेलिस्ट `x` को संपादित किया जा रहा है", + "Show more": "अधिक देखें", + "Show less": "कम देखें", + "Watch on YouTube": "YouTube पर देखें", + "Switch Invidious Instance": "Invidious उदाहरण बदलें", + "search_message_no_results": "कोई परिणाम नहीं मिला।", + "search_message_change_filters_or_query": "अपने खोज क्वेरी को और चौड़ा करें और/या फ़िल्टर बदलें।", + "search_message_use_another_instance": " आप <a href=\"`x`\">दूसरे उदाहरण पर भी खोज सकते हैं</a>।", + "Hide annotations": "टिप्पणियाँ छिपाएँ", + "Show annotations": "टिप्पणियाँ दिखाएँ", + "Genre: ": "श्रेणी: ", + "Family friendly? ": "परिवार के लिए ठीक है? ", + "Engagement: ": "सगाई: ", + "Whitelisted regions: ": "स्वीकृत क्षेत्र: ", + "Blacklisted regions: ": "अस्वीकृत क्षेत्र: ", + "Shared `x`": "`x` बाँटा गया", + "Premieres in `x`": "`x` बाद प्रीमियर होगा", + "Premieres `x`": "`x` को प्रीमिर होगा", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "नमस्कार! ऐसा लगता है कि आपका जावास्क्रिप्ट अक्षम है। टिप्पणियाँ देखने के लिए यहाँ क्लिक करें, लेकिन याद रखें कि इन्हें लोड होने में थोड़ा ज़्यादा समय लग सकता है।", + "View YouTube comments": "YouTube टिप्पणियाँ देखें", + "View more comments on Reddit": "Reddit पर अधिक टिप्पणियाँ देखें", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` टिप्पणी देखें", + "": "`x` टिप्पणियाँ देखें" + }, + "View Reddit comments": "Reddit पर टिप्पणियाँ", + "Hide replies": "जवाब छिपाएँ", + "Show replies": "जवाब दिखाएँ", + "Incorrect password": "गलत पासवर्ड", + "CAPTCHA is a required field": "CAPTCHA एक ज़रूरी फ़ील्ड है", + "User ID is a required field": "सदस्य ID एक ज़रूरी फ़ील्ड है", + "Password is a required field": "पासवर्ड एक ज़रूरी फ़ील्ड है", + "Wrong username or password": "गलत सदस्यनाम या पासवर्ड", + "Password cannot be empty": "पासवर्ड खाली नहीं हो सकता", + "Password cannot be longer than 55 characters": "पासवर्ड में अधिकतम 55 अक्षर हो सकते हैं", + "Invidious Private Feed for `x`": "`x` के लिए Invidious निजी फ़ीड", + "channel:`x`": "चैनल:`x`", + "Deleted or invalid channel": "हटाया गया या अमान्य चैनल", + "This channel does not exist.": "यह चैनल मौजूद नहीं है।", + "Could not get channel info.": "चैनल की जानकारी प्राप्त न की जा सकी।", + "comments_view_x_replies": "{{count}} टिप्पणी देखें", + "comments_view_x_replies_plural": "{{count}} टिप्पणियाँ देखें", + "`x` ago": "`x` पहले", + "Load more": "अधिक लोड करें", + "Could not create mix.": "मिक्स न बनाया जा सका।", + "Empty playlist": "खाली प्लेलिस्ट", + "Not a playlist.": "यह प्लेलिस्ट नहीं है।", + "Playlist does not exist.": "प्लेलिस्ट मौजूद नहीं है।", + "Could not pull trending pages.": "रुझान के पृष्ठ प्राप्त न किए जा सके।", + "Hidden field \"challenge\" is a required field": "छिपाया गया फ़ील्ड \"चुनौती\" एक आवश्यक फ़ील्ड है", + "Hidden field \"token\" is a required field": "छिपाया गया फ़ील्ड \"टोकन\" एक आवश्यक फ़ील्ड है", + "Erroneous challenge": "त्रुटिपूर्ण चुनौती", + "Erroneous token": "त्रुटिपूर्ण टोकन", + "No such user": "यह सदस्य मौजूद नहीं हैं", + "Token is expired, please try again": "टोकन की समय-सीमा समाप्त हो चुकी है, कृपया दोबारा कोशिश करें", + "English": "अंग्रेज़ी", + "English (United Kingdom)": "अंग्रेज़ी (यूनाइटेड किंग्डम)", + "English (United States)": "अंग्रेज़ी (संयुक्त राष्ट्र)", + "English (auto-generated)": "अंग्रेज़ी (अपने-आप जनरेट हुआ)", + "Afrikaans": "अफ़्रीकी", + "Albanian": "अल्बानियाई", + "Amharic": "अम्हेरी", + "Arabic": "अरबी", + "Armenian": "आर्मेनियाई", + "Belarusian": "बेलारूसी", + "Azerbaijani": "अज़रबैजानी", + "Bangla": "बंगाली", + "Basque": "बास्क", + "Cantonese (Hong Kong)": "कैंटोनीज़ (हाँग काँग)", + "Catalan": "कातालान", + "Cebuano": "सेबुआनो", + "Chinese": "चीनी", + "Chinese (China)": "चीनी (चीन)", + "Chinese (Hong Kong)": "चीनी (हाँग काँग)", + "Chinese (Simplified)": "चीनी (सरलीकृत)", + "Chinese (Taiwan)": "चीनी (ताइवान)", + "Corsican": "कोर्सिकन", + "Croatian": "क्रोएशियाई", + "Czech": "चेक", + "Danish": "डेनिश", + "Dutch": "डच", + "Dutch (auto-generated)": "डच (अपने-आप जनरेट हुआ)", + "Esperanto": "एस्पेरांतो", + "Estonian": "एस्टोनियाई", + "Filipino": "फ़िलिपीनो", + "Finnish": "फ़िनिश", + "French": "फ़्रेंच", + "French (auto-generated)": "फ़्रेंच (अपने-आप जनरेट हुआ)", + "Galician": "गैलिशियन", + "Georgian": "जॉर्जियाई", + "German": "जर्मन", + "German (auto-generated)": "जर्मन (अपने-आप जनरेट हुआ)", + "Greek": "यूनानी", + "Gujarati": "गुजराती", + "Haitian Creole": "हैती क्रियोल", + "Hausa": "हौसा", + "Hawaiian": "हवाई", + "Hebrew": "हीब्रू", + "Hindi": "हिन्दी", + "Hmong": "हमोंग", + "Hungarian": "हंगेरी", + "Icelandic": "आइसलैंडिक", + "Igbo": "इग्बो", + "Indonesian": "इंडोनेशियाई", + "Indonesian (auto-generated)": "इंडोनेशियाई (अपने-आप जनरेट हुआ)", + "Interlingue": "इंटरलिंगुआ", + "Irish": "आयरिश", + "Italian": "इतालवी", + "Italian (auto-generated)": "इतालवी (अपने-आप जनरेट हुआ)", + "Japanese": "जापानी", + "Japanese (auto-generated)": "जापानी (अपने-आप जनरेट हुआ)", + "Javanese": "जावानीज़", + "Kannada": "कन्नड़", + "Kazakh": "कज़ाख़", + "Khmer": "खमेर", + "Korean": "कोरियाई", + "Korean (auto-generated)": "कोरियाई (अपने-आप जनरेट हुआ)", + "Kyrgyz": "किर्गीज़", + "Lao": "लाओ", + "Latin": "लैटिन", + "Latvian": "लातवियाई", + "Lithuanian": "लिथुएनियाई", + "Luxembourgish": "लग्ज़मबर्गी", + "Macedonian": "मकादूनियाई", + "Malagasy": "मालागासी", + "Malay": "मलय", + "Malayalam": "मलयालम", + "Maltese": "माल्टीज़", + "Maori": "माओरी", + "Marathi": "मराठी", + "Mongolian": "मंगोलियाई", + "Nepali": "नेपाली", + "Norwegian Bokmål": "नॉर्वेजियाई", + "Nyanja": "न्यानजा", + "Pashto": "पश्तो", + "Persian": "फ़ारसी", + "Polish": "पोलिश", + "Portuguese": "पुर्तगाली", + "Portuguese (auto-generated)": "पुर्तगाली (अपने-आप जनरेट हुआ)", + "Portuguese (Brazil)": "पुर्तगाली (ब्राज़ील)", + "Romanian": "रोमेनियाई", + "Russian": "रूसी", + "Russian (auto-generated)": "रूसी (अपने-आप जनरेट हुआ)", + "Samoan": "सामोन", + "Scottish Gaelic": "स्कॉटिश गाएलिक", + "Serbian": "सर्बियाई", + "Shona": "शोणा", + "Sindhi": "सिंधी", + "Slovenian": "स्लोवेनियाई", + "Somali": "सोमाली", + "Southern Sotho": "दक्षिणी सोथो", + "Spanish": "स्पेनी", + "Spanish (auto-generated)": "स्पेनी (अपने-आप जनरेट हुआ)", + "Spanish (Latin America)": "स्पेनी (लातिन अमेरिकी)", + "Spanish (Mexico)": "स्पेनी (मेक्सिको)", + "Spanish (Spain)": "स्पेनी (स्पेन)", + "Sundanese": "सुंडानी", + "Swahili": "स्वाहिली", + "Swedish": "स्वीडिश", + "Tajik": "ताजीक", + "Tamil": "तमिल", + "Telugu": "तेलुगु", + "Thai": "थाई", + "Turkish": "तुर्की", + "Turkish (auto-generated)": "तुर्की (अपने-आप जनरेट हुआ)", + "Ukrainian": "यूक्रेनी", + "Urdu": "उर्दू", + "Uzbek": "उज़्बेक", + "Vietnamese": "वियतनामी", + "Vietnamese (auto-generated)": "वियतनामी (अपने-आप जनरेट हुआ)", + "Welsh": "Welsh", + "Western Frisian": "पश्चिमी फ़्रिसियाई", + "Xhosa": "खोसा", + "Yiddish": "यहूदी", + "generic_count_years": "{{count}} वर्ष", + "generic_count_years_plural": "{{count}} वर्ष", + "Yoruba": "योरुबा", + "generic_count_months": "{{count}} महीने", + "generic_count_months_plural": "{{count}} महीने", + "Zulu": "ज़ूलू", + "generic_count_weeks": "{{count}} हफ़्ते", + "generic_count_weeks_plural": "{{count}} हफ़्ते", + "Fallback comments: ": "फ़ॉलबैक टिप्पणियाँ: ", + "Popular": "प्रसिद्ध", + "Search": "खोजें", + "Top": "ऊपर", + "About": "जानकारी", + "Rating: ": "रेटिंग: ", + "preferences_locale_label": "भाषा: ", + "View as playlist": "प्लेलिस्ट के रूप में देखें", + "Default": "डिफ़ॉल्ट", + "Download": "डाउनलोड करें", + "Download as: ": "इस रूप में डाउनलोड करें: ", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "Music": "संगीत", + "Gaming": "गेमिंग", + "News": "समाचार", + "Movies": "फ़िल्में", + "(edited)": "(संपादित)", + "YouTube comment permalink": "YouTube पर टिप्पणी की स्थायी कड़ी", + "permalink": "स्थायी कड़ी", + "channel_tab_videos_label": "वीडियो", + "`x` marked it with a ❤": "`x` ने इसे एक ❤ से चिह्नित किया", + "Audio mode": "ऑडियो मोड", + "Playlists": "प्लेलिस्ट्स", + "Video mode": "वीडियो मोड", + "channel_tab_community_label": "समुदाय", + "search_filters_title": "फ़िल्टर", + "search_filters_date_label": "अपलोड करने का समय", + "search_filters_date_option_none": "कोई भी समय", + "search_filters_date_option_week": "इस हफ़्ते", + "search_filters_date_option_month": "इस महीने", + "search_filters_date_option_hour": "पिछला घंटा", + "search_filters_date_option_today": "आज", + "search_filters_date_option_year": "इस साल", + "search_filters_type_label": "प्रकार", + "search_filters_type_option_all": "कोई भी प्रकार", + "search_filters_type_option_video": "वीडियो", + "search_filters_type_option_channel": "चैनल", + "search_filters_sort_option_relevance": "प्रासंगिकता", + "search_filters_type_option_playlist": "प्लेलिस्ट", + "search_filters_type_option_movie": "फ़िल्म", + "search_filters_type_option_show": "शो", + "search_filters_duration_label": "अवधि", + "search_filters_duration_option_none": "कोई भी अवधि", + "search_filters_duration_option_short": "4 मिनट से कम", + "search_filters_duration_option_medium": "4 से 20 मिनट तक", + "search_filters_duration_option_long": "20 मिनट से ज़्यादा", + "search_filters_features_label": "सुविधाएँ", + "search_filters_features_option_live": "लाइव", + "search_filters_sort_option_rating": "रेटिंग", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "उपशीर्षक/कैप्शन", + "search_filters_features_option_c_commons": "क्रिएटिव कॉमन्स", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_vr180": "VR180", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_hdr": "HDR", + "search_filters_features_option_location": "जगह", + "search_filters_features_option_purchased": "खरीदा गया", + "search_filters_sort_label": "इस क्रम से लगाएँ", + "search_filters_sort_option_date": "अपलोड की ताऱीख", + "search_filters_sort_option_views": "देखे जाने की संख्या", + "search_filters_apply_button": "चयनित फ़िल्टर लागू करें", + "footer_documentation": "प्रलेख", + "footer_source_code": "स्रोत कोड", + "footer_original_source_code": "मूल स्रोत कोड", + "footer_modfied_source_code": "बदला गया स्रोत कोड", + "Current version: ": "वर्तमान संस्करण: ", + "next_steps_error_message": "इसके बाद आपके ये आज़माने चाहिए: ", + "next_steps_error_message_refresh": "साफ़ करें", + "next_steps_error_message_go_to_youtube": "YouTube पर जाएँ", + "footer_donate_page": "दान करें", + "adminprefs_modified_source_code_url_label": "बदले गए स्रोत कोड के रिपॉज़िटरी का URL", + "none": "कुछ नहीं", + "videoinfo_started_streaming_x_ago": "`x` पहले स्ट्रीम करना शुरू किया", + "videoinfo_watch_on_youTube": "YouTube पर देखें", + "Video unavailable": "वीडियो उपलब्ध नहीं है", + "preferences_save_player_pos_label": "यहाँ से चलाना शुरू करें: ", + "crash_page_you_found_a_bug": "शायद आपको Invidious में कोई बग नज़र आ गया है!", + "videoinfo_youTube_embed_link": "एम्बेड करें", + "videoinfo_invidious_embed_link": "एम्बेड करने की कड़ी", + "download_subtitles": "उपशीर्षक - `x` (.vtt)", + "user_created_playlists": "बनाए गए `x` प्लेलिस्ट्स", + "user_saved_playlists": "सहेजे गए `x` प्लेलिस्ट्स", + "crash_page_before_reporting": "बग रिपोर्ट करने से पहले:", + "crash_page_switch_instance": "<a href=\"`x`\">किसी दूसरे उदाहरण का इस्तेमाल करें</a>", + "crash_page_read_the_faq": "<a href=\"`x`\">अक्सर पूछे जाने वाले प्रश्न (FAQ)</a> पढ़ें", + "crash_page_refresh": "<a href=\"`x`\">पृष्ठ को एक बार साफ़ करें</a>", + "crash_page_search_issue": "<a href=\"`x`\">GitHub पर मौजूदा मुद्दे</a> ढूँढ़ें", + "Popular enabled: ": "लोकप्रिय सक्षम: ", + "Artist: ": "कलाकार: ", + "Music in this video": "इस वीडियो में संगीत", + "Album: ": "एल्बम: ", + "error_video_not_in_playlist": "अनुरोधित वीडियो इस प्लेलिस्ट में मौजूद नहीं है। <a href=\"`x`\">प्लेलिस्ट के मुखपृष्ठ पर जाने के लिए यहाँ क्लिक करें।</a>", + "channel_tab_shorts_label": "शॉर्ट्स", + "channel_tab_streams_label": "लाइवस्ट्रीम्स", + "channel_tab_playlists_label": "प्लेलिस्ट्स", + "channel_tab_channels_label": "चैनल्स", + "generic_button_save": "सहेजें", + "generic_button_cancel": "रद्द करें", + "generic_button_rss": "आरएसएस", + "generic_button_edit": "संपादित करें", + "generic_button_delete": "हटाएं", + "playlist_button_add_items": "वीडियो जोड़ें", + "Song: ": "गाना: ", + "channel_tab_podcasts_label": "पाॅडकास्ट", + "channel_tab_releases_label": "रिलीज़ेस्", + "Import YouTube playlist (.csv)": "YouTube प्लेलिस्ट (.csv) आयात करें", + "Standard YouTube license": "मानक यूट्यूब लाइसेंस", + "Channel Sponsor": "चैनल प्रायोजक", + "Download is disabled": "डाउनलोड करना अक्षम है", + "generic_channels_count": "{{count}} चैनल", + "generic_channels_count_plural": "{{count}} चैनल", + "Import YouTube watch history (.json)": "YouTube पर देखने का इतिहास आयात करें (.json)", + "Add to playlist": "प्लेलिस्ट में जोड़ें", + "Answer": "जवाब", + "The Popular feed has been disabled by the administrator.": "लोकप्रिय फ़ीड व्यवस्थापक द्वारा अक्षम कर दिया गया है।", + "toggle_theme": "थीम टॉगल करें", + "carousel_slide": "{{total}} में से स्लाइड {{current}}", + "carousel_skip": "कैरोसेल छोड़ें", + "Add to playlist: ": "प्लेलिस्ट में जोड़ें: ", + "Search for videos": "वीडियो खोजें", + "carousel_go_to": "स्लाइड `x` पर जाएँ" +} diff --git a/locales/hr.json b/locales/hr.json index 2142a052..7b76a41f 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -1,47 +1,34 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pretplatnika.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` pretplatnika." - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videa.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` videa." - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playliste.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` playliste." - }, "LIVE": "UŽIVO", "Shared `x` ago": "Dijeljeno prije `x`", "Unsubscribe": "Odjavi pretplatu", "Subscribe": "Pretplati se", "View channel on YouTube": "Prikaži kanal na YouTubeu", - "View playlist on YouTube": "Prikaži playlistu na YouTubeu", + "View playlist on YouTube": "Prikaži zbirku na YouTubeu", "newest": "najnovije", "oldest": "najstarije", - "popular": "popularni", - "last": "zadnji", + "popular": "popularne", + "last": "zadnje", "Next page": "Sljedeća stranica", "Previous page": "Prethodna stranica", "Clear watch history?": "Izbrisati povijest gledanja?", "New password": "Nova lozinka", "New passwords must match": "Nove lozinke se moraju poklapati", - "Cannot change password for Google accounts": "Nije moguće promijeniti lozinku za Google račune", "Authorize token?": "Autorizirati token?", "Authorize token for `x`?": "Autorizirati token za `x`?", "Yes": "Da", "No": "Ne", "Import and Export Data": "Uvezi i izvezi podatke", "Import": "Uvezi", - "Import Invidious data": "Uvezi Invidious podatke", - "Import YouTube subscriptions": "Uvezi YouTube pretplate", + "Import Invidious data": "Uvezi Invidious JSON podatke", + "Import YouTube subscriptions": "Uvezi YouTube CSV ili OPML pretplate", "Import FreeTube subscriptions (.db)": "Uvezi FreeTube pretplate (.db)", "Import NewPipe subscriptions (.json)": "Uvezi NewPipe pretplate (.json)", "Import NewPipe data (.zip)": "Uvezi NewPipe podatke (.zip)", "Export": "Izvezi", "Export subscriptions as OPML": "Izvezi pretplate kao OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Izvezi pretplate kao OPML (za NewPipe i FreeTube)", - "Export data as JSON": "Izvezi podatke kao JSON", + "Export data as JSON": "Izvezi Invidious podatke kao JSON", "Delete account?": "Izbrisati račun?", "History": "Povijest", "An alternative front-end to YouTube": "Alternativa za YouTube", @@ -49,47 +36,48 @@ "source": "izvor", "Log in": "Prijavi se", "Log in/register": "Prijavi se/registriraj se", - "Log in with Google": "Prijavi se pomoću Googlea", "User ID": "Korisnički ID", "Password": "Lozinka", "Time (h:mm:ss):": "Vrijeme (h:mm:ss):", "Text CAPTCHA": "Tekstualni CAPTCHA", "Image CAPTCHA": "Slikovni CAPTCHA", - "Sign In": "Prijava", + "Sign In": "Prijavi se", "Register": "Registriraj se", - "E-mail": "E-mail", - "Google verification code": "Googleov potvrdni kod", + "E-mail": "E-mail adresa", "Preferences": "Postavke", - "Player preferences": "Postavke playera", - "Always loop: ": "Uvijek ponavljaj: ", - "Autoplay: ": "Automatski reproduciraj: ", - "Play next by default: ": "Standardno reproduciraj sljedeći: ", - "Autoplay next video: ": "Automatski reproduciraj sljedeći video: ", - "Listen by default: ": "Standardno slušaj: ", - "Proxy videos: ": "Koristi posrednika videa: ", - "Default speed: ": "Standardna brzina: ", - "Preferred video quality: ": "Primarna kvaliteta videa: ", - "Player volume: ": "Glasnoća playera: ", - "Default comments: ": "Standardni komentari: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "Standardni titlovi: ", + "preferences_category_player": "Postavke playera", + "preferences_video_loop_label": "Uvijek ponavljaj: ", + "preferences_autoplay_label": "Automatski reproduciraj: ", + "preferences_continue_label": "Standardno reproduciraj sljedeći: ", + "preferences_continue_autoplay_label": "Automatski reproduciraj sljedeći video: ", + "preferences_listen_label": "Standardno slušaj: ", + "preferences_local_label": "Koristi posrednika videa: ", + "preferences_speed_label": "Standardna brzina: ", + "preferences_quality_label": "Preferirana kvaliteta videa: ", + "preferences_volume_label": "Glasnoća playera: ", + "preferences_comments_label": "Standardni komentari: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Standardni titlovi: ", "Fallback captions: ": "Alternativni titlovi: ", - "Show related videos: ": "Prikaži povezana videa: ", - "Show annotations by default: ": "Standardno prikaži napomene: ", - "Automatically extend video description: ": "", - "Visual preferences": "Postavke prikaza", - "Player style: ": "Stil playera: ", + "preferences_related_videos_label": "Prikaži povezana videa: ", + "preferences_annotations_label": "Standardno prikaži napomene: ", + "preferences_extend_desc_label": "Automatski proširi opis videa: ", + "preferences_vr_mode_label": "Interaktivna videa od 360 stupnjeva (zahtijeva WebGL): ", + "preferences_category_visual": "Postavke prikaza", + "preferences_player_style_label": "Stil playera: ", "Dark mode: ": "Tamni modus: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "tamno", "light": "svijetlo", - "Thin mode: ": "Pojednostavljen prikaz: ", - "Subscription preferences": "Postavke pretplata", - "Show annotations by default for subscribed channels: ": "Standardno prikaži napomene za pretplaćene kanale: ", + "preferences_thin_mode_label": "Pojednostavljen prikaz: ", + "preferences_category_misc": "Razne postavke", + "preferences_automatic_instance_redirect_label": "Automatsko preusmjeravanje instance (u krajnjem slučaju će se koristiti redirect.invidious.io): ", + "preferences_category_subscription": "Postavke pretplata", + "preferences_annotations_subscribed_label": "Standardno prikaži napomene za pretplaćene kanale: ", "Redirect homepage to feed: ": "Preusmjeri početnu stranicu na feed: ", - "Number of videos shown in feed: ": "Broj prikazanih videa u feedu: ", - "Sort videos by: ": "Razvrstaj videa prema: ", + "preferences_max_results_label": "Broj prikazanih videa u feedu: ", + "preferences_sort_label": "Razvrstaj videa prema: ", "published": "objavljeno", "published - reverse": "objavljeno – obrnuto", "alphabetically": "abecednim redom", @@ -97,13 +85,13 @@ "channel name": "ime kanala", "channel name - reverse": "ime kanala – obrnuto", "Only show latest video from channel: ": "Prikaži samo najnovija videa kanala: ", - "Only show latest unwatched video from channel: ": "Prikaži samo najnovija nepogledana videa kanala: ", - "Only show unwatched: ": "Prikaži samo nepogledane: ", - "Only show notifications (if there are any): ": "Prikaži samo obavijesti (ako ih ima): ", + "Only show latest unwatched video from channel: ": "Prikaži samo najnovija nepogledana videa od kanala: ", + "preferences_unseen_only_label": "Prikaži samo nepogledane: ", + "preferences_notifications_only_label": "Prikaži samo obavijesti (ako ih ima): ", "Enable web notifications": "Aktiviraj web-obavijesti", "`x` uploaded a video": "`x` je poslao/la video", "`x` is live": "`x` je uživo", - "Data preferences": "Postavke podataka", + "preferences_category_data": "Postavke podataka", "Clear watch history": "Izbriši povijest gledanja", "Import/export data": "Uvezi/izvezi podatke", "Change password": "Promijeni lozinku", @@ -111,11 +99,12 @@ "Manage tokens": "Upravljaj tokenima", "Watch history": "Povijest gledanja", "Delete account": "Izbriši račun", - "Administrator preferences": "Postavke administratora", - "Default homepage: ": "Standardna početna stranica: ", - "Feed menu: ": "Izbornik za feedove: ", + "preferences_category_admin": "Postavke administratora", + "preferences_default_home_label": "Standardna početna stranica: ", + "preferences_feed_menu_label": "Izbornik za feedove: ", + "preferences_show_nick_label": "Prikaži nadimak na vrhu: ", "Top enabled: ": "Najbolji aktivirani: ", - "CAPTCHA enabled: ": "Aktivirani CAPTCHA: ", + "CAPTCHA enabled: ": "CAPTCHA aktiviran: ", "Login enabled: ": "Prijava aktivirana: ", "Registration enabled: ": "Registracija aktivirana: ", "Report statistics: ": "Izvještaj o statistici: ", @@ -123,25 +112,13 @@ "Subscription manager": "Upravljanje pretplatama", "Token manager": "Upravljanje tokenima", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pretplate.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` pretplate." - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokena.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` tokena." - }, "Import/export": "Uvezi/izvezi", "unsubscribe": "odjavi pretplatu", "revoke": "opozovi", "Subscriptions": "Pretplate", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` neviđene obavijesti.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` neviđene obavijesti." - }, "search": "traži", "Log out": "Odjavi se", - "Released under the AGPLv3 by Omar Roth.": "Izdano pod licencom AGPLv3, Omar Roth.", + "Released under the AGPLv3 on Github.": "Izdano pod licencom AGPLv3 na GitHub-u.", "Source available here.": "Izvor je ovdje dostupan.", "View JavaScript license information.": "Prikaži informacije o JavaScript licenci.", "View privacy policy.": "Prikaži politiku privatnosti.", @@ -149,17 +126,18 @@ "Public": "Javno", "Unlisted": "Nenavedeno", "Private": "Privatno", - "View all playlists": "Prikaži sve playliste", + "View all playlists": "Prikaži sve zbirke", "Updated `x` ago": "Aktualizirano prije `x`", - "Delete playlist `x`?": "Izbrisati playlistu `x`?", - "Delete playlist": "Izbriši playlistu", - "Create playlist": "Stvori playlistu", + "Delete playlist `x`?": "Izbrisati zbirku `x`?", + "Delete playlist": "Izbriši zbirku", + "Create playlist": "Stvori zbirku", "Title": "Naslov", - "Playlist privacy": "Privatnost playliste", - "Editing playlist `x`": "Uređivanje playliste `x`", - "Show more": "", - "Show less": "", + "Playlist privacy": "Privatnost zbirke", + "Editing playlist `x`": "Uređivanje zbirke `x`", + "Show more": "Prikaži više", + "Show less": "Prikaži manje", "Watch on YouTube": "Gledaj na YouTubeu", + "Switch Invidious Instance": "Promijeni Invidious instancu", "Hide annotations": "Sakrij napomene", "Show annotations": "Prikaži napomene", "Genre: ": "Žanr: ", @@ -170,10 +148,6 @@ "Whitelisted regions: ": "Odobrene regije: ", "Blacklisted regions: ": "Blokirane regije: ", "Shared `x`": "Dijeljeno `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gledanja.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` gledanja." - }, "Premieres in `x`": "Premijera za `x`", "Premieres `x`": "Premijera `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Bok! Izgleda da je JavaScript isključen. Pritisni ovdje za prikaz komentara. Učitavanje će možda trajati malo duže.", @@ -181,23 +155,18 @@ "View more comments on Reddit": "Prikaži još komentara na Redditu", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` komentara.([^.,0-9]|^)1([^.,0-9]|$)", - "": "Prikaži `x` komentara." + "": "Prikaži `x` komentara" }, "View Reddit comments": "Prikaži Reddit komentare", "Hide replies": "Sakrij odgovore", "Show replies": "Prikaži odgovore", "Incorrect password": "Neispravna lozinka", - "Quota exceeded, try again in a few hours": "Kvota je prekoračena. Pokušaj ponovo za par sati", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Prijava neuspjela. Provjeri da je dvofaktorska autentifikacija uključena (Authenticator ili SMS).", - "Invalid TFA code": "Neispravan TFA kod", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Prijava neuspjela. Možda zato što za tvoj račun nije uključena dvofaktorska autentifikacija.", "Wrong answer": "Krivi odgovor", "Erroneous CAPTCHA": "Neispravan CAPTCHA", "CAPTCHA is a required field": "CAPTCHA je obavezno polje", "User ID is a required field": "Korisnički ID je obavezno polje", "Password is a required field": "Polje lozinke je obavezno polje", "Wrong username or password": "Krivo korisničko ime ili lozinka", - "Please sign in using 'Log in with Google'": "Za prijavu koristi „Prijavi se pomoću Googlea”", "Password cannot be empty": "Polje lozinke ne smije ostati prazno", "Password cannot be longer than 55 characters": "Lozinka ne može biti duža od 55 znakova", "Please log in": "Prijavi se", @@ -207,20 +176,12 @@ "This channel does not exist.": "Ovaj kanal ne postoji.", "Could not get channel info.": "Neuspjelo dobivanje podataka kanala.", "Could not fetch comments": "Neuspjelo dohvaćanje komentara", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Prikaži `x` odgovora.([^.,0-9]|^)1([^.,0-9]|$)", - "": "Prikaži `x` odgovora." - }, "`x` ago": "prije `x`", "Load more": "Učitaj više", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bodova.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` bodova." - }, "Could not create mix.": "Neuspjelo stvaranje miksa.", - "Empty playlist": "Prazna playlista", - "Not a playlist.": "Nije playlista.", - "Playlist does not exist.": "Playlista ne postoji.", + "Empty playlist": "Prazna zbirka", + "Not a playlist.": "Nije zbirka.", + "Playlist does not exist.": "Zbirka ne postoji.", "Could not pull trending pages.": "Neuspjelo preuzimanje stranica u trendu.", "Hidden field \"challenge\" is a required field": "Skriveno polje „izazov” je obavezno polje", "Hidden field \"token\" is a required field": "Skriveno polje „token” je obavezno polje", @@ -334,42 +295,14 @@ "Yiddish": "Jidiš", "Yoruba": "Jorubški", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` g.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` g." - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mj.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` mj." - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tj.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` tj." - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dana.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` dana." - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` h.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` h." - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` min.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` min." - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` s.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` s." - }, "Fallback comments: ": "Alternativni komentari: ", "Popular": "Popularni", "Search": "Traži", "Top": "Najbolji", "About": "Informacije", "Rating: ": "Ocjena: ", - "Language: ": "Jezik: ", - "View as playlist": "Prikaži kao playlistu", + "preferences_locale_label": "Jezik: ", + "View as playlist": "Prikaži kao zbirku", "Default": "Standardno", "Music": "Glazba", "Gaming": "Videoigre", @@ -379,40 +312,206 @@ "Download as: ": "Preuzmi kao: ", "%A %B %-d, %Y": "%A, %-d. %B %Y.", "(edited)": "(uređeno)", - "YouTube comment permalink": "Permalink YouTube komentara", - "permalink": "permalink", + "YouTube comment permalink": "Stalna poveznica YouTube komentara", + "permalink": "stalna poveznica", "`x` marked it with a ❤": "Označeno sa ❤ od `x`", "Audio mode": "Audio modus", "Video mode": "Videomodus", - "Videos": "Videa", - "Playlists": "Playliste", - "Community": "Zajednica", - "relevance": "značaj", - "rating": "ocjena", - "date": "datum", - "views": "prikazi", - "content_type": "vrsta_sadržaja", - "duration": "trajanje", - "features": "funkcije", - "sort": "redoslijed", - "hour": "sat", - "today": "danas", - "week": "tjedan", - "month": "mjesec", - "year": "godina", - "video": "video", - "channel": "kanal", - "playlist": "playlista", - "movie": "film", - "show": "emisija", - "hd": "hd", - "subtitles": "titlovi", - "creative_commons": "creative_commons", - "3d": "3d", - "live": "uživo", - "4k": "4k", - "location": "lokacija", - "hdr": "hdr", - "filter": "filtar", - "Current version: ": "Trenutačna verzija: " -}
\ No newline at end of file + "channel_tab_videos_label": "Videa", + "Playlists": "Zbirke", + "channel_tab_community_label": "Zajednica", + "search_filters_sort_option_relevance": "Značaj", + "search_filters_sort_option_rating": "Ocjena", + "search_filters_sort_option_date": "Datum prijenosa", + "search_filters_sort_option_views": "Broj gledanja", + "search_filters_type_label": "Vrsta", + "search_filters_duration_label": "Trajanje", + "search_filters_features_label": "Funkcije", + "search_filters_sort_label": "Redoslijed", + "search_filters_date_option_hour": "Zadnjih sat vremena", + "search_filters_date_option_today": "Danas", + "search_filters_date_option_week": "Ovaj tjedan", + "search_filters_date_option_month": "Ovaj mjesec", + "search_filters_date_option_year": "Ova godina", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Kanal", + "search_filters_type_option_playlist": "Zbirka", + "search_filters_type_option_movie": "Film", + "search_filters_type_option_show": "Emisija", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Titlovi/CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Uživo", + "search_filters_features_option_four_k": "4k", + "search_filters_features_option_location": "Lokacija", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Trenutačna verzija: ", + "next_steps_error_message": "Nakon toga bi trebali pokušati sljedeće: ", + "next_steps_error_message_refresh": "Aktualiziraj stranicu", + "next_steps_error_message_go_to_youtube": "Idi na YouTube", + "footer_donate_page": "Doniraj", + "adminprefs_modified_source_code_url_label": "URL do repozitorija prilagođenog izvornog koda", + "search_filters_duration_option_short": "Kratko (< 4 minute)", + "search_filters_duration_option_long": "Dugo (> 20 minute)", + "footer_source_code": "Izvorni kod", + "footer_modfied_source_code": "Prilagođen izvorni kod", + "footer_documentation": "Dokumentacija", + "footer_original_source_code": "Prvobitan izvorni kod", + "preferences_region_label": "Zemlja sadržaja: ", + "preferences_quality_dash_label": "Preferirana DASH videokvaliteta: ", + "preferences_quality_option_dash": "DASH (adaptativna kvaliteta)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Srednja", + "preferences_quality_dash_option_worst": "Najgora", + "preferences_quality_dash_option_4320p": "4320 p", + "preferences_quality_dash_option_2160p": "2160 p", + "preferences_quality_dash_option_1440p": "1440 p", + "preferences_quality_dash_option_1080p": "1080 p", + "preferences_quality_dash_option_360p": "360 p", + "preferences_quality_dash_option_240p": "240 p", + "preferences_quality_dash_option_144p": "144 p", + "invidious": "Invidious", + "search_filters_features_option_purchased": "Kupljeno", + "search_filters_features_option_three_sixty": "360 °", + "none": "bez", + "videoinfo_youTube_embed_link": "Ugradi", + "user_created_playlists": "`x` je stvorio/la zbirke", + "user_saved_playlists": "`x` je spremio/la zbirke", + "Video unavailable": "Video nedostupan", + "preferences_save_player_pos_label": "Spremi mjesto reprodukcije: ", + "videoinfo_watch_on_youTube": "Gledaj na YouTubeu", + "download_subtitles": "Podnaslovi - `x` (.vtt)", + "preferences_quality_dash_option_auto": "Automatska", + "preferences_quality_option_small": "Niska", + "preferences_quality_dash_option_best": "Najbolja", + "preferences_quality_dash_option_720p": "720 p", + "preferences_quality_dash_option_480p": "480 p", + "videoinfo_started_streaming_x_ago": "Započet prijenos prije `x`", + "videoinfo_invidious_embed_link": "Ugradi poveznicu", + "generic_count_hours_0": "{{count}} sat", + "generic_count_hours_1": "{{count}} sata", + "generic_count_hours_2": "{{count}} sati", + "generic_subscribers_count_0": "{{count}} pretplatnik", + "generic_subscribers_count_1": "{{count}} pretplatnika", + "generic_subscribers_count_2": "{{count}} pretplatnika", + "tokens_count_0": "{{count}} token", + "tokens_count_1": "{{count}} tokena", + "tokens_count_2": "{{count}} tokena", + "subscriptions_unseen_notifs_count_0": "{{count}} neviđena obavijest", + "subscriptions_unseen_notifs_count_1": "{{count}} neviđene obavijesti", + "subscriptions_unseen_notifs_count_2": "{{count}} neviđenih obavijesti", + "generic_count_years_0": "{{count}} godina", + "generic_count_years_1": "{{count}} godine", + "generic_count_years_2": "{{count}} godina", + "generic_count_months_0": "{{count}} mjesec", + "generic_count_months_1": "{{count}} mjeseca", + "generic_count_months_2": "{{count}} mjeseci", + "generic_count_weeks_0": "{{count}} tjedan", + "generic_count_weeks_1": "{{count}} tjedna", + "generic_count_weeks_2": "{{count}} tjedana", + "generic_count_minutes_0": "{{count}} minuta", + "generic_count_minutes_1": "{{count}} minute", + "generic_count_minutes_2": "{{count}} minuta", + "generic_count_seconds_0": "{{count}} sekunda", + "generic_count_seconds_1": "{{count}} sekunde", + "generic_count_seconds_2": "{{count}} sekundi", + "comments_points_count_0": "{{count}} točka", + "comments_points_count_1": "{{count}} točke", + "comments_points_count_2": "{{count}} točaka", + "generic_subscriptions_count_0": "{{count}} pretplata", + "generic_subscriptions_count_1": "{{count}} pretplate", + "generic_subscriptions_count_2": "{{count}} pretplata", + "generic_playlists_count_0": "{{count}} zbirka", + "generic_playlists_count_1": "{{count}} zbirke", + "generic_playlists_count_2": "{{count}} zbiraka", + "generic_videos_count_0": "{{count}} video", + "generic_videos_count_1": "{{count}} videa", + "generic_videos_count_2": "{{count}} videa", + "generic_count_days_0": "{{count}} dan", + "generic_count_days_1": "{{count}} dana", + "generic_count_days_2": "{{count}} dana", + "generic_views_count_0": "{{count}} prikaz", + "generic_views_count_1": "{{count}} prikaza", + "generic_views_count_2": "{{count}} prikaza", + "comments_view_x_replies_0": "Prikaži {{count}} odgovor", + "comments_view_x_replies_1": "Prikaži {{count}} odgovora", + "comments_view_x_replies_2": "Prikaži {{count}} odgovora", + "crash_page_you_found_a_bug": "Čini se da si pronašao/la grešku u Invidiousu!", + "crash_page_before_reporting": "Prije prijavljivanja greške:", + "crash_page_refresh": "pokušaj <a href=\"`x`\">aktualizirati stranicu</a>", + "crash_page_switch_instance": "pokušaj <a href=\"`x`\">koristiti jednu drugu instancu</a>", + "crash_page_read_the_faq": "pročitaj <a href=\"`x`\">Često postavljena pitanja (ČPP)</a>", + "crash_page_search_issue": "pretraži <a href=\"`x`\">postojeće probleme na GitHub-u</a>", + "crash_page_report_issue": "Ako ništa od gore navedenog ne pomaže, <a href=\"`x`\">prijavi novi problem na GitHub-u</a> (po mogućnosti na engleskom) i uključi sljedeći tekst u poruku (NEMOJ prevoditi taj tekst):", + "English (United Kingdom)": "Engleski (Ujedinjeno Kraljevstvo)", + "English (United States)": "Engleski (Sjedinjene Američke Države)", + "Cantonese (Hong Kong)": "Kantonski (Hong Kong)", + "Chinese": "Kineski", + "Chinese (Taiwan)": "Kineski (Tajvan)", + "Dutch (auto-generated)": "Nizozemski (automatski generirano)", + "French (auto-generated)": "Francuski (automatski generirano)", + "Indonesian (auto-generated)": "Indonezijski (automatski generirano)", + "Interlingue": "Interlingua", + "Japanese (auto-generated)": "Japanski (automatski generirano)", + "Russian (auto-generated)": "Ruski (automatski generirano)", + "Turkish (auto-generated)": "Turski (automatski generirano)", + "Vietnamese (auto-generated)": "Vijetnamski (automatski generirano)", + "Spanish (Spain)": "Španjolski (Španjolska)", + "Italian (auto-generated)": "Talijanski (automatski generirano)", + "Portuguese (Brazil)": "Portugalski (Brazil)", + "Spanish (Mexico)": "Španjolski (Meksiko)", + "German (auto-generated)": "Njemački (automatski generirano)", + "Chinese (China)": "Kineski (Kina)", + "Chinese (Hong Kong)": "Kineski (Hong Kong)", + "Korean (auto-generated)": "Korejski (automatski generirano)", + "Portuguese (auto-generated)": "Portugalski (automatski generirano)", + "Spanish (auto-generated)": "Španjolski (automatski generirano)", + "preferences_watch_history_label": "Aktiviraj povijest gledanja: ", + "search_filters_title": "Filtri", + "search_filters_date_option_none": "Bilo koji datum", + "search_filters_date_label": "Datum prijenosa", + "search_message_no_results": "Nema rezultata.", + "search_message_use_another_instance": "Također možeš <a href=\"`x`\">tražiti na jednoj drugoj instanci</a>.", + "search_message_change_filters_or_query": "Pokušaj proširiti upit za pretragu i/ili promijeni filtre.", + "search_filters_features_option_vr180": "VR180", + "search_filters_duration_option_none": "Bilo koje duljine", + "search_filters_duration_option_medium": "Srednje (4 – 20 minuta)", + "search_filters_apply_button": "Primijeni odabrane filtre", + "search_filters_type_option_all": "Bilo koja vrsta", + "Popular enabled: ": "Popularni aktivirani: ", + "error_video_not_in_playlist": "Traženi video ne postoji u ovoj zbirci. <a href=\"`x`\">Pritisni ovdje za početnu stranicu zbirke.</a>", + "channel_tab_streams_label": "Prijenosi uživo", + "channel_tab_playlists_label": "Zbirke", + "channel_tab_channels_label": "Kanali", + "channel_tab_shorts_label": "Kratka videa", + "Music in this video": "Glazba u ovom videu", + "Album: ": "Album: ", + "Artist: ": "Izvođač: ", + "Channel Sponsor": "Sponzor kanala", + "Song: ": "Pjesma: ", + "Standard YouTube license": "Standardna YouTube licenca", + "Download is disabled": "Preuzimanje je deaktivirano", + "Import YouTube playlist (.csv)": "Uvezi YouTube zbirku (.csv)", + "generic_button_delete": "Izbriši", + "playlist_button_add_items": "Dodaj videa", + "channel_tab_podcasts_label": "Podcasti", + "generic_button_edit": "Uredi", + "generic_button_save": "Spremi", + "generic_button_cancel": "Odustani", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "Izdanja", + "generic_channels_count_0": "{{count}} kanal", + "generic_channels_count_1": "{{count}} kanala", + "generic_channels_count_2": "{{count}} kanala", + "Import YouTube watch history (.json)": "Uvezi YouTube povijest gledanja (.json)", + "Add to playlist": "Dodaj u zbirku", + "Add to playlist: ": "Dodaj u zbirku: ", + "Answer": "Odgovor", + "Search for videos": "Traži videa", + "The Popular feed has been disabled by the administrator.": "Popularni feed je administrator deaktivirao.", + "toggle_theme": "Uklj./Isklj. temu", + "carousel_slide": "Kadar {{current}} od {{total}}", + "carousel_go_to": "Idi na kadar `x`", + "carousel_skip": "Preskoči vrtuljak" +} diff --git a/locales/hu-HU.json b/locales/hu-HU.json index a8c9cae1..8fbdd82f 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -1,339 +1,486 @@ { - "`x` subscribers": "`x` feliratkozó", - "`x` videos": "`x` videó", - "`x` playlists": "`x` playlist", + "generic_views_count": "{{count}} már látta", + "generic_views_count_plural": "{{count}} már látta", + "generic_videos_count": "{{count}} videó", + "generic_videos_count_plural": "{{count}} videó", + "generic_playlists_count": "{{count}} lejátszási lista", + "generic_playlists_count_plural": "{{count}} lejátszási lista", + "generic_subscribers_count": "{{count}} feliratkozó", + "generic_subscribers_count_plural": "{{count}} feliratkozó", + "generic_subscriptions_count": "{{count}} csatornára van feliratkozás", + "generic_subscriptions_count_plural": "{{count}} csatornára van feliratkozás", "LIVE": "ÉLŐ", - "Shared `x` ago": "`x` óta megosztva", + "Shared `x` ago": "`x` ezelőtt megosztva", "Unsubscribe": "Leiratkozás", "Subscribe": "Feliratkozás", - "View channel on YouTube": "Csatokrna megtekintése a YouTube-on", - "View playlist on YouTube": "Playlist megtekintése a YouTube-on", + "View channel on YouTube": "Csatorna megnézése YouTube-on", + "View playlist on YouTube": "Lejátszási lista megnézése YouTube-on", "newest": "legújabb", "oldest": "legrégibb", "popular": "népszerű", "last": "utolsó", "Next page": "Következő oldal", "Previous page": "Előző oldal", - "Clear watch history?": "Megtekintési napló törlése?", + "Clear watch history?": "Törölve legyen a megnézett videók naplója?", "New password": "Új jelszó", - "New passwords must match": "Az új jelszavaknak egyezniük kell", - "Cannot change password for Google accounts": "Google fiók jelszavát nem lehet cserélni", - "Authorize token?": "Token felhatalmazása?", - "Authorize token for `x`?": "Token felhatalmazása `x`-ra?", + "New passwords must match": "Az új jelszavaknak egyezniük kell.", + "Authorize token?": "Engedélyezve legyen a token?", + "Authorize token for `x`?": "Engedélyezve legyen a token erre? „`x`”", "Yes": "Igen", "No": "Nem", "Import and Export Data": "Adatok importálása és exportálása", "Import": "Importálás", - "Import Invidious data": "Invidious adatainak importálása", - "Import YouTube subscriptions": "YouTube feliratkozások importálása", - "Import FreeTube subscriptions (.db)": "FreeTube feliratkozások importálása (.db)", - "Import NewPipe subscriptions (.json)": "NewPipe feliratkozások importálása (.json)", + "Import Invidious data": "Az Invidious JSON-adatainak importálása", + "Import YouTube subscriptions": "YouTube- vagy OPML-feliratkozások importálása", + "Import FreeTube subscriptions (.db)": "FreeTube-feliratkozások importálása (.db)", + "Import NewPipe subscriptions (.json)": "NewPipe-feliratkozások importálása (.json)", "Import NewPipe data (.zip)": "NewPipe adatainak importálása (.zip)", "Export": "Exportálás", "Export subscriptions as OPML": "Feliratkozások exportálása OPML-ként", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Feliratkozások exportálása OPML-ként (NewPipe és FreeTube számára)", - "Export data as JSON": "Adat exportálása JSON-ként", - "Delete account?": "Fiók törlése?", - "History": "Megtekintési napló", - "An alternative front-end to YouTube": "Alternatív YouTube front-end", - "JavaScript license information": "JavaScript licensz információ", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Feliratkozások exportálása OPML-ként (NewPipe-hoz és FreeTube-hoz)", + "Export data as JSON": "Az Invidious JSON-adatainak exportálása", + "Delete account?": "Törlésre kerüljön a fiók?", + "History": "Megnézett videók naplója", + "An alternative front-end to YouTube": "Ez az oldal egyike a YouTube alternatív kezelőfelületeinek", + "JavaScript license information": "A JavaScript licencinformációja", "source": "forrás", "Log in": "Bejelentkezés", - "Log in/register": "Bejelentkezés/Regisztráció", - "Log in with Google": "Bejelentkezés Google fiókkal", - "User ID": "Felhasználó-ID", + "Log in/register": "Bejelentkezés/Regisztrálás", + "User ID": "Felhasználói azonosító", "Password": "Jelszó", - "Time (h:mm:ss):": "Idő (h:mm:ss):", - "Text CAPTCHA": "Szöveg-CAPTCHA", - "Image CAPTCHA": "Kép-CAPTCHA", + "Time (h:mm:ss):": "A pontos idő (ó:pp:mm):", + "Text CAPTCHA": "Szöveges CAPTCHA kérése", + "Image CAPTCHA": "Kép CAPTCHA kérése", "Sign In": "Bejelentkezés", - "Register": "Regisztráció", - "E-mail": "E-mail", - "Google verification code": "Google verifikációs kód", + "Register": "Regisztrálás", + "E-mail": "E-mail-cím", "Preferences": "Beállítások", - "Player preferences": "Lejátszó beállítások", - "Always loop: ": "Mindig loop-ol: ", - "Autoplay: ": "Automatikus lejátszás: ", - "Play next by default: ": "Következő lejátszása alapértelmezésben: ", - "Autoplay next video: ": "Következő automatikus lejátszása: ", - "Listen by default: ": "Hallgatás alapértelmezésben: ", - "Proxy videos: ": "Proxy videók: ", - "Default speed: ": "Alapértelmezett sebesség: ", - "Preferred video quality: ": "Kívánt video minőség: ", - "Player volume: ": "Hangerő: ", - "Default comments: ": "Alapértelmezett kommentek: ", + "preferences_category_player": "Lejátszó beállításai", + "preferences_video_loop_label": "Videó állandó ismétlése: ", + "preferences_autoplay_label": "Automatikus lejátszás: ", + "preferences_continue_label": "A következő videót mindig automatikusan játssza le: ", + "preferences_continue_autoplay_label": "A következő videó automatikus lejátszása: ", + "preferences_listen_label": "Mindig csak a hangsáv lejátszása: ", + "preferences_local_label": "Videók proxyn keresztüli lejátszása: ", + "preferences_speed_label": "Alapértelmezett sebesség: ", + "preferences_quality_label": "Videó minősége: ", + "preferences_volume_label": "Hangerő: ", + "preferences_comments_label": "Mindig innen legyenek betöltve a hozzászólások: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Alapértelmezett feliratok: ", + "preferences_captions_label": "Felirat nyelvének sorrendje: ", "Fallback captions: ": "Másodlagos feliratok: ", - "Show related videos: ": "Kapcsolódó videók mutatása: ", - "Show annotations by default: ": "Annotációk mutatása alapértelmetésben: ", - "Automatically extend video description: ": "", - "Visual preferences": "Vizuális preferenciák", - "Player style: ": "Lejátszó stílusa: ", - "Dark mode: ": "Sötét mód: ", - "Theme: ": "Téma: ", - "dark": "Sötét", - "light": "Világos", - "Thin mode: ": "Vékony mód: ", - "Subscription preferences": "Feliratkozási beállítások", - "Show annotations by default for subscribed channels: ": "Annotációk mutatása alapértelmezésben feliratkozott csatornák esetében: ", - "Redirect homepage to feed: ": "Kezdő oldal átirányitása a feed-re: ", - "Number of videos shown in feed: ": "Feed-ben mutatott videók száma: ", - "Sort videos by: ": "Videók sorrendje: ", - "published": "közzétéve", - "published - reverse": "közzétéve (ford.)", - "alphabetically": "ABC sorrend", - "alphabetically - reverse": "ABC sorrend (ford.)", - "channel name": "csatorna neve", - "channel name - reverse": "csatorna neve (ford.)", - "Only show latest video from channel: ": "Csak a legutolsó videó mutatása a csatornából: ", - "Only show latest unwatched video from channel: ": "Csak a legutolsó nem megtekintett videó mutatása a csatornából: ", - "Only show unwatched: ": "Csak a nem megtekintettek mutatása: ", - "Only show notifications (if there are any): ": "Csak értesítések mutatása (ha van): ", - "Enable web notifications": "Web értesítések bekapcsolása", + "preferences_related_videos_label": "Hasonló videók ajánlása: ", + "preferences_annotations_label": "Szövegmagyarázat alapértelmezett mutatása: ", + "preferences_extend_desc_label": "A videó leírása automatikusan látható: ", + "preferences_vr_mode_label": "Interaktív 360 fokos videók (WebGL szükséges): ", + "preferences_category_visual": "Kinézet, elrendezés és régió beállításai", + "preferences_player_style_label": "Lejátszó kinézete: ", + "Dark mode: ": "Elsötétített mód: ", + "preferences_dark_mode_label": "Téma: ", + "dark": "sötét", + "light": "világos", + "preferences_thin_mode_label": "Vékony mód: ", + "preferences_category_subscription": "Feliratkozott tartalmak beállításai", + "preferences_annotations_subscribed_label": "A feliratkozott csatornák szövegmagyarázatának alapértelmezett mutatása: ", + "Redirect homepage to feed: ": "Kezdőoldal átirányitása a feedre: ", + "preferences_max_results_label": "Feedben mutatott videók száma: ", + "preferences_sort_label": "Videók rendezése: ", + "published": "közzététel szerint", + "published - reverse": "közzététel szerint – fordított sorrendben", + "alphabetically": "ABC-sorrend szerint", + "alphabetically - reverse": "Fordított ABC-sorrend szerint", + "channel name": "csatorna neve szerint", + "channel name - reverse": "csatorna neve szerint – fordított sorrendben", + "Only show latest video from channel: ": "Csak a csatorna legújabb videójának mutatása: ", + "Only show latest unwatched video from channel: ": "Csak a csatorna legújabb, de még nem megnézett videójának mutatása: ", + "preferences_unseen_only_label": "A még nem megnézett videók mutatása: ", + "preferences_notifications_only_label": "Csak az értesítések mutatása (ha van): ", + "Enable web notifications": "Böngészőn belüli értesítések bekapcsolása", "`x` uploaded a video": "`x` feltöltött egy videót", - "`x` is live": "`x` élő", - "Data preferences": "Adat beállítások", - "Clear watch history": "Megtekintési napló törlése", - "Import/export data": "Adat Import/Export", - "Change password": "Jelszócsere", + "`x` is live": "`x` élőben közvetít", + "preferences_category_data": "Fiók beállításai és egyéb lehetőségek", + "Clear watch history": "Megnézett videók naplójának törlése", + "Import/export data": "Adatok importálása vagy exportálása", + "Change password": "Jelszó megváltoztatása", "Manage subscriptions": "Feliratkozások kezelése", "Manage tokens": "Tokenek kezelése", - "Watch history": "Megtekintési napló", + "Watch history": "Megnézett videók naplója", "Delete account": "Fiók törlése", - "Administrator preferences": "Adminisztrátor beállítások", - "Default homepage: ": "Alapértelmezett honlap: ", - "Feed menu: ": "Feed menü: ", - "Top enabled: ": "Top lista engedélyezve: ", + "preferences_category_admin": "Adminisztrátorok beállításai", + "preferences_default_home_label": "Kezdőoldal: ", + "preferences_feed_menu_label": "Feed menü sorrendje: ", + "Top enabled: ": "Toplista engedélyezve: ", "CAPTCHA enabled: ": "CAPTCHA engedélyezve: ", "Login enabled: ": "Bejelentkezés engedélyezve: ", - "Registration enabled: ": "Registztráció engedélyezve: ", - "Report statistics: ": "Statisztikák gyűjtése: ", + "Registration enabled: ": "Regisztrálás engedélyezve: ", + "Report statistics: ": "Statisztika jelentése: ", "Save preferences": "Beállítások mentése", - "Subscription manager": "Feliratkozás kezelő", - "Token manager": "Token kezelő", + "Subscription manager": "Feliratkozások kezelője", + "Token manager": "Tokenek kezelője", "Token": "Token", - "`x` subscriptions": "`x` feliratkozás", - "`x` tokens": "`x` token", - "Import/export": "Import/export", + "tokens_count": "{{count}} token", + "tokens_count_plural": "{{count}} token", + "Import/export": "Importálás/exportálás", "unsubscribe": "leiratkozás", "revoke": "visszavonás", "Subscriptions": "Feliratkozások", - "`x` unseen notifications": "`x` kimaradt érdesítés", - "search": "keresés", + "subscriptions_unseen_notifs_count": "{{count}} kimaradt értesítés", + "subscriptions_unseen_notifs_count_plural": "{{count}} kimaradt értesítés", + "search": "Videó keresése", "Log out": "Kijelentkezés", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth által release-elve AGPLv3 licensz alatt.", - "Source available here.": "Forrás elérhető itt.", - "View JavaScript license information.": "JavaScript licensz inforkációk megtekintése.", - "View privacy policy.": "Adatvédelem irányelv megtekintése.", - "Trending": "Trending", - "Public": "Nyilvános", - "Unlisted": "Nem nyilvános", - "Private": "Privát", - "View all playlists": "Minden playlist megtekintése", - "Updated `x` ago": "Frissitve `x`", - "Delete playlist `x`?": "`x` playlist törlése?", - "Delete playlist": "Playlist törlése", - "Create playlist": "Playlist létrehozása", - "Title": "Címe", - "Playlist privacy": "Playlist láthatósága", - "Editing playlist `x`": "`x` playlist szerkesztése", - "Show more": "", - "Show less": "", - "Watch on YouTube": "Megtekintés a YouTube-on", - "Hide annotations": "Annotációk elrejtése", - "Show annotations": "Annotációk mutatása", - "Genre: ": "Zsáner: ", - "License: ": "Licensz: ", + "Source available here.": "A forráskód itt érhető el", + "View JavaScript license information.": "JavaScript licencinformáció megnyitása", + "View privacy policy.": "Adatvédelmi szabályzat megnyitása", + "Trending": "Felkapott", + "Public": "nyilvános", + "Unlisted": "nem nyilvános", + "Private": "magán", + "View all playlists": "Összes lejátszási lista megnézése", + "Updated `x` ago": "`x` ezelőtt frissítve", + "Delete playlist `x`?": "Törlésre kerüljön ez a lejátszási lista? „`x`”", + "Delete playlist": "Lejátszási lista törlése", + "Create playlist": "Lejátszási lista létrehozása", + "Title": "Lejátszási lista címe", + "Playlist privacy": "Lejátszási lista láthatósága", + "Editing playlist `x`": "„`x`” lejátszási lista szerkesztése", + "Show more": "Többi szöveg mutatása", + "Show less": "Kevesebb szöveg mutatása", + "Watch on YouTube": "YouTube-on megnézni", + "Hide annotations": "Megjegyzések elrejtése", + "Show annotations": "Megjegyzések mutatása", + "Genre: ": "Műfaj: ", + "License: ": "Licenc: ", "Family friendly? ": "Családbarát? ", - "Wilson score: ": "Wilson-ponstszém: ", - "Engagement: ": "Engagement: ", + "Wilson score: ": "Wilson-pontszám: ", + "Engagement: ": "Visszajelzési mutató: ", "Whitelisted regions: ": "Engedélyezett régiók: ", "Blacklisted regions: ": "Tiltott régiók: ", - "Shared `x`": "Megosztva `x`", - "`x` views": "`x` megtekintés", - "Premieres in `x`": "Premier `x`", - "Premieres `x`": "Premier `x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.", - "View YouTube comments": "YouTube kommentek megtekintése", - "View more comments on Reddit": "További Reddit kommentek megtekintése", - "View `x` comments": "`x` komment megtekintése", - "View Reddit comments": "Reddit kommentek megtekintése", + "Shared `x`": "`x` dátummal osztották meg", + "Premieres in `x`": "`x` később lesz a premierje", + "Premieres `x`": "`x` lesz a premierje", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Helló! Úgy tűnik a JavaScript ki van kapcsolva a böngészőben. Ide kattintva lehet olvasni a hozzászólásokat, de a betöltésük így kicsit több időbe telik.", + "View YouTube comments": "YouTube-on lévő hozzászólások olvasása", + "View more comments on Reddit": "A többi hozzászólás olvasása Redditen", + "View `x` comments": { + "": "`x` hozzászólás olvasása", + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hozzászólás olvasása" + }, + "View Reddit comments": "Redditen lévő hozzászólások olvasása", "Hide replies": "Válaszok elrejtése", "Show replies": "Válaszok mutatása", - "Incorrect password": "Helytelen jelszó", - "Quota exceeded, try again in a few hours": "Kvóta túllépve, próbálkozz pár órával később", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Sikertelen belépés, győződj meg róla hogy a 2FA (Authenticator vagy SMS) engedélyezve van.", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Sikertelen belépés, győződj meg róla hogy a 2FA (Authenticator vagy SMS) engedélyezve van.", - "Wrong answer": "Rossz válasz", - "Erroneous CAPTCHA": "Hibás CAPTCHA", - "CAPTCHA is a required field": "A CAPTCHA kötelező", - "User ID is a required field": "A felhasználó-ID kötelező", - "Password is a required field": "A jelszó kötelező", - "Wrong username or password": "Rossz felhasználónév vagy jelszó", - "Please sign in using 'Log in with Google'": "Kérem, jelentkezzen be a \"Bejelentkezés Google-el\"", - "Password cannot be empty": "A jelszó nem lehet üres", - "Password cannot be longer than 55 characters": "A jelszó nem lehet hosszabb 55 betűnél", - "Please log in": "Kérem lépjen be", - "Invidious Private Feed for `x`": "`x` Invidious privát feed-je", - "channel:`x`": "`x` csatorna", - "Deleted or invalid channel": "Törölt vagy nemlétező csatorna", - "This channel does not exist.": "Ez a csatorna nem létezik.", - "Could not get channel info.": "Nem megszerezhető a csatorna információ.", - "Could not fetch comments": "Nem megszerezhetőek a kommentek", - "View `x` replies": "`x` válasz megtekintése", - "`x` ago": "`x` óta", - "Load more": "További betöltése", - "`x` points": "`x` pont", - "Could not create mix.": "Nem tudok mix-et készíteni.", - "Empty playlist": "Üres playlist", - "Not a playlist.": "Nem playlist.", - "Playlist does not exist.": "Nem létező playlist.", - "Could not pull trending pages.": "Nem tudom letölteni a trendek adatait.", - "Hidden field \"challenge\" is a required field": "A rejtett \"challenge\" mező kötelező", - "Hidden field \"token\" is a required field": "A rejtett \"token\" mező kötelező", + "Incorrect password": "A jelszó nem megfelelő", + "Wrong answer": "Nem jól válaszoltál.", + "Erroneous CAPTCHA": "A CAPTCHA hibás.", + "CAPTCHA is a required field": "A CAPTCHA-mezőt ki kell tölteni.", + "User ID is a required field": "A felhasználói azonosítót meg kell adni.", + "Password is a required field": "Meg kell adni egy jelszót.", + "Wrong username or password": "Vagy a felhasználói név, vagy pedig a jelszó nem megfelelő.", + "Password cannot be empty": "A jelszót nem lehet kihagyni.", + "Password cannot be longer than 55 characters": "A jelszó nem lehet hosszabb 55 karakternél.", + "Please log in": "Kérjük, jelentkezz be.", + "Invidious Private Feed for `x`": "„`x`” Invidious magán feedje", + "channel:`x`": "`x` csatornája", + "Deleted or invalid channel": "A csatorna érvénytelen, vagy pedig törölve lett.", + "This channel does not exist.": "Nincs ilyen csatorna.", + "Could not get channel info.": "Nem lehetett betölteni a csatorna adatait.", + "Could not fetch comments": "Nem lehetett betölteni a hozzászólásokat.", + "comments_view_x_replies": "{{count}} válasz olvasása", + "comments_view_x_replies_plural": "{{count}} válasz olvasása", + "`x` ago": "`x` ezelőtt", + "Load more": "Többi hozzászólás betöltése", + "comments_points_count": "{{count}} pont", + "comments_points_count_plural": "{{count}} pont", + "Could not create mix.": "A válogatást nem lehetett elkészíteni.", + "Empty playlist": "Üres lejátszási lista", + "Not a playlist.": "Ez nem egy lejátszási lista.", + "Playlist does not exist.": "Nincs ilyen lejátszási lista.", + "Could not pull trending pages.": "Nem lehetett betölteni a felkapott videók oldalát.", + "Hidden field \"challenge\" is a required field": "A rejtett „challenge” mezőt ki kell tölteni.", + "Hidden field \"token\" is a required field": "A rejtett „token” mezőt ki kell tölteni.", "Erroneous challenge": "Hibás challenge", "Erroneous token": "Hibás token", "No such user": "Nincs ilyen felhasználó", - "Token is expired, please try again": "Lejárt token, kérem próbáld újra", - "English": "", - "English (auto-generated)": "English (auto-genererat)", - "Afrikaans": "", - "Albanian": "", - "Amharic": "", - "Arabic": "", - "Armenian": "", - "Azerbaijani": "", - "Bangla": "", - "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", - "Hungarian": "", - "Icelandic": "", - "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian Bokmål": "", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", - "Russian": "", - "Samoan": "", - "Scottish Gaelic": "", - "Serbian": "", - "Shona": "", - "Sindhi": "", - "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", - "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", - "Swedish": "", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", - "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", - "`x` years": "`x` év", - "`x` months": "`x` hónap", - "`x` weeks": "`x` hét", - "`x` days": "`x` nap", - "`x` hours": "`x` óra", - "`x` minutes": "`x` perc", - "`x` seconds": "`x` másodperc", + "Token is expired, please try again": "A token lejárt. Kérjük, próbáld meg újból.", + "English": "angol", + "English (auto-generated)": "angol (automatikusan létrehozott)", + "Afrikaans": "afrikaans", + "Albanian": "albán", + "Amharic": "amhara", + "Arabic": "arab", + "Armenian": "örmény", + "Azerbaijani": "azerbajdzsáni", + "Bangla": "bengáli", + "Basque": "baszk", + "Belarusian": "fehérorosz", + "Bosnian": "bosnyák", + "Bulgarian": "bolgár", + "Burmese": "burmai", + "Catalan": "katalán", + "Cebuano": "szebuano", + "Chinese (Simplified)": "kínai (egyszerűsített)", + "Chinese (Traditional)": "kínai (hagyományos)", + "Corsican": "korzikai", + "Croatian": "horvát", + "Czech": "cseh", + "Danish": "dán", + "Dutch": "holland", + "Esperanto": "eszperantó", + "Estonian": "észt", + "Filipino": "filippínó", + "Finnish": "finn", + "French": "francia", + "Galician": "galiciai", + "Georgian": "grúz", + "German": "német", + "Greek": "görög", + "Gujarati": "gudzsaráti", + "Haitian Creole": "haiti kreol", + "Hausa": "hausza", + "Hawaiian": "hawaii", + "Hebrew": "héber", + "Hindi": "hindi", + "Hmong": "hmong", + "Hungarian": "magyar", + "Icelandic": "izlandi", + "Igbo": "igbo", + "Indonesian": "indonéz", + "Irish": "ír", + "Italian": "olasz", + "Japanese": "japán", + "Javanese": "jávai", + "Kannada": "kannada", + "Kazakh": "kazak", + "Khmer": "khmer", + "Korean": "koreai", + "Kurdish": "kurd", + "Kyrgyz": "kirgiz", + "Lao": "lao", + "Latin": "latin", + "Latvian": "lett", + "Lithuanian": "litván", + "Luxembourgish": "luxemburgi", + "Macedonian": "macedón", + "Malagasy": "madagaszkári", + "Malay": "maláj", + "Malayalam": "malajálam", + "Maltese": "máltai", + "Maori": "maori", + "Marathi": "maráthi", + "Mongolian": "mongol", + "Nepali": "nepáli", + "Norwegian Bokmål": "norvég (bokmål)", + "Nyanja": "njándzsa (csicseva)", + "Pashto": "pastu", + "Persian": "perzsa", + "Polish": "lengyel", + "Portuguese": "portugál", + "Punjabi": "pandzsábi", + "Romanian": "román", + "Russian": "orosz", + "Samoan": "szamoai", + "Scottish Gaelic": "skót gael", + "Serbian": "szerb", + "Shona": "sona", + "Sindhi": "szindi", + "Sinhala": "szingaléz", + "Slovak": "szlovák", + "Slovenian": "szlovén", + "Somali": "szomáliai", + "Southern Sotho": "déli szútú", + "Spanish": "spanyol", + "Spanish (Latin America)": "spanyol (latinamerikai)", + "Sundanese": "szunda", + "Swahili": "szuahéli", + "Swedish": "svéd", + "Tajik": "tádzsik", + "Tamil": "tamil", + "Telugu": "telugu", + "Thai": "thai", + "Turkish": "török", + "Ukrainian": "ukrán", + "Uzbek": "üzbég", + "Vietnamese": "vietnámi", + "Welsh": "walesi", + "Western Frisian": "nyugati fríz", + "Yiddish": "jiddis", + "Yoruba": "joruba", + "Zulu": "zulu", + "generic_count_years": "{{count}} évvel", + "generic_count_years_plural": "{{count}} évvel", + "generic_count_months": "{{count}} hónappal", + "generic_count_months_plural": "{{count}} hónappal", + "generic_count_weeks": "{{count}} héttel", + "generic_count_weeks_plural": "{{count}} héttel", + "generic_count_days": "{{count}} nappal", + "generic_count_days_plural": "{{count}} nappal", + "generic_count_hours": "{{count}} órával", + "generic_count_hours_plural": "{{count}} órával", + "generic_count_minutes": "{{count}} perccel", + "generic_count_minutes_plural": "{{count}} perccel", + "generic_count_seconds": "{{count}} másodperccel", + "generic_count_seconds_plural": "{{count}} másodperccel", "Fallback comments: ": "Másodlagos kommentek: ", "Popular": "Népszerű", - "Search": "", + "Search": "Keresési oldal", "Top": "Top", "About": "Leírás", - "Rating: ": "Besorolás: ", - "Language: ": "Nyelv: ", - "View as playlist": "Megtekintés playlist-ként", + "Rating: ": "Pontszám: ", + "preferences_locale_label": "Nyelv: ", + "View as playlist": "Megnézés lejátszási listában", "Default": "Alapértelmezett", - "Music": "Zene", + "Music": "Zenék", "Gaming": "Játékok", "News": "Hírek", "Movies": "Filmek", "Download": "Letöltés", - "Download as: ": "Letöltés mint: ", - "%A %B %-d, %Y": "", + "Download as: ": "Letöltés másként: ", "(edited)": "(szerkesztve)", - "YouTube comment permalink": "YouTube komment permalink", - "permalink": "permalink", - "`x` marked it with a ❤": "`x` jelölte ❤-vel", - "Audio mode": "Audio mód", - "Video mode": "Video mód", - "Videos": "Videók", - "Playlists": "Playlistek", - "Community": "Közösség", - "Current version: ": "Jelenlegi verzió: " -}
\ No newline at end of file + "YouTube comment permalink": "YouTube-hozzászólás idehivatkozása", + "permalink": "idehivatkozás", + "`x` marked it with a ❤": "`x` ❤ jelet adott a hozzászóláshoz", + "Audio mode": "Csak hanggal", + "Video mode": "Hanggal és képpel", + "channel_tab_videos_label": "Videói", + "Playlists": "Lejátszási listái", + "channel_tab_community_label": "Közösség", + "Current version: ": "Jelenlegi verzió: ", + "preferences_quality_option_medium": "Közepes", + "preferences_quality_dash_option_auto": "Automatikus", + "preferences_quality_dash_option_best": "Legjobb", + "preferences_quality_dash_option_worst": "Legrosszabb", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "videoinfo_started_streaming_x_ago": "`x` ezelőtt kezdte streamelni", + "search_filters_sort_option_views": "Mennyien látták", + "search_filters_features_option_purchased": "Megvásárolt", + "search_filters_features_option_three_sixty": "360°-os virtuális valóság", + "footer_original_source_code": "Eredeti forráskód", + "none": "egyik sem", + "videoinfo_watch_on_youTube": "YouTube-on megnézni", + "videoinfo_youTube_embed_link": "beágyazva", + "videoinfo_invidious_embed_link": "Beágyazott hivatkozás", + "download_subtitles": "Felirat – `x` (.vtt)", + "user_created_playlists": "`x` létrehozott lejátszási lista", + "user_saved_playlists": "`x` mentett lejátszási lista", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_dash": "DASH (adaptív minőség)", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_label": "DASH-videó minősége: ", + "preferences_quality_option_small": "Rossz", + "search_filters_sort_option_date": "Feltöltés dátuma", + "Video unavailable": "A videó nem érhető el", + "preferences_save_player_pos_label": "A videó folytatása onnan, ahol félbe lett hagyva: ", + "preferences_show_nick_label": "Becenév mutatása felül: ", + "Released under the AGPLv3 on Github.": "AGPLv3 licenc alapján a GitHubon", + "search_filters_features_option_three_d": "3D-ben", + "search_filters_features_option_live": "Élőben", + "next_steps_error_message_refresh": "Újratöltés", + "footer_donate_page": "Adakozás", + "footer_source_code": "Forráskód", + "footer_modfied_source_code": "Módosított forráskód", + "adminprefs_modified_source_code_url_label": "A módosított forráskód repositoryjának URL-je:", + "preferences_automatic_instance_redirect_label": "Váltáskor másik Invidious oldal automatikus betöltése (redirect.invidious.io töltődik, ha nem működne): ", + "preferences_region_label": "Ország tartalmainak mutatása: ", + "search_filters_sort_option_relevance": "Relevancia", + "search_filters_sort_option_rating": "Pontszám", + "search_filters_type_label": "Típus", + "search_filters_date_option_today": "Mai napon", + "search_filters_type_option_channel": "Csatorna", + "search_filters_type_option_video": "Videó", + "search_filters_type_option_playlist": "Lejátszási lista", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_label": "Jellemzők", + "search_filters_sort_label": "Rendezés módja", + "preferences_category_misc": "További beállítások", + "%A %B %-d, %Y": "%Y. %B %-d %A", + "search_filters_duration_option_long": "Hosszú (20 percnél hosszabb)", + "search_filters_date_option_year": "Ebben az évben", + "search_filters_date_option_hour": "Az elmúlt órában", + "search_filters_type_option_movie": "Film", + "search_filters_features_option_hdr": "HDR", + "search_filters_duration_label": "Játékidő", + "next_steps_error_message": "Az alábbi lehetőségek állnak rendelkezésre: ", + "Xhosa": "xhosza", + "Switch Invidious Instance": "Váltás másik Invidious-oldalra", + "Urdu": "urdu", + "search_filters_date_option_week": "Ezen a héten", + "footer_documentation": "Dokumentáció", + "search_filters_features_option_hd": "HD", + "next_steps_error_message_go_to_youtube": "Ugrás a YouTube-ra", + "search_filters_type_option_show": "Műsor", + "search_filters_features_option_four_k": "4K", + "search_filters_duration_option_short": "Rövid (4 percnél nem több)", + "search_filters_date_option_month": "Ebben a hónapban", + "search_filters_features_option_subtitles": "Felirattal", + "search_filters_features_option_location": "Közelben", + "crash_page_you_found_a_bug": "Úgy néz ki, találtál egy hibát az Invidiousban.", + "crash_page_before_reporting": "Mielőtt jelentenéd a hibát:", + "crash_page_read_the_faq": "olvasd el a <a href=\"`x`\">Gyakran Ismételt Kérdéseket (GYIK)</a>", + "crash_page_search_issue": "járj utána a <a href=\"`x`\">már meglévő issue-knak a GitHubon</a>", + "crash_page_switch_instance": "válts át <a href=\"`x`\">másik Invidious-oldalra</a>", + "crash_page_refresh": "<a href=\"`x`\">töltsd újra</a> az oldalt", + "crash_page_report_issue": "Ha a fentiek után nem jutottál eredményre, akkor <a href=\"`x`\">nyiss egy új issue-t a GitHubon</a> (lehetőleg angol nyelven írj) és másold be pontosan a lenti szöveget (ezt nem kell lefordítani):", + "Cantonese (Hong Kong)": "kantoni (Hongkong)", + "Chinese": "kínai", + "Chinese (China)": "kínai (Kína)", + "Chinese (Hong Kong)": "kínai (Hongkong)", + "Chinese (Taiwan)": "kínai (Tajvan)", + "German (auto-generated)": "német (automatikusan generált)", + "Interlingue": "interlingva", + "Japanese (auto-generated)": "japán (automatikusan generált)", + "Korean (auto-generated)": "koreai (automatikusan generált)", + "Portuguese (Brazil)": "portugál (Brazília)", + "Russian (auto-generated)": "orosz (automatikusan generált)", + "Spanish (auto-generated)": "spanyol (automatikusan generált)", + "Spanish (Mexico)": "spanyol (Mexikó)", + "Spanish (Spain)": "spanyol (Spanyolország)", + "English (United States)": "angol (Egyesült Államok)", + "Portuguese (auto-generated)": "portugál (automatikusan generált)", + "Turkish (auto-generated)": "török (automatikusan generált)", + "English (United Kingdom)": "angol (Egyesült Királyság)", + "Indonesian (auto-generated)": "indonéz (automatikusan generált)", + "Italian (auto-generated)": "olasz (automatikusan generált)", + "Dutch (auto-generated)": "holland (automatikusan generált)", + "French (auto-generated)": "francia (automatikusan generált)", + "Vietnamese (auto-generated)": "vietnámi (automatikusan generált)", + "search_filters_title": "Szűrők", + "preferences_watch_history_label": "Megnézett videók naplózása: ", + "search_message_no_results": "Nincs találat.", + "search_message_change_filters_or_query": "Próbálj meg bővebben rákeresni vagy a szűrőkön állítani.", + "search_message_use_another_instance": " Megpróbálhatod <a href=\"`x`\">egy másik</a> Invidious-oldalon is a keresést.", + "search_filters_date_label": "Feltöltés ideje", + "search_filters_date_option_none": "Mindegy mikor", + "search_filters_type_option_all": "Bármilyen", + "search_filters_duration_option_none": "Mindegy", + "search_filters_duration_option_medium": "Átlagos (4 és 20 perc között)", + "search_filters_features_option_vr180": "180°-os virtuális valóság", + "search_filters_apply_button": "Keresés a megadott szűrőkkel", + "Popular enabled: ": "Népszerű engedélyezve ", + "error_video_not_in_playlist": "A lejátszási listában keresett videó nem létezik. <a href=\"`x`\">Kattintson ide a lejátszási listához jutáshoz.</a>", + "generic_button_delete": "Törlés", + "generic_button_rss": "RSS", + "Import YouTube playlist (.csv)": "Youtube lejátszási lista (.csv) importálása", + "Standard YouTube license": "Alap YouTube-licensz", + "Add to playlist": "Hozzáadás lejátszási listához", + "Add to playlist: ": "Hozzáadás a lejátszási listához: ", + "Answer": "Válasz", + "Search for videos": "Keresés videókhoz", + "generic_channels_count": "{{count}} csatorna", + "generic_channels_count_plural": "{{count}} csatornák", + "generic_button_edit": "Szerkesztés", + "generic_button_save": "Mentés", + "generic_button_cancel": "Mégsem", + "playlist_button_add_items": "Videók hozzáadása", + "Music in this video": "Zene ezen videóban", + "Song: ": "Dal: ", + "Album: ": "Album: ", + "Import YouTube watch history (.json)": "Youtube megtekintési előzmények (.json) importálása" +} diff --git a/locales/ia.json b/locales/ia.json new file mode 100644 index 00000000..236ec4b4 --- /dev/null +++ b/locales/ia.json @@ -0,0 +1,45 @@ +{ + "New password": "Nove contrasigno", + "preferences_player_style_label": "Stylo de reproductor: ", + "preferences_region_label": "Pais de contento: ", + "oldest": "plus ancian", + "published": "data de publication", + "invidious": "Invidious", + "Image CAPTCHA": "Imagine CAPTCHA", + "newest": "plus nove", + "generic_button_save": "Salveguardar", + "Dark mode: ": "Modo obscur: ", + "preferences_dark_mode_label": "Thema: ", + "preferences_category_subscription": "Preferentias de subscription", + "last": "ultime", + "generic_button_cancel": "Cancellar", + "popular": "popular", + "Time (h:mm:ss):": "Tempore (h:mm:ss):", + "preferences_autoplay_label": "Reproduction automatic: ", + "Sign In": "Aperir le session", + "Log in": "Initiar le session", + "preferences_speed_label": "Velocitate per predefinition: ", + "preferences_comments_label": "Commentos predefinite: ", + "light": "clar", + "No": "Non", + "youtube": "YouTube", + "LIVE": "IN DIRECTO", + "reddit": "Reddit", + "preferences_category_player": "Preferentias de reproductor", + "Preferences": "Preferentias", + "preferences_quality_dash_option_auto": "Automatic", + "dark": "obscur", + "generic_button_rss": "RSS", + "Export": "Exportar", + "History": "Chronologia", + "Password": "Contrasigno", + "User ID": "ID de usator", + "E-mail": "E-mail", + "Delete account?": "Deler conto?", + "preferences_volume_label": "Volumine del reproductor: ", + "preferences_sort_label": "Ordinar le videos per: ", + "Next page": "Pagina sequente", + "Previous page": "Pagina previe", + "Yes": "Si", + "Import": "Importar" +} diff --git a/locales/id.json b/locales/id.json index 61424233..4c6e8548 100644 --- a/locales/id.json +++ b/locales/id.json @@ -1,20 +1,13 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pelanggan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` pelanggan." - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` video." - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` daftar putar.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` daftar putar." - }, + "generic_views_count_0": "{{count}} tampilan", + "generic_videos_count_0": "{{count}} video", + "generic_playlists_count_0": "{{count}} daftar putar", + "generic_subscribers_count_0": "{{count}} pelanggan", + "generic_subscriptions_count_0": "{{count}} langganan", "LIVE": "SIARAN LANGSUNG", - "Shared `x` ago": "Dibagikan`x` lalu", + "Shared `x` ago": "Dibagikan `x` yang lalu", "Unsubscribe": "Batal Langganan", - "Subscribe": "Langganan", + "Subscribe": "Berlangganan", "View channel on YouTube": "Lihat kanal di YouTube", "View playlist on YouTube": "Lihat daftar putar di YouTube", "newest": "terbaru", @@ -26,30 +19,28 @@ "Clear watch history?": "Bersihkan riwayat tontonan?", "New password": "Kata sandi baru", "New passwords must match": "Kata sandi baru harus cocok", - "Cannot change password for Google accounts": "Tidak dapat mengganti kata sandi untuk akun Google", "Authorize token?": "Otorisasi token?", "Authorize token for `x`?": "Otorisasi token untuk `x`?", "Yes": "Ya", "No": "Tidak", "Import and Export Data": "Impor dan Ekspor Data", "Import": "Impor", - "Import Invidious data": "Impor data Invidious", - "Import YouTube subscriptions": "Impor langganan YouTube", + "Import Invidious data": "Impor JSON data Invidious", + "Import YouTube subscriptions": "Impor langganan YouTube/OPML", "Import FreeTube subscriptions (.db)": "Impor langganan FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Impor langganan NewPipe (.json)", "Import NewPipe data (.zip)": "Impor data NewPipe (.zip)", "Export": "Ekspor", "Export subscriptions as OPML": "Ekspor langganan sebagai OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Ekspor langganan sebagai OPML (untuk NewPipe & FreeTube)", - "Export data as JSON": "Ekspor data sebagai JSON", + "Export data as JSON": "Ekspor data Invidious sebagai JSON", "Delete account?": "Hapus akun?", "History": "Riwayat", - "An alternative front-end to YouTube": "Sebuah alternatif front-end untuk YouTube", + "An alternative front-end to YouTube": "Sebuah alternatif layar depan untuk YouTube", "JavaScript license information": "Informasi lisensi JavaScript", "source": "sumber", "Log in": "Masuk", - "Log in/register": "Daftar", - "Log in with Google": "Masuk dengan Google", + "Log in/register": "Masuk/Daftar", "User ID": "ID Pengguna", "Password": "Kata Sandi", "Time (h:mm:ss):": "Waktu (j:mm:dd):", @@ -58,52 +49,54 @@ "Sign In": "Masuk", "Register": "Daftar", "E-mail": "Surel", - "Google verification code": "Kode verifikasi Google", "Preferences": "Preferensi", - "Player preferences": "Preferensi pemutar", - "Always loop: ": "Selalu ulangi: ", - "Autoplay: ": "Putar-Otomatis: ", - "Play next by default: ": "Putar selanjutnya secara default: ", - "Autoplay next video: ": "Otomatis-Putar video berikutnya: ", - "Listen by default: ": "Dengarkan secara default: ", - "Proxy videos: ": "Video Proksi: ", - "Default speed: ": "Kecepatan default: ", - "Preferred video quality: ": "Kualitas video yang disukai: ", - "Player volume: ": "Volume pemutar: ", - "Default comments: ": "Komentar default: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "Subtitel default: ", - "Fallback captions: ": "Subtitel fallback: ", - "Show related videos: ": "Tampilkan video terkait: ", - "Show annotations by default: ": "Tampilkan anotasi secara default: ", - "Automatically extend video description: ": "", - "Visual preferences": "Preferensi visual", - "Player style: ": "Gaya pemutar: ", + "preferences_category_player": "Preferensi pemutar", + "preferences_video_loop_label": "Selalu ulangi: ", + "preferences_autoplay_label": "Putar Otomatis: ", + "preferences_continue_label": "Putar selanjutnya secara baku: ", + "preferences_continue_autoplay_label": "Putar otomatis video berikutnya: ", + "preferences_listen_label": "Dengarkan secara baku: ", + "preferences_local_label": "Proksi video: ", + "preferences_speed_label": "Kecepatan baku: ", + "preferences_quality_label": "Kualitas video yang disukai: ", + "preferences_volume_label": "Volume pemutar: ", + "preferences_comments_label": "Komentar baku: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Takarir baku: ", + "Fallback captions: ": "Takarir cadangan: ", + "preferences_related_videos_label": "Tampilkan video terkait: ", + "preferences_annotations_label": "Tampilkan anotasi secara baku: ", + "preferences_extend_desc_label": "Perluas deskripsi video secara otomatis: ", + "preferences_vr_mode_label": "Video interaktif 360° (memerlukan WebGL): ", + "preferences_category_visual": "Preferensi visual", + "preferences_player_style_label": "Gaya pemutar: ", "Dark mode: ": "Mode gelap: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "gelap", "light": "terang", - "Thin mode: ": "Mode tipis: ", - "Subscription preferences": "Preferensi langganan", - "Show annotations by default for subscribed channels: ": "Tampilkan anotasi secara default untuk kanal langganan: ", + "preferences_thin_mode_label": "Mode tipis: ", + "preferences_category_misc": "Preferensi lainnya", + "preferences_automatic_instance_redirect_label": "Pengalihan peladen otomatis (balik kembali ke redirect.invidious.io): ", + "preferences_category_subscription": "Preferensi langganan", + "preferences_annotations_subscribed_label": "Tampilkan anotasi secara baku untuk kanal yang dilanggan? ", "Redirect homepage to feed: ": "Arahkan kembali laman beranda ke umpan: ", - "Number of videos shown in feed: ": "Jumlah video ditampilkan di umpan: ", - "Sort videos by: ": "Urutkan video berdasarkan: ", + "preferences_max_results_label": "Jumlah video ditampilkan di umpan: ", + "preferences_sort_label": "Urutkan video berdasarkan: ", "published": "dipublikasi", - "published - reverse": "dipublikasi - sebaliknya", + "published - reverse": "dipublikasi - terbalik", "alphabetically": "menurut abjad", - "alphabetically - reverse": "menurut abjad - sebaliknya", + "alphabetically - reverse": "menurut abjad - terbalik", "channel name": "nama kanal", - "channel name - reverse": "nama kanal - sebaliknya", + "channel name - reverse": "nama kanal - terbalik", "Only show latest video from channel: ": "Hanya tampilkan video terbaru dari kanal: ", "Only show latest unwatched video from channel: ": "Hanya tampilkan video belum ditonton terbaru dari kanal: ", - "Only show unwatched: ": "Hanya tampilkan belum ditonton: ", - "Only show notifications (if there are any): ": "Hanya tampilkan pemberitahuan (jika ada): ", + "preferences_unseen_only_label": "Hanya tampilkan belum ditonton: ", + "preferences_notifications_only_label": "Hanya tampilkan pemberitahuan (jika ada): ", "Enable web notifications": "Aktifkan pemberitahuan web", "`x` uploaded a video": "`x` mengunggah video", "`x` is live": "`x` sedang siaran langsung", - "Data preferences": "Preferensi Data", + "preferences_category_data": "Preferensi data", "Clear watch history": "Bersihkan riwayat tontonan", "Import/export data": "Impor/Ekspor data", "Change password": "Ganti kata sandi", @@ -111,37 +104,28 @@ "Manage tokens": "Atur token", "Watch history": "Riwayat tontonan", "Delete account": "Hapus akun", - "Administrator preferences": "Preferensi administrator", - "Default homepage: ": "Laman beranda default: ", - "Feed menu: ": "Menu umpan: ", + "preferences_category_admin": "Preferensi administrator", + "preferences_default_home_label": "Laman beranda baku: ", + "preferences_feed_menu_label": "Menu umpan: ", + "preferences_show_nick_label": "Tampilkan nama panggilan di atas: ", "Top enabled: ": "Teratas diaktifkan: ", "CAPTCHA enabled: ": "CAPTCHA diaktifkan: ", "Login enabled: ": "Masuk diaktifkan: ", - "Registration enabled: ": "Registrasi diaktifkan: ", + "Registration enabled: ": "Pendaftaran diaktifkan: ", "Report statistics: ": "Laporan statistik: ", "Save preferences": "Simpan preferensi", "Subscription manager": "Pengatur langganan", "Token manager": "Pengatur token", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` langganan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` langganan." - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` token." - }, + "tokens_count_0": "{{count}} token", "Import/export": "Impor/ekspor", "unsubscribe": "batal langganan", "revoke": "cabut", "Subscriptions": "Langganan", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pemberitahuan belum dilihat.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` pemberitahuan belum dilihat." - }, - "search": "cari", + "subscriptions_unseen_notifs_count_0": "{{count}} pemberitahuan belum dilihat", + "search": "Telusuri", "Log out": "Keluar", - "Released under the AGPLv3 by Omar Roth.": "Dirilis dibawah AGPLv3 oleh Omar Roth.", + "Released under the AGPLv3 on Github.": "Dirilis di bawah AGPLv3 di GitHub.", "Source available here.": "Sumber tersedia di sini.", "View JavaScript license information.": "Tampilkan informasi lisensi JavaScript.", "View privacy policy.": "Lihat kebijakan privasi.", @@ -157,9 +141,10 @@ "Title": "Judul", "Playlist privacy": "Privasi daftar putar", "Editing playlist `x`": "Menyunting daftar putar `x`", - "Show more": "", - "Show less": "", + "Show more": "Tampilkan lebih banyak", + "Show less": "Tampilkan lebih sedikit", "Watch on YouTube": "Tonton di YouTube", + "Switch Invidious Instance": "Ganti peladen Invidious", "Hide annotations": "Sembunyikan anotasi", "Show annotations": "Tampilkan anotasi", "Genre: ": "Genre: ", @@ -169,54 +154,39 @@ "Engagement: ": "Keterlibatan: ", "Whitelisted regions: ": "Wilayah daftar-putih: ", "Blacklisted regions: ": "Wilayah daftar-hitam: ", - "Shared `x`": "Berbagi`x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tampilan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` tampilan." - }, + "Shared `x`": "Berbagi `x`", "Premieres in `x`": "Tayang dalam `x`", "Premieres `x`": "Tayang `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hai! Kelihatannya JavaScript kamu dimatikan. Klik di sini untuk melihat komentar, perlu diingat hal ini mungkin membutuhkan waktu sedikit lebih lama untuk dimuat.", "View YouTube comments": "Lihat komentar YouTube", "View more comments on Reddit": "Lihat lebih banyak komentar di Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` komentar.([^.,0-9]|^)1([^.,0-9]|$)", - "": "Lihat`x` komentar." + "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` komentar", + "": "Lihat`x` komentar" }, "View Reddit comments": "Lihat komentar Reddit", "Hide replies": "Sembunyikan balasan", "Show replies": "Lihat balasan", "Incorrect password": "Kata sandi salah", - "Quota exceeded, try again in a few hours": "Kuota penuh, coba lagi dalam beberapa jam", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Tidak dapat masuk, pastikan autentikasi dua-faktor (autentikator atau SMS) sudah nyala.", - "Invalid TFA code": "Kode TFA tidak valid", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Gagal masuk. Ini mungkin disebabkan autentikasi dua-faktor tidak dinyalakan untuk akun Anda.", "Wrong answer": "Jawaban salah", "Erroneous CAPTCHA": "CAPTCHA salah", "CAPTCHA is a required field": "CAPTCHA perlu diisi", "User ID is a required field": "ID pengguna perlu diisi", "Password is a required field": "Kata sandi perlu diisi", "Wrong username or password": "Nama pengguna atau kata sandi salah", - "Please sign in using 'Log in with Google'": "Harap masuk menggunakan 'Masuk dengan Google'", "Password cannot be empty": "Kata sandi tidak boleh kosong", "Password cannot be longer than 55 characters": "Kata sandi tidak boleh lebih dari 55 karakter", "Please log in": "Harap masuk", "Invidious Private Feed for `x`": "Umpan pribadi Invidious untuk`x`", "channel:`x`": "kanal:`x`", - "Deleted or invalid channel": "Kanal terhapus atau invalid", + "Deleted or invalid channel": "Kanal terhapus atau tidak valid", "This channel does not exist.": "Kanal ini tidak ada.", "Could not get channel info.": "Tidak bisa mendapatkan info kanal.", "Could not fetch comments": "Tidak dapat memuat komentar", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Lihat`x` balasan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "Lihat `x` balasan." - }, + "comments_view_x_replies_0": "Lihat {{count}} balasan", "`x` ago": "`x` lalu", "Load more": "Muat lebih banyak", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` titik.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` titik." - }, + "comments_points_count_0": "{{count}} poin", "Could not create mix.": "Tidak dapat membuat mix.", "Empty playlist": "Daftar putar kosong", "Not a playlist.": "Bukan daftar putar.", @@ -334,85 +304,171 @@ "Yiddish": "Bahasa Yiddi", "Yoruba": "Bahasa Yoruba", "Zulu": "Bahasa Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tahun.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` tahun." - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bulan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` bulan." - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pekan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` pekan." - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hari.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` hari." - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jam.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` jam." - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` menit.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` menit." - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` detik.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` detik." - }, - "Fallback comments: ": "", + "generic_count_years_0": "{{count}} tahun", + "generic_count_months_0": "{{count}} bulan", + "generic_count_weeks_0": "{{count}} pekan", + "generic_count_days_0": "{{count}} hari", + "generic_count_hours_0": "{{count}} jam", + "generic_count_minutes_0": "{{count}} menit", + "generic_count_seconds_0": "{{count}} detik", + "Fallback comments: ": "Komentar alternatif: ", "Popular": "Populer", "Search": "Cari", "Top": "Teratas", - "About": "Ihwal", - "Rating: ": "Peringkat: ", - "Language: ": "Bahasa: ", - "View as playlist": "Tampilkan sebagai daftar putar", - "Default": "Asali", + "About": "Tentang", + "Rating: ": "Penilaian: ", + "preferences_locale_label": "Bahasa: ", + "View as playlist": "Lihat sebagai daftar putar", + "Default": "Baku", "Music": "Musik", - "Gaming": "Gaming", + "Gaming": "Permainan", "News": "Berita", "Movies": "Film", "Download": "Unduh", "Download as: ": "Unduh sebagai: ", "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(disunting)", - "YouTube comment permalink": "Komentar YouTube permalink", - "permalink": "permalink", + "YouTube comment permalink": "Tautan permanen komentar YouTube", + "permalink": "tautan permanen", "`x` marked it with a ❤": "`x` telah ditandai dengan ❤", "Audio mode": "Mode audio", "Video mode": "Mode video", - "Videos": "Video", + "channel_tab_videos_label": "Video", "Playlists": "Daftar putar", - "Community": "Komunitas", - "relevance": "Relevan", - "rating": "peringkat", - "date": "tanggal", - "views": "ditonton", - "content_type": "tipe_konten", - "duration": "durasi", - "features": "fitur", - "sort": "urut", - "hour": "jam", - "today": "hari ini", - "week": "minggu", - "month": "bulan", - "year": "tahun", - "video": "video", - "channel": "kanal", - "playlist": "daftar putar", - "movie": "film", - "show": "tampilkan", - "hd": "hd", - "subtitles": "subtitel", - "creative_commons": "creative_commons", - "3d": "3d", - "live": "siaran langsung", - "4k": "4k", - "location": "lokasi", - "hdr": "hdr", - "filter": "saring", - "Current version: ": "Versi saat ini: " -}
\ No newline at end of file + "channel_tab_community_label": "Komunitas", + "search_filters_sort_option_relevance": "Relevansi", + "search_filters_sort_option_rating": "Penilaian", + "search_filters_sort_option_date": "Tanggal Unggah", + "search_filters_sort_option_views": "Jumlah ditonton", + "search_filters_type_label": "Tipe", + "search_filters_duration_label": "Durasi", + "search_filters_features_label": "Fitur", + "search_filters_sort_label": "Urut Berdasarkan", + "search_filters_date_option_hour": "Jam Terakhir", + "search_filters_date_option_today": "Hari Ini", + "search_filters_date_option_week": "Pekan Ini", + "search_filters_date_option_month": "Bulan Ini", + "search_filters_date_option_year": "Tahun Ini", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Kanal", + "search_filters_type_option_playlist": "Daftar Putar", + "search_filters_type_option_movie": "Film", + "search_filters_type_option_show": "Pertunjukan/Acara", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Takarir", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Siaran Langsung", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Lokasi", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Versi saat ini: ", + "next_steps_error_message": "Setelah itu Anda harus mencoba: ", + "next_steps_error_message_refresh": "Segarkan", + "next_steps_error_message_go_to_youtube": "Buka YouTube", + "footer_donate_page": "Donasi", + "adminprefs_modified_source_code_url_label": "URL ke repositori kode sumber yang dimodifikasi", + "footer_source_code": "Kode sumber", + "footer_original_source_code": "Kode sumber yang asli", + "search_filters_duration_option_short": "Pendek (< 4 menit)", + "search_filters_duration_option_long": "Panjang (> 20 menit)", + "footer_modfied_source_code": "Kode sumber yang dimodifikasi", + "footer_documentation": "Dokumentasi", + "preferences_region_label": "Konten dari negara: ", + "preferences_quality_dash_label": "Kualitas video DASH yang disukai: ", + "preferences_quality_option_medium": "Medium", + "preferences_quality_option_small": "Rendah", + "preferences_quality_dash_option_best": "Terbaik", + "preferences_quality_dash_option_worst": "Terburuk", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "search_filters_features_option_purchased": "Dibeli", + "search_filters_features_option_three_sixty": "360°", + "none": "tidak ada", + "videoinfo_watch_on_youTube": "Tonton di YouTube", + "videoinfo_youTube_embed_link": "Tersemat", + "videoinfo_invidious_embed_link": "Tautan Tersemat", + "download_subtitles": "Takarir- `x` (.vtt)", + "user_saved_playlists": "`x` daftar putar yang disimpan", + "videoinfo_started_streaming_x_ago": "Mulai siaran `x` yang lalu", + "user_created_playlists": "`x` daftar putar yang dibuat", + "preferences_quality_option_dash": "DASH (kualitas adaptif)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_auto": "Otomatis", + "preferences_quality_dash_option_480p": "480p", + "Video unavailable": "Video tidak tersedia", + "preferences_save_player_pos_label": "Simpan posisi pemutaran: ", + "crash_page_you_found_a_bug": "Sepertinya kamu telah menemukan masalah di invidious!", + "crash_page_before_reporting": "Sebelum melaporkan masalah, pastikan anda memiliki:", + "English (United States)": "Inggris (US)", + "preferences_watch_history_label": "Aktifkan riwayat tontonan: ", + "English (United Kingdom)": "Inggris (UK)", + "search_filters_title": "Saring", + "search_message_no_results": "Tidak ada hasil yang ditemukan.", + "search_message_change_filters_or_query": "Coba perbanyak kueri pencarian dan/atau ubah filter Anda.", + "search_message_use_another_instance": " Anda juga bisa <a href=\"`x`\">mencari di peladen lain</a>.", + "Indonesian (auto-generated)": "Indonesia (dibuat secara otomatis)", + "Japanese (auto-generated)": "Jepang (dibuat secara otomatis)", + "Korean (auto-generated)": "Korea (dibuat secara otomatis)", + "Portuguese (Brazil)": "Portugis (Brasil)", + "Russian (auto-generated)": "Rusia (dibuat secara otomatis)", + "Spanish (Mexico)": "Spanyol (Meksiko)", + "Spanish (Spain)": "Spanyol (Spanyol)", + "Vietnamese (auto-generated)": "Vietnam (dibuat secara otomatis)", + "search_filters_features_option_vr180": "VR180", + "Spanish (auto-generated)": "Spanyol (dibuat secara otomatis)", + "Chinese": "Bahasa Cina", + "Chinese (Taiwan)": "Bahasa Cina (Taiwan)", + "Chinese (Hong Kong)": "Bahasa Cina (Hong Kong)", + "Chinese (China)": "Bahasa Cina (China)", + "French (auto-generated)": "Perancis (dibuat secara otomatis)", + "German (auto-generated)": "Jerman (dibuat secara otomatis)", + "Italian (auto-generated)": "Italia (dibuat secara otomatis)", + "Portuguese (auto-generated)": "Portugis (dibuat secara otomatis)", + "Turkish (auto-generated)": "Turki (dibuat secara otomatis)", + "search_filters_date_label": "Tanggal unggah", + "search_filters_type_option_all": "Segala jenis", + "search_filters_apply_button": "Terapkan saringan yang dipilih", + "Dutch (auto-generated)": "Belanda (dihasilkan secara otomatis)", + "search_filters_date_option_none": "Tanggal berapa pun", + "search_filters_duration_option_none": "Durasi berapa pun", + "search_filters_duration_option_medium": "Sedang (4 - 20 menit)", + "Cantonese (Hong Kong)": "Bahasa Kanton (Hong Kong)", + "crash_page_refresh": "mencoba untuk <a href=\"`x`\">memuat ulang halaman</a>", + "crash_page_switch_instance": "mencoba untuk <a href=\"`x`\">menggunakan peladen lainnya</a>", + "crash_page_read_the_faq": "baca <a href=\"`x`\">Soal Sering Ditanya (SSD/FAQ)</a>", + "crash_page_search_issue": "mencari <a href=\"`x`\">isu yang ada di GitHub</a>", + "crash_page_report_issue": "Jika yang di atas tidak membantu, <a href=\"`x`\">buka isu baru di GitHub</a> (sebaiknya dalam bahasa Inggris) dan sertakan teks berikut dalam pesan Anda (JANGAN terjemahkan teks tersebut):", + "Popular enabled: ": "Populer diaktifkan: ", + "channel_tab_podcasts_label": "Podcast", + "Download is disabled": "Download dinonaktifkan", + "Channel Sponsor": "Saluran Sponsor", + "channel_tab_streams_label": "Streaming langsung", + "playlist_button_add_items": "Tambahkan video", + "Artist: ": "Artis: ", + "generic_button_save": "Simpan", + "generic_button_cancel": "Batal", + "Album: ": "Album: ", + "channel_tab_shorts_label": "Shorts", + "channel_tab_releases_label": "Terbit", + "Interlingue": "Interlingue", + "Song: ": "Lagu: ", + "generic_channels_count_0": "Saluran {{count}}", + "channel_tab_playlists_label": "Daftar putar", + "generic_button_edit": "Ubah", + "Music in this video": "Musik dalam video ini", + "generic_button_rss": "RSS", + "channel_tab_channels_label": "Saluran", + "error_video_not_in_playlist": "Video yang diminta tidak ada dalam daftar putar ini. <a href=\"`x`\">Klik di sini untuk halaman beranda daftar putar.</a>", + "generic_button_delete": "Hapus", + "Import YouTube playlist (.csv)": "Impor daftar putar YouTube (.csv)", + "Standard YouTube license": "Lisensi YouTube standar", + "Import YouTube watch history (.json)": "Impor riwayat tontonan YouTube (.json)" +} diff --git a/locales/is.json b/locales/is.json index 569ee9d0..9d13c5cf 100644 --- a/locales/is.json +++ b/locales/is.json @@ -1,55 +1,41 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` áskrifandar", - "": "`x` áskrifendur" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` myndband", - "": "`x` myndbönd" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` spilunarlist", - "": "`x` spilunarlistar" - }, "LIVE": "BEINT", - "Shared `x` ago": "Deilt `x` síðan", + "Shared `x` ago": "Deilt fyrir `x` síðan", "Unsubscribe": "Afskrá", "Subscribe": "Áskrifa", "View channel on YouTube": "Skoða rás á YouTube", - "View playlist on YouTube": "Skoða spilunarlisti á YouTube", + "View playlist on YouTube": "Skoða spilunarlista á YouTube", "newest": "nýjasta", "oldest": "elsta", "popular": "vinsælt", "last": "síðast", "Next page": "Næsta síða", "Previous page": "Fyrri síða", - "Clear watch history?": "Hreinsa áhorfssögu?", + "Clear watch history?": "Hreinsa áhorfsferil?", "New password": "Nýtt lykilorð", "New passwords must match": "Nýtt lykilorð verður að passa", - "Cannot change password for Google accounts": "Ekki er hægt að breyta lykilorði fyrir Google reikninga", - "Authorize token?": "Leyfa tákn?", - "Authorize token for `x`?": "Leyfa tákn fyrir `x`?", + "Authorize token?": "Leyfa teikn?", + "Authorize token for `x`?": "Leyfa teikn fyrir `x`?", "Yes": "Já", "No": "Nei", - "Import and Export Data": "Innflutningur og Útflutningur Gagna", + "Import and Export Data": "Inn- og útflutningur gagna", "Import": "Flytja inn", - "Import Invidious data": "Flytja inn Invidious gögn", - "Import YouTube subscriptions": "Flytja inn YouTube áskriftir", + "Import Invidious data": "Flytja inn Invidious JSON-gögn", + "Import YouTube subscriptions": "Flytja inn YouTube CSV eða OPML-áskriftir", "Import FreeTube subscriptions (.db)": "Flytja inn FreeTube áskriftir (.db)", "Import NewPipe subscriptions (.json)": "Flytja inn NewPipe áskriftir (.json)", "Import NewPipe data (.zip)": "Flytja inn NewPipe gögn (.zip)", "Export": "Flytja út", "Export subscriptions as OPML": "Flytja út áskriftir sem OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Flytja út áskriftir sem OPML (fyrir NewPipe & FreeTube)", - "Export data as JSON": "Flytja út gögn sem JSON", + "Export data as JSON": "Flytja út Invidious-gögn sem JSON", "Delete account?": "Eyða reikningi?", - "History": "Saga", - "An alternative front-end to YouTube": "Önnur framhlið fyrir YouTube", - "JavaScript license information": "JavaScript leyfi upplýsingar", - "source": "uppspretta", + "History": "Ferill", + "An alternative front-end to YouTube": "Annað viðmót fyrir YouTube", + "JavaScript license information": "Upplýsingar um notkunarleyfi JavaScript", + "source": "uppruni", "Log in": "Skrá inn", "Log in/register": "Innskráning/nýskráning", - "Log in with Google": "Skrá inn með Google", "User ID": "Notandakenni", "Password": "Lykilorð", "Time (h:mm:ss):": "Tími (h:mm: ss):", @@ -58,38 +44,36 @@ "Sign In": "Skrá inn", "Register": "Nýskrá", "E-mail": "Tölvupóstur", - "Google verification code": "Google staðfestingarkóði", "Preferences": "Kjörstillingar", - "Player preferences": "Kjörstillingar spilara", - "Always loop: ": "Alltaf lykkja: ", - "Autoplay: ": "Spila sjálfkrafa: ", - "Play next by default: ": "Spila næst sjálfgefið: ", - "Autoplay next video: ": "Spila næst sjálfkrafa: ", - "Listen by default: ": "Hlusta sjálfgefið: ", - "Proxy videos: ": "Proxy myndbönd? ", - "Default speed: ": "Sjálfgefinn hraði: ", - "Preferred video quality: ": "Æskilegt myndbands gæði: ", - "Player volume: ": "Spilara hljóðstyrkur: ", - "Default comments: ": "Sjálfgefin ummæli: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "Sjálfgefin texti: ", + "preferences_category_player": "Kjörstillingar spilara", + "preferences_video_loop_label": "Alltaf lykkja: ", + "preferences_autoplay_label": "Sjálfvirk spilun: ", + "preferences_continue_label": "Spila næst sjálfgefið: ", + "preferences_continue_autoplay_label": "Spila næsta myndskeið sjálfkrafa: ", + "preferences_listen_label": "Hlusta sjálfgefið: ", + "preferences_local_label": "Milliþjónn fyrir myndskeið: ", + "preferences_speed_label": "Sjálfgefinn hraði: ", + "preferences_quality_label": "Æskileg gæði myndmerkis: ", + "preferences_volume_label": "Spilara hljóðstyrkur: ", + "preferences_comments_label": "Sjálfgefin ummæli: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Sjálfgefin texti: ", "Fallback captions: ": "Varatextar: ", - "Show related videos: ": "Sýna tengd myndbönd? ", - "Show annotations by default: ": "Á að sýna glósur sjálfgefið? ", - "Automatically extend video description: ": "", - "Visual preferences": "Sjónrænar stillingar", - "Player style: ": "Spilara stíl: ", - "Dark mode: ": "Myrkur ham: ", - "Theme: ": "Þema: ", - "dark": "dimmt", + "preferences_related_videos_label": "Sýna tengd myndskeið? ", + "preferences_annotations_label": "Á að sýna glósur sjálfgefið? ", + "preferences_category_visual": "Sjónrænar stillingar", + "preferences_player_style_label": "Stíll spilara: ", + "Dark mode: ": "Dökkur hamur: ", + "preferences_dark_mode_label": "Þema: ", + "dark": "dökkt", "light": "ljóst", - "Thin mode: ": "Þunnt ham: ", - "Subscription preferences": "Áskriftarstillingar", - "Show annotations by default for subscribed channels: ": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ", - "Redirect homepage to feed: ": "Endurbeina heimasíðu að straumi: ", - "Number of videos shown in feed: ": "Fjöldi myndbanda sem sýndir eru í straumi: ", - "Sort videos by: ": "Raða myndbönd eftir: ", + "preferences_thin_mode_label": "Grannur hamur: ", + "preferences_category_subscription": "Áskriftarstillingar", + "preferences_annotations_subscribed_label": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ", + "Redirect homepage to feed: ": "Endurbeina heimasíðu að streymi: ", + "preferences_max_results_label": "Fjöldi myndskeiða sem sýnd eru í streymi: ", + "preferences_sort_label": "Raða myndskeiðum eftir: ", "published": "birt", "published - reverse": "birt - afturábak", "alphabetically": "í stafrófsröð", @@ -98,50 +82,37 @@ "channel name - reverse": "heiti rásar - afturábak", "Only show latest video from channel: ": "Sýna aðeins nýjasta myndband frá rás: ", "Only show latest unwatched video from channel: ": "Sýna aðeins nýjasta óséð myndband frá rás: ", - "Only show unwatched: ": "Sýna aðeins óséð: ", - "Only show notifications (if there are any): ": "Sýna aðeins tilkynningar (ef einhverjar eru): ", + "preferences_unseen_only_label": "Sýna aðeins óséð: ", + "preferences_notifications_only_label": "Sýna aðeins tilkynningar (ef einhverjar eru): ", "Enable web notifications": "Virkja veftilkynningar", "`x` uploaded a video": "`x` hlóð upp myndband", "`x` is live": "`x` er í beinni", - "Data preferences": "Gagnastillingar", - "Clear watch history": "Hreinsa áhorfssögu", + "preferences_category_data": "Gagnastillingar", + "Clear watch history": "Hreinsa áhorfsferil", "Import/export data": "Flytja inn/út gögn", "Change password": "Breyta lykilorði", - "Manage subscriptions": "Stjórna áskriftum", - "Manage tokens": "Stjórna tákn", - "Watch history": "Áhorfssögu", + "Manage subscriptions": "Sýsla með áskriftir", + "Manage tokens": "Sýsla með teikn", + "Watch history": "Áhorfsferill", "Delete account": "Eyða reikningi", - "Administrator preferences": "Kjörstillingar stjórnanda", - "Default homepage: ": "Sjálfgefin heimasíða: ", - "Feed menu: ": "Straum valmynd: ", - "Top enabled: ": "Toppur virkur? ", + "preferences_category_admin": "Kjörstillingar stjórnanda", + "preferences_default_home_label": "Sjálfgefin heimasíða: ", + "preferences_feed_menu_label": "Streymisvalmynd: ", + "Top enabled: ": "Vinsælast virkt? ", "CAPTCHA enabled: ": "CAPTCHA virk? ", "Login enabled: ": "Innskráning virk? ", "Registration enabled: ": "Nýskráning virkjuð? ", - "Report statistics: ": "Skrá talnagögn? ", + "Report statistics: ": "Skrá tölfræði? ", "Save preferences": "Vista stillingar", "Subscription manager": "Áskriftarstjóri", - "Token manager": "Táknstjóri", - "Token": "Tákn", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` áskriftur", - "": "`x` áskriftir" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tákn", - "": "`x` tákn" - }, + "Token manager": "Teiknastjórnun", + "Token": "Teikn", "Import/export": "Flytja inn/út", "unsubscribe": "afskrá", "revoke": "afturkalla", "Subscriptions": "Áskriftir", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` óséðar tilkynning", - "": "`x` óséðar tilkynningar" - }, "search": "leita", - "Log out": "Útskrá", - "Released under the AGPLv3 by Omar Roth.": "Útgefið undir AGPLv3 eftir Omar Roth.", + "Log out": "Skrá út", "Source available here.": "Frumkóði aðgengilegur hér.", "View JavaScript license information.": "Skoða JavaScript leyfisupplýsingar.", "View privacy policy.": "Skoða meðferð persónuupplýsinga.", @@ -151,15 +122,13 @@ "Private": "Einka", "View all playlists": "Skoða alla spilunarlista", "Updated `x` ago": "Uppfært `x` síðann", - "Delete playlist `x`?": "Eiða spilunarlista `x`?", - "Delete playlist": "Eiða spilunarlista", + "Delete playlist `x`?": "Eyða spilunarlista `x`?", + "Delete playlist": "Eyða spilunarlista", "Create playlist": "Búa til spilunarlista", "Title": "Titill", - "Playlist privacy": "Spilunarlista opinberri", - "Editing playlist `x`": "Að breyta spilunarlista `x`", - "Show more": "", - "Show less": "", - "Watch on YouTube": "Horfa á YouTube", + "Playlist privacy": "Friðhelgi spilunarlista", + "Editing playlist `x`": "Breyti spilunarlista `x`", + "Watch on YouTube": "Skoða á YouTube", "Hide annotations": "Fela glósur", "Show annotations": "Sýna glósur", "Genre: ": "Tegund: ", @@ -170,10 +139,6 @@ "Whitelisted regions: ": "Svæði á hvítum lista: ", "Blacklisted regions: ": "Svæði á svörtum lista: ", "Shared `x`": "Deilt `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` áhorf", - "": "`x` áhorf" - }, "Premieres in `x`": "Frumflutt eftir `x`", "Premieres `x`": "Frumflutt `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hæ! Lítur út eins og þú hafir slökkt á JavaScript. Smelltu hér til að skoða ummæli, hafðu í huga að þær geta tekið aðeins lengri tíma að hlaða.", @@ -187,47 +152,34 @@ "Hide replies": "Fela svör", "Show replies": "Sýna svör", "Incorrect password": "Rangt lykilorð", - "Quota exceeded, try again in a few hours": "Kvóti fór yfir, reyndu aftur eftir nokkrar klukkustundir", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Ekki er hægt að skrá þig inn, vertu viss um að tvíþætt staðfesting (Authenticator eða SMS) sé kveikt á.", - "Invalid TFA code": "Ógildur TFA kóði", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Innskráning mistókst. Þetta gæti verið vegna þess að tvíþátta staðfesting er ekki kveikt á reikningnum þínum.", "Wrong answer": "Rangt svar", "Erroneous CAPTCHA": "Rangt CAPTCHA", "CAPTCHA is a required field": "CAPTCHA er nauðsynlegur reitur", "User ID is a required field": "Notandakenni er nauðsynlegur reitur", "Password is a required field": "Lykilorð er nauðsynlegur reitur", "Wrong username or password": "Rangt notandanafn eða lykilorð", - "Please sign in using 'Log in with Google'": "Vinsamlegast skráðu þig inn með því að nota 'Innskráning með Google'", "Password cannot be empty": "Lykilorð má ekki vera autt", "Password cannot be longer than 55 characters": "Lykilorð má ekki vera lengra en 55 stafir", - "Please log in": "Vinsamlegast skráðu þig inn", - "Invidious Private Feed for `x`": "Invidious Persónulegur Straumur fyrir `x`", + "Please log in": "Skráðu þig inn", + "Invidious Private Feed for `x`": "Persónulegt Invidious-streymi fyrir `x`", "channel:`x`": "rás:`x`", "Deleted or invalid channel": "Eytt eða ógild rás", "This channel does not exist.": "Þessi rás er ekki til.", - "Could not get channel info.": "Ekki tókst að fá rásarupplýsingar.", + "Could not get channel info.": "Ekki tókst að fá upplýsingar um rásina.", "Could not fetch comments": "Ekki tókst að sækja ummæli", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Skoða `x` svar", - "": "Skoða `x` svör" - }, "`x` ago": "`x` síðan", "Load more": "Hlaða meira", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` stig", - "": "`x` stig" - }, "Could not create mix.": "Ekki tókst að búa til blöndu.", "Empty playlist": "Tómur spilunarlisti", - "Not a playlist.": "Ekki spilunarlisti.", + "Not a playlist.": "Er ekki spilunarlisti.", "Playlist does not exist.": "Spilunarlisti er ekki til.", "Could not pull trending pages.": "Ekki tókst að draga vinsælar síður.", "Hidden field \"challenge\" is a required field": "Falinn reitur \"áskorun\" er nauðsynlegur reitur", - "Hidden field \"token\" is a required field": "Falinn reitur \"tákn\" er nauðsynlegur reitur", + "Hidden field \"token\" is a required field": "Falinn reitur \"teikn\" er nauðsynlegur reitur", "Erroneous challenge": "Röng áskorun", - "Erroneous token": "Rangt tákn", + "Erroneous token": "Rangt teikn", "No such user": "Enginn slíkur notandi", - "Token is expired, please try again": "Tákn er útrunnið, vinsamlegast reyndu aftur", + "Token is expired, please try again": "Teiknið er útrunnið, reyndu aftur", "English": "Enska", "English (auto-generated)": "Enska (sjálfkrafa)", "Afrikaans": "Afríkanska", @@ -315,14 +267,14 @@ "Somali": "Sómalska", "Southern Sotho": "Suður Sótó", "Spanish": "Spænska", - "Spanish (Latin America)": "Spænska (Rómönsku Ameríka)", + "Spanish (Latin America)": "Spænska (Rómanska Ameríka)", "Sundanese": "Sundaneska", "Swahili": "Svahílí", "Swedish": "Sænska", "Tajik": "Tadsikíska", "Tamil": "Tamílska", "Telugu": "Telúgú", - "Thai": "Taílenska", + "Thai": "Tælenska", "Turkish": "Tyrkneska", "Ukrainian": "Úkraníska", "Urdu": "Úrdú", @@ -334,41 +286,12 @@ "Yiddish": "Jiddíska", "Yoruba": "Jórúba", "Zulu": "Zúlú", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ár", - "": "`x` ár" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mánuð", - "": "`x` mánuði" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vika", - "": "`x` vikur" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dagur", - "": "`x` dagar" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` klukkustund", - "": "`x` klukkustundir" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mínúta", - "": "`x` mínútur" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekúnda", - "": "`x` sekúndur" - }, - "Fallback comments: ": "Vara ummæli: ", + "Fallback comments: ": "Ummæli til vara: ", "Popular": "Vinsælt", - "Search": "", - "Top": "Topp", + "Top": "Vinsælast", "About": "Um", "Rating: ": "Einkunn: ", - "Language: ": "Tungumál: ", + "preferences_locale_label": "Tungumál: ", "View as playlist": "Skoða sem spilunarlista", "Default": "Sjálfgefið", "Music": "Tónlist", @@ -384,35 +307,194 @@ "`x` marked it with a ❤": "`x` merkti það með ❤", "Audio mode": "Hljóð ham", "Video mode": "Myndband ham", - "Videos": "Myndbönd", + "channel_tab_videos_label": "Myndskeið", "Playlists": "Spilunarlistar", - "Community": "Samfélag", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Núverandi útgáfa: " -}
\ No newline at end of file + "channel_tab_community_label": "Samfélag", + "Current version: ": "Núverandi útgáfa: ", + "preferences_watch_history_label": "Virkja áhorfsferil: ", + "Chinese (China)": "Kínverska (Kína)", + "Turkish (auto-generated)": "Tyrkneska (sjálfvirkt útbúið)", + "Search": "Leita", + "preferences_save_player_pos_label": "Vista staðsetningu í afspilun: ", + "Popular enabled: ": "Vinsælt virkjað: ", + "search_filters_features_option_purchased": "Keypt", + "Standard YouTube license": "Staðlað YouTube-notkunarleyfi", + "French (auto-generated)": "Franska (sjálfvirkt útbúið)", + "Spanish (Spain)": "Spænska (Spánn)", + "search_filters_title": "Síur", + "search_filters_date_label": "Dags. innsendingar", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_hd": "HD", + "crash_page_read_the_faq": "lesið <a href=\"`x`\">Algengar spurningar (FAQ)</a>", + "Add to playlist": "Bæta á spilunarlista", + "Add to playlist: ": "Bæta á spilunarlista: ", + "Answer": "Svar", + "Search for videos": "Leita að myndskeiðum", + "generic_channels_count": "{{count}} rás", + "generic_channels_count_plural": "{{count}} rásir", + "generic_videos_count": "{{count}} myndskeið", + "generic_videos_count_plural": "{{count}} myndskeið", + "The Popular feed has been disabled by the administrator.": "Kerfisstjórinn hefur gert Vinsælt-streymið óvirkt.", + "generic_playlists_count": "{{count}} spilunarlisti", + "generic_playlists_count_plural": "{{count}} spilunarlistar", + "generic_subscribers_count": "{{count}} áskrifandi", + "generic_subscribers_count_plural": "{{count}} áskrifendur", + "generic_subscriptions_count": "{{count}} áskrift", + "generic_subscriptions_count_plural": "{{count}} áskriftir", + "generic_button_delete": "Eyða", + "Import YouTube watch history (.json)": "Flytja inn YouTube áhorfsferil (.json)", + "preferences_vr_mode_label": "Gagnvirk 360 gráðu myndskeið (krefst WebGL): ", + "preferences_quality_dash_option_auto": "Sjálfvirkt", + "preferences_quality_dash_option_best": "Best", + "preferences_quality_dash_option_worst": "Verst", + "preferences_quality_dash_label": "Æskileg DASH-gæði myndmerkis: ", + "preferences_extend_desc_label": "Sjálfvirkt útvíkka lýsingu á myndskeiði: ", + "preferences_region_label": "Land efnis: ", + "preferences_show_nick_label": "Birta gælunafn efst: ", + "tokens_count": "{{count}} teikn", + "tokens_count_plural": "{{count}} teikn", + "subscriptions_unseen_notifs_count": "{{count}} óskoðuð tilkynning", + "subscriptions_unseen_notifs_count_plural": "{{count}} óskoðaðar tilkynningar", + "Released under the AGPLv3 on Github.": "Gefið út með AGPLv3-notkunarleyfi á GitHub.", + "Music in this video": "Tónlist í þessu myndskeiði", + "Artist: ": "Flytjandi: ", + "Album: ": "Hljómplata: ", + "comments_view_x_replies": "Skoða {{count}} svar", + "comments_view_x_replies_plural": "Skoða {{count}} svör", + "comments_points_count": "{{count}} punktur", + "comments_points_count_plural": "{{count}} punktar", + "Cantonese (Hong Kong)": "Kantónska (Hong Kong)", + "Chinese": "Kínverska", + "Chinese (Hong Kong)": "Kínverska (Hong Kong)", + "Chinese (Taiwan)": "Kínverska (Taívan)", + "Japanese (auto-generated)": "Japanska (sjálfvirkt útbúið)", + "generic_count_minutes": "{{count}} mínúta", + "generic_count_minutes_plural": "{{count}} mínútur", + "generic_count_seconds": "{{count}} sekúnda", + "generic_count_seconds_plural": "{{count}} sekúndur", + "search_filters_date_option_hour": "Síðustu klukkustund", + "search_filters_apply_button": "Virkja valdar síur", + "next_steps_error_message_go_to_youtube": "Fara á YouTube", + "footer_original_source_code": "Upprunalegur grunnkóði", + "videoinfo_started_streaming_x_ago": "Byrjaði streymi fyrir `x` síðan", + "next_steps_error_message": "Á eftir þessu ættirðu að prófa: ", + "videoinfo_invidious_embed_link": "Ívefja tengil", + "download_subtitles": "Skjátextar - `x` (.vtt)", + "user_created_playlists": "`x` útbjó spilunarlista", + "user_saved_playlists": "`x` vistaði spilunarlista", + "Video unavailable": "Myndskeið ekki tiltækt", + "videoinfo_watch_on_youTube": "Skoða á YouTube", + "crash_page_you_found_a_bug": "Það lítur út eins og þú hafir fundið galla í Invidious!", + "crash_page_before_reporting": "Áður en þú tilkynnir villu, gakktu úr skugga um að þú hafir:", + "crash_page_switch_instance": "reynt að <a href=\"`x`\">nota annað tilvik</a>", + "crash_page_report_issue": "Ef ekkert af ofantöldu hjálpaði, ættirðu að <a href=\"`x`\">opna nýja verkbeiðni (issue) á GitHub</a> (helst á ensku) og láta fylgja eftirfarandi texta í skilaboðunum þínum (alls EKKI þýða þennan texta):", + "channel_tab_shorts_label": "Stuttmyndir", + "carousel_slide": "Skyggna {{current}} af {{total}}", + "carousel_go_to": "Fara á skyggnu `x`", + "channel_tab_streams_label": "Bein streymi", + "channel_tab_playlists_label": "Spilunarlistar", + "toggle_theme": "Víxla þema", + "carousel_skip": "Sleppa hringekjunni", + "preferences_quality_option_medium": "Miðlungs", + "search_message_use_another_instance": "Þú getur líka <a href=\"`x`\">leitað á öðrum netþjóni</a>.", + "footer_source_code": "Grunnkóði", + "English (United Kingdom)": "Enska (Bretland)", + "English (United States)": "Enska (Bandarísk)", + "Vietnamese (auto-generated)": "Víetnamska (sjálfvirkt útbúið)", + "generic_count_months": "{{count}} mánuður", + "generic_count_months_plural": "{{count}} mánuðir", + "search_filters_sort_option_rating": "Einkunn", + "videoinfo_youTube_embed_link": "Ívefja", + "error_video_not_in_playlist": "Umbeðið myndskeið fyrirfinnst ekki í þessum spilunarlista. <a href=\"`x`\">Smelltu hér til að fara á heimasíðu spilunarlistans.</a>", + "generic_views_count": "{{count}} áhorf", + "generic_views_count_plural": "{{count}} áhorf", + "playlist_button_add_items": "Bæta við myndskeiðum", + "Show more": "Sýna meira", + "Show less": "Sýna minna", + "Song: ": "Lag: ", + "channel_tab_podcasts_label": "Hlaðvörp (podcasts)", + "channel_tab_releases_label": "Útgáfur", + "Download is disabled": "Niðurhal er óvirkt", + "search_filters_features_option_location": "Staðsetning", + "preferences_quality_dash_option_720p": "720p", + "Switch Invidious Instance": "Skipta um Invidious-tilvik", + "search_message_no_results": "Engar niðurstöður fundust.", + "search_message_change_filters_or_query": "Reyndu að víkka leitarsviðið og/eða breyta síunum.", + "Dutch (auto-generated)": "Hollenska (sjálfvirkt útbúið)", + "German (auto-generated)": "Þýska (sjálfvirkt útbúið)", + "Indonesian (auto-generated)": "Indónesíska (sjálfvirkt útbúið)", + "Interlingue": "Interlingue", + "Italian (auto-generated)": "Ítalska (sjálfvirkt útbúið)", + "Russian (auto-generated)": "Rússneska (sjálfvirkt útbúið)", + "Spanish (auto-generated)": "Spænska (sjálfvirkt útbúið)", + "Spanish (Mexico)": "Spænska (Mexíkó)", + "generic_count_hours": "{{count}} klukkustund", + "generic_count_hours_plural": "{{count}} klukkustundir", + "generic_count_years": "{{count}} ár", + "generic_count_years_plural": "{{count}} ár", + "generic_count_weeks": "{{count}} vika", + "generic_count_weeks_plural": "{{count}} vikur", + "search_filters_date_option_none": "Hvaða dagsetning sem er", + "Channel Sponsor": "Styrktaraðili rásar", + "search_filters_date_option_week": "Í þessari viku", + "search_filters_date_option_month": "Í þessum mánuði", + "search_filters_date_option_year": "Á þessu ári", + "search_filters_type_option_playlist": "Spilunarlisti", + "search_filters_type_option_show": "Þáttur", + "search_filters_duration_label": "Tímalengd", + "search_filters_duration_option_long": "Langt (> 20 mínútur)", + "search_filters_features_option_live": "Beint", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_vr180": "VR180", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_hdr": "HDR", + "search_filters_sort_label": "Raða eftir", + "search_filters_sort_option_relevance": "Samsvörun", + "footer_donate_page": "Styrkja", + "footer_modfied_source_code": "Breyttur grunnkóði", + "crash_page_refresh": "reynt að <a href=\"`x`\">endurlesa síðuna</a>", + "crash_page_search_issue": "leitað að <a href=\"`x`\">fyrirliggjandi villum á GitHub</a>", + "none": "ekkert", + "adminprefs_modified_source_code_url_label": "Slóð á gagnasafn með breyttum grunnkóða", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_small": "Lítið", + "preferences_category_misc": "Ýmsar kjörstillingar", + "preferences_automatic_instance_redirect_label": "Sjálfvirk endurbeining tilvika (farið til vara á redirect.invidious.io): ", + "Portuguese (auto-generated)": "Portúgalska (sjálfvirkt útbúið)", + "Portuguese (Brazil)": "Portúgalska (Brasilía)", + "generic_button_edit": "Breyta", + "generic_button_save": "Vista", + "generic_button_cancel": "Hætta við", + "generic_button_rss": "RSS", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "Korean (auto-generated)": "Kóreska (sjálfvirkt útbúið)", + "generic_count_days": "{{count}} dagur", + "generic_count_days_plural": "{{count}} dagar", + "search_filters_date_option_today": "Í dag", + "search_filters_type_label": "Tegund", + "search_filters_type_option_all": "Hvaða tegund sem er", + "search_filters_type_option_video": "Myndskeið", + "search_filters_type_option_channel": "Rás", + "search_filters_type_option_movie": "Kvikmynd", + "search_filters_duration_option_none": "Hvaða lengd sem er", + "search_filters_duration_option_short": "Stutt (< 4 mínútur)", + "search_filters_duration_option_medium": "Miðlungs (4 - 20 mínútur)", + "search_filters_features_label": "Eiginleikar", + "search_filters_features_option_subtitles": "Skjátextar/CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_sort_option_date": "Dags. innsendingar", + "search_filters_sort_option_views": "Fjöldi áhorfa", + "next_steps_error_message_refresh": "Endurlesa", + "footer_documentation": "Leiðbeiningar", + "channel_tab_channels_label": "Rásir", + "Import YouTube playlist (.csv)": "Flytja inn YouTube spilunarlista (.csv)", + "preferences_quality_option_dash": "DASH (aðlaganleg gæði)" +} diff --git a/locales/it.json b/locales/it.json index e2228bca..309adb13 100644 --- a/locales/it.json +++ b/locales/it.json @@ -1,16 +1,13 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscritto", - "": "`x` iscritti" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", - "": "`x` video" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist", - "": "`x` playlist" - }, + "generic_subscribers_count_0": "{{count}} iscritto", + "generic_subscribers_count_1": "{{count}} iscritti", + "generic_subscribers_count_2": "{{count}} iscritti", + "generic_videos_count_0": "{{count}} video", + "generic_videos_count_1": "{{count}} video", + "generic_videos_count_2": "{{count}} video", + "generic_playlists_count_0": "{{count}} playlist", + "generic_playlists_count_1": "{{count}} playlist", + "generic_playlists_count_2": "{{count}} playlist", "LIVE": "IN DIRETTA", "Shared `x` ago": "Condiviso `x` fa", "Unsubscribe": "Disiscriviti", @@ -19,29 +16,28 @@ "View playlist on YouTube": "Vedi playlist su YouTube", "newest": "più recente", "oldest": "più vecchio", - "popular": "Tendenze", - "last": "durare", + "popular": "popolare", + "last": "ultimo", "Next page": "Pagina successiva", "Previous page": "Pagina precedente", "Clear watch history?": "Eliminare la cronologia dei video guardati?", "New password": "Nuova password", "New passwords must match": "Le nuove password devono corrispondere", - "Cannot change password for Google accounts": "Non è possibile modificare la password per gli account Google", "Authorize token?": "Autorizzare gettone?", "Authorize token for `x`?": "Autorizzare gettone per `x`?", "Yes": "Sì", "No": "No", "Import and Export Data": "Importazione ed esportazione dati", "Import": "Importa", - "Import Invidious data": "Importa dati Invidious", - "Import YouTube subscriptions": "Importa le iscrizioni da YouTube", + "Import Invidious data": "Importa dati Invidious in formato JSON", + "Import YouTube subscriptions": "Importa iscrizioni in CSV o OPML di YouTube", "Import FreeTube subscriptions (.db)": "Importa le iscrizioni da FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importa le iscrizioni da NewPipe (.json)", "Import NewPipe data (.zip)": "Importa i dati di NewPipe (.zip)", "Export": "Esporta", "Export subscriptions as OPML": "Esporta gli abbonamenti come OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Esporta gli abbonamenti come OPML (per NewPipe e FreeTube)", - "Export data as JSON": "Esporta i dati in formato JSON", + "Export data as JSON": "Esporta i dati Invidious in formato JSON", "Delete account?": "Eliminare l'account?", "History": "Cronologia", "An alternative front-end to YouTube": "Un'interfaccia alternativa per YouTube", @@ -49,7 +45,6 @@ "source": "sorgente", "Log in": "Accedi", "Log in/register": "Accedi/Registrati", - "Log in with Google": "Accedi con Google", "User ID": "ID utente", "Password": "Password", "Time (h:mm:ss):": "Orario (h:mm:ss):", @@ -58,38 +53,36 @@ "Sign In": "Accedi", "Register": "Registrati", "E-mail": "E-mail", - "Google verification code": "Codice di verifica Google", "Preferences": "Preferenze", - "Player preferences": "Preferenze del riproduttore", - "Always loop: ": "Ripeti sempre: ", - "Autoplay: ": "Riproduzione automatica: ", - "Play next by default: ": "Riproduzione successiva predefinita: ", - "Autoplay next video: ": "Riproduci automaticamente il video successivo: ", - "Listen by default: ": "Modalità solo audio predefinita: ", - "Proxy videos: ": "Proxy per i video: ", - "Default speed: ": "Velocità predefinita: ", - "Preferred video quality: ": "Qualità video preferita: ", - "Player volume: ": "Volume di riproduzione: ", - "Default comments: ": "Origine dei commenti: ", + "preferences_category_player": "Preferenze del riproduttore", + "preferences_video_loop_label": "Ripeti sempre: ", + "preferences_autoplay_label": "Riproduzione automatica: ", + "preferences_continue_label": "Riproduzione successiva predefinita: ", + "preferences_continue_autoplay_label": "Riproduci automaticamente il video successivo: ", + "preferences_listen_label": "Modalità solo audio predefinita: ", + "preferences_local_label": "Proxy per i video: ", + "preferences_speed_label": "Velocità predefinita: ", + "preferences_quality_label": "Qualità video preferita: ", + "preferences_volume_label": "Volume di riproduzione: ", + "preferences_comments_label": "Origine dei commenti: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Sottotitoli predefiniti: ", + "preferences_captions_label": "Sottotitoli predefiniti: ", "Fallback captions: ": "Sottotitoli alternativi: ", - "Show related videos: ": "Mostra video correlati: ", - "Show annotations by default: ": "Mostra le annotazioni in modo predefinito: ", - "Automatically extend video description: ": "", - "Visual preferences": "Preferenze grafiche", - "Player style: ": "Stile riproduttore: ", + "preferences_related_videos_label": "Mostra video correlati: ", + "preferences_annotations_label": "Mostra le annotazioni in modo predefinito: ", + "preferences_category_visual": "Preferenze grafiche", + "preferences_player_style_label": "Stile riproduttore: ", "Dark mode: ": "Tema scuro: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "scuro", "light": "chiaro", - "Thin mode: ": "Modalità per connessioni lente: ", - "Subscription preferences": "Preferenze iscrizioni", - "Show annotations by default for subscribed channels: ": "Mostrare annotazioni in modo predefinito per i canali sottoscritti: ", + "preferences_thin_mode_label": "Modalità per connessioni lente: ", + "preferences_category_subscription": "Preferenze iscrizioni", + "preferences_annotations_subscribed_label": "Mostrare annotazioni in modo predefinito per i canali sottoscritti: ", "Redirect homepage to feed: ": "Reindirizza la pagina principale a quella delle iscrizioni: ", - "Number of videos shown in feed: ": "Numero di video da mostrare nelle iscrizioni: ", - "Sort videos by: ": "Ordina i video per: ", + "preferences_max_results_label": "Numero di video da mostrare nelle iscrizioni: ", + "preferences_sort_label": "Ordina i video per: ", "published": "data di pubblicazione", "published - reverse": "data di pubblicazione - decrescente", "alphabetically": "ordine alfabetico", @@ -98,12 +91,12 @@ "channel name - reverse": "nome del canale - decrescente", "Only show latest video from channel: ": "Mostra solo il video più recente del canale: ", "Only show latest unwatched video from channel: ": "Mostra solo il video più recente non guardato del canale: ", - "Only show unwatched: ": "Mostra solo i video non guardati: ", - "Only show notifications (if there are any): ": "Mostra solo le notifiche (se presenti): ", + "preferences_unseen_only_label": "Mostra solo i video non guardati: ", + "preferences_notifications_only_label": "Mostra solo le notifiche (se presenti): ", "Enable web notifications": "Attiva le notifiche web", "`x` uploaded a video": "`x` ha caricato un video", "`x` is live": "`x` è in diretta", - "Data preferences": "Preferenze dati", + "preferences_category_data": "Preferenze dati", "Clear watch history": "Cancella la cronologia dei video guardati", "Import/export data": "Importazione/esportazione dati", "Change password": "Modifica password", @@ -111,9 +104,9 @@ "Manage tokens": "Gestisci i gettoni", "Watch history": "Cronologia dei video", "Delete account": "Elimina l'account", - "Administrator preferences": "Preferenze amministratore", - "Default homepage: ": "Pagina principale predefinita: ", - "Feed menu: ": "Menu iscrizioni: ", + "preferences_category_admin": "Preferenze amministratore", + "preferences_default_home_label": "Pagina principale predefinita: ", + "preferences_feed_menu_label": "Menu iscrizioni: ", "Top enabled: ": "Top abilitato: ", "CAPTCHA enabled: ": "CAPTCHA attivati: ", "Login enabled: ": "Accesso attivato: ", @@ -123,25 +116,21 @@ "Subscription manager": "Gestione delle iscrizioni", "Token manager": "Gestione dei gettoni", "Token": "Gettone", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscrizione", - "": "`x` iscrizioni" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gettone", - "": "`x` gettoni" - }, + "generic_subscriptions_count_0": "{{count}} iscrizione", + "generic_subscriptions_count_1": "{{count}} iscrizioni", + "generic_subscriptions_count_2": "{{count}} iscrizioni", + "tokens_count_0": "{{count}} gettone", + "tokens_count_1": "{{count}} gettoni", + "tokens_count_2": "{{count}} gettoni", "Import/export": "Importa/esporta", "unsubscribe": "disiscriviti", "revoke": "revoca", "Subscriptions": "Iscrizioni", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notifica non visualizzata", - "": "`x` notifiche non visualizzate" - }, + "subscriptions_unseen_notifs_count_0": "{{count}} notifica non visualizzata", + "subscriptions_unseen_notifs_count_1": "{{count}} notifiche non visualizzate", + "subscriptions_unseen_notifs_count_2": "{{count}} notifiche non visualizzate", "search": "Cerca", "Log out": "Esci", - "Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.", "Source available here.": "Codice sorgente.", "View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.", "View privacy policy.": "Vedi la politica sulla privacy.", @@ -157,8 +146,6 @@ "Title": "Titolo", "Playlist privacy": "Privacy playlist", "Editing playlist `x`": "Modificando la playlist `x`", - "Show more": "", - "Show less": "", "Watch on YouTube": "Guarda su YouTube", "Hide annotations": "Nascondi annotazioni", "Show annotations": "Mostra annotazioni", @@ -170,13 +157,12 @@ "Whitelisted regions: ": "Regioni in lista bianca: ", "Blacklisted regions: ": "Regioni in lista nera: ", "Shared `x`": "Condiviso `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizzazione", - "": "`x` visualizzazioni" - }, + "generic_views_count_0": "{{count}} visualizzazione", + "generic_views_count_1": "{{count}} visualizzazioni", + "generic_views_count_2": "{{count}} visualizzazioni", "Premieres in `x`": "In anteprima in `x`", "Premieres `x`": "In anteprima `x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Ciao! Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti. Considera che potrebbe volerci più tempo.", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Ciao, Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti, ma considera che il caricamento potrebbe richiedere più tempo.", "View YouTube comments": "Visualizza i commenti da YouTube", "View more comments on Reddit": "Visualizza più commenti su Reddit", "View `x` comments": { @@ -187,17 +173,12 @@ "Hide replies": "Nascondi le risposte", "Show replies": "Mostra le risposte", "Incorrect password": "Password sbagliata", - "Quota exceeded, try again in a few hours": "Limite superato, prova di nuovo fra qualche ora", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Impossibile autenticarsi, controlla che l'autenticazione in due passaggi (Authenticator o SMS) sia attiva.", - "Invalid TFA code": "Codice di autenticazione a due fattori non valido", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Login fallito. L'errore potrebbe essere causato dal fatto che la verifica in due passaggi non è attiva sul tuo account.", "Wrong answer": "Risposta errata", "Erroneous CAPTCHA": "CAPTCHA errato", "CAPTCHA is a required field": "Il CAPTCHA è un campo obbligatorio", "User ID is a required field": "L'ID utente è obbligatorio", "Password is a required field": "La password è un campo obbligatorio", "Wrong username or password": "Nome utente o password errati", - "Please sign in using 'Log in with Google'": "Per favore accedi con «Entra con Google»", "Password cannot be empty": "La password non può essere vuota", "Password cannot be longer than 55 characters": "La password non può contenere più di 55 caratteri", "Please log in": "Per favore, accedi", @@ -207,16 +188,8 @@ "This channel does not exist.": "Questo canale non esiste.", "Could not get channel info.": "Impossibile ottenere le informazioni del canale.", "Could not fetch comments": "Impossibile recuperare i commenti", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Visualizza `x` risposta", - "": "Visualizza `x` risposte" - }, "`x` ago": "`x` fa", "Load more": "Carica altro", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` punto", - "": "`x` punti" - }, "Could not create mix.": "Impossibile creare il mix.", "Empty playlist": "Playlist vuota", "Not a playlist.": "Non è una playlist.", @@ -238,7 +211,7 @@ "Azerbaijani": "Azero", "Bangla": "Bengalese", "Basque": "Basco", - "Belarusian": "Biellorusso", + "Belarusian": "Bielorusso", "Bosnian": "Bosniaco", "Bulgarian": "Bulgaro", "Burmese": "Birmano", @@ -264,10 +237,10 @@ "Haitian Creole": "Creolo haitiano", "Hausa": "Lingua hausa", "Hawaiian": "Hawaiano", - "Hebrew": "Ebreo", + "Hebrew": "Ebraico", "Hindi": "Hindi", "Hmong": "Hmong", - "Hungarian": "Ungarese", + "Hungarian": "Ungherese", "Icelandic": "Islandese", "Igbo": "Igbo", "Indonesian": "Indonesiano", @@ -280,7 +253,7 @@ "Khmer": "Khmer", "Korean": "Coreano", "Kurdish": "Curdo", - "Kyrgyz": "Kirghize", + "Kyrgyz": "Kirghiso", "Lao": "Lao", "Latin": "Latino", "Latvian": "Lettone", @@ -295,7 +268,7 @@ "Marathi": "Marathi", "Mongolian": "Mongolo", "Nepali": "Nepalese", - "Norwegian Bokmål": "Norvegese", + "Norwegian Bokmål": "Norvegese bokmål", "Nyanja": "Nyanja", "Pashto": "Pashtu", "Persian": "Persiano", @@ -304,7 +277,7 @@ "Punjabi": "Punjabi", "Romanian": "Rumeno", "Russian": "Russo", - "Samoan": "Samoan", + "Samoan": "Samoano", "Scottish Gaelic": "Gaelico scozzese", "Serbian": "Serbo", "Shona": "Shona", @@ -316,59 +289,52 @@ "Southern Sotho": "Sotho del Sud", "Spanish": "Spagnolo", "Spanish (Latin America)": "Spagnolo (America latina)", - "Sundanese": "Sudanese", + "Sundanese": "Sundanese", "Swahili": "Swahili", "Swedish": "Svedese", - "Tajik": "Tajik", + "Tajik": "Tagico", "Tamil": "Tamil", "Telugu": "Telugu", - "Thai": "Thaï", + "Thai": "Thailandese", "Turkish": "Turco", "Ukrainian": "Ucraino", "Urdu": "Urdu", "Uzbek": "Uzbeco", - "Vietnamese": "Vietnamese", + "Vietnamese": "Vietnamita", "Welsh": "Gallese", "Western Frisian": "Frisone occidentale", "Xhosa": "Xhosa", "Yiddish": "Yiddish", "Yoruba": "Yoruba", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anno", - "": "`x` anni" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mese", - "": "`x` mesi" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` settimana", - "": "`x` settimane" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` giorno", - "": "`x` giorni" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ora", - "": "`x` ore" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto", - "": "`x` minuti" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` secondo", - "": "`x` secondi" - }, + "generic_count_years_0": "{{count}} anno", + "generic_count_years_1": "{{count}} anni", + "generic_count_years_2": "{{count}} anni", + "generic_count_months_0": "{{count}} mese", + "generic_count_months_1": "{{count}} mesi", + "generic_count_months_2": "{{count}} mesi", + "generic_count_weeks_0": "{{count}} settimana", + "generic_count_weeks_1": "{{count}} settimane", + "generic_count_weeks_2": "{{count}} settimane", + "generic_count_days_0": "{{count}} giorno", + "generic_count_days_1": "{{count}} giorni", + "generic_count_days_2": "{{count}} giorni", + "generic_count_hours_0": "{{count}} ora", + "generic_count_hours_1": "{{count}} ore", + "generic_count_hours_2": "{{count}} ore", + "generic_count_minutes_0": "{{count}} minuto", + "generic_count_minutes_1": "{{count}} minuti", + "generic_count_minutes_2": "{{count}} minuti", + "generic_count_seconds_0": "{{count}} secondo", + "generic_count_seconds_1": "{{count}} secondi", + "generic_count_seconds_2": "{{count}} secondi", "Fallback comments: ": "Commenti alternativi: ", "Popular": "Popolare", - "Search": "", + "Search": "Cerca", "Top": "Top", "About": "Al riguardo", "Rating: ": "Punteggio: ", - "Language: ": "Lingua: ", + "preferences_locale_label": "Lingua: ", "View as playlist": "Vedi come playlist", "Default": "Predefinito", "Music": "Musica", @@ -380,39 +346,172 @@ "%A %B %-d, %Y": "%A %-d %B %Y", "(edited)": "(modificato)", "YouTube comment permalink": "Link permanente al commento di YouTube", - "permalink": "permalink", + "permalink": "perma-collegamento", "`x` marked it with a ❤": "`x` l'ha contrassegnato con un ❤", "Audio mode": "Modalità audio", "Video mode": "Modalità video", - "Videos": "Video", + "channel_tab_videos_label": "Video", "Playlists": "Playlist", - "Community": "Comunità", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Versione attuale: " -}
\ No newline at end of file + "search_filters_sort_option_relevance": "Pertinenza", + "search_filters_sort_option_rating": "Valutazione", + "search_filters_sort_option_date": "Data di caricamento", + "search_filters_sort_option_views": "Numero di visualizzazioni", + "search_filters_type_label": "Tipo", + "search_filters_duration_label": "Durata", + "search_filters_features_label": "Caratteristiche", + "search_filters_sort_label": "Ordina per", + "search_filters_date_option_hour": "Ultima ora", + "search_filters_date_option_today": "Oggi", + "search_filters_date_option_week": "Questa settimana", + "search_filters_date_option_month": "Questo mese", + "search_filters_date_option_year": "Quest'anno", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Canale", + "search_filters_type_option_playlist": "Playlist", + "search_filters_type_option_movie": "Film", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Sottotitoli / CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "In diretta", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Posizione", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Versione attuale: ", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_4320p": "4320p", + "search_filters_features_option_three_sixty": "360°", + "preferences_quality_dash_option_144p": "144p", + "Released under the AGPLv3 on Github.": "Pubblicato su GitHub con licenza AGPLv3.", + "preferences_quality_option_medium": "Media", + "preferences_quality_option_small": "Limitata", + "preferences_quality_dash_option_best": "Migliore", + "preferences_quality_dash_option_worst": "Peggiore", + "invidious": "Invidious", + "preferences_quality_dash_label": "Qualità video DASH preferita: ", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_auto": "Automatica", + "videoinfo_watch_on_youTube": "Guarda su YouTube", + "preferences_extend_desc_label": "Estendi automaticamente la descrizione del video: ", + "preferences_vr_mode_label": "Video interattivi a 360 gradi: ", + "Show less": "Mostra di meno", + "Switch Invidious Instance": "Cambia istanza Invidious", + "next_steps_error_message_go_to_youtube": "Andare su YouTube", + "footer_documentation": "Documentazione", + "footer_original_source_code": "Codice sorgente originale", + "footer_modfied_source_code": "Codice sorgente modificato", + "none": "nessuno", + "videoinfo_started_streaming_x_ago": "Ha iniziato a trasmettere `x` fa", + "download_subtitles": "Sottotitoli - `x` (.vtt)", + "user_saved_playlists": "playlist salvate da `x`", + "preferences_automatic_instance_redirect_label": "Reindirizzamento automatico dell'istanza (ripiego su redirect.invidious.io): ", + "Video unavailable": "Video non disponibile", + "preferences_show_nick_label": "Mostra nickname in alto: ", + "videoinfo_youTube_embed_link": "Incorpora", + "videoinfo_invidious_embed_link": "Incorpora collegamento", + "user_created_playlists": "playlist create da `x`", + "preferences_save_player_pos_label": "Memorizza il minutaggio raggiunto dal video: ", + "preferences_quality_option_dash": "DASH (qualità adattiva)", + "preferences_region_label": "Nazione del contenuto: ", + "preferences_category_misc": "Preferenze varie", + "next_steps_error_message": "Dopodiché dovresti provare a: ", + "next_steps_error_message_refresh": "Aggiornare", + "footer_donate_page": "Dona", + "footer_source_code": "Codice sorgente", + "adminprefs_modified_source_code_url_label": "Link per il repository del codice sorgente modificato", + "Show more": "Mostra di più", + "search_filters_title": "Filtra", + "search_filters_type_option_show": "Serie", + "search_filters_duration_option_short": "Corto (< 4 minuti)", + "search_filters_duration_option_long": "Lungo (> 20 minuti)", + "search_filters_features_option_purchased": "Acquistato", + "comments_view_x_replies_0": "Vedi {{count}} risposta", + "comments_view_x_replies_1": "Vedi {{count}} risposte", + "comments_view_x_replies_2": "Vedi {{count}} risposte", + "comments_points_count_0": "{{count}} punto", + "comments_points_count_1": "{{count}} punti", + "comments_points_count_2": "{{count}} punti", + "Portuguese (auto-generated)": "Portoghese (generati automaticamente)", + "crash_page_you_found_a_bug": "Sembra che tu abbia trovato un bug in Invidious!", + "crash_page_switch_instance": "provato a <a href=\"`x`\">usare un'altra istanza</a>", + "crash_page_before_reporting": "Prima di segnalare un bug, assicurati di aver:", + "crash_page_read_the_faq": "letto le <a href=\"`x`\">domande più frequenti (FAQ)</a>", + "crash_page_search_issue": "cercato tra <a href=\"`x`\"> i problemi esistenti su GitHub</a>", + "crash_page_report_issue": "Se niente di tutto ciò ha aiutato, per favore <a href=\"`x`\">apri un nuovo problema su GitHub</a> (preferibilmente in inglese) e includi il seguente testo nel tuo messaggio (NON tradurre il testo):", + "Popular enabled: ": "Popolare attivato: ", + "English (United Kingdom)": "Inglese (Regno Unito)", + "Portuguese (Brazil)": "Portoghese (Brasile)", + "preferences_watch_history_label": "Attiva cronologia di riproduzione: ", + "French (auto-generated)": "Francese (generati automaticamente)", + "search_message_use_another_instance": "Puoi anche <a href=\"`x`\">cercare in un'altra istanza</a>.", + "search_message_no_results": "Nessun risultato trovato.", + "search_message_change_filters_or_query": "Prova ad ampliare la ricerca e/o modificare i filtri.", + "English (United States)": "Inglese (Stati Uniti)", + "Cantonese (Hong Kong)": "Cantonese (Hong Kong)", + "Chinese": "Cinese", + "Chinese (China)": "Cinese (Cina)", + "Chinese (Hong Kong)": "Cinese (Hong Kong)", + "Chinese (Taiwan)": "Cinese (Taiwan)", + "Dutch (auto-generated)": "Olandese (generati automaticamente)", + "German (auto-generated)": "Tedesco (generati automaticamente)", + "Indonesian (auto-generated)": "Indonesiano (generati automaticamente)", + "Interlingue": "Interlingua", + "Italian (auto-generated)": "Italiano (generati automaticamente)", + "Japanese (auto-generated)": "Giapponese (generati automaticamente)", + "Korean (auto-generated)": "Coreano (generati automaticamente)", + "Russian (auto-generated)": "Russo (generati automaticamente)", + "Spanish (auto-generated)": "Spagnolo (generati automaticamente)", + "Spanish (Mexico)": "Spagnolo (Messico)", + "Spanish (Spain)": "Spagnolo (Spagna)", + "Turkish (auto-generated)": "Turco (auto-generato)", + "Vietnamese (auto-generated)": "Vietnamita (auto-generato)", + "search_filters_date_label": "Data caricamento", + "search_filters_date_option_none": "Qualunque data", + "search_filters_type_option_all": "Qualunque tipo", + "search_filters_duration_option_none": "Qualunque durata", + "search_filters_duration_option_medium": "Media (4 - 20 minuti)", + "search_filters_features_option_vr180": "VR180", + "search_filters_apply_button": "Applica filtri selezionati", + "crash_page_refresh": "provato a <a href=\"`x`\">ricaricare la pagina</a>", + "error_video_not_in_playlist": "Il video richiesto non esiste in questa playlist. <a href=\"`x`\">Fai clic qui per la pagina iniziale della playlist.</a>", + "channel_tab_shorts_label": "Short", + "channel_tab_playlists_label": "Playlist", + "channel_tab_channels_label": "Canali", + "channel_tab_streams_label": "Trasmissioni in diretta", + "channel_tab_community_label": "Comunità", + "Music in this video": "Musica in questo video", + "Artist: ": "Artista: ", + "Album: ": "Album: ", + "Download is disabled": "Il download è disabilitato", + "Song: ": "Canzone: ", + "Standard YouTube license": "Licenza standard di YouTube", + "Channel Sponsor": "Sponsor del canale", + "Import YouTube playlist (.csv)": "Importa playlist di YouTube (.csv)", + "generic_button_edit": "Modifica", + "generic_button_cancel": "Annulla", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "Pubblicazioni", + "generic_button_delete": "Elimina", + "generic_button_save": "Salva", + "playlist_button_add_items": "Aggiungi video", + "channel_tab_podcasts_label": "Podcast", + "generic_channels_count_0": "{{count}} canale", + "generic_channels_count_1": "{{count}} canali", + "generic_channels_count_2": "{{count}} canali", + "Import YouTube watch history (.json)": "Importa la cronologia delle visualizzazioni di YouTube (.json)", + "Answer": "Risposta", + "toggle_theme": "Cambia Tema", + "Add to playlist": "Aggiungi alla playlist", + "Add to playlist: ": "Aggiungi alla playlist ", + "Search for videos": "Cerca dei video", + "The Popular feed has been disabled by the administrator.": "La sezione dei contenuti popolari è stata disabilitata dall'amministratore.", + "carousel_slide": "Fotogramma {{current}} di {{total}}", + "carousel_skip": "Salta la galleria", + "carousel_go_to": "Vai al fotogramma `x`" +} diff --git a/locales/ja.json b/locales/ja.json index e570ec35..7fc9d604 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1,22 +1,15 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 人の登録者", - "": "`x` 人の登録者" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の動画", - "": "`x` 個の動画" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の再生リスト", - "": "`x` 個の再生リスト" - }, + "generic_views_count_0": "{{count}} 回視聴", + "generic_videos_count_0": "{{count}}本の動画", + "generic_playlists_count_0": "{{count}}個の再生リスト", + "generic_subscribers_count_0": "{{count}} 人の登録者", + "generic_subscriptions_count_0": "{{count}}個の登録チャンネル", "LIVE": "ライブ", - "Shared `x` ago": "`x`前に共有", + "Shared `x` ago": "`x`前に公開", "Unsubscribe": "登録解除", "Subscribe": "登録", - "View channel on YouTube": "YouTube でチャンネルを見る", - "View playlist on YouTube": "YouTube で再生リストを見る", + "View channel on YouTube": "YouTube でチャンネルを表示", + "View playlist on YouTube": "YouTube で再生リストを表示", "newest": "新しい順", "oldest": "古い順", "popular": "人気順", @@ -26,22 +19,21 @@ "Clear watch history?": "再生履歴を削除しますか?", "New password": "新しいパスワード", "New passwords must match": "新しいパスワードが一致していません", - "Cannot change password for Google accounts": "Google アカウントのパスワードは変更できません", "Authorize token?": "トークンを認証しますか?", "Authorize token for `x`?": "トークン `x` を認証しますか?", "Yes": "はい", "No": "いいえ", "Import and Export Data": "データのインポートとエクスポート", "Import": "インポート", - "Import Invidious data": "Invidious データをインポート", - "Import YouTube subscriptions": "YouTube 登録チャンネルをインポート", + "Import Invidious data": "Invidious JSONデータをインポート", + "Import YouTube subscriptions": "YouTube/OPML 登録チャンネルをインポート", "Import FreeTube subscriptions (.db)": "FreeTube 登録チャンネルをインポート (.db)", "Import NewPipe subscriptions (.json)": "NewPipe 登録チャンネルをインポート (.json)", "Import NewPipe data (.zip)": "NewPipe データをインポート (.zip)", "Export": "エクスポート", "Export subscriptions as OPML": "登録チャンネルを OPML でエクスポート", "Export subscriptions as OPML (for NewPipe & FreeTube)": "登録チャンネルを OPML でエクスポート (NewPipe & FreeTube 用)", - "Export data as JSON": "データを JSON でエクスポート", + "Export data as JSON": "Invidious のデータを JSON でエクスポート", "Delete account?": "アカウントを削除しますか?", "History": "履歴", "An alternative front-end to YouTube": "YouTube 向けの代用フロントエンド", @@ -49,7 +41,6 @@ "source": "ソース", "Log in": "ログイン", "Log in/register": "ログイン/登録", - "Log in with Google": "Google でログイン", "User ID": "ユーザー ID", "Password": "パスワード", "Time (h:mm:ss):": "時間 (時:分分:秒秒):", @@ -58,38 +49,40 @@ "Sign In": "サインイン", "Register": "登録", "E-mail": "メールアドレス", - "Google verification code": "Google 認証コード", "Preferences": "設定", - "Player preferences": "プレイヤー設定", - "Always loop: ": "常にループ: ", - "Autoplay: ": "自動再生: ", - "Play next by default: ": "デフォルトで次を再生: ", - "Autoplay next video: ": "次の動画を自動再生: ", - "Listen by default: ": "デフォルトでオーディオモードを使用: ", - "Proxy videos: ": "動画をプロキシーに通す: ", - "Default speed: ": "デフォルトの再生速度: ", - "Preferred video quality: ": "優先する画質: ", - "Player volume: ": "プレイヤーの音量: ", - "Default comments: ": "デフォルトのコメント: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "デフォルトの字幕: ", + "preferences_category_player": "プレイヤーの設定", + "preferences_video_loop_label": "常にループ: ", + "preferences_autoplay_label": "自動再生: ", + "preferences_continue_label": "次の動画に移動: ", + "preferences_continue_autoplay_label": "次の動画を自動再生: ", + "preferences_listen_label": "音声モードを使用: ", + "preferences_local_label": "動画視聴にプロキシを経由: ", + "preferences_speed_label": "再生速度の初期値: ", + "preferences_quality_label": "優先する画質: ", + "preferences_volume_label": "プレイヤーの音量: ", + "preferences_comments_label": "デフォルトのコメント: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "優先する字幕: ", "Fallback captions: ": "フォールバック時の字幕: ", - "Show related videos: ": "関連動画を表示: ", - "Show annotations by default: ": "デフォルトでアノテーションを表示: ", - "Automatically extend video description: ": "", - "Visual preferences": "外観設定", - "Player style: ": "プレイヤースタイル: ", + "preferences_related_videos_label": "関連動画を表示: ", + "preferences_annotations_label": "最初からアノテーションを表示: ", + "preferences_extend_desc_label": "動画の説明文を自動的に拡張: ", + "preferences_vr_mode_label": "対話的な360°動画 (WebGLが必要): ", + "preferences_category_visual": "外観設定", + "preferences_player_style_label": "プレイヤーのスタイル: ", "Dark mode: ": "ダークモード: ", - "Theme: ": "テーマ: ", + "preferences_dark_mode_label": "テーマ: ", "dark": "ダーク", "light": "ライト", - "Thin mode: ": "最小モード: ", - "Subscription preferences": "登録チャンネル設定", - "Show annotations by default for subscribed channels: ": "デフォルトで登録チャンネルのアノテーションを表示しますか? ", + "preferences_thin_mode_label": "最小モード: ", + "preferences_category_misc": "ほかの設定", + "preferences_automatic_instance_redirect_label": "インスタンスの自動転送 (redirect.invidious.ioにフォールバック): ", + "preferences_category_subscription": "登録チャンネル設定", + "preferences_annotations_subscribed_label": "最初から登録チャンネルのアノテーションを表示 ", "Redirect homepage to feed: ": "ホームからフィードにリダイレクト: ", - "Number of videos shown in feed: ": "フィードに表示する動画の量: ", - "Sort videos by: ": "動画を並び替え: ", + "preferences_max_results_label": "フィードに表示する動画数: ", + "preferences_sort_label": "動画を並び替え: ", "published": "投稿日", "published - reverse": "投稿日 - 逆順", "alphabetically": "アルファベット", @@ -98,12 +91,12 @@ "channel name - reverse": "チャンネル名 - 逆順", "Only show latest video from channel: ": "チャンネルの最新動画のみを表示: ", "Only show latest unwatched video from channel: ": "チャンネルの最新未視聴動画のみを表示: ", - "Only show unwatched: ": "未視聴のみを表示: ", - "Only show notifications (if there are any): ": "通知のみを表示 (ある場合): ", + "preferences_unseen_only_label": "未視聴のみを表示: ", + "preferences_notifications_only_label": "通知のみを表示 (ある場合): ", "Enable web notifications": "ウェブ通知を有効化", "`x` uploaded a video": "`x` が動画を投稿しました", "`x` is live": "`x` がライブ中です", - "Data preferences": "データ設定", + "preferences_category_data": "データ設定", "Clear watch history": "再生履歴の削除", "Import/export data": "データのインポート/エクスポート", "Change password": "パスワードを変更", @@ -111,123 +104,100 @@ "Manage tokens": "トークンを管理", "Watch history": "再生履歴", "Delete account": "アカウントを削除", - "Administrator preferences": "管理者設定", - "Default homepage: ": "デフォルトのホーム: ", - "Feed menu: ": "フィードメニュー: ", + "preferences_category_admin": "管理者設定", + "preferences_default_home_label": "ホームに表示するページ: ", + "preferences_feed_menu_label": "フィードのメニュー: ", + "preferences_show_nick_label": "ログイン名を上部に表示: ", "Top enabled: ": "トップページを有効化: ", "CAPTCHA enabled: ": "CAPTCHA を有効化: ", "Login enabled: ": "ログインを有効化: ", "Registration enabled: ": "登録を有効化: ", "Report statistics: ": "統計を報告: ", "Save preferences": "設定を保存", - "Subscription manager": "登録チャンネルマネージャー", - "Token manager": "トークンマネージャー", + "Subscription manager": "登録チャンネルの管理", + "Token manager": "トークンの管理", "Token": "トークン", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の登録チャンネル", - "": "`x` 個の登録チャンネル" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個のトークン", - "": "`x` 個のトークン" - }, + "tokens_count_0": "{{count}}個のトークン", "Import/export": "インポート/エクスポート", "unsubscribe": "登録解除", - "revoke": "revoke", + "revoke": "取り消す", "Subscriptions": "登録チャンネル", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の未読通知", - "": "`x` 個の未読通知" - }, + "subscriptions_unseen_notifs_count_0": "{{count}}件の未読通知", "search": "検索", "Log out": "ログアウト", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth によって AGPLv3 でリリースされています", + "Released under the AGPLv3 on Github.": "GitHub上でAGPLv3の元で公開", "Source available here.": "ソースはここで閲覧可能です。", - "View JavaScript license information.": "JavaScript ライセンス情報", - "View privacy policy.": "プライバシーポリシー", + "View JavaScript license information.": "JavaScriptライセンス情報", + "View privacy policy.": "個人情報保護方針", "Trending": "急上昇", "Public": "公開", "Unlisted": "限定公開", "Private": "非公開", - "View all playlists": "再生リストをすべて見る", + "View all playlists": "すべての再生リストを表示", "Updated `x` ago": "`x`前に更新", "Delete playlist `x`?": "再生リスト `x` を削除しますか?", "Delete playlist": "再生リストを削除", "Create playlist": "再生リストを作成", "Title": "タイトル", - "Playlist privacy": "再生リストのプライバシー", + "Playlist privacy": "再生リストの公開状態", "Editing playlist `x`": "再生リスト `x` を編集中", - "Show more": "", - "Show less": "", - "Watch on YouTube": "YouTube で視聴", + "Show more": "もっと見る", + "Show less": "表示を少なく", + "Watch on YouTube": "YouTubeで視聴", + "Switch Invidious Instance": "Invidiousインスタンスの変更", "Hide annotations": "アノテーションを隠す", "Show annotations": "アノテーションを表示", "Genre: ": "ジャンル: ", "License: ": "ライセンス: ", "Family friendly? ": "家族向け: ", - "Wilson score: ": "ウィルソンスコア: ", + "Wilson score: ": "ウィルソン得点区間: ", "Engagement: ": "エンゲージメント: ", "Whitelisted regions: ": "ホワイトリストの地域: ", "Blacklisted regions: ": "ブラックリストの地域: ", - "Shared `x`": "`x`に共有", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 回視聴", - "": "`x` 回視聴" - }, + "Shared `x`": "公開日 `x`", "Premieres in `x`": "`x`後にプレミア公開", "Premieres `x`": "`x`にプレミア公開", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "やあ!君は JavaScript を無効にしているのかな?ここをクリックしてコメントを見れるけど、読み込みには少し時間がかかることがあるのを覚えておいてね。", - "View YouTube comments": "YouTube のコメントを見る", + "View YouTube comments": "YouTube のコメントを表示", "View more comments on Reddit": "Reddit でコメントをもっと見る", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 件のコメントを見る", - "": "`x` 件のコメントを見る" + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 件のコメントを表示", + "": "`x` 件のコメントを表示" }, - "View Reddit comments": "Reddit のコメントを見る", + "View Reddit comments": "Reddit のコメントを表示", "Hide replies": "返信を非表示", "Show replies": "返信を表示", "Incorrect password": "パスワードが間違っています", - "Quota exceeded, try again in a few hours": "試行を制限中です。数時間後にやり直してください", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "ログインできませんでした。2段階認証 (認証アプリまたは SMS) が有効になっていることを確認してください。", - "Invalid TFA code": "TFA (2段階認証) コードが無効です", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "ログインに失敗しました。あなたのアカウントで2段階認証が有効になっていない可能性があります。", "Wrong answer": "回答が間違っています", "Erroneous CAPTCHA": "CAPTCHA が間違っています", "CAPTCHA is a required field": "CAPTCHA は必須項目です", "User ID is a required field": "ユーザー ID は必須項目です", "Password is a required field": "パスワードは必須項目です", "Wrong username or password": "ユーザー名またはパスワードが間違っています", - "Please sign in using 'Log in with Google'": "'Google でログイン' を使用してログインしてください", - "Password cannot be empty": "パスワードを空にすることはできません", + "Password cannot be empty": "パスワードは空にできません", "Password cannot be longer than 55 characters": "パスワードは55文字より長くできません", - "Please log in": "ログインをしてください", - "Invidious Private Feed for `x`": "`x` の Invidious プライベートフィード", + "Please log in": "ログインしてください", + "Invidious Private Feed for `x`": "`x` 個人の Invidious によるフィード", "channel:`x`": "チャンネル:`x`", "Deleted or invalid channel": "削除済みまたは無効なチャンネルです", "This channel does not exist.": "このチャンネルは存在しません。", "Could not get channel info.": "チャンネル情報を取得できませんでした。", "Could not fetch comments": "コメントを取得できませんでした", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 件の返信を見る", - "": "`x` 件の返信を見る" - }, + "comments_view_x_replies_0": "{{count}}件の返信を表示", "`x` ago": "`x`前", - "Load more": "もっと読み込む", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ポイント", - "": "`x` ポイント" - }, + "Load more": "もっと見る", + "comments_points_count_0": "{{count}}点", "Could not create mix.": "ミックスを作成できませんでした。", "Empty playlist": "空の再生リスト", "Not a playlist.": "再生リストではありません。", "Playlist does not exist.": "再生リストが存在しません。", "Could not pull trending pages.": "急上昇ページを取得できませんでした。", - "Hidden field \"challenge\" is a required field": "非表示項目 \"challenge\" は必須項目です", - "Hidden field \"token\" is a required field": "非表示項目 \"token\" は必須項目です", + "Hidden field \"challenge\" is a required field": "非表示項目 challenge は必須項目です", + "Hidden field \"token\" is a required field": "非表示項目 token は必須項目です", "Erroneous challenge": "チャレンジが間違っています", "Erroneous token": "トークンが間違っています", "No such user": "ユーザーが存在しません", - "Token is expired, please try again": "トークンが期限切れです。再度試してください", + "Token is expired, please try again": "トークンが期限切れです。再度お試しください", "English": "英語", "English (auto-generated)": "英語 (自動生成)", "Afrikaans": "アフリカーンス語", @@ -334,43 +304,22 @@ "Yiddish": "イディッシュ語", "Yoruba": "ヨルバ語", "Zulu": "ズール語", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x`年", - "": "`x`年" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x`ヶ月", - "": "`x`ヶ月" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x`週", - "": "`x`週" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x`日", - "": "`x`日" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x`時間", - "": "`x`時間" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x`分", - "": "`x`分" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x`秒", - "": "`x`秒" - }, + "generic_count_years_0": "{{count}}年", + "generic_count_months_0": "{{count}}か月", + "generic_count_weeks_0": "{{count}}週間", + "generic_count_days_0": "{{count}}日", + "generic_count_hours_0": "{{count}}時間", + "generic_count_minutes_0": "{{count}}分", + "generic_count_seconds_0": "{{count}}秒", "Fallback comments: ": "フォールバック時のコメント: ", "Popular": "人気", - "Search": "", + "Search": "検索", "Top": "トップ", "About": "このサービスについて", "Rating: ": "評価: ", - "Language: ": "言語: ", - "View as playlist": "再生リストで見る", - "Default": "デフォルト", + "preferences_locale_label": "言語: ", + "View as playlist": "再生リストとして閲覧", + "Default": "標準", "Music": "音楽", "Gaming": "ゲーム", "News": "ニュース", @@ -381,38 +330,154 @@ "(edited)": "(編集済み)", "YouTube comment permalink": "YouTube コメントのパーマリンク", "permalink": "パーマリンク", - "`x` marked it with a ❤": "`x` が❤を込めてマークしました", - "Audio mode": "オーディオモード", - "Video mode": "ビデオモード", - "Videos": "動画", - "Playlists": "プレイリスト", - "Community": "コミュニティ", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "現在のバージョン: " -}
\ No newline at end of file + "`x` marked it with a ❤": "`x` が❤を送りました", + "Audio mode": "音声モード", + "Video mode": "動画モード", + "channel_tab_videos_label": "動画", + "Playlists": "再生リスト", + "channel_tab_community_label": "コミュニティ", + "search_filters_sort_option_relevance": "関連度", + "search_filters_sort_option_rating": "評価", + "search_filters_sort_option_date": "アップロード日", + "search_filters_sort_option_views": "視聴回数", + "search_filters_type_label": "種類", + "search_filters_duration_label": "再生時間", + "search_filters_features_label": "特徴", + "search_filters_sort_label": "順番", + "search_filters_date_option_hour": "1時間以内", + "search_filters_date_option_today": "今日", + "search_filters_date_option_week": "今週", + "search_filters_date_option_month": "今月", + "search_filters_date_option_year": "今年", + "search_filters_type_option_video": "動画", + "search_filters_type_option_channel": "チャンネル", + "search_filters_type_option_playlist": "再生リスト", + "search_filters_type_option_movie": "映画", + "search_filters_type_option_show": "番組", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "字幕", + "search_filters_features_option_c_commons": "クリエイティブ・コモンズ", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "ライブ", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "場所", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "現在のバージョン: ", + "next_steps_error_message": "以下をお試しください: ", + "next_steps_error_message_refresh": "再読み込み", + "next_steps_error_message_go_to_youtube": "YouTubeを開く", + "search_filters_duration_option_short": "4分未満", + "footer_documentation": "説明書", + "footer_source_code": "ソースコード", + "footer_original_source_code": "元のソースコード", + "footer_modfied_source_code": "改変して使用", + "adminprefs_modified_source_code_url_label": "改変されたソースコードのレポジトリのURL", + "search_filters_duration_option_long": "20分以上", + "preferences_region_label": "地域: ", + "footer_donate_page": "寄付する", + "preferences_quality_dash_label": "優先するDASH画質: ", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "中", + "preferences_quality_option_small": "小", + "invidious": "Invidious", + "preferences_quality_dash_option_auto": "自動", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_480p": "480p", + "videoinfo_youTube_embed_link": "埋め込み", + "videoinfo_invidious_embed_link": "埋め込みリンク", + "none": "なし", + "download_subtitles": "字幕 - `x` (.vtt)", + "search_filters_features_option_purchased": "購入済み", + "preferences_quality_option_dash": "DASH (適応的画質)", + "preferences_quality_dash_option_worst": "最低", + "preferences_quality_dash_option_best": "最高", + "videoinfo_started_streaming_x_ago": "`x`前に配信を開始", + "videoinfo_watch_on_youTube": "YouTubeで視聴", + "user_created_playlists": "`x`個の作成した再生リスト", + "Video unavailable": "動画は利用できません", + "Chinese": "中国語", + "Chinese (Taiwan)": "中国語 (台湾)", + "Korean (auto-generated)": "韓国語 (自動生成)", + "Portuguese (auto-generated)": "ポルトガル語 (自動生成)", + "Turkish (auto-generated)": "トルコ語 (自動生成)", + "English (United Kingdom)": "英語 (イギリス)", + "Cantonese (Hong Kong)": "広東語 (香港)", + "Chinese (China)": "中国語 (中国)", + "Chinese (Hong Kong)": "中国語 (香港)", + "Dutch (auto-generated)": "オランダ語 (自動生成)", + "French (auto-generated)": "フランス語 (自動生成)", + "German (auto-generated)": "ドイツ語 (自動生成)", + "Indonesian (auto-generated)": "インドネシア語 (自動生成)", + "Italian (auto-generated)": "イタリア語 (自動生成)", + "Japanese (auto-generated)": "日本語 (自動生成)", + "Interlingue": "インターリング", + "Portuguese (Brazil)": "ポルトガル語 (ブラジル)", + "Russian (auto-generated)": "ロシア語 (自動生成)", + "Spanish (auto-generated)": "スペイン語 (自動生成)", + "Spanish (Mexico)": "スペイン語 (メキシコ)", + "Spanish (Spain)": "スペイン語 (スペイン)", + "Vietnamese (auto-generated)": "ベトナム語 (自動生成)", + "search_filters_title": "フィルタ", + "search_filters_features_option_three_sixty": "360°", + "search_message_change_filters_or_query": "別の検索語句を試したり、検索フィルタを変更してください。", + "search_message_no_results": "一致する検索結果はありません。", + "English (United States)": "英語 (アメリカ)", + "search_filters_date_label": "アップロード日", + "search_filters_features_option_vr180": "VR180", + "crash_page_switch_instance": "<a href=\"`x`\">別のインスタンスを使用</a>を試す", + "crash_page_read_the_faq": "<a href=\"`x`\">よくある質問 (FAQ)</a> を読む", + "Popular enabled: ": "人気動画を有効化 ", + "search_message_use_another_instance": "<a href=\"`x`\">別のインスタンス上での検索</a>も可能です。", + "search_filters_apply_button": "選択したフィルターを適用", + "user_saved_playlists": "`x`個の保存済みの再生リスト", + "crash_page_you_found_a_bug": "Invidious のバグのようです!", + "crash_page_refresh": "<a href=\"`x`\">ページを更新</a>を試す", + "preferences_watch_history_label": "再生履歴を有効化 ", + "search_filters_date_option_none": "すべて", + "search_filters_type_option_all": "すべての種類", + "search_filters_duration_option_none": "すべての長さ", + "search_filters_duration_option_medium": "4 ~ 20分", + "preferences_save_player_pos_label": "再生位置を保存: ", + "crash_page_before_reporting": "バグを報告する前に、次のことを確認してください。", + "crash_page_report_issue": "上記が助けにならないなら、<a href=\"`x`\">GitHub</a> に新しい issue を作成し(英語が好ましい)、メッセージに次のテキストを含めてください(テキストは翻訳しない)。", + "crash_page_search_issue": "<a href=\"`x`\">GitHub の既存の問題 (issue)</a> を検索", + "channel_tab_streams_label": "ライブ", + "channel_tab_playlists_label": "再生リスト", + "error_video_not_in_playlist": "要求された動画はこの再生リスト内に存在しません。<a href=\"`x`\">再生リストのホームへ。</a>", + "channel_tab_shorts_label": "ショート", + "channel_tab_channels_label": "チャンネル", + "Music in this video": "この動画の音楽", + "Artist: ": "アーティスト: ", + "Album: ": "アルバム: ", + "Song: ": "曲: ", + "Channel Sponsor": "チャンネルのスポンサー", + "Standard YouTube license": "標準 Youtube ライセンス", + "Download is disabled": "ダウンロード: このインスタンスは未対応", + "Import YouTube playlist (.csv)": "YouTube 再生リストをインポート (.csv)", + "generic_button_delete": "削除", + "generic_button_cancel": "キャンセル", + "channel_tab_podcasts_label": "ポッドキャスト", + "channel_tab_releases_label": "リリース", + "generic_button_edit": "編集", + "generic_button_save": "保存", + "generic_button_rss": "RSS", + "playlist_button_add_items": "動画を追加", + "generic_channels_count_0": "{{count}}個のチャンネル", + "Import YouTube watch history (.json)": "YouTube 視聴履歴をインポート (.json)", + "Add to playlist": "再生リストに追加", + "Add to playlist: ": "再生リストに追加: ", + "Answer": "回答", + "Search for videos": "動画を検索", + "The Popular feed has been disabled by the administrator.": "人気の動画のページは管理者によって無効にされています。", + "carousel_go_to": "スライド`x`を表示", + "carousel_slide": "スライド{{current}} / 全{{total}}個中", + "carousel_skip": "画像のスライド表示をスキップ", + "toggle_theme": "テーマの切り替え" +} diff --git a/locales/ko.json b/locales/ko.json new file mode 100644 index 00000000..4864860a --- /dev/null +++ b/locales/ko.json @@ -0,0 +1,483 @@ +{ + "preferences_sort_label": "동영상 정렬 기준: ", + "preferences_max_results_label": "피드에 표시된 동영상 수: ", + "Redirect homepage to feed: ": "피드로 홈페이지 리디렉션: ", + "preferences_annotations_subscribed_label": "구독한 채널에 기본으로 주석 표시: ", + "preferences_category_subscription": "구독 설정", + "preferences_automatic_instance_redirect_label": "자동 인스턴스 리디렉션 (redirect.invidious.io로 대체): ", + "preferences_thin_mode_label": "단순 모드: ", + "light": "라이트", + "dark": "다크", + "preferences_dark_mode_label": "테마: ", + "Dark mode: ": "다크 모드: ", + "preferences_player_style_label": "플레이어 스타일: ", + "preferences_category_visual": "환경 설정", + "preferences_vr_mode_label": "360도 영상 활성화 (WebGL 필요): ", + "preferences_extend_desc_label": "자동으로 비디오 설명 펼치기: ", + "preferences_annotations_label": "기본으로 주석 표시: ", + "preferences_related_videos_label": "관련 동영상 보기: ", + "Fallback captions: ": "대체 자막: ", + "preferences_captions_label": "기본 자막: ", + "reddit": "레딧", + "youtube": "유튜브", + "preferences_comments_label": "기본 댓글: ", + "preferences_volume_label": "플레이어 볼륨: ", + "preferences_quality_label": "선호하는 비디오 품질: ", + "preferences_speed_label": "기본 속도: ", + "preferences_local_label": "비디오를 프록시: ", + "preferences_listen_label": "라디오 모드: ", + "preferences_continue_autoplay_label": "다음 동영상 자동재생: ", + "preferences_continue_label": "다음 동영상으로 이동: ", + "preferences_autoplay_label": "자동재생: ", + "preferences_video_loop_label": "항상 반복: ", + "preferences_category_player": "플레이어 설정", + "Preferences": "설정", + "E-mail": "이메일", + "Register": "회원가입", + "Sign In": "로그인", + "preferences_category_misc": "기타 설정", + "Image CAPTCHA": "이미지 캡차", + "Text CAPTCHA": "텍스트 캡차", + "Time (h:mm:ss):": "시각 (h:mm:ss):", + "Password": "비밀번호", + "User ID": "사용자 ID", + "Log in/register": "로그인/회원가입", + "Log in": "로그인", + "source": "출처", + "JavaScript license information": "자바스크립트 라이선스 정보", + "An alternative front-end to YouTube": "유튜브의 프론트엔드 대안", + "History": "시청 기록", + "Delete account?": "계정을 삭제 하시겠습니까?", + "Export data as JSON": "인비디어스 데이터 내보내기 (.json)", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML로 구독 내보내기 (뉴파이프 및 프리튜브)", + "Export subscriptions as OPML": "OPML로 구독 내보내기", + "Export": "내보내기", + "Import NewPipe data (.zip)": "뉴파이프 데이터 가져오기 (.zip)", + "Import NewPipe subscriptions (.json)": "뉴파이프 구독 가져오기 (.json)", + "Import FreeTube subscriptions (.db)": "프리튜브 구독 가져오기 (.db)", + "Import YouTube subscriptions": "유튜브 구독 가져오기", + "Import Invidious data": "인비디어스 데이터 가져오기 (.json)", + "Import": "가져오기", + "Import and Export Data": "데이터 가져오기 및 내보내기", + "No": "아니요", + "Yes": "예", + "Authorize token for `x`?": "`x` 에 대한 토큰을 승인하시겠습니까?", + "Authorize token?": "토큰을 승인하시겠습니까?", + "New passwords must match": "새 비밀번호는 일치해야 합니다", + "New password": "새 비밀번호", + "Clear watch history?": "시청 기록을 지우시겠습니까?", + "Previous page": "이전 페이지", + "Next page": "다음 페이지", + "last": "마지막", + "Shared `x` ago": "`x` 전", + "popular": "인기", + "oldest": "과거순", + "newest": "최신순", + "View playlist on YouTube": "유튜브에서 재생목록 보기", + "View channel on YouTube": "유튜브에서 채널 보기", + "Subscribe": "구독", + "Unsubscribe": "구독 취소", + "LIVE": "실시간", + "generic_views_count_0": "{{count}} 조회수", + "generic_videos_count_0": "{{count}} 동영상", + "generic_playlists_count_0": "{{count}} 재생목록", + "generic_subscribers_count_0": "{{count}} 구독자", + "generic_subscriptions_count_0": "{{count}} 구독", + "search_filters_type_option_playlist": "재생목록", + "Korean": "한국어", + "Japanese": "일본어", + "Greek": "그리스어", + "German": "독일어", + "Chinese (Traditional)": "중국어 (정체자)", + "Chinese (Simplified)": "중국어 (간체자)", + "French": "프랑스어", + "Finnish": "핀란드어", + "Basque": "바스크어", + "Bangla": "벵골어", + "Azerbaijani": "아제르바이잔어", + "Armenian": "아르메니아어", + "Arabic": "아랍어", + "Amharic": "암하라어", + "Albanian": "알바니아어", + "Afrikaans": "아프리카어", + "English (auto-generated)": "영어 (자동 생성됨)", + "English": "영어", + "Token is expired, please try again": "토큰이 만료되었습니다. 다시 시도해 주세요", + "Load more": "더 불러오기", + "Could not fetch comments": "댓글을 가져올 수 없습니다", + "Could not get channel info.": "채널 정보를 가져올 수 없습니다.", + "This channel does not exist.": "이 채널은 존재하지 않습니다.", + "Deleted or invalid channel": "삭제되었거나 더 이상 존재하지 않는 채널", + "channel:`x`": "채널:`x`", + "Show replies": "댓글 보기", + "Hide replies": "댓글 숨기기", + "Incorrect password": "잘못된 비밀번호", + "License: ": "라이선스: ", + "Genre: ": "장르: ", + "Editing playlist `x`": "재생목록 `x` 수정하기", + "Playlist privacy": "재생목록 공개 범위", + "Watch on YouTube": "유튜브에서 보기", + "Show less": "간략히", + "Show more": "더보기", + "Title": "제목", + "Create playlist": "재생목록 생성", + "Trending": "급상승", + "Delete playlist": "재생목록 삭제", + "Delete playlist `x`?": "재생목록 `x` 를 삭제하시겠습니까?", + "Updated `x` ago": "`x` 전에 업데이트됨", + "Released under the AGPLv3 on Github.": "깃허브에 AGPLv3 으로 배포됩니다.", + "View all playlists": "모든 재생목록 보기", + "Private": "비공개", + "Unlisted": "목록에 없음", + "Public": "공개", + "View privacy policy.": "개인정보 처리방침 보기.", + "View JavaScript license information.": "자바스크립트 라이선스 정보 보기.", + "Source available here.": "소스는 여기에서 사용할 수 있습니다.", + "Log out": "로그아웃", + "search": "검색", + "subscriptions_unseen_notifs_count_0": "{{count}} 읽지 않은 알림", + "Subscriptions": "구독", + "revoke": "철회", + "unsubscribe": "구독 취소", + "Import/export": "가져오기/내보내기", + "tokens_count_0": "{{count}} 토큰", + "Token": "토큰", + "Token manager": "토큰 관리자", + "Subscription manager": "구독 관리자", + "Save preferences": "설정 저장", + "Report statistics: ": "통계 보고: ", + "Registration enabled: ": "회원가입 활성화: ", + "Login enabled: ": "로그인 활성화: ", + "CAPTCHA enabled: ": "캡차 활성화: ", + "Top enabled: ": "Top 활성화: ", + "preferences_show_nick_label": "상단에 닉네임 표시: ", + "preferences_feed_menu_label": "피드 메뉴: ", + "preferences_default_home_label": "기본 홈페이지: ", + "preferences_category_admin": "관리자 설정", + "Delete account": "계정 삭제", + "Watch history": "시청 기록", + "Manage tokens": "토큰 관리", + "Manage subscriptions": "구독 관리", + "Change password": "비밀번호 변경", + "Import/export data": "데이터 가져오기/내보내기", + "Clear watch history": "시청 기록 지우기", + "preferences_category_data": "데이터 설정", + "`x` is live": "`x` 이(가) 라이브 중입니다", + "`x` uploaded a video": "`x` 동영상 게시됨", + "Enable web notifications": "웹 알림 활성화", + "preferences_notifications_only_label": "알림만 표시 (있는 경우): ", + "preferences_unseen_only_label": "시청하지 않은 것만 표시: ", + "Only show latest unwatched video from channel: ": "채널의 시청하지 않은 최신 동영상만 표시: ", + "Only show latest video from channel: ": "채널의 최신 동영상만 표시: ", + "channel name - reverse": "채널 이름 - 역순", + "alphabetically - reverse": "알파벳순 - 역순", + "published - reverse": "게시일 - 역순", + "published": "게시일", + "channel name": "채널 이름", + "alphabetically": "알파벳순", + "Samoan": "사모아어", + "Russian": "러시아어", + "Romanian": "루마니아어", + "Punjabi": "펀자브어", + "Portuguese": "포르투갈어", + "Polish": "폴란드어", + "Persian": "페르시아어", + "Pashto": "파슈토어", + "Nyanja": "냔자어", + "Norwegian Bokmål": "노르웨이 부크몰어", + "Nepali": "네팔어", + "Mongolian": "몽골어", + "Marathi": "마라티어", + "Maori": "마오리어", + "Maltese": "몰타어", + "Wrong answer": "잘못된 답변", + "search_filters_features_option_live": "실시간", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_location": "지역", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "현재 버전: ", + "next_steps_error_message_refresh": "새로 고침", + "next_steps_error_message_go_to_youtube": "유튜브로 가기", + "search_filters_features_option_subtitles": "자막", + "`x` marked it with a ❤": "`x`님의 ❤", + "Download as: ": "다음으로 다운로드: ", + "Download": "다운로드", + "Search": "검색", + "preferences_locale_label": "언어: ", + "Malayalam": "말라얄람어", + "Malay": "말레이어", + "Malagasy": "말라가시어", + "Macedonian": "마케도니아어", + "Luxembourgish": "룩셈부르크어", + "Lithuanian": "리투아니아어", + "Latvian": "라트비아어", + "Latin": "라틴어", + "Lao": "라오어", + "search_filters_type_option_channel": "채널", + "Kyrgyz": "키르기스어", + "Kurdish": "쿠르드어", + "Khmer": "크메르어", + "Kazakh": "카자흐어", + "Kannada": "칸나다어", + "Javanese": "자바어", + "Italian": "이탈리아어", + "Irish": "아일랜드어", + "Indonesian": "인도네시아어", + "Igbo": "이보어", + "Icelandic": "아이슬란드어", + "Hungarian": "헝가리어", + "Hmong": "몽어", + "Hindi": "힌디어", + "Hebrew": "히브리어", + "Hawaiian": "하와이어", + "Hausa": "하우사어", + "No such user": "해당 사용자 없음", + "Erroneous token": "잘못된 token", + "Erroneous challenge": "잘못된 challenge", + "Hidden field \"token\" is a required field": "숨겨진 필드 \"token\"은 필수 필드입니다", + "Hidden field \"challenge\" is a required field": "숨겨진 필드 \"challenge\"는 필수 필드입니다", + "Could not pull trending pages.": "인기 급상승 페이지를 가져올 수 없습니다.", + "Could not create mix.": "믹스를 생성할 수 없습니다.", + "`x` ago": "`x` 전", + "comments_view_x_replies_0": "답글 {{count}}개 보기", + "View Reddit comments": "레딧 댓글 보기", + "Engagement: ": "약속: ", + "Wilson score: ": "Wilson Score: ", + "Family friendly? ": "전연령 영상입니까? ", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "`x`개의 댓글 보기", + "": "`x`개의 댓글 보기" + }, + "Haitian Creole": "아이티 크레올어", + "Gujarati": "구자라트어", + "Esperanto": "에스페란토", + "Georgian": "조지아어", + "Galician": "갈리시아어", + "Filipino": "타갈로그어(필리핀어)", + "Estonian": "에스토니아어", + "Dutch": "네덜란드어", + "Danish": "덴마크어", + "Czech": "체코어", + "Croatian": "크로아티아어", + "Corsican": "코르시카어", + "Cebuano": "세부아노어", + "Catalan": "카탈루냐어", + "Burmese": "버마어", + "Bulgarian": "불가리아어", + "Bosnian": "보스니아어", + "Belarusian": "벨라루스어", + "View more comments on Reddit": "레딧에서 댓글 더 보기", + "View YouTube comments": "유튜브 댓글 보기", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "자바스크립트가 꺼져 있는 것 같습니다! 댓글을 보려면 여기를 클릭하세요. 댓글을 로드하는 데 시간이 조금 더 걸릴 수 있습니다.", + "Shared `x`": "`x` 업로드", + "Whitelisted regions: ": "차단되지 않은 지역: ", + "search_filters_sort_option_views": "조회수", + "Please log in": "로그인하세요", + "Password cannot be longer than 55 characters": "비밀번호는 55자 이하여야 합니다", + "Password cannot be empty": "비밀번호는 비워둘 수 없습니다", + "Wrong username or password": "잘못된 사용자 이름 또는 비밀번호", + "Password is a required field": "비밀번호는 필수 입력란입니다", + "User ID is a required field": "사용자 ID는 필수 입력란입니다", + "CAPTCHA is a required field": "캡차는 필수 입력란입니다", + "Erroneous CAPTCHA": "잘못된 캡차", + "Blacklisted regions: ": "차단된 지역: ", + "Playlists": "재생목록", + "View as playlist": "재생목록으로 보기", + "Playlist does not exist.": "재생목록이 존재하지 않음.", + "Not a playlist.": "재생목록이 아님.", + "Empty playlist": "재생목록 비어 있음", + "Show annotations": "주석 보이기", + "Hide annotations": "주석 숨기기", + "Switch Invidious Instance": "인비디어스 인스턴스 변경", + "Spanish": "스페인어", + "Southern Sotho": "소토어", + "Somali": "소말리어", + "Slovenian": "슬로베니아어", + "Slovak": "슬로바키아어", + "Sinhala": "싱할라어", + "Sindhi": "신드어", + "Shona": "쇼나어", + "Serbian": "세르비아어", + "Scottish Gaelic": "스코틀랜드 게일어", + "Popular": "인기", + "Fallback comments: ": "대체 댓글: ", + "Swahili": "스와힐리어", + "Sundanese": "순다어", + "generic_count_years_0": "{{count}}년", + "generic_count_months_0": "{{count}}개월", + "generic_count_weeks_0": "{{count}}주", + "generic_count_days_0": "{{count}}일", + "generic_count_hours_0": "{{count}}시간", + "generic_count_minutes_0": "{{count}}분", + "generic_count_seconds_0": "{{count}}초", + "Zulu": "줄루어", + "Yoruba": "요루바어", + "Yiddish": "이디시어", + "Xhosa": "코사어", + "Western Frisian": "서부 프리지아어", + "Welsh": "웨일스어", + "Vietnamese": "베트남어", + "Uzbek": "우즈베크어", + "Urdu": "우르두어", + "Ukrainian": "우크라이나어", + "Turkish": "터키어", + "Thai": "태국어", + "Telugu": "텔루구어", + "Tamil": "타밀어", + "Tajik": "타지크어", + "Swedish": "스웨덴어", + "Spanish (Latin America)": "스페인어 (라틴 아메리카)", + "comments_points_count_0": "{{count}} 포인트", + "Invidious Private Feed for `x`": "`x` 에 대한 인비디어스 비공개 피드", + "Premieres `x`": "최초 공개 `x`", + "Premieres in `x`": "`x` 후 최초 공개", + "next_steps_error_message": "다음 방법을 시도해 보세요: ", + "search_filters_features_option_c_commons": "크리에이티브 커먼즈", + "search_filters_duration_label": "길이", + "search_filters_type_label": "구분", + "search_filters_sort_option_date": "업로드 날짜", + "search_filters_sort_option_rating": "평점", + "search_filters_sort_option_relevance": "관련성", + "channel_tab_community_label": "커뮤니티", + "channel_tab_videos_label": "동영상", + "Video mode": "비디오 모드", + "Audio mode": "오디오 모드", + "permalink": "퍼머링크", + "YouTube comment permalink": "유튜브 댓글 퍼머링크", + "(edited)": "(수정됨)", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "Movies": "영화", + "News": "뉴스", + "Gaming": "게임", + "Music": "음악", + "Default": "전체", + "Rating: ": "평점: ", + "About": "정보", + "Top": "최고", + "search_filters_features_option_hd": "HD", + "search_filters_type_option_show": "쇼", + "search_filters_type_option_movie": "영화", + "search_filters_type_option_video": "동영상", + "search_filters_date_option_year": "올해", + "search_filters_date_option_month": "이번 달", + "search_filters_date_option_week": "이번 주", + "search_filters_date_option_today": "오늘", + "search_filters_date_option_hour": "지난 1시간", + "search_filters_sort_label": "정렬기준", + "search_filters_features_label": "기능별", + "search_filters_duration_option_short": "짧음 (4분 미만)", + "search_filters_duration_option_long": "김 (20분 초과)", + "footer_documentation": "문서", + "footer_source_code": "소스 코드", + "footer_original_source_code": "원본 소스 코드", + "footer_modfied_source_code": "수정된 소스 코드", + "adminprefs_modified_source_code_url_label": "수정된 소스 코드 저장소의 URL", + "search_filters_title": "필터", + "preferences_quality_dash_option_4320p": "4320p", + "Popular enabled: ": "인기 활성화: ", + "Dutch (auto-generated)": "네덜란드어 (자동 생성됨)", + "Chinese (Hong Kong)": "중국어 (홍콩)", + "Chinese (Taiwan)": "중국어 (대만)", + "German (auto-generated)": "독일어 (자동 생성됨)", + "Interlingue": "Interlingue", + "search_filters_date_label": "업로드 날짜", + "search_filters_date_option_none": "모든 날짜", + "search_filters_duration_option_none": "모든 기간", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_purchased": "구입한 항목", + "search_filters_apply_button": "선택한 필터 적용하기", + "preferences_quality_dash_option_240p": "240p", + "preferences_region_label": "국가: ", + "preferences_quality_dash_option_1440p": "1440p", + "French (auto-generated)": "프랑스어 (자동 생성됨)", + "Indonesian (auto-generated)": "인도네시아어 (자동 생성됨)", + "Turkish (auto-generated)": "터키어 (자동 생성됨)", + "Vietnamese (auto-generated)": "베트남어 (자동 생성됨)", + "preferences_quality_dash_option_2160p": "2160p", + "Italian (auto-generated)": "이탈리아어 (자동 생성됨)", + "preferences_quality_option_medium": "보통", + "preferences_quality_dash_option_720p": "720p", + "search_filters_duration_option_medium": "중간 (4 - 20분)", + "preferences_quality_dash_option_best": "최고", + "Portuguese (auto-generated)": "포르투갈어 (자동 생성됨)", + "Spanish (Spain)": "스페인어 (스페인)", + "preferences_quality_dash_label": "선호하는 DASH 비디오 품질: ", + "preferences_quality_option_hd720": "HD720", + "Spanish (auto-generated)": "스페인어 (자동 생성됨)", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_worst": "최저", + "preferences_watch_history_label": "시청 기록 저장: ", + "invidious": "인비디어스", + "preferences_quality_option_small": "낮음", + "preferences_quality_dash_option_auto": "자동", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_144p": "144p", + "English (United Kingdom)": "영어 (영국)", + "search_filters_features_option_vr180": "VR180", + "Cantonese (Hong Kong)": "광동어 (홍콩)", + "Portuguese (Brazil)": "포르투갈어 (브라질)", + "search_message_no_results": "결과가 없습니다.", + "search_message_change_filters_or_query": "필터를 변경하시거나 검색어를 넓게 시도해보세요.", + "search_message_use_another_instance": " <a href=\"`x`\">다른 인스턴스에서 검색</a>할 수도 있습니다.", + "English (United States)": "영어 (미국)", + "Chinese": "중국어", + "Chinese (China)": "중국어 (중국)", + "Japanese (auto-generated)": "일본어 (자동 생성됨)", + "Korean (auto-generated)": "한국어 (자동 생성됨)", + "Russian (auto-generated)": "러시아어 (자동 생성됨)", + "Spanish (Mexico)": "스페인어 (멕시코)", + "search_filters_type_option_all": "모든 유형", + "footer_donate_page": "기부하기", + "preferences_quality_option_dash": "DASH (다양한 화질)", + "preferences_quality_dash_option_360p": "360p", + "preferences_save_player_pos_label": "이어서 보기: ", + "none": "없음", + "videoinfo_started_streaming_x_ago": "`x` 전에 스트리밍을 시작했습니다", + "crash_page_you_found_a_bug": "인비디어스에서 버그를 찾은 것 같습니다!", + "download_subtitles": "자막 - `x`(.vtt)", + "user_saved_playlists": "`x`개의 저장된 재생목록", + "crash_page_before_reporting": "버그를 보고하기 전에 다음 사항이 있는지 확인합니다:", + "crash_page_search_issue": "<a href=\"`x`\">깃허브에서 기존 이슈</a>를 검색했습니다", + "Video unavailable": "비디오를 사용할 수 없음", + "crash_page_refresh": "<a href=\"`x`\">페이지를 새로고침</a>하려고 했습니다", + "videoinfo_watch_on_youTube": "유튜브에서 보기", + "crash_page_switch_instance": "<a href=\"`x`\">다른 인스턴스를 사용</a>하려고 했습니다", + "crash_page_read_the_faq": "<a href=\"`x`\">자주 묻는 질문(FAQ)</a> 읽기", + "user_created_playlists": "`x`개의 생성된 재생목록", + "crash_page_report_issue": "위의 방법 중 어느 것도 도움이 되지 않았다면, <a href=\"`x`\">깃허브에서 새 이슈를 열고</a>(가능하면 영어로) 메시지에 다음 텍스트를 포함하세요(해당 텍스트를 번역하지 마십시오):", + "videoinfo_youTube_embed_link": "임베드", + "videoinfo_invidious_embed_link": "임베드 링크", + "error_video_not_in_playlist": "요청한 동영상이 이 재생목록에 없습니다. <a href=\"`x`\">재생목록 목록을 보려면 여기를 클릭하십시오.</a>", + "channel_tab_shorts_label": "쇼츠", + "channel_tab_streams_label": "실시간 스트리밍", + "channel_tab_channels_label": "채널", + "channel_tab_playlists_label": "재생목록", + "Standard YouTube license": "표준 유튜브 라이선스", + "Song: ": "제목: ", + "Channel Sponsor": "채널 스폰서", + "Album: ": "앨범: ", + "Music in this video": "동영상 속 음악", + "Artist: ": "아티스트: ", + "Download is disabled": "다운로드가 비활성화 되어있음", + "Import YouTube playlist (.csv)": "유튜브 재생목록 가져오기 (.csv)", + "playlist_button_add_items": "동영상 추가", + "channel_tab_podcasts_label": "팟캐스트", + "generic_button_delete": "삭제", + "generic_button_edit": "편집", + "generic_button_save": "저장", + "generic_button_cancel": "취소", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "발매", + "generic_channels_count_0": "{{count}} 채널", + "Import YouTube watch history (.json)": "유튜브 시청 기록 가져오기 (.json)", + "Add to playlist": "재생목록에 추가", + "Add to playlist: ": "재생목록에 추가: ", + "Answer": "답", + "The Popular feed has been disabled by the administrator.": "관리자가 인기 피드를 비활성화했습니다.", + "carousel_skip": "캐러셀 건너뛰기", + "carousel_go_to": "`x` 슬라이드로 이동", + "Search for videos": "비디오 검색", + "toggle_theme": "테마 전환", + "carousel_slide": "{{total}}의 슬라이드 {{current}}" +} diff --git a/locales/lmo.json b/locales/lmo.json new file mode 100644 index 00000000..9d2fe2a8 --- /dev/null +++ b/locales/lmo.json @@ -0,0 +1,232 @@ +{ + "Add to playlist": "Giont a la playlist", + "generic_button_edit": "Modifega", + "generic_button_save": "Salva", + "LIVE": "EN DÌRETT", + "Shared `x` ago": "Compartiss `x` fa", + "View channel on YouTube": "Varda el canal sul YouTube", + "newest": "plù nöeuf", + "oldest": "plù végh", + "Search for videos": "Càuta dei video", + "The Popular feed has been disabled by the administrator.": "la seziùn Pupular la è stada disabilidada par l'amministratòr.", + "generic_channels_count": "{{count}} canal", + "generic_channels_count_plural": "{{count}} canai", + "popular": "pupular", + "generic_views_count": "{{count}} visualisazión", + "generic_views_count_plural": "{{count}} visualisazióni", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} video", + "generic_playlists_count": "{{count}} playlist", + "generic_playlists_count_plural": "{{count}} playlist", + "generic_subscriptions_count": "{{count}} inscrizion", + "generic_subscriptions_count_plural": "{{count}} inscrizioni", + "generic_button_cancel": "Cançéla", + "generic_button_delete": "Scassa via", + "Unsubscribe": "Disinscriviti", + "Next page": "Pagina siguènt", + "Previous page": "Pagina indrèe", + "Clear watch history?": "Cançélar la istoria dei video vardàa?", + "New password": "Nöeva password", + "Import and Export Data": "Importazion ed esportazion dei dat", + "Import": "Importa", + "Import Invidious data": "Importa i dat de l'Invidious en el formàt JSON", + "Import YouTube subscriptions": "Importa le inscrizioni dal YouTube/OPML", + "Import YouTube playlist (.csv)": "Importa le playlist dal YouTube (.csv)", + "Import YouTube watch history (.json)": "Importa la istoria de visualizazzion dal YouTube (.json)", + "Import FreeTube subscriptions (.db)": "Importa le inscrizioni dal FreeTube (.db)", + "Import NewPipe data (.zip)": "importa i dat del NewPipe (.zip)", + "Export": "Esporta", + "Export subscriptions as OPML": "Esporta inscrizioni com OPML", + "Export data as JSON": "Esporta i dat de l'Invidious com JSON", + "Delete account?": "Eliminà 'l profil?", + "History": "Istoria", + "An alternative front-end to YouTube": "Una interfacia alternatif al YouTube", + "JavaScript license information": "Informaziòn su la licensa JavaScript", + "source": "font", + "Log in": "Và dent", + "Text CAPTCHA": "Tèst del CAPTCHA", + "Image CAPTCHA": "Imàgen del CAPTCHA", + "Sign In": "Ven denter", + "Register": "Registres", + "E-mail": "E-mail", + "Preferences": "Priferenze", + "preferences_category_player": "Priferenze del riprodutòr", + "preferences_quality_option_dash": "DASH (qualità adatif)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Media", + "preferences_quality_option_small": "Picinina", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_best": "Meglior", + "preferences_quality_dash_option_worst": "Peggior", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "reddit": "Reddit", + "invidious": "Invidious", + "light": "ciar", + "dark": "scur", + "preferences_category_misc": "Priferenze varie", + "preferences_category_subscription": "Priferenze de le inscrizioni", + "published": "data de publicazion", + "published - reverse": "data de publicazion - invertì", + "alphabetically": "orden alfabetegh", + "channel name": "nòm del canal", + "channel name - reverse": "nòm del canal - invertì", + "Enable web notifications": "Empisa le notifeghe da la red", + "`x` uploaded a video": "`x` la ghàa cargà un video", + "`x` is live": "`x` l'è 'n dirétt adés", + "preferences_category_data": "Priferenze dei dat", + "Import/export data": "Importa/esporta i dat", + "Change password": "Cambia la parola ciav", + "Manage subscriptions": "Organisa le inscrizioni", + "Manage tokens": "Organisa i tokens", + "Watch history": "Istoria dei video vardà", + "Delete account": "Cançéla 'l profil", + "Save preferences": "Salva priferenze", + "Subscription manager": "Manegia le inscrizioni", + "Token": "Token", + "tokens_count": "{{count}} token", + "tokens_count_plural": "{{count}} token", + "Import/export": "Importa/esporta", + "unsubscribe": "disinscriviti", + "subscriptions_unseen_notifs_count": "{{count}} notifega mia visualisada", + "subscriptions_unseen_notifs_count_plural": "{{count}} notifeghe mia visualisade", + "Log out": "Sortiss", + "Released under the AGPLv3 on Github.": "Publicà en el GitHub suta licenza AGPLv3.", + "Source available here.": "Codegh de la font disponivel chì.", + "View privacy policy.": "Varda la pulitega de la privacy.", + "Trending": "De moda", + "Public": "Publico", + "Unlisted": "Non en lista", + "Private": "Privàt", + "View all playlists": "Varda tute le playlist", + "Updated `x` ago": "Giurnà `x` fa", + "Delete playlist `x`?": "Cançéla la playlist `x`?", + "Delete playlist": "Cançéla playlist", + "Create playlist": "Crea playlist", + "Title": "Titel", + "Playlist privacy": "Privacy de la playlist", + "Editing playlist `x`": "Modifega playlist `x`", + "playlist_button_add_items": "Gionta video", + "Show more": "Varda plù", + "Show less": "Varda mèn", + "Watch on YouTube": "Varda sul YouTube", + "Switch Invidious Instance": "Cambia la instanza del Invidious", + "search_message_no_results": "Non è stat truvà nigun resultat.", + "Cebuano": "Cebuano", + "Chinese (Traditional)": "Cines (Tradizional)", + "Corsican": "Còrso", + "Croatian": "Cruat", + "Georgian": "Georgian", + "Gujarati": "Gujarati", + "Hawaiian": "Hawaiian", + "Kurdish": "Curd", + "Latin": "Latin", + "Latvian": "Letton", + "Lithuanian": "Lituan", + "Malay": "Males", + "Maltese": "Maltes", + "Mongolian": "móngol", + "Persian": "Persian", + "Polish": "Polacch", + "Portuguese": "Portoghes", + "Romanian": "Romen", + "Scottish Gaelic": "Gaelich Scusses", + "Spanish (Latin America)": "Spagnöl (America do Sùd)", + "Thai": "Thai", + "Western Frisian": "Frisian do ponente", + "Basque": "Basco", + "Chinese (Simplified)": "Cines (Semplificà)", + "Haitian Creole": "Creolo de Haiti", + "Galician": "Galiçian", + "Hebrew": "Ebraich", + "Korean": "Corean", + "View playlist on YouTube": "Varda la playlist sul YouTube", + "Southern Sotho": "Sotho do Sùd", + "generic_button_rss": "RSS", + "Welsh": "Galés", + "Answer": "Resposta", + "New passwords must match": "Le nöeve password la deven esere uguai", + "Authorize token?": "Autorisà 'l token?", + "Authorize token for `x`?": "Autorisà 'l token par `x`?", + "Yes": "Sì", + "No": "No", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Esporta inscrizioni com OPML (par 'l NewPipe e 'l FreeTube)", + "Log in/register": "Va dent/Registres", + "User ID": "ID utent", + "Password": "Parola ciav", + "Time (h:mm:ss):": "Temp (h:mm:ss):", + "Import NewPipe subscriptions (.json)": "importa le inscrizioni dal NewPipe (.json)", + "youtube": "YouTube", + "alphabetically - reverse": "orden alfabetegh - invertì", + "preferences_category_visual": "Priferenze grafeghe", + "Clear watch history": "Scompartiss la istoria dei video vardà", + "preferences_category_admin": "Priferenze de l'amministratòr", + "Token manager": "Manegia i token", + "Subscriptions": "Inscrizioni", + "search": "cerca", + "View JavaScript license information.": "Varda le informazion su la licenza JavaScript.", + "search_message_change_filters_or_query": "Ti pödi pruà a slargà la reçerca e/or a cangià i filter.", + "generic_subscribers_count": "{{count}} inscritt", + "generic_subscribers_count_plural": "{{count}} inscriti", + "Subscribe": "Inscriviti", + "last": "ùltim", + "Add to playlist: ": "Giont a la playlist: ", + "preferences_autoplay_label": "Reproduzion automatega: ", + "preferences_continue_label": "Reproduzion seguént preimpostà: ", + "preferences_continue_autoplay_label": "Fa partì en automatico el video seguént: ", + "preferences_listen_label": "Modalità de sól audio preimpostà: ", + "preferences_local_label": "Proxy par i video: ", + "preferences_watch_history_label": "Ativà la istoria de reproduzion: ", + "preferences_speed_label": "Velocità preimpostà: ", + "preferences_volume_label": "Volume del reprodutòr: ", + "preferences_region_label": "Nazion del contenut: ", + "Dark mode: ": "Tema scur ", + "preferences_dark_mode_label": "Tema: ", + "preferences_thin_mode_label": "Modalità legera: ", + "preferences_automatic_instance_redirect_label": "Reindirizazzion automatega de la instansa (rivèrt a redirect.invidious.io): ", + "Hide annotations": "Piaca le notazioni", + "Show annotations": "Mostra le notazioni", + "Family friendly? ": "Adàt a tüti? ", + "Whitelisted regions: ": "Regioni en lista bianca: ", + "Blacklisted regions: ": "Regioni en lista negher ", + "Artist: ": "Artista: ", + "Song: ": "Cansòn ", + "Album: ": "Album: ", + "View YouTube comments": "Varda i comment dal YouTube", + "Password cannot be empty": "La parola ciav la no po miga esser voeut", + "channel:`x`": "Canal:`x`", + "Bangla": "Bengales", + "Hausa": "Hausa", + "Hindi": "Hindi", + "Hmong": "Hmong", + "Igbo": "Igbo", + "Javanese": "Javanese", + "Kannada": "Kannada", + "Kazakh": "Kazach", + "Khmer": "Khmer", + "Kyrgyz": "Kirghiz", + "Lao": "Lao", + "Luxembourgish": "Lussemburghes", + "Macedonian": "Macedon", + "Malagasy": "Malagascio", + "Malayalam": "Malayalam", + "Maori": "Maori", + "Marathi": "Marati", + "Nepali": "Nepales", + "Nyanja": "Nyanja", + "Pashto": "Pashtu", + "Punjabi": "Punjabi", + "Samoan": "Samoan", + "Standard YouTube license": "licensa predefinida de Youtube", + "License: ": "Licensa: ", + "Music in this video": "Musica en sto video", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Ué! Sembra che ti la g'hà desabilitàa el JavaScript. Schisa chì para vardà i comment, ma cunsidera che peul vörse 'n po plu de temp a cargà.", + "preferences_video_loop_label": "Reproduci sèmper: " +} diff --git a/locales/lt.json b/locales/lt.json new file mode 100644 index 00000000..740be7b6 --- /dev/null +++ b/locales/lt.json @@ -0,0 +1,485 @@ +{ + "LIVE": "LIVE", + "Shared `x` ago": "Pasidalino prieš `x`", + "Unsubscribe": "Atšaukti prenumeratą", + "Subscribe": "Prenumeruoti", + "View channel on YouTube": "Peržiūrėti kanalą YouTube", + "View playlist on YouTube": "Peržiūrėti grojaraštį YouTube", + "newest": "naujausia", + "oldest": "seniausia", + "popular": "populiaru", + "last": "paskutinis", + "Next page": "Kitas puslapis", + "Previous page": "Ankstesnis puslapis", + "Clear watch history?": "Išvalyti žiūrėjimo istoriją?", + "New password": "Naujas slaptažodis", + "New passwords must match": "Naujas slaptažodis turi sutapti", + "Authorize token?": "Autorizuoti žetoną?", + "Authorize token for `x`?": "Autorizuoti žetoną `x`?", + "Yes": "Taip", + "No": "Ne", + "Import and Export Data": "Importuoti ir eksportuoti duomenis", + "Import": "Importuoti", + "Import Invidious data": "Importuoti Invidious JSON duomenis", + "Import YouTube subscriptions": "Importuoti YouTube/OPML prenumeratas", + "Import FreeTube subscriptions (.db)": "Importuoti FreeTube prenumeratas (.db)", + "Import NewPipe subscriptions (.json)": "Importuoti NewPipe prenumeratas (.json)", + "Import NewPipe data (.zip)": "Importuoti NewPipe duomenis (.zip)", + "Export": "Eksportuoti", + "Export subscriptions as OPML": "Eksportuoti prenumeratas kaip OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportuoti prenumeratas kaip OPML (skirta NewPipe & FreeTube)", + "Export data as JSON": "Eksportuoti Invidious duomenis kaip JSON", + "Delete account?": "Ištrinti paskyrą?", + "History": "Istorija", + "An alternative front-end to YouTube": "Alternatyvus YouTube žiūrėjimo būdas", + "JavaScript license information": "JavaScript licencijos informacija", + "source": "šaltinis", + "Log in": "Prisijungti", + "Log in/register": "Prisijungti/ registruotis", + "User ID": "Naudotojo ID", + "Password": "Slaptažodis", + "Time (h:mm:ss):": "Laikas (h:mm:ss):", + "Text CAPTCHA": "CAPTCHA tekstas", + "Image CAPTCHA": "CAPTCHA paveikslėlis", + "Sign In": "Prisijungti", + "Register": "Registruotis", + "E-mail": "El. paštas", + "Preferences": "Pasirinktys", + "preferences_category_player": "Grotuvo pasirinktys", + "preferences_video_loop_label": "Visada kartoti: ", + "preferences_autoplay_label": "Leisti automatiškai: ", + "preferences_continue_label": "Leisti sekantį automatiškai kaip nustatyta: ", + "preferences_continue_autoplay_label": "Automatiškai leisti sekantį vaizdo įrašą: ", + "preferences_listen_label": "Klausytis kaip nustatyta: ", + "preferences_local_label": "Vaizdo įrašams naudoti proxy: ", + "preferences_speed_label": "Numatytasis greitis: ", + "preferences_quality_label": "Pageidaujama vaizdo kokybė: ", + "preferences_volume_label": "Grotuvo garsas: ", + "preferences_comments_label": "Numatytieji komentarai: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Numatytieji subtitrai: ", + "Fallback captions: ": "Atsarginiai subtitrai: ", + "preferences_related_videos_label": "Rodyti susijusius vaizdo įrašus: ", + "preferences_annotations_label": "Rodyti anotacijas pagal nutylėjimą: ", + "preferences_extend_desc_label": "Automatiškai išplėsti vaizdo įrašo aprašymą: ", + "preferences_vr_mode_label": "Interaktyvūs 360 laipsnių vaizdo įrašai (reikalingas WebGL): ", + "preferences_category_visual": "Vizualinės nuostatos", + "preferences_player_style_label": "Vaizdo grotuvo stilius: ", + "Dark mode: ": "Tamsus rėžimas: ", + "preferences_dark_mode_label": "Tema: ", + "dark": "tamsi", + "light": "šviesi", + "preferences_thin_mode_label": "Sugretintas rėžimas: ", + "preferences_category_misc": "Įvairios nuostatos", + "preferences_automatic_instance_redirect_label": "Automatinis šaltinio nukreipimas (atsarginis nukreipimas į redirect.Invidous.io): ", + "preferences_category_subscription": "Prenumeratų nuostatos", + "preferences_annotations_subscribed_label": "Prenumeruojamiems kanalams subtitrus rodyti pagal nutylėjimą: ", + "Redirect homepage to feed: ": "Peradresuoti pagrindinį puslapį į kanalų sąrašą: ", + "preferences_max_results_label": "Vaizdo įrašų kiekis kanalų sąraše: ", + "preferences_sort_label": "Rūšiuoti vaizdo įrašus pagal: ", + "published": "paskelbta", + "published - reverse": "paskelbta - atvirkštine tvarka", + "alphabetically": "pagal abėcėlę", + "alphabetically - reverse": "pagal abėcėlę - atvirkštine tvarka", + "channel name": "kanalo pavadinimas", + "channel name - reverse": "kanalo pavadinimas - atvirkštine tvarka", + "Only show latest video from channel: ": "Rodyti tik naujausius vaizdo įrašus iš kanalo: ", + "Only show latest unwatched video from channel: ": "Rodyti tik naujausius nežiūrėtus vaizdo įrašus iš kanalo: ", + "preferences_unseen_only_label": "Rodyti tik nežiūrėtus: ", + "preferences_notifications_only_label": "Rodyti tik pranešimus (jei yra): ", + "Enable web notifications": "Įgalinti žiniatinklio pranešimus", + "`x` uploaded a video": "`x` įkėlė vaizdo įrašą", + "`x` is live": "`x` transliuoja tiesiogiai", + "preferences_category_data": "Duomenų parinktys", + "Clear watch history": "Išvalyti žiūrėjimo istoriją", + "Import/export data": "Importuoti/ eksportuoti duomenis", + "Change password": "Pakeisti slaptažodį", + "Manage subscriptions": "Valdyti prenumeratas", + "Manage tokens": "Valdyti žetonus", + "Watch history": "Žiūrėjimo istorija", + "Delete account": "Ištrinti paskyrą", + "preferences_category_admin": "Administratoriaus nuostatos", + "preferences_default_home_label": "Numatytasis pagrindinis puslapis ", + "preferences_feed_menu_label": "Kanalų sąrašo meniu: ", + "preferences_show_nick_label": "Rodyti slapyvardį viršuje: ", + "Top enabled: ": "Įgalinti viršų: ", + "CAPTCHA enabled: ": "Įgalinta CAPTCHA: ", + "Login enabled: ": "Įgalintas prisijungimas: ", + "Registration enabled: ": "Įgalinta registracija: ", + "Report statistics: ": "Dalintis statistika: ", + "Save preferences": "Išsaugoti nuostatas", + "Subscription manager": "Prenumeratų valdytojas", + "Token manager": "Žetonų valdytojas", + "Token": "Žetonas", + "Import/export": "Importuoti/ eksportuoti", + "unsubscribe": "atšaukti prenumeratą", + "revoke": "atšaukti", + "Subscriptions": "Prenumeratos", + "search": "ieškoti", + "Log out": "Atsijungti", + "Released under the AGPLv3 on Github.": "Išleista pagal AGPLv3 licenciją GitHub.", + "Source available here.": "Kodas prieinamas čia.", + "View JavaScript license information.": "Žiūrėti JavaScript licencijos informaciją.", + "View privacy policy.": "Žiūrėti privatumo politiką.", + "Trending": "Tendencijos", + "Public": "Viešas", + "Unlisted": "Neįtrauktas į sąrašą", + "Private": "Neviešas", + "View all playlists": "Žiūrėti visus grojaraščius", + "Updated `x` ago": "Atnaujinta prieš `x`", + "Delete playlist `x`?": "Ištrinti grojaraštį `x`?", + "Delete playlist": "Ištrinti grojaraštį", + "Create playlist": "Sukurti grojaraštį", + "Title": "Pavadinimas", + "Playlist privacy": "Grojaraščio privatumas", + "Editing playlist `x`": "Redaguojamas grojaraštis `x`", + "Show more": "Rodyti daugiau", + "Show less": "Rodyti mažiau", + "Watch on YouTube": "Žiaurėti Youtube", + "Switch Invidious Instance": "Keisti Invidious šaltinį", + "Hide annotations": "Slėpti anotacijas", + "Show annotations": "Rodyti anotacijas", + "Genre: ": "Žanras: ", + "License: ": "Licencija: ", + "Family friendly? ": "Draugiška šeimai? ", + "Wilson score: ": "Wilson taškai: ", + "Engagement: ": "Įsitraukimas: ", + "Whitelisted regions: ": "Prieinantys regionai: ", + "Blacklisted regions: ": "Blokuojami regionai: ", + "Shared `x`": "Pasidalino `x`", + "Premieres in `x`": "Premjera už `x`", + "Premieres `x`": "Premjera`x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Sveiki! Panašu, kad turite išjungę „JavaScript“. Spustelėkite čia norėdami peržiūrėti komentarus, atminkite, kad jų įkėlimas gali užtrukti šiek tiek ilgiau.", + "View YouTube comments": "Žiūrėti YouTube komentarus", + "View more comments on Reddit": "Žiūrėti daugiau komentarų Reddit", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Žiūrėti `x` komentarus", + "": "Žiūrėti `x` komentarus" + }, + "View Reddit comments": "Žiūrėti Reddit komentarus", + "Hide replies": "Slėpti atsakymus", + "Show replies": "Rodyti atsakymus", + "Incorrect password": "Slaptažodis neteisingas", + "Wrong answer": "Atsakymas neteisingas", + "Erroneous CAPTCHA": "Klaidinga CAPTCHA", + "CAPTCHA is a required field": "CAPTCHA yra reikalinga šiam laukeliui", + "User ID is a required field": "Vartotojo ID yra reikalingas šiam laukeliui", + "Password is a required field": "Slaptažodis yra reikalingas šiam laukeliui", + "Wrong username or password": "Neteisingas vartotojo vardas arba slaptažodis", + "Password cannot be empty": "Slaptažodžio laukelis negali būti tuščias", + "Password cannot be longer than 55 characters": "Slaptažodis negali būti ilgesnis nei 55 simboliai", + "Please log in": "Prašome prisijungti", + "Invidious Private Feed for `x`": "Invidious neviešas kanalų sąrašas `x`", + "channel:`x`": "kanalas:`x`", + "Deleted or invalid channel": "Panaikintas arba netinkamas kanalas", + "This channel does not exist.": "Šis kanalas neegzistuoja.", + "Could not get channel info.": "Nepavyko gauti kanalo informacijos.", + "Could not fetch comments": "Nepavyko atsiųsti komentarų", + "`x` ago": "`x` prieš", + "Load more": "Pakrauti daugiau", + "Could not create mix.": "Nepavyko sukurti derinio.", + "Empty playlist": "Tuščias grojaraštis", + "Not a playlist.": "Ne grojaraštis.", + "Playlist does not exist.": "Grojaraštis neegzistuoja.", + "Could not pull trending pages.": "Nepavyko ištraukti tendencijų puslapių.", + "Hidden field \"challenge\" is a required field": "Paslėptas laukas „iššūkis“ yra privalomas laukas", + "Hidden field \"token\" is a required field": "Paslėptas laukas „žetonas“ yra privalomas laukas", + "Erroneous challenge": "Klaidingas iššūkis", + "Erroneous token": "Klaidingas žetonas", + "No such user": "Nėra tokio vartotojo", + "Token is expired, please try again": "Žetonas pasibaigęs, prašome bandyti dar kartą", + "English": "Anglų", + "English (auto-generated)": "Anglų (Sugeneruota automatiškai)", + "Afrikaans": "Afrikans", + "Albanian": "Albanų", + "Amharic": "Amharų", + "Arabic": "Arabų", + "Armenian": "Armėnų", + "Azerbaijani": "Azerbaidžanų", + "Bangla": "Bengalų", + "Basque": "Baskų", + "Belarusian": "Baltarusių", + "Bosnian": "Bosnių", + "Bulgarian": "Bulgarų", + "Burmese": "Birmiečių", + "Catalan": "Katalonų", + "Cebuano": "Cebuano", + "Chinese (Simplified)": "Kinų (supaprastinta)", + "Chinese (Traditional)": "Kinų (tradicinė)", + "Corsican": "Korsikiečių", + "Croatian": "Kroatų", + "Czech": "Čekų", + "Danish": "Danų", + "Dutch": "Nyderlandų", + "Esperanto": "Esperanto", + "Estonian": "Estų", + "Filipino": "Filipiniečių", + "Finnish": "Suomių", + "French": "Prancūzų", + "Galician": "Galicijos", + "Georgian": "Sakartveliečių", + "German": "Vokiečių", + "Greek": "Graikų", + "Gujarati": "Gujarati", + "Haitian Creole": "Haičio kreolė", + "Hausa": "Hausa", + "Hawaiian": "Havajiečių", + "Hebrew": "Hebrajų", + "Hindi": "Hindi", + "Hmong": "Hmong", + "Hungarian": "Vengrų", + "Icelandic": "Islandų", + "Igbo": "Igbo", + "Indonesian": "Indoneziečių", + "Irish": "Airių", + "Italian": "Italų", + "Japanese": "Japonų", + "Javanese": "Javos", + "Kannada": "Kannada", + "Kazakh": "Kazachų", + "Khmer": "Khmerų", + "Korean": "Korejiėčių", + "Kurdish": "Kurdų", + "Kyrgyz": "Kirgizų", + "Lao": "Lao", + "Latin": "Lotynų", + "Latvian": "Latvių", + "Lithuanian": "Lietuvių", + "Luxembourgish": "Liuksemburgiečių", + "Macedonian": "Šiaurės makedonų", + "Malagasy": "Malagasi", + "Malay": "Malajų", + "Malayalam": "Malayalam", + "Maltese": "Maltiečių", + "Maori": "Maori", + "Marathi": "Marathi", + "Mongolian": "Mongolų", + "Nepali": "Nepaliečių", + "Norwegian Bokmål": "Norvegų Bokmål", + "Nyanja": "Nyanja", + "Pashto": "Paštunų", + "Persian": "Persų", + "Polish": "Lenkų", + "Portuguese": "Portugalų", + "Punjabi": "Punjabi", + "Romanian": "Romėnų", + "Russian": "Rusų", + "Samoan": "Samoa", + "Scottish Gaelic": "Škotų Gaelic", + "Serbian": "Serbų", + "Shona": "Shona", + "Sindhi": "Sindhi", + "Sinhala": "Sinhala", + "Slovak": "Slovakų", + "Slovenian": "Slovėnų", + "Somali": "Somaliečių", + "Southern Sotho": "Pietų Sotho", + "Spanish": "Ispanų", + "Spanish (Latin America)": "Ispanų (Lotynų Amerika)", + "Sundanese": "Sudaniečių", + "Swahili": "Svahili", + "Swedish": "Švedų", + "Tajik": "Tadžikų", + "Tamil": "Tamilų", + "Telugu": "Telugų", + "Thai": "Talaindiečių", + "Turkish": "Turkų", + "Ukrainian": "Ukrainiečių", + "Urdu": "Udrų", + "Uzbek": "Uzbekų", + "Vietnamese": "Vietnamiečių", + "Welsh": "Velso", + "Western Frisian": "Vakarų Fryzų", + "Xhosa": "Xhosa", + "Yiddish": "Jidiš", + "Yoruba": "Yorubiečių", + "Zulu": "Zulu", + "Fallback comments: ": "Atsarginiai komentarai: ", + "Popular": "Populiaru", + "Search": "Paieška", + "Top": "Top", + "About": "Apie", + "Rating: ": "Reitingas: ", + "preferences_locale_label": "Kalba: ", + "View as playlist": "Žiūrėti kaip grojaraštį", + "Default": "Numatytasis", + "Music": "Muzika", + "Gaming": "Žaidimai", + "News": "Naujienos", + "Movies": "Filmai", + "Download": "Atsisiųsti", + "Download as: ": "Atsisiųsti kaip: ", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "(edited)": "(redaguota)", + "YouTube comment permalink": "YouTube komentaro adresas", + "permalink": "adresas", + "`x` marked it with a ❤": "`x` pažymėjo tai su ❤", + "Audio mode": "Garso rėžimas", + "Video mode": "Vaizdo rėžimas", + "channel_tab_videos_label": "Vaizdo įrašai", + "Playlists": "Grojaraiščiai", + "channel_tab_community_label": "Bendruomenė", + "search_filters_sort_option_relevance": "Aktualumas", + "search_filters_sort_option_rating": "Reitingas", + "search_filters_sort_option_date": "Įkėlimo data", + "search_filters_sort_option_views": "Peržiūrų skaičius", + "search_filters_type_label": "Tipas", + "search_filters_duration_label": "Trukmė", + "search_filters_features_label": "Funkcijos", + "search_filters_sort_label": "Rūšiuoti pagal", + "search_filters_date_option_hour": "Per paskutinę valandą", + "search_filters_date_option_today": "Šiandien", + "search_filters_date_option_week": "Šią savaitę", + "search_filters_date_option_month": "Šį mėnesį", + "search_filters_date_option_year": "Šiais metais", + "search_filters_type_option_video": "Vaizdo įrašas", + "search_filters_type_option_channel": "Kanalas", + "search_filters_type_option_playlist": "Grojaraštis", + "search_filters_type_option_movie": "Filmas", + "search_filters_type_option_show": "Serialas", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Subtitrai/CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Tiesiogiai", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Vietovė", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Dabartinė versija: ", + "next_steps_error_message": "Po to turėtumėte pabandyti: ", + "next_steps_error_message_refresh": "Atnaujinti", + "next_steps_error_message_go_to_youtube": "Eiti į YouTube", + "search_filters_duration_option_short": "Trumpas (< 4 minučių)", + "search_filters_duration_option_long": "Ilgas (> 20 minučių)", + "footer_documentation": "Dokumentacija", + "footer_source_code": "Pirminis kodas", + "footer_original_source_code": "Pradinis pirminis kodas", + "adminprefs_modified_source_code_url_label": "URL į pakeisto pirminio kodo repozitoriją", + "footer_modfied_source_code": "Pakeistas pirminis kodas", + "footer_donate_page": "Paaukoti", + "preferences_region_label": "Turinio šalis: ", + "preferences_quality_dash_label": "Pageidaujama DASH vaizdo kokybė: ", + "preferences_quality_dash_option_best": "Geriausia", + "preferences_quality_dash_option_worst": "Blogiausia", + "preferences_quality_dash_option_auto": "Automatinis", + "search_filters_title": "Filtras", + "generic_videos_count_0": "{{count}} vaizdo įrašas", + "generic_videos_count_1": "{{count}} vaizdo įrašai", + "generic_videos_count_2": "{{count}} vaizdo įrašų", + "generic_subscribers_count_0": "{{count}} prenumeratorius", + "generic_subscribers_count_1": "{{count}} prenumeratoriai", + "generic_subscribers_count_2": "{{count}} prenumeratorių", + "generic_subscriptions_count_0": "{{count}} prenumerata", + "generic_subscriptions_count_1": "{{count}} prenumeratos", + "generic_subscriptions_count_2": "{{count}} prenumeratų", + "preferences_watch_history_label": "Įgalinti žiūrėjimo istoriją: ", + "preferences_quality_dash_option_1080p": "1080p", + "invidious": "Invidious", + "preferences_quality_dash_option_720p": "720p", + "generic_playlists_count_0": "{{count}} grojaraštis", + "generic_playlists_count_1": "{{count}} grojaraščiai", + "generic_playlists_count_2": "{{count}} grojaraščių", + "preferences_quality_option_medium": "Vidutinė", + "preferences_quality_option_small": "Maža", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_144p": "144p", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_option_dash": "DASH (prisitaikanti kokybė)", + "generic_views_count_0": "{{count}} peržiūra", + "generic_views_count_1": "{{count}} peržiūros", + "generic_views_count_2": "{{count}} peržiūrų", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_240p": "240p", + "none": "nėra", + "search_filters_type_option_all": "Bet koks tipas", + "videoinfo_started_streaming_x_ago": "Pradėjo transliuoti prieš `x`", + "crash_page_switch_instance": "pabandėte <a href=\"`x`\">naudoti kitą perdavimo šaltinį</a>", + "search_filters_duration_option_none": "Bet kokia trukmė", + "search_filters_duration_option_medium": "Vidutinio ilgumo (4 - 20 minučių)", + "search_filters_features_option_vr180": "VR180", + "crash_page_before_reporting": "Prieš pranešdami apie klaidą įsitikinkite, kad:", + "crash_page_read_the_faq": "perskaitėte <a href=\"`x`\">Dažniausiai užduodamus klausimus (DUK)</a>", + "crash_page_search_issue": "ieškojote <a href=\"`x`\"> esamų problemų GitHub</a>", + "error_video_not_in_playlist": "Prašomo vaizdo įrašo šiame grojaraštyje nėra. <a href=\"`x`\">Spustelėkite čia, kad pamatytumėte grojaraščio pagrindinį puslapį.</a>", + "crash_page_report_issue": "Jei nė vienas iš pirmiau pateiktų būdų nepadėjo, prašome <a href=\"`x`\">atidaryti naują problemą GitHub</a> (pageidautina anglų kalba) ir į savo pranešimą įtraukti šį tekstą (NEVERSKITE šio teksto):", + "subscriptions_unseen_notifs_count_0": "{{count}} nematytas pranešimas", + "subscriptions_unseen_notifs_count_1": "{{count}} nematyti pranešimai", + "subscriptions_unseen_notifs_count_2": "{{count}} nematytų pranešimų", + "Vietnamese (auto-generated)": "Vietnamiečių kalba (automatiškai sugeneruota)", + "Dutch (auto-generated)": "Olandų kalba (automatiškai sugeneruota)", + "generic_count_weeks_0": "{{count}} savaitę", + "generic_count_weeks_1": "{{count}} savaitės", + "generic_count_weeks_2": "{{count}} savaičių", + "Interlingue": "Interlingue", + "Italian (auto-generated)": "Italų kalba (automatiškai sugeneruota)", + "Japanese (auto-generated)": "Japonų kalba (automatiškai sugeneruota)", + "Korean (auto-generated)": "Korėjiečių kalba (automatiškai sugeneruota)", + "generic_count_months_0": "{{count}} mėnesį", + "generic_count_months_1": "{{count}} mėnesius", + "generic_count_months_2": "{{count}} mėnesių", + "generic_count_days_0": "{{count}} dieną", + "generic_count_days_1": "{{count}} dienas", + "generic_count_days_2": "{{count}} dienų", + "generic_count_hours_0": "{{count}} valandą", + "generic_count_hours_1": "{{count}} valandas", + "generic_count_hours_2": "{{count}} valandų", + "generic_count_seconds_0": "{{count}} sekundę", + "generic_count_seconds_1": "{{count}} sekundes", + "generic_count_seconds_2": "{{count}} sekundžių", + "generic_count_minutes_0": "{{count}} minutę", + "generic_count_minutes_1": "{{count}} minutes", + "generic_count_minutes_2": "{{count}} minučių", + "generic_count_years_0": "{{count}} metus", + "generic_count_years_1": "{{count}} metus", + "generic_count_years_2": "{{count}} metų", + "Popular enabled: ": "Populiarūs įgalinti: ", + "Portuguese (auto-generated)": "Portugalų kalba (automatiškai sugeneruota)", + "videoinfo_watch_on_youTube": "Žiaurėti Youtube", + "Chinese (China)": "Kinų kalba (Kinija)", + "crash_page_you_found_a_bug": "Atrodo, kad radote \"Invidious\" klaidą!", + "search_filters_features_option_three_sixty": "360°", + "English (United Kingdom)": "Anglų kalba (Jungtinė Karalystė)", + "Chinese (Hong Kong)": "Kinų kalba (Honkongas)", + "search_message_change_filters_or_query": "Pabandykite išplėsti paieškos užklausą ir (arba) pakeisti filtrus.", + "English (United States)": "Anglų kalba (Jungtinės Amerikos Valstijos)", + "Chinese (Taiwan)": "Kinų kalba (Taivanas)", + "search_message_use_another_instance": " Taip pat galite <a href=\"`x`\">ieškoti kitame perdavimo šaltinyje</a>.", + "tokens_count_0": "{{count}} žetonas", + "tokens_count_1": "{{count}} žetonai", + "tokens_count_2": "{{count}} žetonų", + "search_message_no_results": "Rezultatų nerasta.", + "comments_view_x_replies_0": "Žiūrėti {{count}} atsakymą", + "comments_view_x_replies_1": "Žiūrėti {{count}} atsakymus", + "comments_view_x_replies_2": "Žiūrėti {{count}} atsakymų", + "comments_points_count_0": "{{count}} taškas", + "comments_points_count_1": "{{count}} taškai", + "comments_points_count_2": "{{count}} taškų", + "Cantonese (Hong Kong)": "Kantono kalba (Honkongas)", + "Chinese": "Kinų", + "French (auto-generated)": "Prancūzų kalba (automatiškai sugeneruota)", + "German (auto-generated)": "Vokiečių kalba (automatiškai sugeneruota)", + "Indonesian (auto-generated)": "Indoneziečių kalba (automatiškai sugeneruota)", + "Portuguese (Brazil)": "Portugalų kalba (Brazilija)", + "Russian (auto-generated)": "Rusų kalba (automatiškai sugeneruota)", + "Spanish (Mexico)": "Ispanų kalba (Meksika)", + "Spanish (auto-generated)": "Ispanų kalba (automatiškai sugeneruota)", + "Spanish (Spain)": "Ispanų kalba (Ispanija)", + "Turkish (auto-generated)": "Turkų kalba (automatiškai sugeneruota)", + "search_filters_date_label": "Įkėlimo data", + "search_filters_date_option_none": "Bet kokia data", + "search_filters_features_option_purchased": "Įsigyta", + "search_filters_apply_button": "Taikyti pasirinktus filtrus", + "download_subtitles": "Subtitrai - `x` (.vtt)", + "user_created_playlists": "`x` sukurti grojaraščiai", + "user_saved_playlists": "`x` išsaugoti grojaraščiai", + "Video unavailable": "Vaizdo įrašas nepasiekiamas", + "preferences_save_player_pos_label": "Išsaugoti atkūrimo padėtį: ", + "videoinfo_youTube_embed_link": "Įterpti", + "videoinfo_invidious_embed_link": "Įterpti nuorodą", + "crash_page_refresh": "pabandėte <a href=\"`x`\">atnaujinti puslapį</a>", + "Album: ": "Albumas " +} diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 0cccdd77..17d64baf 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -1,16 +1,4 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnenter", - "": "`x` abonnenter" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videoer", - "": "`x` videoer" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` spillelister", - "": "`x` spillelister" - }, "LIVE": "SANNTIDSVISNING", "Shared `x` ago": "Delt for `x` siden", "Unsubscribe": "Opphev abonnement", @@ -26,22 +14,21 @@ "Clear watch history?": "Tøm visningshistorikk?", "New password": "Nytt passord", "New passwords must match": "Nye passordfelter må stemme overens", - "Cannot change password for Google accounts": "Kan ikke endre passord for Google-kontoer", "Authorize token?": "Identitetsbekreft symbol?", "Authorize token for `x`?": "Identitetsbekreft symbol for `x`?", "Yes": "Ja", "No": "Nei", "Import and Export Data": "Importer- og eksporter data", "Import": "Importer", - "Import Invidious data": "Importer Invidious-data", - "Import YouTube subscriptions": "Importer YouTube-abonnementer", + "Import Invidious data": "Importer Invidious-JSON-data", + "Import YouTube subscriptions": "Importer YouTube CSV eller OPML-abonnementer", "Import FreeTube subscriptions (.db)": "Importer FreeTube-abonnementer (.db)", "Import NewPipe subscriptions (.json)": "Importer NewPipe-abonnementer (.json)", "Import NewPipe data (.zip)": "Importer NewPipe-data (.zip)", "Export": "Eksporter", "Export subscriptions as OPML": "Eksporter abonnementer som OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksporter abonnementer som OPML (for NewPipe og FreeTube)", - "Export data as JSON": "Eksporter data som JSON", + "Export data as JSON": "Eksporter Invidiousdata som JSON", "Delete account?": "Slett konto?", "History": "Historikk", "An alternative front-end to YouTube": "En alternativ grenseflate for YouTube", @@ -49,7 +36,6 @@ "source": "kilde", "Log in": "Logg inn", "Log in/register": "Logg inn/registrer", - "Log in with Google": "Logg inn med Google", "User ID": "Bruker-ID", "Password": "Passord", "Time (h:mm:ss):": "Tid (h:mm:ss):", @@ -58,38 +44,40 @@ "Sign In": "Innlogging", "Register": "Registrer", "E-mail": "E-post", - "Google verification code": "Google-bekreftelseskode", "Preferences": "Innstillinger", - "Player preferences": "Avspillerinnstillinger", - "Always loop: ": "Alltid gjenta: ", - "Autoplay: ": "Autoavspilling: ", - "Play next by default: ": "Spill neste som forvalg: ", - "Autoplay next video: ": "Autospill neste video: ", - "Listen by default: ": "Lytt som forvalg: ", - "Proxy videos: ": "Mellomtjen videoer? ", - "Default speed: ": "Forvalgt hastighet: ", - "Preferred video quality: ": "Foretrukket videokvalitet: ", - "Player volume: ": "Avspillerlydstyrke: ", - "Default comments: ": "Forvalgte kommentarer: ", + "preferences_category_player": "Avspillerinnstillinger", + "preferences_video_loop_label": "Alltid gjenta: ", + "preferences_autoplay_label": "Autoavspilling: ", + "preferences_continue_label": "Spill neste som forvalg: ", + "preferences_continue_autoplay_label": "Autospill neste video: ", + "preferences_listen_label": "Lytt som forvalg: ", + "preferences_local_label": "Mellomtjen videoer? ", + "preferences_speed_label": "Forvalgt hastighet: ", + "preferences_quality_label": "Foretrukket videokvalitet: ", + "preferences_volume_label": "Avspillerlydstyrke: ", + "preferences_comments_label": "Forvalgte kommentarer: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Forvalgte undertitler: ", + "preferences_captions_label": "Forvalgte undertitler: ", "Fallback captions: ": "Tilbakefallsundertitler: ", - "Show related videos: ": "Vis relaterte videoer? ", - "Show annotations by default: ": "Vis merknader som forvalg? ", - "Automatically extend video description: ": "", - "Visual preferences": "Visuelle innstillinger", - "Player style: ": "Avspillerstil: ", + "preferences_related_videos_label": "Vis relaterte videoer? ", + "preferences_annotations_label": "Vis merknader som forvalg? ", + "preferences_extend_desc_label": "Utvid videobeskrivelse automatisk: ", + "preferences_vr_mode_label": "Interaktive 360-gradersfilmer (krever WebGL): ", + "preferences_category_visual": "Visuelle innstillinger", + "preferences_player_style_label": "Avspillerstil: ", "Dark mode: ": "Mørk drakt: ", - "Theme: ": "Drakt: ", + "preferences_dark_mode_label": "Drakt: ", "dark": "Mørk", "light": "Lys", - "Thin mode: ": "Tynt modus: ", - "Subscription preferences": "Abonnementsinnstillinger", - "Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ", + "preferences_thin_mode_label": "Tynt modus: ", + "preferences_category_misc": "Ulike innstillinger", + "preferences_automatic_instance_redirect_label": "Automatisk instansomdirigering (faller tilbake til redirect.invidious.io): ", + "preferences_category_subscription": "Abonnementsinnstillinger", + "preferences_annotations_subscribed_label": "Vis merknader som forvalg for kanaler det abonneres på? ", "Redirect homepage to feed: ": "Videresend hjemmeside til kilde: ", - "Number of videos shown in feed: ": "Antall videoer å vise i kilde: ", - "Sort videos by: ": "Sorter videoer etter: ", + "preferences_max_results_label": "Antall videoer å vise i kilde: ", + "preferences_sort_label": "Sorter videoer etter: ", "published": "publisert", "published - reverse": "publisert - motsatt", "alphabetically": "alfabetisk", @@ -98,12 +86,12 @@ "channel name - reverse": "kanalnavn - motsatt", "Only show latest video from channel: ": "Kun vis siste video fra kanal: ", "Only show latest unwatched video from channel: ": "Kun vis siste usette video fra kanal: ", - "Only show unwatched: ": "Kun vis usette: ", - "Only show notifications (if there are any): ": "Kun vis merknader (hvis det er noen): ", + "preferences_unseen_only_label": "Kun vis usette: ", + "preferences_notifications_only_label": "Kun vis merknader (hvis det er noen): ", "Enable web notifications": "Skru på nettmerknader", "`x` uploaded a video": "`x` lastet opp en video", "`x` is live": "`x` er pålogget", - "Data preferences": "Datainnstillinger", + "preferences_category_data": "Datainnstillinger", "Clear watch history": "Tøm visningshistorikk", "Import/export data": "Importer/eksporter data", "Change password": "Endre passord", @@ -111,9 +99,10 @@ "Manage tokens": "Behandle symboler", "Watch history": "Visningshistorikk", "Delete account": "Slett konto", - "Administrator preferences": "Administratorinnstillinger", - "Default homepage: ": "Forvalgt hjemmeside: ", - "Feed menu: ": "Kilde-meny: ", + "preferences_category_admin": "Administratorinnstillinger", + "preferences_default_home_label": "Forvalgt hjemmeside: ", + "preferences_feed_menu_label": "Kilde-meny: ", + "preferences_show_nick_label": "Vis kallenavn på toppen: ", "Top enabled: ": "Topp påskrudd? ", "CAPTCHA enabled: ": "CAPTCHA påskrudd? ", "Login enabled: ": "Innlogging påskrudd? ", @@ -123,25 +112,13 @@ "Subscription manager": "Abonnementsbehandler", "Token manager": "Symbolbehandler", "Token": "Symbol", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnementer", - "": "`x` abonnementer" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` symboler", - "": "`x` symboler" - }, "Import/export": "Importer/eksporter", "unsubscribe": "opphev abonnement", "revoke": "tilbakekall", "Subscriptions": "Abonnement", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` usette merknader", - "": "`x` usette merknader" - }, "search": "søk", "Log out": "Logg ut", - "Released under the AGPLv3 by Omar Roth.": "Utgitt med AGPLv3+lisens av Omar Roth.", + "Released under the AGPLv3 on Github.": "Tilgjengelig med AGPLv3-lisens på GitHub.", "Source available here.": "Kildekode tilgjengelig her.", "View JavaScript license information.": "Vis JavaScript-lisensinfo.", "View privacy policy.": "Vis personvernspraksis.", @@ -157,9 +134,10 @@ "Title": "Tittel", "Playlist privacy": "Vern av spilleliste", "Editing playlist `x`": "Endre spilleliste «x»", - "Show more": "", - "Show less": "", + "Show more": "Vis mer", + "Show less": "Vis mindre", "Watch on YouTube": "Vis video på YouTube", + "Switch Invidious Instance": "Bytt Invidious-instans", "Hide annotations": "Skjul merknader", "Show annotations": "Vis merknader", "Genre: ": "Sjanger: ", @@ -170,34 +148,25 @@ "Whitelisted regions: ": "Hvitlistede regioner: ", "Blacklisted regions: ": "Svartelistede regioner: ", "Shared `x`": "Delt `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visninger", - "": "`x` visninger" - }, "Premieres in `x`": "Premiere om `x`", "Premieres `x`": "Première `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hei. Det ser ut til at du har JavaScript avslått. Klikk her for å vise kommentarer, ha i minnet at innlasting tar lengre tid.", "View YouTube comments": "Vis YouTube-kommentarer", "View more comments on Reddit": "Vis flere kommenterer på Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` kommentarer", + "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` kommentar", "": "Vis `x` kommentarer" }, "View Reddit comments": "Vis Reddit-kommentarer", "Hide replies": "Skjul svar", "Show replies": "Vis svar", "Incorrect password": "Feil passord", - "Quota exceeded, try again in a few hours": "Kvote overskredet, prøv igjen om et par timer", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Kunne ikke logge inn, forsikre deg om at tofaktor-identitetsbekreftelse (Authenticator eller SMS) er skrudd på.", - "Invalid TFA code": "Ugyldig tofaktorkode", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Innlogging mislyktes. Dette kan være fordi tofaktor-identitetsbekreftelse er skrudd av på kontoen din.", "Wrong answer": "Ugyldig svar", "Erroneous CAPTCHA": "Ugyldig CAPTCHA", "CAPTCHA is a required field": "CAPTCHA er et påkrevd felt", "User ID is a required field": "Bruker-ID er et påkrevd felt", "Password is a required field": "Passord er et påkrevd felt", "Wrong username or password": "Ugyldig brukernavn eller passord", - "Please sign in using 'Log in with Google'": "Logg inn ved bruk av \"Google-innlogging\"", "Password cannot be empty": "Passordet kan ikke være tomt", "Password cannot be longer than 55 characters": "Passordet kan ikke være lengre enn 55 tegn", "Please log in": "Logg inn", @@ -207,16 +176,8 @@ "This channel does not exist.": "Denne kanalen finnes ikke.", "Could not get channel info.": "Kunne ikke innhente kanalinfo.", "Could not fetch comments": "Kunne ikke hente kommentarer", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Vis `x` svar", - "": "Vis `x` svar" - }, "`x` ago": "`x` siden", "Load more": "Last inn flere", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` poeng", - "": "`x` poeng" - }, "Could not create mix.": "Kunne ikke opprette miks.", "Empty playlist": "Spillelisten er tom", "Not a playlist.": "Ugyldig spilleliste.", @@ -229,7 +190,7 @@ "No such user": "Ugyldig bruker", "Token is expired, please try again": "Symbol utløpt, prøv igjen", "English": "Engelsk", - "English (auto-generated)": "Engelsk (auto-generert)", + "English (auto-generated)": "Engelsk (laget automatisk)", "Afrikaans": "Afrikansk", "Albanian": "Albansk", "Amharic": "Amharisk", @@ -334,41 +295,13 @@ "Yiddish": "Jiddisk", "Yoruba": "Joruba", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` år", - "": "`x` år" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` måneder", - "": "`x` måneder" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` uker", - "": "`x` uker" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dager", - "": "`x` dager" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` timer", - "": "`x` timer" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutter", - "": "`x` minutter" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekunder", - "": "`x` sekunder" - }, "Fallback comments: ": "Tilbakefallskommentarer: ", "Popular": "Populært", - "Search": "", + "Search": "Søk", "Top": "Topp", "About": "Om", "Rating: ": "Vurdering: ", - "Language: ": "Språk: ", + "preferences_locale_label": "Språk: ", "View as playlist": "Vis som spilleliste", "Default": "Forvalg", "Music": "Musikk", @@ -384,35 +317,184 @@ "`x` marked it with a ❤": "`x` levnet et ❤", "Audio mode": "Lydmodus", "Video mode": "Video-modus", - "Videos": "Videoer", + "channel_tab_videos_label": "Videoer", "Playlists": "Spillelister", - "Community": "Gemenskap", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Gjeldende versjon: " -}
\ No newline at end of file + "channel_tab_community_label": "Gemenskap", + "search_filters_sort_option_relevance": "relevans", + "search_filters_sort_option_rating": "vurdering", + "search_filters_sort_option_date": "Opplastingsdato", + "search_filters_sort_option_views": "visninger", + "search_filters_type_label": "innholdstype", + "search_filters_duration_label": "varighet", + "search_filters_features_label": "funksjoner", + "search_filters_sort_label": "sorter", + "search_filters_date_option_hour": "Siste time", + "search_filters_date_option_today": "i dag", + "search_filters_date_option_week": "uke", + "search_filters_date_option_month": "måned", + "search_filters_date_option_year": "år", + "search_filters_type_option_video": "video", + "search_filters_type_option_channel": "kanal", + "search_filters_type_option_playlist": "spilleliste", + "search_filters_type_option_movie": "film", + "search_filters_type_option_show": "vis", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "undertekster", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "direkte", + "search_filters_features_option_four_k": "4k", + "search_filters_features_option_location": "sted", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Gjeldende versjon: ", + "next_steps_error_message": "Etterpå bør du prøve dette: ", + "next_steps_error_message_refresh": "Gjenoppfrisk", + "next_steps_error_message_go_to_youtube": "Gå til YouTube", + "search_filters_duration_option_long": "Lang (> 20 minutter)", + "footer_donate_page": "Doner", + "search_filters_duration_option_short": "Kort (< 4 minutter)", + "footer_documentation": "Dokumentasjon", + "footer_source_code": "Kildekode", + "footer_original_source_code": "Opprinnelig kildekode", + "footer_modfied_source_code": "Endret kildekode", + "adminprefs_modified_source_code_url_label": "Nettadresse til kodelager inneholdende endret kildekode", + "preferences_quality_dash_label": "Foretrukket DASH-videokvalitet: ", + "preferences_region_label": "Innholdsland: ", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_small": "Lav", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_best": "Best", + "preferences_quality_dash_option_worst": "Verst", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "search_filters_features_option_purchased": "Kjøpt", + "search_filters_features_option_three_sixty": "360°", + "none": "intet", + "videoinfo_watch_on_youTube": "Se på YouTube", + "videoinfo_youTube_embed_link": "Bak inn", + "videoinfo_invidious_embed_link": "Bak inn lenke", + "download_subtitles": "Undertekster - `x` (.vtt)", + "user_created_playlists": "`x` spillelister opprettet", + "user_saved_playlists": "`x` spillelister lagret", + "Video unavailable": "Utilgjengelig video", + "preferences_quality_option_dash": "DASH (tilpasset kvalitet)", + "preferences_quality_option_medium": "Medium", + "preferences_quality_dash_option_2160p": "2160p", + "videoinfo_started_streaming_x_ago": "Strømmen startet for `x` siden", + "generic_count_seconds": "{{count}} sekund", + "generic_count_seconds_plural": "{{count}} sekunder", + "preferences_save_player_pos_label": "Lagre avspillingsposisjon: ", + "generic_views_count": "{{count}} visning", + "generic_views_count_plural": "{{count}} visninger", + "tokens_count": "{{count}} symbol", + "tokens_count_plural": "{{count}} symboler", + "generic_subscriptions_count": "{{count}} abonnement", + "generic_subscriptions_count_plural": "{{count}} abonnementer", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videoer", + "generic_playlists_count": "{{count}} spilleliste", + "generic_playlists_count_plural": "{{count}} spillelister", + "subscriptions_unseen_notifs_count": "{{count}} usett merknad", + "subscriptions_unseen_notifs_count_plural": "{{count}} usette merknader", + "comments_view_x_replies": "Vis {{count}} svar", + "comments_view_x_replies_plural": "Vis {{count}} svar", + "generic_subscribers_count": "{{count}} abonnent", + "generic_subscribers_count_plural": "{{count}}abonnenter", + "generic_count_months": "{{count}} måned", + "generic_count_months_plural": "{{count}} måneder", + "generic_count_days": "{{count}} dag", + "generic_count_days_plural": "{{count}} dager", + "comments_points_count": "{{count}} poeng", + "comments_points_count_plural": "{{count}} poeng", + "generic_count_weeks": "{{count}} uke", + "generic_count_weeks_plural": "{{count}} uker", + "generic_count_hours": "{{count}} time", + "generic_count_hours_plural": "{{count}} timer", + "generic_count_minutes": "{{count}} minutt", + "generic_count_minutes_plural": "{{count}} minutter", + "generic_count_years": "{{count}} år", + "generic_count_years_plural": "{{count}} år", + "crash_page_read_the_faq": "lest de <a href=\"`x`\">Ofte stilte spørsmålene (OSS/FAQ)</a>", + "crash_page_search_issue": "søkt etter <a href=\"`x`\">eksisterende utfordringer på GitHub</a>", + "crash_page_you_found_a_bug": "Det ser ut til at du fant en feil i Invidious!", + "crash_page_refresh": "forsøkt å <a href=\"`x`\">laste siden på nytt</a>", + "crash_page_switch_instance": "forsøkt et <a href=\"`x`\">annet eksemplar</a>", + "crash_page_before_reporting": "Før du rapporterer en feil, sikre at du har:", + "crash_page_report_issue": "Sett at det overnevnte ikke hjalp, <a href=\"`x`\">lag en ny utfordring på GitHub</a> (fortrinnsvis på engelsk) og få med følgende tekstbit i meldingen dithen (IKKE oversett denne teksten):", + "English (United Kingdom)": "Engelsk (Storbritannia)", + "English (United States)": "Engelsk (USA)", + "Cantonese (Hong Kong)": "Kantonesisk (Hong Kong)", + "Portuguese (Brazil)": "Portugisisk (Brasil)", + "Spanish (Mexico)": "Spansk (Mexico)", + "Spanish (Spain)": "Spansk (Spania)", + "Spanish (auto-generated)": "Spansk (laget automatisk)", + "Vietnamese (auto-generated)": "Vietnamesisk (laget automatisk)", + "preferences_watch_history_label": "Aktiver seerhistorikk: ", + "Chinese": "Kinesisk", + "Chinese (China)": "Kinesisk (Kina)", + "Chinese (Hong Kong)": "Kinesisk (Hong Kong)", + "Chinese (Taiwan)": "Kinesisk (Taiwan)", + "French (auto-generated)": "Fransk (laget automatisk)", + "German (auto-generated)": "Tysk (laget automatisk)", + "Indonesian (auto-generated)": "Indonesisk (laget automatisk)", + "Interlingue": "Interlingue", + "Italian (auto-generated)": "Italiensk (laget automatisk)", + "Japanese (auto-generated)": "Japansk (laget automatisk)", + "Korean (auto-generated)": "Koreansk (laget automatisk)", + "Portuguese (auto-generated)": "Portugisisk (laget automatisk)", + "Russian (auto-generated)": "Russisk (laget automatisk)", + "Dutch (auto-generated)": "Nederlandsk (laget automatisk)", + "Turkish (auto-generated)": "Tyrkisk (laget automatisk)", + "search_filters_title": "Filtrer", + "Popular enabled: ": "Populære aktiv: ", + "search_message_change_filters_or_query": "Prøv ett mindre snevert søk og/eller endre filterne.", + "search_filters_duration_option_medium": "Middels (4–20 minutter)", + "search_message_no_results": "Resultatløst.", + "search_filters_type_option_all": "Alle typer", + "search_filters_duration_option_none": "Enhver varighet", + "search_message_use_another_instance": "Du kan også <a href=\"`x`\">søke på en annen instans</a>.", + "search_filters_date_label": "Opplastningsdato", + "search_filters_apply_button": "Bruk valgte filtre", + "search_filters_date_option_none": "Siden begynnelsen", + "search_filters_features_option_vr180": "VR180", + "error_video_not_in_playlist": "Forespurt video finnes ikke i denne spillelisten. <a href=\"`x`\">Trykk her for spillelistens hjemmeside.</a>", + "Standard YouTube license": "Standard YouTube-lisens", + "Song: ": "Sang: ", + "channel_tab_streams_label": "Direktesendinger", + "channel_tab_shorts_label": "Kortvideoer", + "channel_tab_playlists_label": "Spillelister", + "Music in this video": "Musikk i denne videoen", + "channel_tab_channels_label": "Kanaler", + "Artist: ": "Artist: ", + "Album: ": "Album: ", + "Download is disabled": "Nedlasting er avskrudd", + "Channel Sponsor": "Kanalsponsor", + "Import YouTube playlist (.csv)": "Importer YouTube-spilleliste (.csv)", + "channel_tab_podcasts_label": "Podkaster", + "channel_tab_releases_label": "Utgaver", + "generic_button_delete": "Slett", + "generic_button_edit": "Endre", + "generic_button_save": "Lagre", + "generic_button_cancel": "Avbryt", + "generic_button_rss": "RSS", + "playlist_button_add_items": "Legg til videoer", + "generic_channels_count": "{{count}} kanal", + "generic_channels_count_plural": "{{count}} kanaler", + "Import YouTube watch history (.json)": "Importere YouTube visningshistorikk (.json)", + "carousel_go_to": "Gå til lysark `x`", + "Search for videos": "Søk i videoer", + "Answer": "Svar", + "carousel_slide": "Lysark {{current}} av {{total}}", + "carousel_skip": "Hopp over karusellen", + "Add to playlist": "Legg til i spilleliste", + "Add to playlist: ": "Legg til i spilleliste: ", + "The Popular feed has been disabled by the administrator.": "Populært-kilden er koblet ut av administratoren.", + "toggle_theme": "Endre utseende" +} diff --git a/locales/nl.json b/locales/nl.json index 6d2ff68c..f10b3593 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -1,16 +1,4 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnees", - "": "`x` abonnees" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video's", - "": "`x` video's" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` afspeellijsten", - "": "`x` afspeellijsten" - }, "LIVE": "LIVE", "Shared `x` ago": "Gedeeld: `x` geleden", "Unsubscribe": "Deabonneren", @@ -26,30 +14,28 @@ "Clear watch history?": "Wil je de kijkgeschiedenis wissen?", "New password": "Nieuw wachtwoord", "New passwords must match": "De nieuwe wachtwoorden moeten overeenkomen", - "Cannot change password for Google accounts": "Kan het wachtwoord van Google-accounts niet wijzigen", "Authorize token?": "Wil je de toegangssleutel machtigen?", "Authorize token for `x`?": "Wil je de toegangssleutel machtigen voor `x`?", "Yes": "Ja", "No": "Nee", "Import and Export Data": "Gegevens im- en exporteren", "Import": "Importeren", - "Import Invidious data": "Invidious-gegevens importeren", - "Import YouTube subscriptions": "YouTube-abonnementen importeren", + "Import Invidious data": "JSON-gegevens Invidious importeren", + "Import YouTube subscriptions": "YouTube CVS of OPML-abonnementen importeren", "Import FreeTube subscriptions (.db)": "FreeTube-abonnementen importeren (.db)", "Import NewPipe subscriptions (.json)": "NewPipe-abonnementen importeren (.json)", "Import NewPipe data (.zip)": "NewPipe-gegevens importeren (.zip)", "Export": "Exporteren", "Export subscriptions as OPML": "Abonnementen exporteren als OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonnementen exporteren als OPML (voor NewPipe en FreeTube)", - "Export data as JSON": "Gegevens exporteren als JSON", - "Delete account?": "Wil je je account verwijderen?", + "Export data as JSON": "Invidious-gegevens naar JSON exporteren", + "Delete account?": "Wilt u uw account verwijderen?", "History": "Geschiedenis", "An alternative front-end to YouTube": "Een alternatief front-end voor YouTube", "JavaScript license information": "JavaScript-licentieinformatie", "source": "bron", "Log in": "Inloggen", "Log in/register": "Inloggen/Registreren", - "Log in with Google": "Inloggen met Google", "User ID": "Gebruikers-id", "Password": "Wachtwoord", "Time (h:mm:ss):": "Tijd (h:mm:ss):", @@ -58,38 +44,38 @@ "Sign In": "Inloggen", "Register": "Registreren", "E-mail": "E-mailadres", - "Google verification code": "Google-verificatiecode", "Preferences": "Instellingen", - "Player preferences": "Spelerinstellingen", - "Always loop: ": "Altijd herhalen: ", - "Autoplay: ": "Automatisch afspelen: ", - "Play next by default: ": "Standaard volgende video afspelen: ", - "Autoplay next video: ": "Volgende video automatisch afspelen: ", - "Listen by default: ": "Standaard luisteren: ", - "Proxy videos: ": "Video's afspelen via proxy? ", - "Default speed: ": "Standaard afspeelsnelheid: ", - "Preferred video quality: ": "Voorkeurskwaliteit: ", - "Player volume: ": "Spelervolume: ", - "Default comments: ": "Reacties tonen van: ", + "preferences_category_player": "Spelerinstellingen", + "preferences_video_loop_label": "Altijd herhalen: ", + "preferences_autoplay_label": "Automatisch afspelen: ", + "preferences_continue_label": "Standaard volgende video afspelen: ", + "preferences_continue_autoplay_label": "Volgende video automatisch afspelen: ", + "preferences_listen_label": "Standaard luisteren: ", + "preferences_local_label": "Video's afspelen via proxy? ", + "preferences_speed_label": "Standaard afspeelsnelheid: ", + "preferences_quality_label": "Voorkeurskwaliteit: ", + "preferences_volume_label": "Spelervolume: ", + "preferences_comments_label": "Reacties tonen van: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Standaard ondertiteling: ", + "preferences_captions_label": "Standaard ondertiteling: ", "Fallback captions: ": "Alternatieve ondertiteling: ", - "Show related videos: ": "Gerelateerde video's tonen? ", - "Show annotations by default: ": "Standaard annotaties tonen? ", - "Automatically extend video description: ": "", - "Visual preferences": "Visuele instellingen", - "Player style: ": "Speler vormgeving ", + "preferences_related_videos_label": "Gerelateerde video's tonen? ", + "preferences_annotations_label": "Standaard annotaties tonen? ", + "preferences_extend_desc_label": "Breid videobeschrijving automatisch uit: ", + "preferences_vr_mode_label": "Interactieve 360-graden-video's (vereist WebGL) ", + "preferences_category_visual": "Visuele instellingen", + "preferences_player_style_label": "Speler vormgeving ", "Dark mode: ": "Donkere modus: ", - "Theme: ": "Thema: ", + "preferences_dark_mode_label": "Thema: ", "dark": "donker", "light": "licht", - "Thin mode: ": "Smalle modus: ", - "Subscription preferences": "Abonnementsinstellingen", - "Show annotations by default for subscribed channels: ": "Standaard annotaties tonen voor geabonneerde kanalen? ", + "preferences_thin_mode_label": "Smalle modus: ", + "preferences_category_subscription": "Abonnementsinstellingen", + "preferences_annotations_subscribed_label": "Standaard annotaties tonen voor geabonneerde kanalen? ", "Redirect homepage to feed: ": "Startpagina omleiden naar feed: ", - "Number of videos shown in feed: ": "Aantal te tonen video's in feed: ", - "Sort videos by: ": "Video's sorteren op: ", + "preferences_max_results_label": "Aantal te tonen video's in feed: ", + "preferences_sort_label": "Video's sorteren op: ", "published": "publicatiedatum", "published - reverse": "publicatiedatum - omgekeerd", "alphabetically": "alfabetische volgorde", @@ -98,12 +84,12 @@ "channel name - reverse": "kanaalnaam - omgekeerd", "Only show latest video from channel: ": "Alleen nieuwste video van kanaal tonen: ", "Only show latest unwatched video from channel: ": "Alleen nieuwste niet-bekeken video van kanaal tonen: ", - "Only show unwatched: ": "Alleen niet-bekeken videos tonen: ", - "Only show notifications (if there are any): ": "Alleen meldingen tonen (als die er zijn): ", - "Enable web notifications": "Systemmeldingen inschakelen", + "preferences_unseen_only_label": "Alleen niet-bekeken videos tonen: ", + "preferences_notifications_only_label": "Alleen meldingen tonen (als die er zijn): ", + "Enable web notifications": "Systeemmeldingen inschakelen", "`x` uploaded a video": "`x` heeft een video geüpload", "`x` is live": "`x` zendt nu live uit", - "Data preferences": "Gegevensinstellingen", + "preferences_category_data": "Gegevensinstellingen", "Clear watch history": "Kijkgeschiedenis wissen", "Import/export data": "Gegevens im-/exporteren", "Change password": "Wachtwoord wijzigen", @@ -111,9 +97,9 @@ "Manage tokens": "Toegangssleutels beheren", "Watch history": "Kijkgeschiedenis", "Delete account": "Account verwijderen", - "Administrator preferences": "Beheerdersinstellingen", - "Default homepage: ": "Standaard startpagina: ", - "Feed menu: ": "Feedmenu: ", + "preferences_category_admin": "Beheerdersinstellingen", + "preferences_default_home_label": "Standaard startpagina: ", + "preferences_feed_menu_label": "Feedmenu: ", "Top enabled: ": "Bovenkant inschakelen? ", "CAPTCHA enabled: ": "CAPTCHA gebruiken? ", "Login enabled: ": "Inloggen toestaan? ", @@ -121,27 +107,14 @@ "Report statistics: ": "Statistieken bijhouden? ", "Save preferences": "Instellingen opslaan", "Subscription manager": "Abonnementen beheren", - "Token manager": "Toegangssleutels beheren", + "Token manager": "Toegangssleutelbeheerder", "Token": "Toegangssleutel", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnementen", - "": "`x` abonnementen" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` toegangssleutels", - "": "`x` toegangssleutels" - }, "Import/export": "Importeren/Exporteren", - "unsubscribe": "Deabonneren", + "unsubscribe": "deabonneren", "revoke": "Intrekken", "Subscriptions": "Abonnementen", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ongelezen meldingen", - "": "`x` ongelezen meldingen" - }, "search": "zoeken", "Log out": "Uitloggen", - "Released under the AGPLv3 by Omar Roth.": "Uitgebracht onder de AGPLv3-licentie, door Omar Roth.", "Source available here.": "De broncode is hier beschikbaar.", "View JavaScript license information.": "JavaScript-licentieinformatie tonen.", "View privacy policy.": "Privacybeleid tonen.", @@ -157,8 +130,8 @@ "Title": "Titel", "Playlist privacy": "Afspeellijst privacy", "Editing playlist `x`": "Afspeellijst `x` wijzigen", - "Show more": "", - "Show less": "", + "Show more": "Toon meer", + "Show less": "Toon minder", "Watch on YouTube": "Video bekijken op YouTube", "Hide annotations": "Annotaties verbergen", "Show annotations": "Annotaties tonen", @@ -170,10 +143,6 @@ "Whitelisted regions: ": "Toegestane regio's: ", "Blacklisted regions: ": "Geblokkeerde regio's: ", "Shared `x`": "`x` gedeeld", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` weergaven", - "": "`x` weergaven" - }, "Premieres in `x`": "Verschijnt over `x`", "Premieres `x`": "Verschijnt op `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hoi! Het lijkt erop dat je JavaScript hebt uitgeschakeld. Klik hier om de reacties te bekijken. Let op: het laden duurt wat langer.", @@ -187,17 +156,12 @@ "Hide replies": "Antwoorden verbergen", "Show replies": "Antwoorden tonen", "Incorrect password": "Wachtwoord is onjuist", - "Quota exceeded, try again in a few hours": "Quota overschreden; probeer het over een paar uur opnieuw", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Kan niet inloggen. Zorg ervoor dat authenticatie in twee stappen (Authenticator of sms) is ingeschakeld.", - "Invalid TFA code": "Onjuiste TFA-code", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Inloggen mislukt. Wellicht is authenticatie in twee stappen niet ingeschakeld op je account.", "Wrong answer": "Onjuist antwoord", "Erroneous CAPTCHA": "Onjuiste CAPTCHA", "CAPTCHA is a required field": "CAPTCHA is vereist", "User ID is a required field": "Gebruikers-id is vereist", "Password is a required field": "Wachtwoord is vereist", "Wrong username or password": "Onjuiste gebruikersnaam of wachtwoord", - "Please sign in using 'Log in with Google'": "Log in via 'Inloggen met Google'", "Password cannot be empty": "Het wachtwoordveld mag niet leeg zijn", "Password cannot be longer than 55 characters": "Het wachtwoord mag niet langer dan 55 tekens zijn", "Please log in": "Log in", @@ -207,16 +171,8 @@ "This channel does not exist.": "Dit kanaal bestaat niet.", "Could not get channel info.": "Kan geen kanaalinformatie ophalen.", "Could not fetch comments": "Kan reacties niet ophalen", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` antwoorden tonen", - "": "`x` antwoorden tonen" - }, "`x` ago": "`x` geleden", "Load more": "Meer laden", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` punten", - "": "`x` punten" - }, "Could not create mix.": "Kan geen mix maken.", "Empty playlist": "Lege afspeellijst", "Not a playlist.": "Ongeldige afspeellijst.", @@ -236,15 +192,15 @@ "Arabic": "Arabisch", "Armenian": "Armeens", "Azerbaijani": "Azerbeidzjaans", - "Bangla": "Bangla", + "Bangla": "Bengaals", "Basque": "Baskisch", - "Belarusian": "Wit-Rrussisch", + "Belarusian": "Wit-Russisch", "Bosnian": "Bosnisch", "Bulgarian": "Bulgaars", "Burmese": "Birmaans", "Catalan": "Catalaans", - "Cebuano": "Cebuano", - "Chinese (Simplified)": "Chinees (Veereenvoudigd)", + "Cebuano": "Cebuaans", + "Chinese (Simplified)": "Chinees (Vereenvoudigd)", "Chinese (Traditional)": "Chinees (Traditioneel)", "Corsican": "Corsicaans", "Croatian": "Kroatisch", @@ -261,23 +217,23 @@ "German": "Duits", "Greek": "Grieks", "Gujarati": "Gujarati", - "Haitian Creole": "Creools", + "Haitian Creole": "Haïtiaans Creools", "Hausa": "Hausa", "Hawaiian": "Hawaïaans", - "Hebrew": "Heebreeuws", + "Hebrew": "Hebreeuws", "Hindi": "Hindi", "Hmong": "Hmong", "Hungarian": "Hongaars", "Icelandic": "IJslands", - "Igbo": "Igbo", + "Igbo": "Ikbo", "Indonesian": "Indonesisch", "Irish": "Iers", "Italian": "Italiaans", "Japanese": "Japans", "Javanese": "Javaans", - "Kannada": "Kannada", + "Kannada": "Kannada-taal", "Kazakh": "Kazachs", - "Khmer": "Khmer", + "Khmer": "Khmer-taal", "Korean": "Koreaans", "Kurdish": "Koerdisch", "Kyrgyz": "Kirgizisch", @@ -289,10 +245,10 @@ "Macedonian": "Macedonisch", "Malagasy": "Malagassisch", "Malay": "Maleisisch", - "Malayalam": "Malayalam", + "Malayalam": "Malayalam-taal", "Maltese": "Maltees", "Maori": "Maorisch", - "Marathi": "Marathi", + "Marathi": "Marathi-taal", "Mongolian": "Mongools", "Nepali": "Nepalees", "Norwegian Bokmål": "Noors (Bokmål)", @@ -334,41 +290,13 @@ "Yiddish": "Joods", "Yoruba": "Yoruba", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jaar", - "": "`x` jaar" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` maanden", - "": "`x` maanden" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` weken", - "": "`x` weken" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dagen", - "": "`x` dagen" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` uur", - "": "`x` uur" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuten", - "": "`x` minuten" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` seconden", - "": "`x` seconden" - }, "Fallback comments: ": "Terugvallen op ", "Popular": "Populair", - "Search": "", + "Search": "Zoeken", "Top": "Top", "About": "Over", "Rating: ": "Waardering: ", - "Language: ": "Taal: ", + "preferences_locale_label": "Taal: ", "View as playlist": "Tonen als afspeellijst", "Default": "Standaard", "Music": "Muziek", @@ -381,38 +309,192 @@ "(edited)": "(bewerkt)", "YouTube comment permalink": "Link naar YouTube-reactie", "permalink": "permalink", - "`x` marked it with a ❤": "`x` heeft dit gemarkeerd met ❤", + "`x` marked it with a ❤": "`x` heeft dit gemarkeerd met een ❤", "Audio mode": "Audiomodus", "Video mode": "Videomodus", - "Videos": "Video's", + "channel_tab_videos_label": "Video's", "Playlists": "Afspeellijsten", - "Community": "Gemeenschap", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Huidige versie: " + "channel_tab_community_label": "Gemeenschap", + "search_filters_sort_option_relevance": "relevantie", + "search_filters_sort_option_rating": "beoordeling", + "search_filters_sort_option_date": "Upload datum", + "search_filters_sort_option_views": "keren bekeken", + "search_filters_type_label": "Type inhoud", + "search_filters_duration_label": "duur", + "search_filters_features_label": "eigenschappen", + "search_filters_sort_label": "sorteren", + "search_filters_date_option_hour": "Laatste uur", + "search_filters_date_option_today": "vandaag", + "search_filters_date_option_week": "week", + "search_filters_date_option_month": "maand", + "search_filters_date_option_year": "jaar", + "search_filters_type_option_video": "video", + "search_filters_type_option_channel": "kanaal", + "search_filters_type_option_playlist": "afspeellijst", + "search_filters_type_option_movie": "film", + "search_filters_type_option_show": "show", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "ondertitels", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Live", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "locatie", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Huidige versie: ", + "Switch Invidious Instance": "Schakel tussen de Invidious Instanties", + "preferences_automatic_instance_redirect_label": "Automatische instantie-omleiding (terugval naar redirect.invidious.io): ", + "preferences_quality_dash_label": "Gewenste DASH-videokwaliteit: ", + "preferences_region_label": "Inhoud land: ", + "preferences_category_misc": "Diverse voorkeuren", + "preferences_show_nick_label": "Toon bijnaam bovenaan: ", + "Released under the AGPLv3 on Github.": "Uitgebracht onder de AGPLv3 op GitHub.", + "search_filters_duration_option_short": "Kort (<4 minuten)", + "next_steps_error_message_refresh": "Vernieuwen", + "next_steps_error_message_go_to_youtube": "Ga naar YouTube", + "footer_donate_page": "Doneren", + "footer_documentation": "Documentatie", + "footer_original_source_code": "Originele bron-code", + "footer_modfied_source_code": "Gewijzigde bron-code", + "adminprefs_modified_source_code_url_label": "URL naar gewijzigde bron-code-opslagplaats", + "next_steps_error_message": "Waarna u zou kunnen proberen om: ", + "footer_source_code": "Bron-code", + "search_filters_duration_option_long": "Lang (> 20 minuten)", + "preferences_quality_option_dash": "DASH (adaptieve kwaliteit)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Gemiddeld", + "preferences_quality_option_small": "Klein", + "preferences_quality_dash_option_auto": "Automatisch", + "preferences_quality_dash_option_best": "Beste", + "preferences_quality_dash_option_worst": "Slechtste", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "videoinfo_started_streaming_x_ago": "Stream `x` geleden begonnen", + "videoinfo_watch_on_youTube": "Bekijken op YouTube", + "videoinfo_youTube_embed_link": "Inbedden", + "videoinfo_invidious_embed_link": "Link ingebedde versie", + "download_subtitles": "Ondertiteling - `x` (.vtt)", + "user_created_playlists": "`x` afspeellijsten aangemaakt", + "user_saved_playlists": "`x` afspeellijsten opgeslagen", + "Video unavailable": "Video onbeschikbaar", + "preferences_save_player_pos_label": "Afspeelpositie opslaan: ", + "none": "geen", + "search_filters_features_option_purchased": "Gekocht", + "search_filters_features_option_three_sixty": "360º", + "search_filters_title": "Verfijnen", + "generic_count_days": "{{count}} dag", + "generic_count_days_plural": "{{count}} dagen", + "Chinese (Taiwan)": "Chinees (Taiwan)", + "Dutch (auto-generated)": "Nederlands (automatisch gegenereerd)", + "tokens_count": "{{count}} token", + "tokens_count_plural": "{{count}} tokens", + "generic_count_seconds": "{{count}} seconde", + "generic_count_seconds_plural": "{{count}} seconden", + "generic_count_weeks": "{{count}} week", + "generic_count_weeks_plural": "{{count}} weken", + "English (United States)": "Engels (Verenigde Staten)", + "generic_views_count": "{{count}} keer bekeken", + "generic_views_count_plural": "{{count}} keren bekeken", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} video's", + "generic_subscriptions_count": "{{count}} abonnement", + "generic_subscriptions_count_plural": "{{count}} abonnementen", + "subscriptions_unseen_notifs_count": "{{count}} ongeziene melding", + "subscriptions_unseen_notifs_count_plural": "{{count}} ongeziene meldingen", + "preferences_watch_history_label": "Kijkgeschiedenis inschakelen: ", + "crash_page_switch_instance": "geprobeerd hebt om <a href=\"`x`\">een andere instantie te gebruiken</a>", + "Portuguese (auto-generated)": "Portugees (automatisch gegenereerd)", + "Russian (auto-generated)": "Russisch (automatisch gegenereerd)", + "Vietnamese (auto-generated)": "Vietnamees (automatisch gegenereerd)", + "comments_points_count": "{{count}} punt", + "comments_points_count_plural": "{{count}} punten", + "crash_page_before_reporting": "Voor je een bug rapporteert, kijk even na of je:", + "Chinese": "Chinees", + "search_filters_features_option_vr180": "VR180", + "search_filters_date_label": "Uploaddatum", + "Portuguese (Brazil)": "Portugees (Brazilië)", + "Interlingue": "Interlingue", + "Turkish (auto-generated)": "Turks (automatisch gegenereerd)", + "search_filters_date_option_none": "Alle datums", + "generic_subscribers_count": "{{count}} abonnee", + "generic_subscribers_count_plural": "{{count}} abonnees", + "search_message_no_results": "Geen resultaten teruggevonden.", + "search_message_change_filters_or_query": "Probeer je zoekopdracht uit te breiden en/of de filters aan te passen.", + "English (United Kingdom)": "Engels (Verenigd Koninkrijk)", + "German (auto-generated)": "Duits (automatisch gegenereerd)", + "Spanish (Mexico)": "Spaans (Mexico)", + "Spanish (Spain)": "Spaans (Spanje)", + "search_filters_type_option_all": "Alle types", + "crash_page_refresh": "geprobeerd hebt om <a href=\"`x`\">de pagina te herladen</a>", + "comments_view_x_replies": "{{count}} reactie bekijken", + "comments_view_x_replies_plural": "{{count}} reacties bekijken", + "generic_count_years": "{{count}} jaar", + "generic_count_years_plural": "{{count}} jaren", + "generic_count_months": "{{count}} maand", + "generic_count_months_plural": "{{count}} maanden", + "generic_count_hours": "{{count}} uur", + "generic_count_hours_plural": "{{count}} uren", + "generic_count_minutes": "{{count}} minuut", + "generic_count_minutes_plural": "{{count}} minuten", + "French (auto-generated)": "Frans (automatisch gegenereerd)", + "generic_playlists_count": "{{count}} afspeellijst", + "generic_playlists_count_plural": "{{count}} afspeellijsten", + "Chinese (Hong Kong)": "Chinees (Hongkong)", + "Korean (auto-generated)": "Koreaans (automatisch gegenereerd)", + "search_filters_apply_button": "Geselecteerde filters toepassen", + "search_message_use_another_instance": "Je kan ook <a href=\"`x`\">zoeken op een andere instantie</a>.", + "Cantonese (Hong Kong)": "Kantonees (Hongkong)", + "Chinese (China)": "Chinees (China)", + "crash_page_read_the_faq": "de <a href=\"`x`\">veelgestelde vragen (FAQ)</a> gelezen hebt", + "crash_page_search_issue": "gezocht hebt op <a href=\"`x`\">bestaande problemen op GitHub</a>", + "search_filters_duration_option_none": "Alle lengtes", + "Indonesian (auto-generated)": "Indonesisch (automatisch gegenereerd)", + "Italian (auto-generated)": "Italiaans (automatisch gegenereerd)", + "Japanese (auto-generated)": "Japans (automatisch gegenereerd)", + "Spanish (auto-generated)": "Spaans (automatisch gegenereerd)", + "crash_page_you_found_a_bug": "Je lijkt een bug in Invidious tegengekomen te zijn!", + "search_filters_duration_option_medium": "Gemiddeld (4 - 20 minuten)", + "crash_page_report_issue": "Indien het bovenstaande niet hielp, gelieve dan <a href=\"`x`\">een nieuw ticket op GitHub</a> te openen (liefst in het Engels) en neem de volgende tekst op in je bericht (gelieve deze NIET te vertalen):", + "channel_tab_podcasts_label": "Podcasts", + "Download is disabled": "Downloaden is uitgeschakeld", + "Channel Sponsor": "Kanaalsponsor", + "channel_tab_streams_label": "Livestreams", + "playlist_button_add_items": "Video's toevoegen", + "Artist: ": "Artiest: ", + "generic_button_save": "Opslaan", + "generic_button_cancel": "Annuleren", + "Album: ": "Album: ", + "channel_tab_shorts_label": "Shorts", + "channel_tab_releases_label": "Uitgaves", + "Song: ": "Lied: ", + "generic_channels_count": "{{count}} kanaal", + "generic_channels_count_plural": "{{count}} kanalen", + "Popular enabled: ": "Populair ingeschakeld: ", + "channel_tab_playlists_label": "Afspeellijsten", + "generic_button_edit": "Bewerken", + "Music in this video": "Muziek in deze video", + "generic_button_rss": "RSS", + "channel_tab_channels_label": "Kanalen", + "error_video_not_in_playlist": "De gevraagde video bestaat niet in deze afspeellijst. <a href=\"`x`\">Klik hier voor de startpagina van de afspeellijst.</a>", + "generic_button_delete": "Verwijderen", + "Import YouTube playlist (.csv)": "YouTube-afspeellijst importeren (.csv)", + "Standard YouTube license": "Standaard YouTube-licentie", + "Import YouTube watch history (.json)": "YouTube-kijkgeschiedenis importeren (.json)", + "Add to playlist": "Aan afspeellijst toevoegen", + "The Popular feed has been disabled by the administrator.": "De Populaire feed werd uitgeschakeld door een beheerder.", + "carousel_slide": "Dia {{current}} van {{total}}", + "carousel_go_to": "Naar dia `x` gaan", + "Add to playlist: ": "Aan afspeellijst toevoegen: ", + "Answer": "Antwoorden", + "Search for videos": "Naar video's zoeken", + "carousel_skip": "Carousel overslaan", + "toggle_theme": "Thema omschakelen" } diff --git a/locales/or.json b/locales/or.json new file mode 100644 index 00000000..948610f1 --- /dev/null +++ b/locales/or.json @@ -0,0 +1,29 @@ +{ + "preferences_quality_dash_option_720p": "୭୨୦ପି", + "preferences_quality_dash_option_4320p": "୪୩୨୦ପି", + "preferences_quality_dash_option_240p": "୨୪୦ପି", + "preferences_quality_dash_option_2160p": "୨୧୬୦ପି", + "preferences_quality_dash_option_144p": "୧୪୪ପି", + "reddit": "Reddit", + "preferences_quality_dash_option_480p": "୪୮୦ପି", + "preferences_dark_mode_label": "ଥିମ୍: ", + "dark": "ଗାଢ଼", + "published": "ପ୍ରକାଶିତ", + "generic_videos_count": "{{count}}ଟିଏ ଵିଡ଼ିଓ", + "generic_videos_count_plural": "{{count}}ଟି ଵିଡ଼ିଓ", + "generic_button_edit": "ସମ୍ପାଦନା", + "light": "ହାଲୁକା", + "last": "ଗତ", + "New password": "ନୂଆ ପାସ୍ୱର୍ଡ଼", + "preferences_quality_dash_option_1440p": "୧୪୪୦ପି", + "preferences_quality_dash_option_360p": "୩୬୦ପି", + "preferences_quality_option_medium": "ମଧ୍ୟମ", + "preferences_quality_dash_option_1080p": "୧୦୮୦ପି", + "youtube": "YouTube", + "preferences_quality_option_hd720": "HD୭୨୦", + "invidious": "Invidious", + "generic_playlists_count": "{{count}}ଟିଏ ଚାଳନାତାଲିକା", + "generic_playlists_count_plural": "{{count}}ଟି ଚାଳନାତାଲିକା", + "Yes": "ହଁ", + "No": "ନାହିଁ" +} diff --git a/locales/pl.json b/locales/pl.json index 3d64aa07..73d65647 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -1,16 +1,4 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subskrybcji", - "": "`x` subskrybcji" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` filmów", - "": "`x` filmów" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist", - "": "`x` playlist" - }, "LIVE": "NA ŻYWO", "Shared `x` ago": "Udostępniono `x` temu", "Unsubscribe": "Odsubskrybuj", @@ -26,22 +14,21 @@ "Clear watch history?": "Wyczyścić historię?", "New password": "Nowe hasło", "New passwords must match": "Nowe hasła muszą być identyczne", - "Cannot change password for Google accounts": "Nie można zmienić hasła do konta Google", "Authorize token?": "Autoryzować token?", "Authorize token for `x`?": "Autoryzować token dla `x`?", "Yes": "Tak", "No": "Nie", "Import and Export Data": "Import i eksport danych", "Import": "Import", - "Import Invidious data": "Importuj dane Invidious", - "Import YouTube subscriptions": "Importuj subskrybcje z YouTube", - "Import FreeTube subscriptions (.db)": "Importuj subskrybcje z FreeTube (.db)", - "Import NewPipe subscriptions (.json)": "Importuj subskrybcje z NewPipe (.json)", + "Import Invidious data": "Importuj dane JSON Invidious", + "Import YouTube subscriptions": "Importuj subskrypcje YouTube w formacie CSV lub OPML", + "Import FreeTube subscriptions (.db)": "Importuj subskrypcje FreeTube (.db)", + "Import NewPipe subscriptions (.json)": "Importuj subskrypcje NewPipe (.json)", "Import NewPipe data (.zip)": "Importuj dane NewPipe (.zip)", "Export": "Eksport", - "Export subscriptions as OPML": "Eksportuj subskrybcje jako OPML", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportuj subskrybcje jako OPML (dla NewPipe i FreeTube)", - "Export data as JSON": "Eksportuj dane jako JSON", + "Export subscriptions as OPML": "Eksportuj subskrypcje jako OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportuj subskrypcje jako OPML (dla NewPipe i FreeTube)", + "Export data as JSON": "Eksportuj dane Invidious jako JSON", "Delete account?": "Usunąć konto?", "History": "Historia", "An alternative front-end to YouTube": "Alternatywny front-end dla YouTube", @@ -49,7 +36,6 @@ "source": "źródło", "Log in": "Zaloguj", "Log in/register": "Zaloguj/Zarejestruj", - "Log in with Google": "Zaloguj do Google", "User ID": "ID użytkownika", "Password": "Hasło", "Time (h:mm:ss):": "Godzina (h:mm:ss):", @@ -58,38 +44,40 @@ "Sign In": "Zaloguj się", "Register": "Zarejestruj się", "E-mail": "E-mail", - "Google verification code": "Kod weryfikacyjny Google", "Preferences": "Preferencje", - "Player preferences": "Ustawienia odtwarzacza", - "Always loop: ": "Zawsze zapętlaj: ", - "Autoplay: ": "Autoodtwarzanie: ", - "Play next by default: ": "Domyślnie odtwarzaj następny: ", - "Autoplay next video: ": "Odtwórz następny film: ", - "Listen by default: ": "Tryb dźwiękowy: ", - "Proxy videos: ": "Filmy przez proxy? ", - "Default speed: ": "Domyślna prędkość: ", - "Preferred video quality: ": "Preferowana jakość filmów: ", - "Player volume: ": "Głośność odtwarzacza: ", - "Default comments: ": "Domyślne komentarze: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "Domyślne napisy: ", + "preferences_category_player": "Ustawienia odtwarzacza", + "preferences_video_loop_label": "Zawsze zapętlaj: ", + "preferences_autoplay_label": "Autoodtwarzanie: ", + "preferences_continue_label": "Domyślnie odtwarzaj następny: ", + "preferences_continue_autoplay_label": "Odtwórz następny film: ", + "preferences_listen_label": "Tryb dźwiękowy: ", + "preferences_local_label": "Wideo przez proxy? ", + "preferences_speed_label": "Domyślna prędkość: ", + "preferences_quality_label": "Preferowana jakość filmów: ", + "preferences_volume_label": "Głośność odtwarzacza: ", + "preferences_comments_label": "Domyślne komentarze: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Domyślne napisy: ", "Fallback captions: ": "Zastępcze napisy: ", - "Show related videos: ": "Pokaż powiązane filmy? ", - "Show annotations by default: ": "Domyślnie pokazuj adnotacje: ", - "Automatically extend video description: ": "", - "Visual preferences": "Preferencje Wizualne", - "Player style: ": "Styl odtwarzacza: ", + "preferences_related_videos_label": "Pokaż powiązane filmy? ", + "preferences_annotations_label": "Domyślnie pokazuj adnotacje: ", + "preferences_extend_desc_label": "Automatycznie rozwijaj opisy filmów: ", + "preferences_vr_mode_label": "Interaktywne filmy 360 stopni (wymaga WebGL): ", + "preferences_category_visual": "Preferencje wizualne", + "preferences_player_style_label": "Styl odtwarzacza: ", "Dark mode: ": "Ciemny motyw: ", - "Theme: ": "Motyw: ", + "preferences_dark_mode_label": "Motyw: ", "dark": "ciemny", "light": "jasny", - "Thin mode: ": "Tryb minimalny: ", - "Subscription preferences": "Preferencje subskrybcji", - "Show annotations by default for subscribed channels: ": "Domyślnie wyświetlaj adnotacje dla subskrybowanych kanałów: ", + "preferences_thin_mode_label": "Tryb minimalny: ", + "preferences_category_misc": "Różne preferencje", + "preferences_automatic_instance_redirect_label": "Automatycznie przekierowanie instancji (powrót do redirect.invidious.io): ", + "preferences_category_subscription": "Preferencje subskrypcji", + "preferences_annotations_subscribed_label": "Domyślnie wyświetlaj adnotacje dla subskrybowanych kanałów: ", "Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ", - "Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ", - "Sort videos by: ": "Sortuj filmy: ", + "preferences_max_results_label": "Liczba filmów widoczna na stronie subskrybcji: ", + "preferences_sort_label": "Sortuj filmy: ", "published": "po czasie publikacji", "published - reverse": "po czasie publikacji od najstarszych", "alphabetically": "alfabetycznie", @@ -98,50 +86,38 @@ "channel name - reverse": "po nazwie kanału od tyłu", "Only show latest video from channel: ": "Pokazuj tylko najnowszy film z kanału: ", "Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ", - "Only show unwatched: ": "Pokazuj tylko nie obejrzane: ", - "Only show notifications (if there are any): ": "Pokazuj tylko powiadomienia (jeśli są): ", + "preferences_unseen_only_label": "Pokazuj tylko nie obejrzane: ", + "preferences_notifications_only_label": "Pokazuj tylko powiadomienia (jeśli są): ", "Enable web notifications": "Włącz powiadomienia", "`x` uploaded a video": "`x` dodał film", "`x` is live": "'x ' jest na żywo", - "Data preferences": "Preferencje danych", + "preferences_category_data": "Preferencje danych", "Clear watch history": "Wyczyść historię", "Import/export data": "Import/Eksport danych", "Change password": "Zmień hasło", - "Manage subscriptions": "Organizuj subskrybcje", + "Manage subscriptions": "Organizuj subskrypcje", "Manage tokens": "Zarządzaj tokenami", "Watch history": "Historia", "Delete account": "Usuń konto", - "Administrator preferences": "Preferencje administratora", - "Default homepage: ": "Domyślna strona główna: ", - "Feed menu: ": "", + "preferences_category_admin": "Preferencje administratora", + "preferences_default_home_label": "Domyślna strona główna: ", + "preferences_feed_menu_label": "Menu aktualności ", + "preferences_show_nick_label": "Pokaż pseudonim na górze: ", "Top enabled: ": "\"Top\" aktywne: ", "CAPTCHA enabled: ": "CAPTCHA aktywna? ", "Login enabled: ": "Logowanie włączone? ", "Registration enabled: ": "Rejestracja włączona? ", "Report statistics: ": "Raportować statystyki? ", "Save preferences": "Zapisz preferencje", - "Subscription manager": "Manager subskrybcji", + "Subscription manager": "Menedżer subskrypcji", "Token manager": "Menedżer tokenów", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subskrybcji", - "": "`x` subskrybcji" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, "Import/export": "Import/Eksport", "unsubscribe": "odsubskrybuj", "revoke": "cofnij", - "Subscriptions": "Subskrybcje", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` nowych powiadomień", - "": "`x` nowych powiadomień" - }, + "Subscriptions": "Subskrypcje", "search": "szukaj", "Log out": "Wyloguj", - "Released under the AGPLv3 by Omar Roth.": "Wydano na licencji AGPLv3 przez Omar Roth.", "Source available here.": "Kod źródłowy dostępny tutaj.", "View JavaScript license information.": "Wyświetl informację o licencji JavaScript.", "View privacy policy.": "Polityka prywatności.", @@ -157,9 +133,10 @@ "Title": "Tytuł", "Playlist privacy": "Widoczność playlisty", "Editing playlist `x`": "Edycja playlisty `x`", - "Show more": "", - "Show less": "", + "Show more": "Pokaż więcej", + "Show less": "Pokaż mniej", "Watch on YouTube": "Zobacz film na YouTube", + "Switch Invidious Instance": "Przełącz instancję Invidious", "Hide annotations": "Ukryj adnotacje", "Show annotations": "Pokaż adnotacje", "Genre: ": "Gatunek: ", @@ -170,53 +147,36 @@ "Whitelisted regions: ": "Dostępny na obszarach: ", "Blacklisted regions: ": "Niedostępny na obszarach: ", "Shared `x`": "Udostępniono `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` wyświetleń", - "": "`x` wyświetleń" - }, "Premieres in `x`": "Publikacja za `x`", - "Premieres `x`": "Publikacja za `x`", + "Premieres `x`": "Publikacja `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Cześć! Wygląda na to, że masz wyłączoną obsługę JavaScriptu. Kliknij tutaj, żeby zobaczyć komentarze. Pamiętaj, że wczytywanie może potrwać dłużej.", "View YouTube comments": "Wyświetl komentarze z YouTube", "View more comments on Reddit": "Wyświetl więcej komentarzy na Reddicie", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Wyświetl `x` komentarzy", + "([^.,0-9]|^)1([^.,0-9]|$)": "Wyświetl `x` komentarz", "": "Wyświetl `x` komentarzy" }, "View Reddit comments": "Wyświetl komentarze z Redditta", "Hide replies": "Ukryj odpowiedzi", "Show replies": "Pokaż odpowiedzi", "Incorrect password": "Niepoprawne hasło", - "Quota exceeded, try again in a few hours": "Przekroczony limit zapytań, spróbuj ponownie za kilka godzin", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Nie udało się zalogować, upewnij się, że dwuetapowe uwierzytelnianie (Autentykator lub SMS) jest aktywne.", - "Invalid TFA code": "Niepoprawny kod TFA", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Nie udało się zalogować. To może być spowodowane wyłączoną dwustopniową autoryzacją na twoim koncie.", "Wrong answer": "Niepoprawna odpowiedź", "Erroneous CAPTCHA": "CAPTCHA wykonane błędnie", "CAPTCHA is a required field": "CAPTCHA jest polem wymaganym", "User ID is a required field": "ID użytkownika jest polem wymaganym", "Password is a required field": "Hasło jest polem wymaganym", "Wrong username or password": "Niepoprawny login lub hasło", - "Please sign in using 'Log in with Google'": "Zaloguj się używając \"Zaloguj się przez Google\"", "Password cannot be empty": "Hasło nie może być puste", "Password cannot be longer than 55 characters": "Hasło nie może być dłuższe niż 55 znaków", "Please log in": "Proszę się zalogować", - "Invidious Private Feed for `x`": "", + "Invidious Private Feed for `x`": "Prywatne aktualności dla `x`", "channel:`x`": "kanał:`x", "Deleted or invalid channel": "Usunięty lub niepoprawny kanał", "This channel does not exist.": "Ten kanał nie istnieje.", "Could not get channel info.": "Nie udało się uzyskać informacji o kanale.", "Could not fetch comments": "Nie udało się pobrać komentarzy", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Wyświetl `x` odpowiedzi", - "": "Wyświetl `x` odpowiedzi" - }, "`x` ago": "`x` temu", "Load more": "Wczytaj więcej", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` punktów", - "": "`x` punktów" - }, "Could not create mix.": "Nie udało się utworzyć miksu.", "Empty playlist": "Lista odtwarzania jest pusta", "Not a playlist.": "Niepoprawna lista.", @@ -315,7 +275,7 @@ "Somali": "somalijski", "Southern Sotho": "sotho południowy", "Spanish": "hiszpański", - "Spanish (Latin America)": "hiszpański (ameryka łacińska)", + "Spanish (Latin America)": "hiszpański (Ameryka Łacińska)", "Sundanese": "sundajski", "Swahili": "suahili", "Swedish": "szwedzki", @@ -334,41 +294,13 @@ "Yiddish": "jidysz", "Yoruba": "joruba", "Zulu": "zuluski", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` lat", - "": "`x` lat" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` miesięcy", - "": "`x` miesięcy" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tygodni", - "": "`x` tygodni" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dni", - "": "`x` dni" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` godzin", - "": "`x` godzin" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minut", - "": "`x` minut" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekund", - "": "`x` sekund" - }, "Fallback comments: ": "Zastępcze komentarze: ", "Popular": "Popularne", - "Search": "", + "Search": "Szukaj", "Top": "Top", "About": "Informacje", "Rating: ": "Ocena: ", - "Language: ": "Język: ", + "preferences_locale_label": "Język: ", "View as playlist": "Obejrzyj w playliście", "Default": "Domyślnie", "Music": "Muzyka", @@ -377,6 +309,7 @@ "Movies": "Filmy", "Download": "Pobierz", "Download as: ": "Pobierz jako: ", + "Download is disabled": "Pobieranie jest wyłączone", "%A %B %-d, %Y": "%A, %-d %B %Y", "(edited)": "(edytowany)", "YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube", @@ -384,35 +317,201 @@ "`x` marked it with a ❤": "`x` oznaczonych ❤", "Audio mode": "Tryb audio", "Video mode": "Tryb wideo", - "Videos": "Filmy", + "channel_tab_videos_label": "Wideo", "Playlists": "Playlisty", - "Community": "Społeczność", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Aktualna wersja: " -}
\ No newline at end of file + "channel_tab_community_label": "Społeczność", + "search_filters_sort_option_relevance": "Trafność", + "search_filters_sort_option_rating": "Ocena", + "search_filters_sort_option_date": "Data przesłania", + "search_filters_sort_option_views": "Liczba wyświetleń", + "search_filters_type_label": "Typ", + "search_filters_duration_label": "Długość", + "search_filters_features_label": "Funkcje", + "search_filters_sort_label": "Sortuj wg", + "search_filters_date_option_hour": "Ostatnia godzina", + "search_filters_date_option_today": "Dzisiaj", + "search_filters_date_option_week": "W tym tygodniu", + "search_filters_date_option_month": "W tym miesiącu", + "search_filters_date_option_year": "W tym roku", + "search_filters_type_option_video": "Wideo", + "search_filters_type_option_channel": "Kanał", + "search_filters_type_option_playlist": "Playlista", + "search_filters_type_option_movie": "Film", + "search_filters_type_option_show": "Pokaż", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Napisy/CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Na żywo", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Lokalizacja", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Aktualna wersja: ", + "next_steps_error_message": "Po czym należy spróbować: ", + "next_steps_error_message_refresh": "Odśwież", + "next_steps_error_message_go_to_youtube": "Przejdź do YouTube", + "invidious": "Invidious", + "tokens_count_0": "{{count}} token", + "tokens_count_1": "{{count}} tokeny", + "tokens_count_2": "{{count}} tokenów", + "generic_videos_count_0": "{{count}} film", + "generic_videos_count_1": "{{count}} filmy", + "generic_videos_count_2": "{{count}} filmów", + "generic_views_count_0": "{{count}} wyświetlenie", + "generic_views_count_1": "{{count}} wyświetlenia", + "generic_views_count_2": "{{count}} wyświetleń", + "generic_playlists_count_0": "{{count}} playlista", + "generic_playlists_count_1": "{{count}} playlisty", + "generic_playlists_count_2": "{{count}} playlist", + "generic_subscribers_count_0": "{{count}} subskrybent", + "generic_subscribers_count_1": "{{count}} subskrybentów", + "generic_subscribers_count_2": "{{count}} subskrybentów", + "generic_subscriptions_count_0": "{{count}} subskrypcja", + "generic_subscriptions_count_1": "{{count}} subskrypcje", + "generic_subscriptions_count_2": "{{count}} subskrypcji", + "comments_view_x_replies_0": "Pokaż {{count}} odpowiedź", + "comments_view_x_replies_1": "Pokaż {{count}} odpowiedzi", + "comments_view_x_replies_2": "Pokaż {{count}} odpowiedzi", + "comments_points_count_0": "{{count}} punkt", + "comments_points_count_1": "{{count}} punkty", + "comments_points_count_2": "{{count}} punktów", + "generic_count_months_0": "{{count}} miesiąc", + "generic_count_months_1": "{{count}} miesiące", + "generic_count_months_2": "{{count}} miesięcy", + "generic_count_weeks_0": "{{count}} tydzień", + "generic_count_weeks_1": "{{count}} tygodnie", + "generic_count_weeks_2": "{{count}} tygodni", + "generic_count_days_0": "{{count}} dzień", + "generic_count_days_1": "{{count}} dni", + "generic_count_days_2": "{{count}} dni", + "generic_count_hours_0": "{{count}} godzina", + "generic_count_hours_1": "{{count}} godziny", + "generic_count_hours_2": "{{count}} godzin", + "generic_count_seconds_0": "{{count}} sekunda", + "generic_count_seconds_1": "{{count}} sekundy", + "generic_count_seconds_2": "{{count}} sekund", + "crash_page_you_found_a_bug": "Wygląda na to, że udało ci się znaleźć błąd w Invidious!", + "crash_page_refresh": "próbowano <a href=\"`x`\">odświeżyć stronę</a>", + "crash_page_switch_instance": "próbowano <a href=\"`x`\">użyć innej instancji</a>", + "crash_page_read_the_faq": "przeczytaj <a href=\"`x`\">Najczęściej zadawane pytania (FAQ)</a>", + "crash_page_search_issue": "próbowano poszukać <a href=\"`x`\">istniejących zgłoszeń na GitHubie</a>", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_144p": "144p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "subscriptions_unseen_notifs_count_0": "{{count}} nieodczytane powiadomienie", + "subscriptions_unseen_notifs_count_1": "{{count}} nieodczytane powiadomienia", + "subscriptions_unseen_notifs_count_2": "{{count}} nieodczytanych powiadomień", + "generic_count_minutes_0": "{{count}} minuta", + "generic_count_minutes_1": "{{count}} minuty", + "generic_count_minutes_2": "{{count}} minut", + "generic_count_years_0": "{{count}} rok", + "generic_count_years_1": "{{count}} lata", + "generic_count_years_2": "{{count}} lat", + "crash_page_before_reporting": "Przed zgłoszeniem błędu, upewnij się, że masz:", + "crash_page_report_issue": "Jeżeli nic z powyższych opcji nie pomogło, proszę <a href=\"`x`\">otworzyć nowe zgłoszenie na GitHubie</a> (najlepiej po angielsku) i dodać poniższy tekst w twojej wiadomości (NIE tłumacz tego tekstu):", + "preferences_quality_dash_option_auto": "Automatyczna", + "preferences_quality_dash_option_best": "Najlepsza", + "preferences_quality_dash_option_worst": "Najgorsza", + "preferences_quality_option_dash": "DASH (jakość adaptacyjna)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Średnia", + "preferences_quality_option_small": "Mała", + "preferences_quality_dash_label": "Preferowana jakość filmu DASH: ", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "search_filters_features_option_purchased": "Zakupione", + "search_filters_features_option_three_sixty": "360°", + "footer_donate_page": "Dotacja", + "none": "żadne", + "videoinfo_started_streaming_x_ago": "Transmisja rozpoczęta `x` temu", + "videoinfo_watch_on_youTube": "Obejrzyj na YouTube", + "videoinfo_youTube_embed_link": "Odtwarzacz typu Embed", + "videoinfo_invidious_embed_link": "Link do Embed", + "download_subtitles": "Napisy - `x` (.vtt)", + "user_created_playlists": "`x` utworzonych playlist", + "user_saved_playlists": "`x` zapisanych playlist", + "Video unavailable": "Film niedostępny", + "preferences_save_player_pos_label": "Zapisz pozycję odtwarzania: ", + "preferences_region_label": "Kraj treści: ", + "Released under the AGPLv3 on Github.": "Wydany na licencji AGPLv3 na GitHub.", + "search_filters_duration_option_short": "Krótka (< 4 minut)", + "search_filters_duration_option_long": "Długa (> 20 minut)", + "footer_documentation": "Dokumentacja", + "footer_source_code": "Kod źródłowy", + "footer_modfied_source_code": "Zmodyfikowany kod źródłowy", + "footer_original_source_code": "Oryginalny kod źródłowy", + "adminprefs_modified_source_code_url_label": "Adres URL do repozytorium ze zmodyfikowanym kodem źródłowym", + "English (United Kingdom)": "angielski (Wielka Brytania)", + "English (United States)": "angielski (Stany Zjednoczone)", + "Cantonese (Hong Kong)": "kantoński (Hongkong)", + "Chinese": "chiński", + "Chinese (China)": "chiński (Chiny)", + "Chinese (Hong Kong)": "chiński (Hongkong)", + "Chinese (Taiwan)": "chiński (Tajwan)", + "Dutch (auto-generated)": "niderlandzki (wygenerowany automatycznie)", + "French (auto-generated)": "francuski (wygenerowany automatycznie)", + "German (auto-generated)": "niemiecki (wygenerowany automatycznie)", + "Indonesian (auto-generated)": "indonezyjski (wygenerowany automatycznie)", + "Interlingue": "interlingue", + "Italian (auto-generated)": "włoski (wygenerowany automatycznie)", + "Korean (auto-generated)": "koreański (wygenerowany automatycznie)", + "Spanish (auto-generated)": "hiszpański (wygenerowany automatycznie)", + "Spanish (Mexico)": "hiszpański (Meksyk)", + "Spanish (Spain)": "hiszpański (Hiszpania)", + "Turkish (auto-generated)": "turecki (wygenerowany automatycznie)", + "Vietnamese (auto-generated)": "wietnamski (wygenerowany automatycznie)", + "Japanese (auto-generated)": "japoński (wygenerowany automatycznie)", + "Russian (auto-generated)": "rosyjski (wygenerowany automatycznie)", + "Portuguese (auto-generated)": "portugalski (wygenerowany automatycznie)", + "Portuguese (Brazil)": "portugalski (Brazylia)", + "search_filters_title": "Filtr", + "error_video_not_in_playlist": "Żądany film nie istnieje na tej playliście. <a href=\"`x`\">Kliknij tutaj, aby przejść do strony głównej playlisty.</a>", + "Popular enabled: ": "Popularne włączone: ", + "search_message_no_results": "Nie znaleziono wyników.", + "preferences_watch_history_label": "Włącz historię oglądania: ", + "search_filters_apply_button": "Zastosuj wybrane filtry", + "search_message_change_filters_or_query": "Spróbuj poszerzyć zapytanie wyszukiwania i/lub zmienić filtry.", + "search_filters_date_label": "Data przesłania", + "search_filters_features_option_vr180": "VR180", + "search_filters_date_option_none": "Dowolna data", + "search_message_use_another_instance": "Możesz także <a href=\"`x`\">wyszukać w innej instancji</a>.", + "search_filters_type_option_all": "Dowolny typ", + "search_filters_duration_option_none": "Dowolna długość", + "search_filters_duration_option_medium": "Średnia (4-20 minut)", + "channel_tab_streams_label": "Na żywo", + "channel_tab_channels_label": "Kanały", + "channel_tab_playlists_label": "Playlisty", + "channel_tab_shorts_label": "Shorts", + "Music in this video": "Muzyka w tym filmie", + "Artist: ": "Wykonawca: ", + "Album: ": "Album: ", + "Song: ": "Piosenka: ", + "Channel Sponsor": "Sponsor kanału", + "Standard YouTube license": "Standardowa licencja YouTube", + "Import YouTube playlist (.csv)": "Importuj playlistę z YouTube (.csv)", + "generic_button_edit": "Edytuj", + "generic_button_cancel": "Anuluj", + "generic_button_rss": "RSS", + "channel_tab_podcasts_label": "Podkasty", + "channel_tab_releases_label": "Wydania", + "generic_button_delete": "Usuń", + "generic_button_save": "Zapisz", + "playlist_button_add_items": "Dodaj filmy", + "generic_channels_count_0": "{{count}} kanał", + "generic_channels_count_1": "{{count}} kanały", + "generic_channels_count_2": "{{count}} kanałów", + "Import YouTube watch history (.json)": "Importuj historię oglądania z YouTube (.json)", + "toggle_theme": "Przełącz motyw", + "The Popular feed has been disabled by the administrator.": "Kanał Popularne został wyłączony przez administratora.", + "Answer": "Odpowiedź", + "Search for videos": "Wyszukaj filmy", + "Add to playlist": "Dodaj do playlisty", + "Add to playlist: ": "Dodaj do playlisty: ", + "carousel_slide": "Slajd {{current}} z {{total}}", + "carousel_skip": "Pomiń karuzelę", + "carousel_go_to": "Przejdź do slajdu `x`" +} diff --git a/locales/pt-BR.json b/locales/pt-BR.json index d358928e..1d29d2fe 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -1,227 +1,191 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` inscritos", - "": "`x` inscritos" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vídeos", - "": "`x` vídeos" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reprodução", - "": "`x` listas de reprodução" - }, "LIVE": "AO VIVO", - "Shared `x` ago": "Compartilhado `x` atrás", + "Shared `x` ago": "Publicado há `x`", "Unsubscribe": "Cancelar inscrição", "Subscribe": "Inscrever-se", "View channel on YouTube": "Ver canal no YouTube", - "View playlist on YouTube": "Ver lista de reprodução no YouTube", + "View playlist on YouTube": "Ver playlist no YouTube", "newest": "mais recentes", "oldest": "mais antigos", "popular": "populares", - "last": "último", + "last": "últimos", "Next page": "Próxima página", "Previous page": "Página anterior", - "Clear watch history?": "Limpar histórico de reprodução?", + "Clear watch history?": "Limpar histórico de exibição?", "New password": "Nova senha", - "New passwords must match": "Nova senha deve ser igual", - "Cannot change password for Google accounts": "Não é possível alterar sua senha de contas do Google", - "Authorize token?": "Autorizar o token?", - "Authorize token for `x`?": "Autorizar o token para `x`?", + "New passwords must match": "As senhas devem ser iguais", + "Authorize token?": "Autorizar token?", + "Authorize token for `x`?": "Autorizar token para `x`?", "Yes": "Sim", "No": "Não", - "Import and Export Data": "Importar e Exportar Dados", + "Import and Export Data": "Importar/exportar dados", "Import": "Importar", - "Import Invidious data": "Importar dados do Invidious", - "Import YouTube subscriptions": "Importar inscrições do YouTube", + "Import Invidious data": "Importar dados JSON do Invidious", + "Import YouTube subscriptions": "Importar inscrições no formato CSV ou OPML do YouTube", "Import FreeTube subscriptions (.db)": "Importar inscrições do FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importar inscrições do NewPipe (.json)", "Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)", "Export": "Exportar", "Export subscriptions as OPML": "Exportar inscrições como OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar inscrições como OPML (para NewPipe e FreeTube)", - "Export data as JSON": "Exportar dados como JSON", + "Export data as JSON": "Exportar dados Invidious como JSON", "Delete account?": "Excluir conta?", "History": "Histórico", "An alternative front-end to YouTube": "Uma interface alternativa para o YouTube", - "JavaScript license information": "Informação de licença do JavaScript", - "source": "código-fonte", - "Log in": "Entrar", - "Log in/register": "Entrar/Registrar", - "Log in with Google": "Entrar com conta Google", + "JavaScript license information": "Informações sobre a licença do JavaScript", + "source": "fonte", + "Log in": "Fazer login", + "Log in/register": "Fazer login/criar conta", "User ID": "Usuário", "Password": "Senha", "Time (h:mm:ss):": "Hora (h:mm:ss):", - "Text CAPTCHA": "CAPTCHA em texto", - "Image CAPTCHA": "CAPTCHA em imagem", - "Sign In": "Entrar", - "Register": "Registrar", + "Text CAPTCHA": "Mudar para um desafio de texto", + "Image CAPTCHA": "Mudar para um desafio visual", + "Sign In": "Fazer login", + "Register": "Criar conta", "E-mail": "E-mail", - "Google verification code": "Código de verificação do Google", "Preferences": "Preferências", - "Player preferences": "Preferências do reprodutor", - "Always loop: ": "Repetir sempre: ", - "Autoplay: ": "Reprodução automática: ", - "Play next by default: ": "Sempre reproduzir próximo: ", - "Autoplay next video: ": "Reproduzir próximo vídeo automaticamente: ", - "Listen by default: ": "Apenas áudio por padrão: ", - "Proxy videos: ": "Usar proxy nos vídeos: ", - "Default speed: ": "Velocidade padrão: ", - "Preferred video quality: ": "Qualidade de vídeo preferida: ", - "Player volume: ": "Volume de reprodução: ", - "Default comments: ": "Preferência de comentários: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "Preferência de legendas: ", + "preferences_category_player": "Preferências de reprodução", + "preferences_video_loop_label": "Repetir sempre: ", + "preferences_autoplay_label": "Reprodução automática: ", + "preferences_continue_label": "Reproduzir a seguir, por padrão: ", + "preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ", + "preferences_listen_label": "Apenas áudio por padrão: ", + "preferences_local_label": "Usar proxy nos vídeos: ", + "preferences_speed_label": "Velocidade padrão: ", + "preferences_quality_label": "Qualidade de vídeo preferida: ", + "preferences_volume_label": "Volume de reprodução: ", + "preferences_comments_label": "Comentários padrão: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Legendas padrão: ", "Fallback captions: ": "Legendas alternativas: ", - "Show related videos: ": "Mostrar vídeos relacionados: ", - "Show annotations by default: ": "Sempre mostrar anotações: ", - "Automatically extend video description: ": "", - "Visual preferences": "Preferências visuais", - "Player style: ": "Estilo do tocador: ", + "preferences_related_videos_label": "Mostrar vídeos relacionados: ", + "preferences_annotations_label": "Sempre mostrar anotações: ", + "preferences_extend_desc_label": "Expandir automaticamente a descrição do vídeo: ", + "preferences_vr_mode_label": "Vídeos interativos de 360 graus (requer WebGL): ", + "preferences_category_visual": "Preferências visuais", + "preferences_player_style_label": "Estilo de reprodução: ", "Dark mode: ": "Modo escuro: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "escuro", "light": "claro", - "Thin mode: ": "Modo compacto: ", - "Subscription preferences": "Preferências de inscrições", - "Show annotations by default for subscribed channels: ": "Sempre mostrar anotações dos vídeos de canais inscritos: ", + "preferences_thin_mode_label": "Modo compacto: ", + "preferences_category_misc": "Preferências diversas", + "preferences_automatic_instance_redirect_label": "Redirecionamento automático de instâncias (alternativa para redirect.invidious.io): ", + "preferences_category_subscription": "Preferências de inscrições", + "preferences_annotations_subscribed_label": "Mostrar anotações por padrão para canais inscritos? ", "Redirect homepage to feed: ": "Redirecionar página inicial para o feed: ", - "Number of videos shown in feed: ": "Número de vídeos no feed: ", - "Sort videos by: ": "Ordenar vídeos por: ", + "preferences_max_results_label": "Número de vídeos no feed: ", + "preferences_sort_label": "Ordenar vídeos por: ", "published": "publicado", "published - reverse": "publicado - ordem inversa", "alphabetically": "alfabética", "alphabetically - reverse": "alfabética - ordem inversa", "channel name": "nome do canal", "channel name - reverse": "nome do canal - ordem inversa", - "Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ", - "Only show latest unwatched video from channel: ": "Mostrar apenas o vídeo mais recente não visualizado do canal: ", - "Only show unwatched: ": "Mostrar apenas vídeos não visualizados: ", - "Only show notifications (if there are any): ": "Mostrar apenas notificações (se existentes): ", - "Enable web notifications": "Ativar notificações pela web", - "`x` uploaded a video": "`x` publicou um novo vídeo", + "Only show latest video from channel: ": "Mostrar apenas vídeos mais recentes do canal: ", + "Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não assistido do canal: ", + "preferences_unseen_only_label": "Mostrar apenas vídeos não assistido: ", + "preferences_notifications_only_label": "Mostrar apenas notificações (se houver): ", + "Enable web notifications": "Ativar notificações da Web", + "`x` uploaded a video": "`x` publicou um vídeo", "`x` is live": "`x` está ao vivo", - "Data preferences": "Preferências de dados", - "Clear watch history": "Limpar histórico de reprodução", - "Import/export data": "Importar/Exportar dados", + "preferences_category_data": "Preferências de dados", + "Clear watch history": "Limpar histórico de exibição", + "Import/export data": "Importar/exportar dados", "Change password": "Alterar senha", "Manage subscriptions": "Gerenciar inscrições", "Manage tokens": "Gerenciar tokens", - "Watch history": "Histórico de reprodução", - "Delete account": "Apagar sua conta", - "Administrator preferences": "Preferências de administrador", - "Default homepage: ": "Página de início padrão: ", - "Feed menu: ": "Menu do feed: ", - "Top enabled: ": "Habilitar destaques: ", - "CAPTCHA enabled: ": "Habilitar CAPTCHA: ", - "Login enabled: ": "Habilitar login: ", - "Registration enabled: ": "Habilitar registro: ", - "Report statistics: ": "Habilitar estatísticas: ", + "Watch history": "Histórico de exibição", + "Delete account": "Excluir conta", + "preferences_category_admin": "Preferências de administrador", + "preferences_default_home_label": "Página inicial padrão: ", + "preferences_feed_menu_label": "Guias de feed preferidos: ", + "preferences_show_nick_label": "Mostrar nome de usuário na parte superior: ", + "Top enabled: ": "Destaques ativados: ", + "CAPTCHA enabled: ": "CAPTCHA ativado: ", + "Login enabled: ": "Fazer login ativado: ", + "Registration enabled: ": "Criar conta ativado: ", + "Report statistics: ": "Relatório de estatísticas: ", "Save preferences": "Salvar preferências", "Subscription manager": "Gerenciador de inscrições", "Token manager": "Gerenciador de tokens", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` inscrições", - "": "`x` inscrições" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens", - "": "`x` tokens" - }, - "Import/export": "Importar/Exportar", + "tokens_count_0": "{{count}} token", + "tokens_count_1": "{{count}} tokens", + "tokens_count_2": "{{count}} tokens", + "Import/export": "Importar/exportar", "unsubscribe": "cancelar inscrição", "revoke": "revogar", "Subscriptions": "Inscrições", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificações não visualizadas", - "": "`x` notificações não visualizadas" - }, - "search": "Pesquisar", + "search": "pesquisar", "Log out": "Sair", - "Released under the AGPLv3 by Omar Roth.": "Publicado sob a licença AGPLv3, por Omar Roth.", + "Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no GitHub.", "Source available here.": "Código-fonte disponível aqui.", - "View JavaScript license information.": "Ver informações da licença do JavaScript.", - "View privacy policy.": "Ver a política de privacidade.", - "Trending": "Tendências", + "View JavaScript license information.": "Informações de licença JavaScript.", + "View privacy policy.": "Política de privacidade.", + "Trending": "Em alta", "Public": "Público", "Unlisted": "Não listado", "Private": "Privado", - "View all playlists": "Mostrar todas listas de reprodução", + "View all playlists": "Ver todas as playlists", "Updated `x` ago": "Atualizado `x` atrás", - "Delete playlist `x`?": "Apagar a playlist `x`?", - "Delete playlist": "Apagar playlist", + "Delete playlist `x`?": "Excluir playlist `x`?", + "Delete playlist": "Excluir playlist", "Create playlist": "Criar playlist", "Title": "Título", "Playlist privacy": "Privacidade da playlist", "Editing playlist `x`": "Editando playlist `x`", - "Show more": "", - "Show less": "", + "Show more": "Mostrar mais", + "Show less": "Mostrar menos", "Watch on YouTube": "Assistir no YouTube", + "Switch Invidious Instance": "Alterar instância Invidious", "Hide annotations": "Ocultar anotações", "Show annotations": "Mostrar anotações", "Genre: ": "Gênero: ", "License: ": "Licença: ", "Family friendly? ": "Filtrar conteúdo impróprio: ", "Wilson score: ": "Pontuação de Wilson: ", - "Engagement: ": "Empenho: ", + "Engagement: ": "Engajamento: ", "Whitelisted regions: ": "Regiões permitidas: ", "Blacklisted regions: ": "Regiões bloqueadas: ", - "Shared `x`": "Compartilhado `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizações", - "": "`x` visualizações" - }, + "Shared `x`": "Publicado em `x`", "Premieres in `x`": "Estreia em `x`", "Premieres `x`": "Estreia `x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Oi! Parece que seu JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar um pouco mais de tempo para carregar.", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Olá! Parece que você está com o JavaScript desativado. Clique aqui para ver os comentários, mas lembre-se de que eles podem demorar um pouco mais para carregar.", "View YouTube comments": "Ver comentários no YouTube", "View more comments on Reddit": "Ver mais comentários no Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentários", + "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentário", "": "Ver `x` comentários" }, "View Reddit comments": "Ver comentários no Reddit", "Hide replies": "Ocultar respostas", "Show replies": "Mostrar respostas", "Incorrect password": "Senha incorreta", - "Quota exceeded, try again in a few hours": "Cota excedida, tente novamente em algumas horas", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não foi possível fazer login, sua autenticação em dois passos (app autenticador ou sms) deve estar ativada.", - "Invalid TFA code": "Código TFA inválido", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falha no login. Isso pode acontecer porque a autenticação em dois passos está desativada para sua conta.", "Wrong answer": "Resposta incorreta", "Erroneous CAPTCHA": "CAPTCHA inválido", - "CAPTCHA is a required field": "O CAPTCHA é um campo obrigatório", + "CAPTCHA is a required field": "CAPTCHA é um campo obrigatório", "User ID is a required field": "O nome de usuário é um campo obrigatório", "Password is a required field": "A senha é um campo obrigatório", "Wrong username or password": "Nome de usuário ou senha inválidos", - "Please sign in using 'Log in with Google'": "Por favor, entre usando 'Entrar com conta Google'", "Password cannot be empty": "A senha não pode ficar em branco", "Password cannot be longer than 55 characters": "A senha não pode ter mais que 55 caracteres", "Please log in": "Por favor, inicie sua sessão", "Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`", "channel:`x`": "canal: `x`", - "Deleted or invalid channel": "Este canal foi apagado ou é inválido", + "Deleted or invalid channel": "Canal excluído ou inválido", "This channel does not exist.": "Este canal não existe.", "Could not get channel info.": "Não foi possível obter as informações do canal.", "Could not fetch comments": "Não foi possível obter os comentários", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respostas", - "": "Ver `x` respostas" - }, "`x` ago": "`x` atrás", "Load more": "Carregar mais", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pontos", - "": "`x` pontos" - }, "Could not create mix.": "Não foi possível criar o mix.", - "Empty playlist": "Lista de reprodução vazia", - "Not a playlist.": "Não é uma lista de reprodução.", - "Playlist does not exist.": "A lista de reprodução não existe.", - "Could not pull trending pages.": "Não foi possível obter as páginas dos vídeos em alta.", + "Empty playlist": "Playlist vazia", + "Not a playlist.": "Não é uma playlist.", + "Playlist does not exist.": "A playlist não existe.", + "Could not pull trending pages.": "Não foi possível obter as páginas de vídeos em alta.", "Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório", "Hidden field \"token\" is a required field": "O campo oculto \"token\" é obrigatório", "Erroneous challenge": "Desafio inválido", @@ -334,85 +298,220 @@ "Yiddish": "Iídiche", "Yoruba": "Iorubá", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anos", - "": "`x` anos" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses", - "": "`x` meses" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas", - "": "`x` semanas" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dias", - "": "`x` dias" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas", - "": "`x` horas" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos", - "": "`x` minutos" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos", - "": "`x` segundos" - }, - "Fallback comments: ": "Comentários alternativos: ", + "generic_count_years_0": "{{count}} ano", + "generic_count_years_1": "{{count}} anos", + "generic_count_years_2": "{{count}} anos", + "generic_count_months_0": "{{count}} mês", + "generic_count_months_1": "{{count}} meses", + "generic_count_months_2": "{{count}} meses", + "generic_count_weeks_0": "{{count}} semana", + "generic_count_weeks_1": "{{count}} semanas", + "generic_count_weeks_2": "{{count}} semanas", + "generic_count_days_0": "{{count}} dia", + "generic_count_days_1": "{{count}} dias", + "generic_count_days_2": "{{count}} dias", + "generic_count_hours_0": "{{count}} hora", + "generic_count_hours_1": "{{count}} horas", + "generic_count_hours_2": "{{count}} horas", + "generic_count_minutes_0": "{{count}} minuto", + "generic_count_minutes_1": "{{count}} minutos", + "generic_count_minutes_2": "{{count}} minutos", + "generic_count_seconds_0": "{{count}} segundo", + "generic_count_seconds_1": "{{count}} segundos", + "generic_count_seconds_2": "{{count}} segundos", + "Fallback comments: ": "Alternativa para comentários: ", "Popular": "Populares", - "Search": "", - "Top": "No topo", + "Search": "Pesquisar", + "Top": "Destaques", "About": "Sobre", "Rating: ": "Avaliação: ", - "Language: ": "Idioma: ", - "View as playlist": "Ver como lista de reprodução", + "preferences_locale_label": "Idioma: ", + "View as playlist": "Ver como playlist", "Default": "Padrão", "Music": "Músicas", "Gaming": "Jogos", "News": "Notícias", "Movies": "Filmes", - "Download": "Baixar", + "Download": "Download", "Download as: ": "Baixar como: ", "%A %B %-d, %Y": "%A %-d %B %Y", "(edited)": "(editado)", "YouTube comment permalink": "Link permanente do comentário no YouTube", "permalink": "Link permanente", - "`x` marked it with a ❤": "`x` foi marcado como ❤", + "`x` marked it with a ❤": "`x` foi marcado com um ❤", "Audio mode": "Modo de áudio", "Video mode": "Modo de vídeo", - "Videos": "Vídeos", - "Playlists": "Listas de reprodução", - "Community": "Comunidade", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Versão atual: " -}
\ No newline at end of file + "channel_tab_videos_label": "Vídeos", + "Playlists": "Playlists", + "channel_tab_community_label": "Comunidade", + "search_filters_sort_option_relevance": "Relevância", + "search_filters_sort_option_rating": "Avaliação", + "search_filters_sort_option_date": "Data de publicação", + "search_filters_sort_option_views": "Visualizações", + "search_filters_type_label": "Tipo", + "search_filters_duration_label": "Duração", + "search_filters_features_label": "Características", + "search_filters_sort_label": "Ordenar por", + "search_filters_date_option_hour": "Últimas horas", + "search_filters_date_option_today": "Hoje", + "search_filters_date_option_week": "Esta semana", + "search_filters_date_option_month": "Este mês", + "search_filters_date_option_year": "Este ano", + "search_filters_type_option_video": "Vídeo", + "search_filters_type_option_channel": "Canal", + "search_filters_type_option_playlist": "Playlist", + "search_filters_type_option_movie": "Filme", + "search_filters_type_option_show": "Séries", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Legendas", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "AO VIVO", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Localização", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Versão atual: ", + "next_steps_error_message": "Depois disso, você deve tentar: ", + "next_steps_error_message_refresh": "Recarregar", + "next_steps_error_message_go_to_youtube": "Ir para o YouTube", + "footer_donate_page": "Doar", + "adminprefs_modified_source_code_url_label": "URL para o repositório do código-fonte modificado", + "search_filters_duration_option_long": "Longo (> 20 minutos)", + "search_filters_duration_option_short": "Curto (< 4 minutos)", + "footer_documentation": "Documentação", + "footer_source_code": "Código-fonte", + "footer_original_source_code": "Código-fonte original", + "footer_modfied_source_code": "Código-fonte modificado", + "preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ", + "preferences_region_label": "País do conteúdo: ", + "preferences_quality_dash_option_4320p": "4320p", + "generic_videos_count_0": "{{count}} vídeo", + "generic_videos_count_1": "{{count}} vídeos", + "generic_videos_count_2": "{{count}} vídeos", + "generic_playlists_count_0": "{{count}} playlist", + "generic_playlists_count_1": "{{count}} playlists", + "generic_playlists_count_2": "{{count}} playlists", + "generic_subscribers_count_0": "{{count}} inscrito", + "generic_subscribers_count_1": "{{count}} inscritos", + "generic_subscribers_count_2": "{{count}} inscritos", + "generic_subscriptions_count_0": "{{count}} inscrição", + "generic_subscriptions_count_1": "{{count}} inscrições", + "generic_subscriptions_count_2": "{{count}} inscrições", + "subscriptions_unseen_notifs_count_0": "{{count}} notificação não visualizada", + "subscriptions_unseen_notifs_count_1": "{{count}} notificações não visualizadas", + "subscriptions_unseen_notifs_count_2": "{{count}} notificações não visualizadas", + "comments_view_x_replies_0": "Ver {{count}} resposta", + "comments_view_x_replies_1": "Ver {{count}} respostas", + "comments_view_x_replies_2": "Ver {{count}} respostas", + "comments_points_count_0": "{{count}} ponto", + "comments_points_count_1": "{{count}} pontos", + "comments_points_count_2": "{{count}} pontos", + "crash_page_you_found_a_bug": "Parece que você encontrou um erro no Invidious!", + "crash_page_before_reporting": "Antes de informar um erro, verifique se você:", + "preferences_save_player_pos_label": "Salvar posição de reprodução: ", + "search_filters_features_option_purchased": "Comprado", + "crash_page_refresh": "tentou <a href=\"`x`\">recarregar a página</a>", + "crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</a>", + "crash_page_search_issue": "procurou por um <a href=\"`x`\">erro existente no GitHub</a>", + "crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto (NÃO traduza):", + "crash_page_read_the_faq": "leu as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>", + "generic_views_count_0": "{{count}} visualização", + "generic_views_count_1": "{{count}} visualizações", + "generic_views_count_2": "{{count}} visualizações", + "preferences_quality_option_dash": "DASH (qualidade adaptável)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_small": "Pequeno", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_best": "Melhor qualidade", + "preferences_quality_dash_option_worst": "Pior qualidade", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "preferences_quality_option_medium": "Médio", + "search_filters_features_option_three_sixty": "360°", + "none": "nenhum", + "videoinfo_watch_on_youTube": "Assistir no YouTube", + "videoinfo_youTube_embed_link": "Embed", + "videoinfo_invidious_embed_link": "Embed link", + "download_subtitles": "Legendas - `x` (.vtt)", + "user_created_playlists": "`x` playlists criadas", + "user_saved_playlists": "`x` playlists salvas", + "Video unavailable": "Vídeo indisponível", + "videoinfo_started_streaming_x_ago": "Iniciou a transmissão a `x`", + "search_filters_title": "Filtro", + "preferences_watch_history_label": "Ativar histórico de exibição: ", + "search_message_no_results": "Nenhum resultado encontrado.", + "search_message_change_filters_or_query": "Tente ampliar sua consulta de pesquisa e/ou alterar os filtros.", + "English (United Kingdom)": "Inglês (Reino Unido)", + "English (United States)": "Inglês (Estados Unidos)", + "German (auto-generated)": "Alemão (gerado automaticamente)", + "Chinese": "Chinês", + "Chinese (China)": "Chinês (China)", + "Cantonese (Hong Kong)": "Cantonês (Hong Kong)", + "Interlingue": "Interlíngua", + "search_filters_type_option_all": "Qualquer tipo", + "search_filters_apply_button": "Aplicar filtros selecionados", + "Chinese (Hong Kong)": "Chinês (Hong Kong)", + "Chinese (Taiwan)": "Chinês (Taiwan)", + "Japanese (auto-generated)": "Japonês (gerado automaticamente)", + "Korean (auto-generated)": "Coreano (gerado automaticamente)", + "Portuguese (auto-generated)": "Português (gerado automaticamente)", + "Portuguese (Brazil)": "Português (Brasil)", + "Russian (auto-generated)": "Russo (gerado automaticamente)", + "Vietnamese (auto-generated)": "Vietnamita (gerado automaticamente)", + "search_filters_date_label": "Data de publicação", + "search_filters_date_option_none": "Qualquer data", + "Dutch (auto-generated)": "Holandês (gerado automaticamente)", + "French (auto-generated)": "Francês (gerado automaticamente)", + "Indonesian (auto-generated)": "Indonésio (gerado automaticamente)", + "Italian (auto-generated)": "Italiano (gerado automaticamente)", + "Spanish (auto-generated)": "Espanhol (gerado automaticamente)", + "Spanish (Mexico)": "Espanhol (México)", + "search_filters_duration_option_none": "Qualquer duração", + "search_message_use_another_instance": "Você também pode <a href=\"`x`\">pesquisar em outra instância</a>.", + "Spanish (Spain)": "Espanhol (Espanha)", + "Turkish (auto-generated)": "Turco (gerado automaticamente)", + "search_filters_duration_option_medium": "Médio (4 - 20 minutos)", + "search_filters_features_option_vr180": "VR180", + "Popular enabled: ": "Página \"Populares\" ativada: ", + "error_video_not_in_playlist": "O vídeo solicitado não existe nesta playlist. <a href=\"`x`\">Clique aqui para acessar a página inicial da playlist.</a>", + "channel_tab_channels_label": "Canais", + "channel_tab_playlists_label": "Playlists", + "channel_tab_shorts_label": "Shorts", + "channel_tab_streams_label": "Transmissão ao vivo", + "Music in this video": "Música neste vídeo", + "Artist: ": "Artista: ", + "Album: ": "Álbum: ", + "Standard YouTube license": "Licença padrão do YouTube", + "Song: ": "Música: ", + "Channel Sponsor": "Patrocinador do canal", + "Download is disabled": "Download indisponível", + "Import YouTube playlist (.csv)": "Importar playlist do YouTube (.csv)", + "generic_button_delete": "Excluir", + "generic_button_save": "Salvar", + "generic_button_edit": "Editar", + "playlist_button_add_items": "Adicionar vídeos", + "channel_tab_releases_label": "Lançamentos", + "channel_tab_podcasts_label": "Podcasts", + "generic_button_cancel": "Cancelar", + "generic_button_rss": "RSS", + "generic_channels_count_0": "{{count}} canal", + "generic_channels_count_1": "{{count}} canais", + "generic_channels_count_2": "{{count}} canais", + "Import YouTube watch history (.json)": "Importar histórico de exibição do YouTube (.json)", + "toggle_theme": "Alternar tema", + "Add to playlist": "Adicionar à playlist", + "Add to playlist: ": "Adicionar à playlist: ", + "Search for videos": "Pesquisar vídeos", + "The Popular feed has been disabled by the administrator.": "O feed \"Populares\" foi desativado pelo administrador.", + "Answer": "Resposta", + "carousel_slide": "Slide {{current}} de {{total}}", + "carousel_skip": "Ignorar carrossel", + "carousel_go_to": "Ir ao slide `x`" +} diff --git a/locales/pt-PT.json b/locales/pt-PT.json index c25e9353..f83a80a9 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -1,16 +1,4 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscritores", - "": "`x` subscritores" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videos", - "": "`x` vídeos" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` listas de reprodução", - "": "`x` listas de reprodução" - }, "LIVE": "Em direto", "Shared `x` ago": "Partilhado `x` atrás", "Unsubscribe": "Anular subscrição", @@ -26,70 +14,70 @@ "Clear watch history?": "Limpar histórico de reprodução?", "New password": "Nova palavra-chave", "New passwords must match": "As novas palavra-chaves devem corresponder", - "Cannot change password for Google accounts": "Não é possível alterar a palavra-passe para contas do Google", "Authorize token?": "Autorizar token?", "Authorize token for `x`?": "Autorizar token para `x`?", "Yes": "Sim", "No": "Não", - "Import and Export Data": "Importar e Exportar Dados", + "Import and Export Data": "Importar e exportar dados", "Import": "Importar", - "Import Invidious data": "Importar dados do Invidious", - "Import YouTube subscriptions": "Importar subscrições do YouTube", + "Import Invidious data": "Importar dados JSON do Invidious", + "Import YouTube subscriptions": "Importar subscrições do YouTube/OPML", "Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)", "Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)", "Export": "Exportar", "Export subscriptions as OPML": "Exportar subscrições como OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)", - "Export data as JSON": "Exportar dados como JSON", - "Delete account?": "Apagar conta?", + "Export data as JSON": "Exportar dados Invidious como JSON", + "Delete account?": "Eliminar conta?", "History": "Histórico", "An alternative front-end to YouTube": "Uma interface alternativa ao YouTube", "JavaScript license information": "Informação de licença do JavaScript", "source": "código-fonte", "Log in": "Iniciar sessão", - "Log in/register": "Iniciar sessão/Registar", - "Log in with Google": "Iniciar sessão com o Google", + "Log in/register": "Iniciar sessão/registar", "User ID": "Utilizador", "Password": "Palavra-chave", "Time (h:mm:ss):": "Tempo (h:mm:ss):", "Text CAPTCHA": "Texto CAPTCHA", "Image CAPTCHA": "Imagem CAPTCHA", - "Sign In": "Iniciar Sessão", + "Sign In": "Iniciar sessão", "Register": "Registar", "E-mail": "E-mail", - "Google verification code": "Código de verificação do Google", "Preferences": "Preferências", - "Player preferences": "Preferências do reprodutor", - "Always loop: ": "Repetir sempre: ", - "Autoplay: ": "Reprodução automática: ", - "Play next by default: ": "Sempre reproduzir próximo: ", - "Autoplay next video: ": "Reproduzir próximo vídeo automaticamente: ", - "Listen by default: ": "Apenas áudio: ", - "Proxy videos: ": "Usar proxy nos vídeos: ", - "Default speed: ": "Velocidade preferida: ", - "Preferred video quality: ": "Qualidade de vídeo preferida: ", - "Player volume: ": "Volume da reprodução: ", - "Default comments: ": "Preferência dos comentários: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "Legendas predefinidas: ", + "preferences_category_player": "Preferências do reprodutor", + "preferences_video_loop_label": "Repetir sempre: ", + "preferences_autoplay_label": "Reprodução automática: ", + "preferences_continue_label": "Reproduzir sempre o próximo: ", + "preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ", + "preferences_listen_label": "Apenas áudio: ", + "preferences_local_label": "Usar proxy nos vídeos: ", + "preferences_speed_label": "Velocidade preferida: ", + "preferences_quality_label": "Qualidade de vídeo preferida: ", + "preferences_volume_label": "Volume da reprodução: ", + "preferences_comments_label": "Preferência dos comentários: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Legendas predefinidas: ", "Fallback captions: ": "Legendas alternativas: ", - "Show related videos: ": "Mostrar vídeos relacionados: ", - "Show annotations by default: ": "Mostrar sempre anotações: ", - "Automatically extend video description: ": "", - "Visual preferences": "Preferências visuais", - "Player style: ": "Estilo do reprodutor: ", + "preferences_related_videos_label": "Mostrar vídeos relacionados: ", + "preferences_annotations_label": "Mostrar anotações sempre: ", + "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ", + "preferences_vr_mode_label": "Vídeos interativos de 360 graus (necessita de WebGL): ", + "preferences_category_visual": "Preferências visuais", + "preferences_player_style_label": "Estilo do reprodutor: ", "Dark mode: ": "Modo escuro: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "escuro", "light": "claro", - "Thin mode: ": "Modo compacto: ", - "Subscription preferences": "Preferências de subscrições", - "Show annotations by default for subscribed channels: ": "Mostrar sempre anotações aos canais subscritos: ", + "preferences_thin_mode_label": "Modo compacto: ", + "preferences_category_misc": "Preferências diversas", + "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ", + "preferences_category_subscription": "Preferências de subscrições", + "preferences_annotations_subscribed_label": "Mostrar sempre anotações aos canais subscritos: ", "Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ", - "Number of videos shown in feed: ": "Quantidade de vídeos nas subscrições: ", - "Sort videos by: ": "Ordenar vídeos por: ", + "preferences_max_results_label": "Quantidade de vídeos nas subscrições: ", + "preferences_sort_label": "Ordenar vídeos por: ", "published": "publicado", "published - reverse": "publicado - inverso", "alphabetically": "alfabeticamente", @@ -98,50 +86,41 @@ "channel name - reverse": "nome do canal - inverso", "Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ", "Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não visualizados do canal: ", - "Only show unwatched: ": "Mostrar apenas vídeos não visualizados: ", - "Only show notifications (if there are any): ": "Mostrar apenas notificações (se existirem): ", + "preferences_unseen_only_label": "Mostrar apenas vídeos não visualizados: ", + "preferences_notifications_only_label": "Mostrar apenas notificações (se existirem): ", "Enable web notifications": "Ativar notificações pela web", "`x` uploaded a video": "`x` publicou um novo vídeo", "`x` is live": "`x` está em direto", - "Data preferences": "Preferências de dados", + "preferences_category_data": "Preferências de dados", "Clear watch history": "Limpar histórico de reprodução", - "Import/export data": "Importar/Exportar dados", + "Import/export data": "Importar / exportar dados", "Change password": "Alterar palavra-chave", "Manage subscriptions": "Gerir as subscrições", "Manage tokens": "Gerir tokens", "Watch history": "Histórico de reprodução", - "Delete account": "Apagar conta", - "Administrator preferences": "Preferências de administrador", - "Default homepage: ": "Página inicial predefinida: ", - "Feed menu: ": "Menu de subscrições: ", - "Top enabled: ": "Top ativado: ", + "Delete account": "Eliminar conta", + "preferences_category_admin": "Preferências de administrador", + "preferences_default_home_label": "Página inicial predefinida: ", + "preferences_feed_menu_label": "Menu de subscrições: ", + "preferences_show_nick_label": "Mostrar nome de utilizador em cima: ", + "Top enabled: ": "Destaques ativados: ", "CAPTCHA enabled: ": "CAPTCHA ativado: ", "Login enabled: ": "Iniciar sessão ativado: ", "Registration enabled: ": "Registar ativado: ", "Report statistics: ": "Relatório de estatísticas: ", - "Save preferences": "Gravar preferências", + "Save preferences": "Guardar preferências", "Subscription manager": "Gerir subscrições", "Token manager": "Gerir tokens", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscrições", - "": "`x` subscrições" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens", - "": "`x` tokens" - }, - "Import/export": "Importar/Exportar", - "unsubscribe": "Anular subscrição", + "tokens_count": "{{count}} token", + "tokens_count_plural": "{{count}} tokens", + "Import/export": "Importar / exportar", + "unsubscribe": "anular subscrição", "revoke": "revogar", "Subscriptions": "Subscrições", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificações não vistas", - "": "`x` notificações não vistas" - }, - "search": "Pesquisar", + "search": "pesquisar", "Log out": "Terminar sessão", - "Released under the AGPLv3 by Omar Roth.": "Publicado sob a licença AGPLv3, por Omar Roth.", + "Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no GitHub.", "Source available here.": "Código-fonte disponível aqui.", "View JavaScript license information.": "Ver informações da licença do JavaScript.", "View privacy policy.": "Ver a política de privacidade.", @@ -151,15 +130,16 @@ "Private": "Privado", "View all playlists": "Ver todas as listas de reprodução", "Updated `x` ago": "Atualizado `x` atrás", - "Delete playlist `x`?": "Apagar a lista de reprodução 'x'?", - "Delete playlist": "Apagar lista de reprodução", + "Delete playlist `x`?": "Eliminar a lista de reprodução `x`?", + "Delete playlist": "Eliminar lista de reprodução", "Create playlist": "Criar lista de reprodução", "Title": "Título", "Playlist privacy": "Privacidade da lista de reprodução", - "Editing playlist `x`": "A editar lista de reprodução 'x'", - "Show more": "", - "Show less": "", + "Editing playlist `x`": "A editar lista de reprodução `x`", + "Show more": "Mostrar mais", + "Show less": "Mostrar menos", "Watch on YouTube": "Ver no YouTube", + "Switch Invidious Instance": "Mudar a instância do Invidious", "Hide annotations": "Ocultar anotações", "Show annotations": "Mostrar anotações", "Genre: ": "Género: ", @@ -170,13 +150,9 @@ "Whitelisted regions: ": "Regiões permitidas: ", "Blacklisted regions: ": "Regiões bloqueadas: ", "Shared `x`": "Partilhado `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizações", - "": "`x` visualizações" - }, - "Premieres in `x`": "Estreias em 'x'", - "Premieres `x`": "Estreias 'x'", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Oi! Parece que JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.", + "Premieres in `x`": "Estreias em `x`", + "Premieres `x`": "Estreias `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, entretanto eles podem levar mais tempo para carregar.", "View YouTube comments": "Ver comentários do YouTube", "View more comments on Reddit": "Ver mais comentários no Reddit", "View `x` comments": { @@ -187,41 +163,28 @@ "Hide replies": "Ocultar respostas", "Show replies": "Mostrar respostas", "Incorrect password": "Palavra-chave incorreta", - "Quota exceeded, try again in a few hours": "Cota excedida. Tente novamente dentro de algumas horas", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Não é possível iniciar sessão, certifique-se de que a autenticação de dois fatores (Autenticador ou SMS) está ativada.", - "Invalid TFA code": "Código TFA inválido", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Falhou o início de sessão. Isto pode ser devido a dois fatores de autenticação não está ativado para sua conta.", "Wrong answer": "Resposta errada", "Erroneous CAPTCHA": "CAPTCHA inválido", "CAPTCHA is a required field": "CAPTCHA é um campo obrigatório", "User ID is a required field": "O nome de utilizador é um campo obrigatório", "Password is a required field": "Palavra-chave é um campo obrigatório", "Wrong username or password": "Nome de utilizador ou palavra-chave incorreto", - "Please sign in using 'Log in with Google'": "Por favor, inicie sessão usando 'Iniciar sessão com o Google'", "Password cannot be empty": "A palavra-chave não pode estar vazia", "Password cannot be longer than 55 characters": "A palavra-chave não pode ser superior a 55 caracteres", "Please log in": "Por favor, inicie sessão", "Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`", - "channel:`x`": "canal:'x'", - "Deleted or invalid channel": "Canal apagado ou inválido", + "channel:`x`": "canal:`x`", + "Deleted or invalid channel": "Canal eliminado ou inválido", "This channel does not exist.": "Este canal não existe.", "Could not get channel info.": "Não foi possível obter as informações do canal.", "Could not fetch comments": "Não foi possível obter os comentários", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` respostas", - "": "Ver `x` respostas" - }, "`x` ago": "`x` atrás", "Load more": "Carregar mais", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pontos", - "": "`x` pontos" - }, - "Could not create mix.": "Não foi possível criar mistura.", + "Could not create mix.": "Não foi possível criar a mistura.", "Empty playlist": "Lista de reprodução vazia", "Not a playlist.": "Não é uma lista de reprodução.", "Playlist does not exist.": "A lista de reprodução não existe.", - "Could not pull trending pages.": "Não foi possível obter páginas de tendências.", + "Could not pull trending pages.": "Não foi possível obter as páginas de tendências.", "Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório", "Hidden field \"token\" is a required field": "O campo oculto \"token\" é um campo obrigatório", "Erroneous challenge": "Desafio inválido", @@ -244,8 +207,8 @@ "Burmese": "Birmanês", "Catalan": "Catalão", "Cebuano": "Cebuano", - "Chinese (Simplified)": "Chinês (Simplificado)", - "Chinese (Traditional)": "Chinês (Tradicional)", + "Chinese (Simplified)": "Chinês (simplificado)", + "Chinese (Traditional)": "Chinês (tradicional)", "Corsican": "Corso", "Croatian": "Croata", "Czech": "Checo", @@ -334,85 +297,179 @@ "Yiddish": "Iídiche", "Yoruba": "Ioruba", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anos", - "": "`x` anos" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` meses", - "": "`x` meses" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semanas", - "": "`x` semanas" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dias", - "": "`x` dias" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` horas", - "": "`x` horas" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minutos", - "": "`x` minutos" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundos", - "": "`x` segundos" - }, + "generic_count_years": "{{count}} ano", + "generic_count_years_plural": "{{count}} anos", + "generic_count_months": "{{count}} mês", + "generic_count_months_plural": "{{count}} meses", + "generic_count_weeks": "{{count}} seman", + "generic_count_weeks_plural": "{{count}} semanas", + "generic_count_days": "{{count}} dia", + "generic_count_days_plural": "{{count}} dias", + "generic_count_hours": "{{count}} hora", + "generic_count_hours_plural": "{{count}} horas", + "generic_count_minutes": "{{count}} minuto", + "generic_count_minutes_plural": "{{count}} minutos", + "generic_count_seconds": "{{count}} segundo", + "generic_count_seconds_plural": "{{count}} segundos", "Fallback comments: ": "Comentários alternativos: ", "Popular": "Popular", - "Search": "", - "Top": "Top", + "Search": "Pesquisar", + "Top": "Destaques", "About": "Sobre", "Rating: ": "Avaliação: ", - "Language: ": "Idioma: ", + "preferences_locale_label": "Idioma: ", "View as playlist": "Ver como lista de reprodução", - "Default": "Predefinição", + "Default": "Predefinido", "Music": "Música", "Gaming": "Jogos", "News": "Notícias", "Movies": "Filmes", - "Download": "Transferir", - "Download as: ": "Transferir como: ", + "Download": "Descarregar", + "Download as: ": "Descarregar como: ", "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(editado)", - "YouTube comment permalink": "Hiperligação permanente ao comentário do YouTube", - "permalink": "ligação permanente", + "YouTube comment permalink": "Hiperligação permanente do comentário no YouTube", + "permalink": "hiperligação permanente", "`x` marked it with a ❤": "`x` foi marcado como ❤", "Audio mode": "Modo de áudio", "Video mode": "Modo de vídeo", - "Videos": "Vídeos", + "channel_tab_videos_label": "Vídeos", "Playlists": "Listas de reprodução", - "Community": "Comunidade", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Versão atual: " -}
\ No newline at end of file + "channel_tab_community_label": "Comunidade", + "search_filters_sort_option_relevance": "Relevância", + "search_filters_sort_option_rating": "Avaliação", + "search_filters_sort_option_date": "Data de envio", + "search_filters_sort_option_views": "Visualizações", + "search_filters_type_label": "Tipo", + "search_filters_duration_label": "Duração", + "search_filters_features_label": "Funcionalidades", + "search_filters_sort_label": "Ordenar por", + "search_filters_date_option_hour": "Última hora", + "search_filters_date_option_today": "Hoje", + "search_filters_date_option_week": "Esta semana", + "search_filters_date_option_month": "Este mês", + "search_filters_date_option_year": "Este ano", + "search_filters_type_option_video": "Vídeo", + "search_filters_type_option_channel": "Canal", + "search_filters_type_option_playlist": "Lista de reprodução", + "search_filters_type_option_movie": "Filme", + "search_filters_type_option_show": "Espetáculo", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Legendas", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Em direto", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Localização", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Versão atual: ", + "next_steps_error_message": "Pode tentar as seguintes opções: ", + "next_steps_error_message_refresh": "Atualizar", + "next_steps_error_message_go_to_youtube": "Ir ao YouTube", + "search_filters_title": "Filtro", + "generic_videos_count": "{{count}} vídeo", + "generic_videos_count_plural": "{{count}} vídeos", + "generic_playlists_count": "{{count}} lista de reprodução", + "generic_playlists_count_plural": "{{count}} listas de reprodução", + "generic_subscriptions_count": "{{count}} inscrição", + "generic_subscriptions_count_plural": "{{count}} inscrições", + "generic_views_count": "{{count}} visualização", + "generic_views_count_plural": "{{count}} visualizações", + "generic_subscribers_count": "{{count}} inscrito", + "generic_subscribers_count_plural": "{{count}} inscritos", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ", + "preferences_quality_dash_option_2160p": "2160p", + "subscriptions_unseen_notifs_count": "{{count}} notificação não vista", + "subscriptions_unseen_notifs_count_plural": "{{count}} notificações não vistas", + "Popular enabled: ": "Página \"popular\" ativada: ", + "search_message_no_results": "Nenhum resultado encontrado.", + "preferences_quality_dash_option_auto": "Automático", + "preferences_region_label": "País do conteúdo: ", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_720p": "720p", + "preferences_watch_history_label": "Ativar histórico de reprodução: ", + "preferences_quality_dash_option_best": "Melhor", + "preferences_quality_dash_option_worst": "Pior", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_dash": "DASH (qualidade adaptativa)", + "preferences_quality_option_medium": "Média", + "preferences_quality_option_small": "Baixa", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "Video unavailable": "Vídeo não disponível", + "Russian (auto-generated)": "Russo (gerado automaticamente)", + "comments_view_x_replies": "Ver {{count}} resposta", + "comments_view_x_replies_plural": "Ver {{count}} respostas", + "comments_points_count": "{{count}} ponto", + "comments_points_count_plural": "{{count}} pontos", + "English (United Kingdom)": "Inglês (Reino Unido)", + "Chinese (Hong Kong)": "Chinês (Hong Kong)", + "Chinese (Taiwan)": "Chinês (Taiwan)", + "Dutch (auto-generated)": "Holandês (gerado automaticamente)", + "French (auto-generated)": "Francês (gerado automaticamente)", + "German (auto-generated)": "Alemão (gerado automaticamente)", + "Indonesian (auto-generated)": "Indonésio (gerado automaticamente)", + "Interlingue": "Interlíngua", + "Italian (auto-generated)": "Italiano (gerado automaticamente)", + "Japanese (auto-generated)": "Japonês (gerado automaticamente)", + "Korean (auto-generated)": "Coreano (gerado automaticamente)", + "Portuguese (auto-generated)": "Português (gerado automaticamente)", + "Portuguese (Brazil)": "Português (Brasil)", + "Spanish (Spain)": "Espanhol (Espanha)", + "Vietnamese (auto-generated)": "Vietnamita (gerado automaticamente)", + "search_filters_type_option_all": "Qualquer tipo", + "search_filters_duration_option_none": "Qualquer duração", + "search_filters_duration_option_short": "Curto (< 4 minutos)", + "search_filters_duration_option_medium": "Médio (4 - 20 minutos)", + "search_filters_duration_option_long": "Longo (> 20 minutos)", + "search_filters_features_option_purchased": "Comprado", + "search_filters_apply_button": "Aplicar filtros selecionados", + "videoinfo_watch_on_youTube": "Ver no YouTube", + "videoinfo_youTube_embed_link": "Incorporar", + "adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte alterado", + "videoinfo_invidious_embed_link": "Incorporar hiperligação", + "none": "nenhum", + "videoinfo_started_streaming_x_ago": "Iniciou a transmissão há `x`", + "download_subtitles": "Legendas - `x` (.vtt)", + "user_created_playlists": "`x` listas de reprodução criadas", + "user_saved_playlists": "`x` listas de reprodução guardadas", + "preferences_save_player_pos_label": "Guardar a posição de reprodução atual do vídeo: ", + "Turkish (auto-generated)": "Turco (gerado automaticamente)", + "Cantonese (Hong Kong)": "Cantonês (Hong Kong)", + "Chinese (China)": "Chinês (China)", + "Spanish (auto-generated)": "Espanhol (gerado automaticamente)", + "Spanish (Mexico)": "Espanhol (México)", + "English (United States)": "Inglês (Estados Unidos)", + "footer_donate_page": "Doar", + "footer_documentation": "Documentação", + "footer_source_code": "Código-fonte", + "footer_original_source_code": "Código-fonte original", + "footer_modfied_source_code": "Código-fonte alterado", + "Chinese": "Chinês", + "search_filters_date_label": "Data de publicação", + "search_filters_date_option_none": "Qualquer data", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_vr180": "VR180", + "search_message_use_another_instance": " Também pode <a href=\"`x`\">pesquisar noutra instância</a>.", + "crash_page_you_found_a_bug": "Parece que encontrou um erro no Invidious!", + "crash_page_before_reporting": "Antes de reportar um erro, verifique se:", + "crash_page_read_the_faq": "leia as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>", + "crash_page_search_issue": "procurou se <a href=\"`x`\">o erro já foi reportado no GitHub</a>", + "crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto tal qual (NÃO o traduza):", + "search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.", + "crash_page_refresh": "tentou <a href=\"`x`\">recarregar a página</a>", + "crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</a>", + "error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para a página inicial da lista de reprodução.</a>", + "Artist: ": "Artista: ", + "Album: ": "Álbum: ", + "channel_tab_streams_label": "Diretos", + "channel_tab_playlists_label": "Listas de reprodução", + "channel_tab_channels_label": "Canais", + "Music in this video": "Música neste vídeo", + "channel_tab_shorts_label": "Curtos" +} diff --git a/locales/pt.json b/locales/pt.json new file mode 100644 index 00000000..0bb1be66 --- /dev/null +++ b/locales/pt.json @@ -0,0 +1,517 @@ +{ + "search_filters_type_option_show": "Séries", + "search_filters_sort_option_views": "Visualizações", + "search_filters_sort_option_date": "Data de carregamento", + "search_filters_sort_option_rating": "Avaliação", + "search_filters_sort_option_relevance": "Relevância", + "Switch Invidious Instance": "Alterar instância Invidious", + "Show less": "Mostrar menos", + "Show more": "Mostrar mais", + "Released under the AGPLv3 on Github.": "Disponibilizada sob a AGPLv3 no GitHub.", + "preferences_show_nick_label": "Mostrar nome de utilizador em cima: ", + "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ", + "preferences_category_misc": "Preferências diversas", + "preferences_vr_mode_label": "Vídeos interativos de 360 graus (requer WebGL): ", + "preferences_extend_desc_label": "Expandir automaticamente a descrição do vídeo: ", + "next_steps_error_message_go_to_youtube": "Ir para o YouTube", + "next_steps_error_message": "Pode tentar as seguintes opções: ", + "next_steps_error_message_refresh": "Recarregar", + "search_filters_features_option_hdr": "HDR", + "search_filters_features_option_location": "Localização", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_live": "Direto", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_subtitles": "Legendas", + "search_filters_features_option_hd": "HD", + "search_filters_type_option_movie": "Filme", + "search_filters_type_option_playlist": "Lista de reprodução", + "search_filters_type_option_channel": "Canal", + "search_filters_type_option_video": "Vídeo", + "search_filters_date_option_year": "Este ano", + "search_filters_date_option_month": "Este mês", + "search_filters_date_option_week": "Esta semana", + "search_filters_date_option_today": "Hoje", + "search_filters_date_option_hour": "Última hora", + "search_filters_sort_label": "Ordenar por", + "search_filters_features_label": "Funcionalidades", + "search_filters_duration_label": "Duração", + "search_filters_type_label": "Tipo", + "permalink": "ligação permanente", + "YouTube comment permalink": "Ligação permanente do comentário no YouTube", + "Download as: ": "Descarregar como: ", + "Download": "Descarregar", + "Default": "Padrão", + "Top": "Destaques", + "Search": "Pesquisar", + "generic_count_years_0": "{{count}} ano", + "generic_count_years_1": "{{count}} anos", + "generic_count_years_2": "{{count}} anos", + "generic_count_months_0": "{{count}} mês", + "generic_count_months_1": "{{count}} meses", + "generic_count_months_2": "{{count}} meses", + "generic_count_weeks_0": "{{count}} semana", + "generic_count_weeks_1": "{{count}} semanas", + "generic_count_weeks_2": "{{count}} semanas", + "generic_count_days_0": "{{count}} dia", + "generic_count_days_1": "{{count}} dias", + "generic_count_days_2": "{{count}} dias", + "generic_count_hours_0": "{{count}} hora", + "generic_count_hours_1": "{{count}} horas", + "generic_count_hours_2": "{{count}} horas", + "generic_count_minutes_0": "{{count}} minuto", + "generic_count_minutes_1": "{{count}} minutos", + "generic_count_minutes_2": "{{count}} minutos", + "generic_count_seconds_0": "{{count}} segundo", + "generic_count_seconds_1": "{{count}} segundos", + "generic_count_seconds_2": "{{count}} segundos", + "Chinese (Traditional)": "Chinês (tradicional)", + "Chinese (Simplified)": "Chinês (simplificado)", + "Could not pull trending pages.": "Não foi possível obter a página de tendências.", + "Could not create mix.": "Não foi possível criar o mix.", + "Deleted or invalid channel": "Canal eliminado ou inválido", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Olá! Parece que o JavaScript está desativado. Clique aqui para ver os comentários, mas tenha e conta que podem levar mais tempo para carregar.", + "Delete playlist": "Eliminar lista de reprodução", + "Delete playlist `x`?": "Eliminar lista de reprodução `x`?", + "search": "pesquisar", + "unsubscribe": "anular subscrição", + "Import/export": "Importar/exportar", + "Save preferences": "Guardar preferências", + "Top enabled: ": "Destaques ativados: ", + "Delete account": "Eliminar conta", + "Import/export data": "Importar/exportar dados", + "preferences_annotations_label": "Mostrar anotações sempre: ", + "preferences_continue_label": "Reproduzir sempre o seguinte: ", + "Sign In": "Entrar", + "Log in/register": "Iniciar sessão/registar", + "Delete account?": "Eliminar conta?", + "Import and Export Data": "Importar e exportar dados", + "Filipino": "Filipino", + "Estonian": "Estónio", + "Esperanto": "Esperanto", + "Dutch": "Holandês", + "Danish": "Dinamarquês", + "Czech": "Checo", + "Croatian": "Croata", + "Corsican": "Córsego", + "Cebuano": "Cebuano", + "Catalan": "Catalão", + "Burmese": "Birmanês", + "Bulgarian": "Búlgaro", + "Bosnian": "Bósnio", + "Belarusian": "Bielorrusso", + "Basque": "Basco", + "Bangla": "Bangla", + "Azerbaijani": "Azerbaijano", + "Armenian": "Arménio", + "Arabic": "Árabe", + "Amharic": "Amárico", + "Albanian": "Albanês", + "Afrikaans": "Africânder", + "English (auto-generated)": "Inglês (auto-gerado)", + "English": "Inglês", + "Token is expired, please try again": "Token caducado, tente novamente", + "No such user": "Utilizador inválido", + "Erroneous token": "Token inválido", + "Erroneous challenge": "Desafio inválido", + "Hidden field \"token\" is a required field": "O campo oculto \"token\" é um campo obrigatório", + "Hidden field \"challenge\" is a required field": "O campo oculto \"desafio\" é obrigatório", + "Playlist does not exist.": "A lista de reprodução não existe.", + "Not a playlist.": "Não é uma lista de reprodução.", + "Empty playlist": "Lista de reprodução vazia", + "Load more": "Carregar mais", + "`x` ago": "`x` atrás", + "Could not fetch comments": "Não foi possível obter os comentários", + "Could not get channel info.": "Não foi possível obter as informações do canal.", + "This channel does not exist.": "Este canal não existe.", + "channel:`x`": "canal:`x`", + "Invidious Private Feed for `x`": "Feed Privado do Invidious para `x`", + "Please log in": "Por favor, inicie sessão", + "Password cannot be longer than 55 characters": "A palavra-passe não pode ter mais do que 55 caracteres", + "Password cannot be empty": "A palavra-passe não pode estar vazia", + "Wrong username or password": "Nome de utilizador ou palavra-passe incorreta", + "Password is a required field": "Palavra-passe é um campo obrigatório", + "User ID is a required field": "O nome de utilizador é um campo obrigatório", + "CAPTCHA is a required field": "CAPTCHA é um campo obrigatório", + "Erroneous CAPTCHA": "CAPTCHA inválido", + "Wrong answer": "Resposta errada", + "Incorrect password": "Palavra-passe incorreta", + "Show replies": "Mostrar respostas", + "Hide replies": "Ocultar respostas", + "View Reddit comments": "Ver comentários do Reddit", + "View `x` comments": { + "": "Ver `x` comentários", + "([^.,0-9]|^)1([^.,0-9]|$)": "Ver `x` comentário" + }, + "View more comments on Reddit": "Ver mais comentários no Reddit", + "View YouTube comments": "Ver comentários do YouTube", + "Premieres `x`": "Estreia `x`", + "Premieres in `x`": "Estreia a `x`", + "Shared `x`": "Partilhado `x`", + "Blacklisted regions: ": "Regiões bloqueadas: ", + "Whitelisted regions: ": "Regiões permitidas: ", + "Engagement: ": "Compromisso: ", + "Wilson score: ": "Pontuação de Wilson: ", + "Family friendly? ": "Filtrar conteúdo impróprio: ", + "License: ": "Licença: ", + "Genre: ": "Género: ", + "Show annotations": "Mostrar anotações", + "Hide annotations": "Ocultar anotações", + "Watch on YouTube": "Ver no YouTube", + "Editing playlist `x`": "A editar lista de reprodução `x`", + "Playlist privacy": "Privacidade da lista de reprodução", + "Title": "Título", + "Create playlist": "Criar lista de reprodução", + "Updated `x` ago": "Atualizado há `x`", + "View all playlists": "Ver todas as listas de reprodução", + "Private": "Privado", + "Unlisted": "Não listado", + "Public": "Público", + "Trending": "Tendências", + "View privacy policy.": "Ver política de privacidade.", + "View JavaScript license information.": "Ver informações da licença JavaScript.", + "Source available here.": "Código-fonte disponível aqui.", + "Log out": "Terminar sessão", + "Subscriptions": "Subscrições", + "revoke": "revogar", + "tokens_count_0": "{{count}} token", + "tokens_count_1": "{{count}} tokens", + "tokens_count_2": "{{count}} tokens", + "Token": "Token", + "Token manager": "Gestor de tokens", + "Subscription manager": "Gestor de subscrições", + "Report statistics: ": "Relatório de estatísticas: ", + "Registration enabled: ": "Registar ativado: ", + "Login enabled: ": "Iniciar sessão ativado: ", + "CAPTCHA enabled: ": "CAPTCHA ativado: ", + "preferences_feed_menu_label": "Menu de subscrições: ", + "preferences_default_home_label": "Página inicial padrão: ", + "preferences_category_admin": "Preferências de administrador", + "Watch history": "Histórico de reprodução", + "Manage tokens": "Gerir tokens", + "Manage subscriptions": "Gerir subscrições", + "Change password": "Alterar palavra-passe", + "Clear watch history": "Limpar histórico de reprodução", + "preferences_category_data": "Preferências de dados", + "`x` is live": "`x` está em direto", + "`x` uploaded a video": "`x` publicou um vídeo", + "Enable web notifications": "Ativar notificações web", + "preferences_notifications_only_label": "Mostrar apenas notificações (se existirem): ", + "preferences_unseen_only_label": "Mostrar apenas vídeos não visualizados: ", + "Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não visualizados do canal: ", + "Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ", + "channel name - reverse": "nome do canal - inverso", + "channel name": "nome do canal", + "alphabetically - reverse": "alfabeticamente - inverso", + "alphabetically": "alfabeticamente", + "published - reverse": "publicado - inverso", + "published": "publicado", + "preferences_sort_label": "Ordenar vídeos por: ", + "preferences_max_results_label": "Número de vídeos nas subscrições: ", + "Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ", + "preferences_annotations_subscribed_label": "Mostrar sempre anotações nos canais subscritos: ", + "preferences_category_subscription": "Preferências de subscrições", + "preferences_thin_mode_label": "Modo compacto: ", + "light": "claro", + "dark": "escuro", + "preferences_dark_mode_label": "Tema: ", + "Dark mode: ": "Modo escuro: ", + "preferences_player_style_label": "Estilo do reprodutor: ", + "preferences_category_visual": "Preferências visuais", + "preferences_related_videos_label": "Mostrar vídeos relacionados: ", + "Fallback captions: ": "Legendas alternativas: ", + "preferences_captions_label": "Legendas padrão: ", + "reddit": "Reddit", + "youtube": "YouTube", + "preferences_comments_label": "Comentários padrão: ", + "preferences_volume_label": "Volume de reprodução: ", + "preferences_quality_label": "Qualidade de vídeo preferida: ", + "preferences_speed_label": "Velocidade preferida: ", + "preferences_local_label": "Usar proxy nos vídeos: ", + "preferences_listen_label": "Apenas áudio: ", + "preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ", + "preferences_autoplay_label": "Reprodução automática: ", + "preferences_video_loop_label": "Repetir sempre: ", + "preferences_category_player": "Preferências do reprodutor", + "Preferences": "Preferências", + "E-mail": "E-mail", + "Register": "Registar", + "Image CAPTCHA": "Imagem CAPTCHA", + "Text CAPTCHA": "Texto CAPTCHA", + "Time (h:mm:ss):": "Tempo (h:mm:ss):", + "Password": "Palavra-passe", + "User ID": "Utilizador", + "Log in": "Iniciar sessão", + "source": "fonte", + "JavaScript license information": "Informação da licença JavaScript", + "An alternative front-end to YouTube": "Uma interface alternativa ao YouTube", + "History": "Histórico", + "Export data as JSON": "Exportar dados Invidious como JSON", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportar subscrições como OPML (para NewPipe e FreeTube)", + "Export subscriptions as OPML": "Exportar subscrições como OPML", + "Export": "Exportar", + "Import NewPipe data (.zip)": "Importar dados do NewPipe (.zip)", + "Import NewPipe subscriptions (.json)": "Importar subscrições do NewPipe (.json)", + "Import FreeTube subscriptions (.db)": "Importar subscrições do FreeTube (.db)", + "Import YouTube subscriptions": "Importar via YouTube csv ou subscrição OPML", + "Import Invidious data": "Importar dados JSON do Invidious", + "Import": "Importar", + "No": "Não", + "Yes": "Sim", + "Authorize token for `x`?": "Autorizar 'token' para `x`?", + "Authorize token?": "Autorizar 'token'?", + "New passwords must match": "As novas palavras-passe devem ser iguais", + "New password": "Nova palavra-passe", + "Clear watch history?": "Limpar histórico de reprodução?", + "Previous page": "Página anterior", + "Next page": "Página seguinte", + "last": "últimos", + "Current version: ": "Versão atual: ", + "channel_tab_community_label": "Comunidade", + "Playlists": "Listas de reprodução", + "channel_tab_videos_label": "Vídeos", + "Video mode": "Modo de vídeo", + "Audio mode": "Modo de áudio", + "`x` marked it with a ❤": "`x` foi marcado com um ❤", + "(edited)": "(editado)", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "Movies": "Filmes", + "News": "Notícias", + "Gaming": "Jogos", + "Music": "Músicas", + "View as playlist": "Ver como lista de reprodução", + "preferences_locale_label": "Idioma: ", + "Rating: ": "Avaliação: ", + "About": "Acerca", + "Popular": "Popular", + "Fallback comments: ": "Alternativa para comentários: ", + "Zulu": "Zulu", + "Yoruba": "Ioruba", + "Yiddish": "Iídiche", + "Xhosa": "Xhosa", + "Western Frisian": "Frísio Ocidental", + "Welsh": "Galês", + "Vietnamese": "Vietnamita", + "Uzbek": "Uzbeque", + "Urdu": "Urdu", + "Ukrainian": "Ucraniano", + "Turkish": "Turco", + "Thai": "Tailandês", + "Telugu": "Telugu", + "Tamil": "Tâmil", + "Tajik": "Tajique", + "Swedish": "Sueco", + "Swahili": "Suaíli", + "Sundanese": "Sudanês", + "Spanish (Latin America)": "Espanhol (América Latina)", + "Spanish": "Espanhol", + "Southern Sotho": "Sotho do Sul", + "Somali": "Somali", + "Slovenian": "Esloveno", + "Slovak": "Eslovaco", + "Sinhala": "Cingalês", + "Sindhi": "Sindhi", + "Shona": "Shona", + "Serbian": "Sérvio", + "Scottish Gaelic": "Gaélico escocês", + "Samoan": "Samoano", + "Russian": "Russo", + "Romanian": "Romeno", + "Punjabi": "Punjabi", + "Portuguese": "Português", + "Polish": "Polaco", + "Persian": "Persa", + "Pashto": "Pashto", + "Nyanja": "Nyanja", + "Norwegian Bokmål": "Bokmål norueguês", + "Nepali": "Nepalês", + "Mongolian": "Mongol", + "Marathi": "Marathi", + "Maori": "Maori", + "Maltese": "Maltês", + "Malayalam": "Malaialaio", + "Malay": "Malaio", + "Malagasy": "Malgaxe", + "Macedonian": "Macedónio", + "Luxembourgish": "Luxemburguês", + "Lithuanian": "Lituano", + "Latvian": "Letão", + "Latin": "Latim", + "Lao": "Laosiano", + "Kyrgyz": "Quirguiz", + "Kurdish": "Curdo", + "Korean": "Coreano", + "Khmer": "Khmer", + "Kazakh": "Cazaque", + "Kannada": "Canarim", + "Javanese": "Javanês", + "Japanese": "Japonês", + "Italian": "Italiano", + "Irish": "Irlandês", + "Indonesian": "Indonésio", + "Igbo": "Igbo", + "Icelandic": "Islandês", + "Hungarian": "Húngaro", + "Hmong": "Hmong", + "Hindi": "Hindi", + "Hebrew": "Hebraico", + "Hawaiian": "Havaiano", + "Hausa": "Hauçá", + "Haitian Creole": "Crioulo haitiano", + "Gujarati": "Guzerate", + "Greek": "Grego", + "German": "Alemão", + "Georgian": "Georgiano", + "Galician": "Galego", + "French": "Francês", + "Finnish": "Finlandês", + "popular": "populares", + "oldest": "antigos", + "newest": "recentes", + "View playlist on YouTube": "Ver lista de reprodução no YouTube", + "View channel on YouTube": "Ver canal no YouTube", + "Subscribe": "Subscrever", + "Unsubscribe": "Anular subscrição", + "Shared `x` ago": "Partilhado `x` atrás", + "LIVE": "Direto", + "search_filters_duration_option_short": "Curto (< 4 minutos)", + "search_filters_duration_option_long": "Longo (> 20 minutos)", + "footer_source_code": "Código-fonte", + "footer_original_source_code": "Código-fonte original", + "adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte alterado", + "footer_documentation": "Documentação", + "footer_modfied_source_code": "Código-fonte alterado", + "footer_donate_page": "Doar", + "preferences_region_label": "País do conteúdo: ", + "preferences_quality_dash_label": "Qualidade de vídeo DASH preferida: ", + "preferences_quality_option_small": "Baixa", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_auto": "Automática", + "preferences_quality_dash_option_best": "Melhor", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "search_filters_features_option_purchased": "Comprado", + "search_filters_features_option_three_sixty": "360°", + "videoinfo_invidious_embed_link": "Incorporar ligação", + "Video unavailable": "Vídeo não disponível", + "invidious": "Invidious", + "preferences_quality_option_medium": "Média", + "preferences_quality_option_dash": "DASH (qualidade adaptativa)", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_480p": "480p", + "videoinfo_watch_on_youTube": "Ver no YouTube", + "preferences_quality_dash_option_worst": "Pior", + "none": "nenhum", + "videoinfo_youTube_embed_link": "Incorporar", + "preferences_save_player_pos_label": "Guardar posição de reprodução: ", + "download_subtitles": "Legendas - `x` (.vtt)", + "generic_views_count_0": "{{count}} visualização", + "generic_views_count_1": "{{count}} visualizações", + "generic_views_count_2": "{{count}} visualizações", + "videoinfo_started_streaming_x_ago": "Iniciou a transmissão há `x`", + "user_saved_playlists": "`x` listas de reprodução guardadas", + "generic_videos_count_0": "{{count}} vídeo", + "generic_videos_count_1": "{{count}} vídeos", + "generic_videos_count_2": "{{count}} vídeos", + "generic_playlists_count_0": "{{count}} lista de reprodução", + "generic_playlists_count_1": "{{count}} listas de reprodução", + "generic_playlists_count_2": "{{count}} listas de reprodução", + "subscriptions_unseen_notifs_count_0": "{{count}} notificação não vista", + "subscriptions_unseen_notifs_count_1": "{{count}} notificações não vistas", + "subscriptions_unseen_notifs_count_2": "{{count}} notificações não vistas", + "comments_view_x_replies_0": "Ver {{count}} resposta", + "comments_view_x_replies_1": "Ver {{count}} respostas", + "comments_view_x_replies_2": "Ver {{count}} respostas", + "generic_subscribers_count_0": "{{count}} subscritor", + "generic_subscribers_count_1": "{{count}} subscritores", + "generic_subscribers_count_2": "{{count}} subscritores", + "generic_subscriptions_count_0": "{{count}} subscrição", + "generic_subscriptions_count_1": "{{count}} subscrições", + "generic_subscriptions_count_2": "{{count}} subscrições", + "comments_points_count_0": "{{count}} ponto", + "comments_points_count_1": "{{count}} pontos", + "comments_points_count_2": "{{count}} pontos", + "crash_page_you_found_a_bug": "Parece que encontrou um erro no Invidious!", + "crash_page_before_reporting": "Antes de reportar um erro, verifique se:", + "crash_page_refresh": "tentou <a href=\"`x`\">recarregar a página</a>", + "crash_page_switch_instance": "tentou <a href=\"`x`\">usar outra instância</a>", + "crash_page_read_the_faq": "leu as <a href=\"`x`\">Perguntas frequentes (FAQ)</a>", + "crash_page_search_issue": "procurou se <a href=\"`x`\">o erro já foi reportado no GitHub</a>", + "crash_page_report_issue": "Se nenhuma opção acima ajudou, por favor <a href=\"`x`\">abra um novo problema no Github</a> (preferencialmente em inglês) e inclua o seguinte texto (NÃO o traduza):", + "user_created_playlists": "`x` listas de reprodução criadas", + "search_filters_title": "Filtro", + "Chinese (Taiwan)": "Chinês (Taiwan)", + "search_message_no_results": "Nenhum resultado encontrado.", + "search_message_change_filters_or_query": "Tente alargar os termos genéricos da pesquisa e/ou alterar os filtros.", + "search_message_use_another_instance": "Também pode <a href=\"`x`\">pesquisar noutra instância</a>.", + "English (United Kingdom)": "Inglês (Reino Unido)", + "English (United States)": "Inglês (Estados Unidos)", + "Cantonese (Hong Kong)": "Cantonês (Hong Kong)", + "Chinese": "Chinês", + "Chinese (Hong Kong)": "Chinês (Hong Kong)", + "Dutch (auto-generated)": "Holandês (gerado automaticamente)", + "French (auto-generated)": "Francês (gerado automaticamente)", + "German (auto-generated)": "Alemão (gerado automaticamente)", + "Indonesian (auto-generated)": "Indonésio (gerado automaticamente)", + "Interlingue": "Interlíngua", + "Italian (auto-generated)": "Italiano (gerado automaticamente)", + "Japanese (auto-generated)": "Japonês (gerado automaticamente)", + "Korean (auto-generated)": "Coreano (gerado automaticamente)", + "Portuguese (auto-generated)": "Português (gerado automaticamente)", + "Portuguese (Brazil)": "Português (Brasil)", + "Turkish (auto-generated)": "Turco (gerado automaticamente)", + "Vietnamese (auto-generated)": "Vietnamita (gerado automaticamente)", + "search_filters_duration_option_medium": "Médio (4 - 20 minutos)", + "search_filters_features_option_vr180": "VR180", + "search_filters_apply_button": "Aplicar filtros selecionados", + "Spanish (auto-generated)": "Espanhol (gerado automaticamente)", + "Spanish (Mexico)": "Espanhol (México)", + "preferences_watch_history_label": "Ativar histórico de reprodução: ", + "Chinese (China)": "Chinês (China)", + "Russian (auto-generated)": "Russo (gerado automaticamente)", + "Spanish (Spain)": "Espanhol (Espanha)", + "search_filters_date_label": "Data de publicação", + "search_filters_date_option_none": "Qualquer data", + "search_filters_type_option_all": "Qualquer tipo", + "search_filters_duration_option_none": "Qualquer duração", + "Popular enabled: ": "Página \"popular\" ativada: ", + "error_video_not_in_playlist": "O vídeo pedido não existe nesta lista de reprodução. <a href=\"`x`\">Clique aqui para voltar à página inicial da lista de reprodução.</a>", + "channel_tab_playlists_label": "Listas de reprodução", + "channel_tab_channels_label": "Canais", + "channel_tab_shorts_label": "Curtos", + "channel_tab_streams_label": "Emissões em direto", + "Music in this video": "Música neste vídeo", + "Artist: ": "Artista: ", + "Album: ": "Álbum: ", + "Song: ": "Canção: ", + "Channel Sponsor": "Patrocinador do canal", + "Standard YouTube license": "Licença padrão do YouTube", + "Download is disabled": "A descarga está desativada", + "Import YouTube playlist (.csv)": "Importar lista de reprodução do YouTube (.csv)", + "generic_button_delete": "Eliminar", + "generic_button_edit": "Editar", + "generic_button_rss": "RSS", + "channel_tab_podcasts_label": "Podcasts", + "channel_tab_releases_label": "Lançamentos", + "generic_button_save": "Guardar", + "generic_button_cancel": "Cancelar", + "playlist_button_add_items": "Adicionar vídeos", + "generic_channels_count_0": "{{count}} canal", + "generic_channels_count_1": "{{count}} canais", + "generic_channels_count_2": "{{count}} canais", + "Import YouTube watch history (.json)": "Importar histórico de reprodução do YouTube (.json)", + "toggle_theme": "Trocar tema", + "Add to playlist": "Adicionar à lista de reprodução", + "Add to playlist: ": "Adicionar à lista de reprodução: ", + "Answer": "Responder", + "Search for videos": "Procurar vídeos", + "carousel_slide": "Diapositivo {{current}} de{{total}}", + "carousel_skip": "Ignorar carrossel", + "carousel_go_to": "Ir para o diapositivo`x`", + "The Popular feed has been disabled by the administrator.": "O feed Popular foi desativado por um administrador." +} diff --git a/locales/ro.json b/locales/ro.json index 057b919c..ccbeef63 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -1,16 +1,4 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonați", - "": "`x` abonați" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videoclipuri", - "": "`x` videoclipuri" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` liste de redare", - "": "`x` liste de redare" - }, "LIVE": "ÎN DIRECT", "Shared `x` ago": "Adăugat acum `x`", "Unsubscribe": "Dezabonați-vă", @@ -26,14 +14,13 @@ "Clear watch history?": "Doriți să ștergeți istoricul?", "New password": "Parola nouă", "New passwords must match": "Câmpurile \"Parolă nouă\" trebuie să fie identice", - "Cannot change password for Google accounts": "Parola pentru un cont Google nu poate fi schimbată de pe Invidious", "Authorize token?": "Autorizați token-ul?", "Authorize token for `x`?": "Autorizați token-ul pentru `x` ?", "Yes": "Da", "No": "Nu", "Import and Export Data": "Importați și Exportați Datele", "Import": "Importați", - "Import Invidious data": "Importați Datele de pe Invidious", + "Import Invidious data": "Importați datele JSON de pe Invidious", "Import YouTube subscriptions": "Importați abonamentele de pe YouTube", "Import FreeTube subscriptions (.db)": "Importați abonamentele de pe FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importați abonamentele de pe NewPipe (.json)", @@ -41,7 +28,7 @@ "Export": "Exportați", "Export subscriptions as OPML": "Exportați abonamentele în format OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportați abonamentele în format OPML (pentru NewPipe și FreeTube)", - "Export data as JSON": "Exportați datele în format JSON", + "Export data as JSON": "Exportați datele Invidious în format JSON", "Delete account?": "Sunteți siguri că doriți să vă ștergeți contul?", "History": "Istoric", "An alternative front-end to YouTube": "O alternativă front-end pentru YouTube", @@ -49,7 +36,6 @@ "source": "sursă", "Log in": "Conectați-vă", "Log in/register": "Conectați-vă/Creați-vă un cont", - "Log in with Google": "Conectați-vă cu Google", "User ID": "ID Utilizator", "Password": "Parolă", "Time (h:mm:ss):": "Ora (h:mm:ss) :", @@ -58,38 +44,36 @@ "Sign In": "Conectați-vă", "Register": "Înregistrați-vă", "E-mail": "E-mail", - "Google verification code": "Cod de verificare Google", "Preferences": "Preferințe", - "Player preferences": "Setări de redare", - "Always loop: ": "Reluați videoclipul la nesfârșit: ", - "Autoplay: ": "Porniți videoclipurile automat: ", - "Play next by default: ": "Vizionați următoarele videoclipuri în mod implicit: ", - "Autoplay next video: ": "Porniți următorul videoclip automat: ", - "Listen by default: ": "Numai audio: ", - "Proxy videos: ": "Redați videoclipurile printr-un proxy: ", - "Default speed: ": "Viteza de redare implicită: ", - "Preferred video quality: ": "Calitatea videoclipurilor: ", - "Player volume: ": "Volumul videoclipurilor: ", - "Default comments: ": "Sursa comentariilor: ", + "preferences_category_player": "Setări de redare", + "preferences_video_loop_label": "Reluați videoclipul la nesfârșit: ", + "preferences_autoplay_label": "Porniți videoclipurile automat: ", + "preferences_continue_label": "Vizionați următoarele videoclipuri în mod implicit: ", + "preferences_continue_autoplay_label": "Porniți următorul videoclip automat: ", + "preferences_listen_label": "Numai audio: ", + "preferences_local_label": "Redați videoclipurile printr-un proxy: ", + "preferences_speed_label": "Viteza de redare implicită: ", + "preferences_quality_label": "Calitatea videoclipurilor: ", + "preferences_volume_label": "Volumul videoclipurilor: ", + "preferences_comments_label": "Sursa comentariilor: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Subtitrări implicite: ", + "preferences_captions_label": "Subtitrări implicite: ", "Fallback captions: ": "Subtitrări alternative: ", - "Show related videos: ": "Afișați videoclipurile asemănătoare: ", - "Show annotations by default: ": "Afișați adnotările în mod implicit: ", - "Automatically extend video description: ": "", - "Visual preferences": "Preferințele site-ului", - "Player style: ": "Stilul player-ului : ", + "preferences_related_videos_label": "Afișați videoclipurile asemănătoare: ", + "preferences_annotations_label": "Afișați adnotările în mod implicit: ", + "preferences_category_visual": "Preferințele site-ului", + "preferences_player_style_label": "Stilul player-ului : ", "Dark mode: ": "Modul întunecat : ", - "Theme: ": "Tema : ", + "preferences_dark_mode_label": "Tema : ", "dark": "întunecat", "light": "luminos", - "Thin mode: ": "Mod lejer: ", - "Subscription preferences": "Preferințele paginii de abonamente", - "Show annotations by default for subscribed channels: ": "Afișați adnotările în mod implicit pentru canalele la care v-ați abonat: ", + "preferences_thin_mode_label": "Mod lejer: ", + "preferences_category_subscription": "Preferințele paginii de abonamente", + "preferences_annotations_subscribed_label": "Afișați adnotările în mod implicit pentru canalele la care v-ați abonat: ", "Redirect homepage to feed: ": "Redirecționați pagina principală la pagina de abonamente: ", - "Number of videos shown in feed: ": "Numărul de videoclipuri afișate pe pagina de abonamente: ", - "Sort videos by: ": "Sortați videoclipurile în funcție de: ", + "preferences_max_results_label": "Numărul de videoclipuri afișate pe pagina de abonamente: ", + "preferences_sort_label": "Sortați videoclipurile în funcție de: ", "published": "data publicării", "published - reverse": "data publicării - inversată", "alphabetically": "în ordine alfabetică", @@ -98,12 +82,12 @@ "channel name - reverse": "numele canalului - inversat", "Only show latest video from channel: ": "Afișați numai cel mai recent videoclip publicat de canalele la care v-ați abonat: ", "Only show latest unwatched video from channel: ": "Afișați numai cel mai recent videoclip nevizionat publicat de canalele la care v-ați abonat: ", - "Only show unwatched: ": "Afișați numai videoclipurile nevizionate: ", - "Only show notifications (if there are any): ": "Afișați numai notificările (dacă există): ", + "preferences_unseen_only_label": "Afișați numai videoclipurile nevizionate: ", + "preferences_notifications_only_label": "Afișați numai notificările (dacă există): ", "Enable web notifications": "Activați notificările web", "`x` uploaded a video": "`x` a publicat un videoclip", "`x` is live": "`x` este în direct", - "Data preferences": "Preferințe legate de date", + "preferences_category_data": "Preferințe legate de date", "Clear watch history": "Ștergeți istoricul videoclipurilor vizionate", "Import/export data": "Importați/exportați datele", "Change password": "Schimbați parola", @@ -111,9 +95,9 @@ "Manage tokens": "Gestionați tokenele", "Watch history": "Istoricul videoclipurilor vizionate", "Delete account": "Ștergeți contul", - "Administrator preferences": "Preferințele Administratorului", - "Default homepage: ": "Pagina principală implicită: ", - "Feed menu: ": "Preferințe legate de pagina de abonamente: ", + "preferences_category_admin": "Preferințele Administratorului", + "preferences_default_home_label": "Pagina principală implicită: ", + "preferences_feed_menu_label": "Preferințe legate de pagina de abonamente: ", "Top enabled: ": "Top activat: ", "CAPTCHA enabled: ": "CAPTCHA activat : ", "Login enabled: ": "Autentificare activată : ", @@ -123,25 +107,12 @@ "Subscription manager": "Gestionați abonamentele", "Token manager": "Manager de Tokene", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonamente", - "": "`x` abonamente" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tokens", - "": "`x` tokens" - }, "Import/export": "Importați/Exportați", "unsubscribe": "dezabonați-vă", "revoke": "revocați", "Subscriptions": "Abonamente", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` notificări nevăzute", - "": "`x` notificări nevăzute" - }, "search": "căutați", "Log out": "Deconectați-vă", - "Released under the AGPLv3 by Omar Roth.": "Publicat sub licența AGPLv3 de Omar Roth.", "Source available here.": "Codul sursă este disponibil aici.", "View JavaScript license information.": "Informații legate de licența JavaScript.", "View privacy policy.": "Politica de confidențialitate.", @@ -157,8 +128,6 @@ "Title": "Titlu", "Playlist privacy": "Parametrii de confidențialitate ai listei de redare", "Editing playlist `x`": "Modificați lista de redare `x`", - "Show more": "", - "Show less": "", "Watch on YouTube": "Urmăriți videoclipul pe YouTube", "Hide annotations": "Ascundeți adnotările", "Show annotations": "Afișați adnotările", @@ -170,10 +139,6 @@ "Whitelisted regions: ": "Regiunile de pe lista albă: ", "Blacklisted regions: ": "Regiunile de pe lista neagră: ", "Shared `x`": "Publicat pe `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vizionări", - "": "`x` vizionări" - }, "Premieres in `x`": "Premiera în `x`", "Premieres `x`": "Premiera pe `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Se pare că ați dezactivat JavaScript. Apăsați aici pentru a vizualiza comentariile. Țineți minte faptul că încărcarea lor ar putea să dureze puțin mai mult.", @@ -187,17 +152,12 @@ "Hide replies": "Ascundeți replicile", "Show replies": "Afișați replicile", "Incorrect password": "Parolă incorectă", - "Quota exceeded, try again in a few hours": "Numărul de tentative de conectare a fost depășit. Va rugăm să încercați din nou în câteva ore.", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Conectare eșuată. Dacă nu reușiți să vă conectați, verificați dacă ați activat autentificarea cu doi factori (Autentificator sau SMS).", - "Invalid TFA code": "Codul de autentificare cu doi factori este invalid", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Conectare eșuată. Acest lucru ar putea fi cauzat de faptul că nu ați activat autentificarea cu doi factori.", "Wrong answer": "Răspuns invalid", "Erroneous CAPTCHA": "CAPTCHA invalid", "CAPTCHA is a required field": "Câmpul CAPTCHA este obligatoriu", "User ID is a required field": "Câmpul ID Utilizator este obligatoriu", "Password is a required field": "Câmpul Parolă este obligatoriu", "Wrong username or password": "Nume de utilizator sau parolă invalidă", - "Please sign in using 'Log in with Google'": "Vă rog conectați-vă folosind \"Conectați-vă cu Google\"", "Password cannot be empty": "Parola nu poate fi goală", "Password cannot be longer than 55 characters": "Parola nu poate să conțină mai mult de 55 de caractere", "Please log in": "Vă rog conectați-vă", @@ -206,17 +166,9 @@ "Deleted or invalid channel": "Canal șters sau invalid", "This channel does not exist.": "Acest canal nu există.", "Could not get channel info.": "Nu am putut primi informații despre acest canal.", - "Could not fetch comments": "Încărcarea comentariilor a eșuat.", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Afișați `x` replici", - "": "Afișați `x` replici" - }, + "Could not fetch comments": "Încărcarea comentariilor a eșuat", "`x` ago": "acum `x`", "Load more": "Vedeți mai mult", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` puncte", - "": "`x` puncte" - }, "Could not create mix.": "Nu am putut crea această listă de redare.", "Empty playlist": "Lista de redare este goală", "Not a playlist.": "Lista de redare este invalidă.", @@ -227,7 +179,7 @@ "Erroneous challenge": "Challenge invalid", "Erroneous token": "Token invalid", "No such user": "Acest utilizator nu există", - "Token is expired, please try again": "Token-ul este expirat, vă rugăm să reîncercați.", + "Token is expired, please try again": "Jetonul a expirat, vă rugăm să încercați din nou", "English": "Engleză", "English (auto-generated)": "Engleză (generată automat)", "Afrikaans": "Afrikaans", @@ -334,41 +286,12 @@ "Yiddish": "Yiddish", "Yoruba": "Yoruba", "Zulu": "Zoulou", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ani", - "": "`x` ani" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` luni", - "": "`x` luni" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` săptămâni", - "": "`x` săptămâni" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` zile", - "": "`x` zile" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ore", - "": "`x` ore" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minute", - "": "`x` minute" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` secunde", - "": "`x` secunde" - }, "Fallback comments: ": "Comentarii alternative: ", - "Popular": "Popular", - "Search": "", + "Popular": "Populare", "Top": "Top", "About": "Despre", "Rating: ": "Evaluare: ", - "Language: ": "Limbă: ", + "preferences_locale_label": "Limbă: ", "View as playlist": "Vizualizați ca listă de redare", "Default": "Implicit", "Music": "Muzică", @@ -384,35 +307,177 @@ "`x` marked it with a ❤": "`x` l-a marcat cu o ❤", "Audio mode": "Mod audio", "Video mode": "Mod video", - "Videos": "Videoclipuri", + "channel_tab_videos_label": "Videoclipuri", "Playlists": "Liste de redare", - "Community": "Comunitate", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Versiunea actuală: " + "channel_tab_community_label": "Comunitate", + "Current version: ": "Versiunea actuală: ", + "crash_page_read_the_faq": "citit lista <a href=\"`x`\">Întrebărilor Frecvente (FAQ)</a>", + "generic_count_days_0": "{{count}} zi", + "generic_count_days_1": "{{count}} zile", + "generic_count_days_2": "{{count}} de zile", + "generic_count_hours_0": "{{count}} oră", + "generic_count_hours_1": "{{count}} ore", + "generic_count_hours_2": "{{count}} de ore", + "generic_count_minutes_0": "{{count}} minut", + "generic_count_minutes_1": "{{count}} minute", + "generic_count_minutes_2": "{{count}} de minute", + "generic_views_count_0": "{{count}} vizionare", + "generic_views_count_1": "{{count}} vizionări", + "generic_views_count_2": "{{count}} de vizionări", + "subscriptions_unseen_notifs_count_0": "{{count}} notificare neverificată", + "subscriptions_unseen_notifs_count_1": "{{count}} notificări neverificate", + "subscriptions_unseen_notifs_count_2": "{{count}} de notificări neverificate", + "crash_page_refresh": "încercat să <a href=\"`x`\">reîmprospătați pagina</a>", + "crash_page_switch_instance": "am încercat să <a href=\"`x`\">folosim o altă instanță</a>", + "preferences_watch_history_label": "Activează istoricul: ", + "invidious": "Invidious", + "preferences_vr_mode_label": "Videoclipuri interactive de 360 de grade (necesită WebGL): ", + "English (United Kingdom)": "Engleză (Regatul Unit)", + "English (United States)": "Engleză (Statele Unite ale Americii)", + "Chinese": "Chineză", + "Chinese (China)": "Chineză (China)", + "Chinese (Hong Kong)": "Chineză (Hong Kong)", + "Chinese (Taiwan)": "Chineză (Taiwan)", + "Cantonese (Hong Kong)": "Cantoneză (Hong Kong)", + "Portuguese (auto-generated)": "Portugheză (generată automat)", + "Portuguese (Brazil)": "Portugheză (Brazilia)", + "Russian (auto-generated)": "Rusă (generată automat)", + "Turkish (auto-generated)": "Turcă (generată automat)", + "Vietnamese (auto-generated)": "Vietnameză (generată automat)", + "videoinfo_started_streaming_x_ago": "În direct de acum `x`", + "preferences_quality_dash_option_2160p": "2160p", + "footer_modfied_source_code": "Codul sursă modificat", + "preferences_quality_dash_label": "Calitatea video DASH preferată: ", + "generic_videos_count_0": "{{count}} videoclip", + "generic_videos_count_1": "{{count}} videoclipuri", + "generic_videos_count_2": "{{count}} de videoclipuri", + "generic_playlists_count_0": "{{count}} playlist", + "generic_playlists_count_1": "{{count}} playlisturi", + "generic_playlists_count_2": "{{count}} de playlisturi", + "tokens_count_0": "{{count}} jeton", + "tokens_count_1": "{{count}} jetoane", + "tokens_count_2": "{{count}} de jetoane", + "comments_points_count_0": "{{count}} punct", + "comments_points_count_1": "{{count}} puncte", + "comments_points_count_2": "{{count}} de puncte", + "Spanish (Spain)": "Spaniolă (Spania)", + "Video unavailable": "Videoclip indisponibil", + "crash_page_search_issue": "căutat <a href=\"`x`\">sugestiile existente pe GitHub</a>", + "Show more": "Afișați mai mult", + "Released under the AGPLv3 on Github.": "Lansat sub licența AGPLv3 pe GitHub.", + "preferences_quality_option_dash": "DASH (calitate adaptativă)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_small": "Mică", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_category_misc": "Setări diverse", + "preferences_automatic_instance_redirect_label": "Redirecționare automată de instanță (trecere prin redirect.invidious.io): ", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_option_medium": "Medie", + "Switch Invidious Instance": "Schimbă instanța Invidious", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_auto": "Automatică", + "preferences_quality_dash_option_best": "Cea mai bună", + "preferences_quality_dash_option_worst": "Cea mai redusă", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_360p": "360p", + "preferences_region_label": "Țară de conținut: ", + "preferences_extend_desc_label": "Extindeți automat descrierea: ", + "preferences_show_nick_label": "Afișați numele de utilizator pe partea de sus: ", + "generic_subscribers_count_0": "{{count}} abonat", + "generic_subscribers_count_1": "{{count}} abonați", + "generic_subscribers_count_2": "{{count}} de abonați", + "generic_subscriptions_count_0": "{{count}} abonament", + "generic_subscriptions_count_1": "{{count}} abonamente", + "generic_subscriptions_count_2": "{{count}} de abonamente", + "Search": "Căutați", + "search_filters_title": "Filtre", + "search_filters_date_label": "Data încărcării", + "none": "niciunul", + "search_message_use_another_instance": " Puteți <a href=\"`x`\">căuta într-o altă instanță</a>.", + "comments_view_x_replies_0": "Afișați {{count}} răspuns", + "comments_view_x_replies_1": "Afișați {{count}} răspunsuri", + "comments_view_x_replies_2": "Afișați {{count}} de răspunsuri", + "search_message_no_results": "Nu s-au găsit rezultate.", + "Dutch (auto-generated)": "Olandeză (generată automat)", + "Indonesian (auto-generated)": "Indoneziană (generată automat)", + "German (auto-generated)": "Germană (generată automat)", + "French (auto-generated)": "Franceză (generată automat)", + "Interlingue": "Interlingue", + "Italian (auto-generated)": "Italiană (generată automat)", + "Japanese (auto-generated)": "Japoneză (generată automat)", + "Korean (auto-generated)": "Coreeană (generată automat)", + "Spanish (auto-generated)": "Spaniolă (generată automat)", + "search_filters_date_option_none": "Oricând", + "search_filters_date_option_year": "an", + "search_filters_type_option_channel": "canal", + "Spanish (Mexico)": "Spaniolă (Mexic)", + "generic_count_weeks_0": "{{count}} săptămână", + "generic_count_weeks_1": "{{count}} săptămâni", + "generic_count_weeks_2": "{{count}} de săptămâni", + "generic_count_seconds_0": "{{count}} secundă", + "generic_count_seconds_1": "{{count}} secunde", + "generic_count_seconds_2": "{{count}} de secunde", + "search_filters_type_option_video": "videoclip", + "generic_count_years_0": "{{count}} an", + "generic_count_years_1": "{{count}} ani", + "generic_count_years_2": "{{count}} de ani", + "generic_count_months_0": "{{count}} lună", + "generic_count_months_1": "{{count}} luni", + "generic_count_months_2": "{{count}} de luni", + "search_filters_duration_label": "durată", + "search_filters_date_option_month": "lună", + "search_filters_type_label": "Tip", + "search_filters_date_option_today": "azi", + "search_filters_date_option_week": "săptămână", + "search_filters_features_option_vr180": "VR180", + "search_filters_type_option_playlist": "playlist", + "search_filters_type_option_movie": "film", + "search_filters_type_option_show": "emisiune", + "search_filters_duration_option_short": "Scurt (< 4 minute)", + "search_filters_duration_option_medium": "Medie (4 - 20 de minute)", + "search_filters_duration_option_none": "Fără limită", + "search_filters_duration_option_long": "Lungă (> 20 de minute)", + "search_filters_features_label": "atribute", + "search_filters_features_option_live": "în direct", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_subtitles": "subtitrări/CC", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_hdr": "HDR", + "search_filters_features_option_purchased": "Cumpărate", + "next_steps_error_message": "După ce ar trebui să încercați să: ", + "user_saved_playlists": "`x` playlisturi salvate", + "search_filters_features_option_location": "locație", + "search_filters_sort_label": "Sortați după", + "search_filters_sort_option_relevance": "relevanță", + "search_filters_sort_option_rating": "clasificare", + "search_filters_sort_option_date": "Data încărcării", + "search_filters_sort_option_views": "Numărul de vizionări", + "footer_source_code": "Codul sursă", + "search_filters_apply_button": "Aplicați filtrele selectate", + "footer_original_source_code": "Codul sursă original", + "next_steps_error_message_refresh": "Reîmprospătează", + "next_steps_error_message_go_to_youtube": "Mergeți pe YouTube", + "footer_donate_page": "Donați", + "adminprefs_modified_source_code_url_label": "URL către depozitul de cod sursă modificat", + "footer_documentation": "Documentație", + "videoinfo_youTube_embed_link": "Încorporați", + "videoinfo_watch_on_youTube": "Vizionați pe YouTube", + "videoinfo_invidious_embed_link": "Link de încorporare", + "download_subtitles": "Subtitrări - `x` (.vtt)", + "user_created_playlists": "`x` playlisturi create", + "preferences_save_player_pos_label": "Salvați poziția de redare: ", + "crash_page_you_found_a_bug": "Se pare că ați găsit un bug în aplicația Invidious!", + "crash_page_before_reporting": "Înainte de a reporta bugul, asigurați-vă că ați:", + "search_filters_date_option_hour": "oră", + "search_message_change_filters_or_query": "Încercați să lărgiți căutarea sau să modificați filtrele.", + "crash_page_report_issue": "Dacă niciuna dintre sugestiile de mai sus v-a ajutat, vă rugăm să <a href=\"`x`\">postați o nouă sugestie pe GitHub</a> (cel mai bine în engleză), și să includeți următorul text în post (să nu îl traduceți):", + "search_filters_type_option_all": "orice tip", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "Show less": "Afișați mai puțin", + "Add to playlist": "Adaugă la playlist" } diff --git a/locales/ru.json b/locales/ru.json index a737745e..80c98de8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1,150 +1,127 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` подписчиков", - "": "`x` подписчиков" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` видео", - "": "`x` видео" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` плейлистов", - "": "`x` плейлистов" - }, "LIVE": "ПРЯМОЙ ЭФИР", "Shared `x` ago": "Опубликовано `x` назад", "Unsubscribe": "Отписаться", "Subscribe": "Подписаться", "View channel on YouTube": "Смотреть канал на YouTube", "View playlist on YouTube": "Посмотреть плейлист на YouTube", - "newest": "самые свежие", - "oldest": "самые старые", + "newest": "сначала новые", + "oldest": "сначала старые", "popular": "популярные", - "last": "недавние", + "last": "последние", "Next page": "Следующая страница", "Previous page": "Предыдущая страница", "Clear watch history?": "Очистить историю просмотров?", "New password": "Новый пароль", "New passwords must match": "Новые пароли не совпадают", - "Cannot change password for Google accounts": "Изменить пароль аккаунта Google невозможно", "Authorize token?": "Авторизовать токен?", - "Authorize token for `x`?": "Авторизовать токен для `x`?", + "Authorize token for `x`?": "Токен авторизации для `x`?", "Yes": "Да", "No": "Нет", "Import and Export Data": "Импорт и экспорт данных", "Import": "Импорт", - "Import Invidious data": "Импортировать данные Invidious", - "Import YouTube subscriptions": "Импортировать подписки из YouTube", + "Import Invidious data": "Импортировать JSON с данными Invidious", + "Import YouTube subscriptions": "Импортировать подписки из CSV или OPML", "Import FreeTube subscriptions (.db)": "Импортировать подписки из FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Импортировать подписки из NewPipe (.json)", "Import NewPipe data (.zip)": "Импортировать данные из NewPipe (.zip)", "Export": "Экспорт", "Export subscriptions as OPML": "Экспортировать подписки в формате OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Экспортировать подписки в формате OPML (для NewPipe и FreeTube)", - "Export data as JSON": "Экспортировать данные в формате JSON", - "Delete account?": "Удалить аккаунт?", + "Export data as JSON": "Экспортировать данные Invidious в формате JSON", + "Delete account?": "Удалить учётную запись?", "History": "История", "An alternative front-end to YouTube": "Альтернативный фронтенд для YouTube", "JavaScript license information": "Информация о лицензиях JavaScript", "source": "источник", "Log in": "Войти", "Log in/register": "Войти или зарегистрироваться", - "Log in with Google": "Войти через Google", - "User ID": "ID пользователя", + "User ID": "ИД пользователя", "Password": "Пароль", "Time (h:mm:ss):": "Время (ч:мм:сс):", - "Text CAPTCHA": "Текст капчи", - "Image CAPTCHA": "Изображение капчи", + "Text CAPTCHA": "Текстовая капча (англ.)", + "Image CAPTCHA": "Капча-картинка", "Sign In": "Войти", - "Register": "Зарегистрироваться", - "E-mail": "Электронная почта", - "Google verification code": "Код подтверждения Google", + "Register": "Регистрация", + "E-mail": "Эл. почта", "Preferences": "Настройки", - "Player preferences": "Настройки проигрывателя", - "Always loop: ": "Всегда повторять: ", - "Autoplay: ": "Автовоспроизведение: ", - "Play next by default: ": "Всегда включать следующее видео? ", - "Autoplay next video: ": "Автопроигрывание следующего видео: ", - "Listen by default: ": "Режим «только аудио» по умолчанию: ", - "Proxy videos: ": "Проигрывать видео через прокси? ", - "Default speed: ": "Скорость видео по умолчанию: ", - "Preferred video quality: ": "Предпочтительное качество видео: ", - "Player volume: ": "Громкость видео: ", - "Default comments: ": "Источник комментариев: ", + "preferences_category_player": "Настройки проигрывателя", + "preferences_video_loop_label": "Всегда повторять: ", + "preferences_autoplay_label": "Автовоспроизведение: ", + "preferences_continue_label": "Переходить к следующему видео? ", + "preferences_continue_autoplay_label": "Автопроигрывание следующего видео: ", + "preferences_listen_label": "Режим «только аудио» по умолчанию: ", + "preferences_local_label": "Проигрывать видео через прокси? ", + "preferences_speed_label": "Скорость видео по умолчанию: ", + "preferences_quality_label": "Предпочтительное качество видео: ", + "preferences_volume_label": "Громкость видео: ", + "preferences_comments_label": "Источник комментариев: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Основной язык субтитров: ", + "preferences_captions_label": "Основной язык субтитров: ", "Fallback captions: ": "Дополнительный язык субтитров: ", - "Show related videos: ": "Показывать похожие видео? ", - "Show annotations by default: ": "Всегда показывать аннотации? ", - "Automatically extend video description: ": "", - "Visual preferences": "Настройки сайта", - "Player style: ": "Стиль проигрывателя: ", + "preferences_related_videos_label": "Показывать похожие видео? ", + "preferences_annotations_label": "Показывать аннотации по умолчанию: ", + "preferences_extend_desc_label": "Автоматически раскрывать описание видео: ", + "preferences_vr_mode_label": "Интерактивные 360-градусные видео (необходим WebGL): ", + "preferences_category_visual": "Настройки сайта", + "preferences_player_style_label": "Стиль проигрывателя: ", "Dark mode: ": "Тёмное оформление: ", - "Theme: ": "Тема: ", - "dark": "темная", + "preferences_dark_mode_label": "Тема: ", + "dark": "тёмная", "light": "светлая", - "Thin mode: ": "Облегчённое оформление: ", - "Subscription preferences": "Настройки подписок", - "Show annotations by default for subscribed channels: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ", - "Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ", - "Number of videos shown in feed: ": "Число видео, на которые вы подписаны, в ленте: ", - "Sort videos by: ": "Сортировать видео: ", - "published": "по дате публикации", - "published - reverse": "по дате публикации в обратном порядке", - "alphabetically": "по алфавиту", - "alphabetically - reverse": "по алфавиту в обратном порядке", - "channel name": "по названию канала", - "channel name - reverse": "по названию канала в обратном порядке", + "preferences_thin_mode_label": "Облегчённое оформление: ", + "preferences_category_misc": "Прочие настройки", + "preferences_automatic_instance_redirect_label": "Автоматическая смена зеркала (переход на redirect.invidious.io): ", + "preferences_category_subscription": "Настройки подписок", + "preferences_annotations_subscribed_label": "Всегда показывать аннотации на каналах из ваших подписок? ", + "Redirect homepage to feed: ": "Показывать подписки на главной странице: ", + "preferences_max_results_label": "Число видео в ленте: ", + "preferences_sort_label": "Сортировать видео по: ", + "published": "дате публикации", + "published - reverse": "дате публикации в обратном порядке", + "alphabetically": "алфавиту", + "alphabetically - reverse": "алфавиту в обратном порядке", + "channel name": "названию канала", + "channel name - reverse": "названию канала в обратном порядке", "Only show latest video from channel: ": "Показывать только последние видео с каналов: ", - "Only show latest unwatched video from channel: ": "Показывать только непросмотренные видео с каналов: ", - "Only show unwatched: ": "Показывать только непросмотренные видео: ", - "Only show notifications (if there are any): ": "Показывать только оповещения, если они есть: ", + "Only show latest unwatched video from channel: ": "Показывать только последние непросмотренные видео с канала: ", + "preferences_unseen_only_label": "Показывать только непросмотренные видео: ", + "preferences_notifications_only_label": "Показывать только оповещения, если они есть: ", "Enable web notifications": "Включить уведомления в браузере", "`x` uploaded a video": "`x` разместил видео", "`x` is live": "`x` в прямом эфире", - "Data preferences": "Настройки данных", + "preferences_category_data": "Настройки данных", "Clear watch history": "Очистить историю просмотров", - "Import/export data": "Импорт/Экспорт данных", + "Import/export data": "Импорт и экспорт данных", "Change password": "Изменить пароль", - "Manage subscriptions": "Управлять подписками", - "Manage tokens": "Управлять токенами", + "Manage subscriptions": "Управление подписками", + "Manage tokens": "Управление токенами", "Watch history": "История просмотров", "Delete account": "Удалить аккаунт", - "Administrator preferences": "Администраторские настройки", - "Default homepage: ": "Главная страница по умолчанию: ", - "Feed menu: ": "Меню ленты видео: ", + "preferences_category_admin": "Настройки администратора", + "preferences_default_home_label": "Главная страница по умолчанию: ", + "preferences_feed_menu_label": "Меню ленты видео: ", + "preferences_show_nick_label": "Показать ник вверху: ", "Top enabled: ": "Включить топ видео? ", "CAPTCHA enabled: ": "Включить капчу? ", - "Login enabled: ": "Включить авторизацию? ", + "Login enabled: ": "Включить авторизацию: ", "Registration enabled: ": "Включить регистрацию? ", "Report statistics: ": "Сообщать статистику? ", "Save preferences": "Сохранить настройки", - "Subscription manager": "Менеджер подписок", - "Token manager": "Менеджер токенов", + "Subscription manager": "Управление подписками", + "Token manager": "Управление токенами", "Token": "Токен", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` подписок", - "": "`x` подписок" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` токенов", - "": "`x` токенов" - }, "Import/export": "Импорт и экспорт", "unsubscribe": "отписаться", "revoke": "отозвать", "Subscriptions": "Подписки", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` непросмотренных оповещений", - "": "`x` непросмотренных оповещений" - }, "search": "поиск", "Log out": "Выйти", - "Released under the AGPLv3 by Omar Roth.": "Реализовано Омаром Ротом по лицензии AGPLv3.", - "Source available here.": "Исходный код доступен здесь.", - "View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.", - "View privacy policy.": "Посмотреть политику конфиденциальности.", + "Released under the AGPLv3 on Github.": "Выпущено под лицензией AGPLv3 на GitHub.", + "Source available here.": "Исходный код.", + "View JavaScript license information.": "Информация о лицензиях JavaScript.", + "View privacy policy.": "Политика конфиденциальности.", "Trending": "В тренде", "Public": "Публичный", "Unlisted": "Нет в списке", @@ -155,49 +132,41 @@ "Delete playlist": "Удалить плейлист", "Create playlist": "Создать плейлист", "Title": "Заголовок", - "Playlist privacy": "Конфиденциальность плейлиста", + "Playlist privacy": "Видимость плейлиста", "Editing playlist `x`": "Редактирование плейлиста `x`", - "Show more": "", - "Show less": "", + "Show more": "Показать больше", + "Show less": "Показать меньше", "Watch on YouTube": "Смотреть на YouTube", + "Switch Invidious Instance": "Сменить зеркало Invidious", "Hide annotations": "Скрыть аннотации", "Show annotations": "Показать аннотации", "Genre: ": "Жанр: ", "License: ": "Лицензия: ", "Family friendly? ": "Семейный просмотр: ", - "Wilson score: ": "Рейтинг Уилсона: ", + "Wilson score: ": "Оценка Уилсона: ", "Engagement: ": "Вовлечённость: ", "Whitelisted regions: ": "Доступно в регионах: ", "Blacklisted regions: ": "Недоступно в регионах: ", "Shared `x`": "Опубликовано `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` просмотров", - "": "`x` просмотров" - }, "Premieres in `x`": "Премьера через `x`", "Premieres `x`": "Премьера `x`", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Похоже, у вас отключён JavaScript. Чтобы увидить комментарии, нажмите сюда, но учтите: они могут загружаться немного медленнее.", - "View YouTube comments": "Смотреть комментарии с YouTube", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Похоже, у вас отключён JavaScript. Нажмите сюда, чтобы увидеть комментарии. Но учтите: они могут загружаться немного медленнее.", + "View YouTube comments": "Показать комментарии с YouTube", "View more comments on Reddit": "Посмотреть больше комментариев на Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Показать `x` комментариев", - "": "Показать `x` комментариев" + "([^.,0-9]|^)1([^.,0-9]|$)": "Показано `x` комментариев", + "": "Показано `x` комментариев" }, "View Reddit comments": "Смотреть комментарии с Reddit", "Hide replies": "Скрыть ответы", "Show replies": "Показать ответы", "Incorrect password": "Неправильный пароль", - "Quota exceeded, try again in a few hours": "Лимит превышен, попробуйте снова через несколько часов", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Войти не удаётся. Проверьте, не включена ли двухфакторная аутентификация (по коду или смс).", - "Invalid TFA code": "Неправильный код двухфакторной аутентификации", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Не удаётся войти. Это может быть из-за того, что в вашем аккаунте не включена двухфакторная аутентификация.", "Wrong answer": "Неправильный ответ", "Erroneous CAPTCHA": "Неправильная капча", - "CAPTCHA is a required field": "Необходимо пройти капчу", - "User ID is a required field": "Необходимо ввести ID пользователя", + "CAPTCHA is a required field": "Необходимо решить капчу", + "User ID is a required field": "Необходимо ввести идентификатор пользователя", "Password is a required field": "Необходимо ввести пароль", "Wrong username or password": "Неправильный логин или пароль", - "Please sign in using 'Log in with Google'": "Пожалуйста, нажмите «Войти через Google»", "Password cannot be empty": "Пароль не может быть пустым", "Password cannot be longer than 55 characters": "Пароль не может быть длиннее 55 символов", "Please log in": "Пожалуйста, войдите", @@ -207,26 +176,18 @@ "This channel does not exist.": "Такого канала не существует.", "Could not get channel info.": "Не удаётся получить информацию об этом канале.", "Could not fetch comments": "Не удаётся загрузить комментарии", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Показать `x` ответов", - "": "Показать `x` ответов" - }, "`x` ago": "`x` назад", - "Load more": "Загрузить больше", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` очков", - "": "`x` очков" - }, - "Could not create mix.": "Не удаётся создать микс.", + "Load more": "Загрузить ещё", + "Could not create mix.": "Не удалось создать микс.", "Empty playlist": "Плейлист пуст", - "Not a playlist.": "Некорректный плейлист.", + "Not a playlist.": "Это не плейлист.", "Playlist does not exist.": "Плейлист не существует.", "Could not pull trending pages.": "Не удаётся загрузить страницы «в тренде».", "Hidden field \"challenge\" is a required field": "Необходимо заполнить скрытое поле «challenge»", "Hidden field \"token\" is a required field": "Необходимо заполнить скрытое поле «токен»", "Erroneous challenge": "Неправильный ответ в «challenge»", "Erroneous token": "Неправильный токен", - "No such user": "Недопустимое имя пользователя", + "No such user": "Пользователь не найден", "Token is expired, please try again": "Срок действия токена истёк, попробуйте позже", "English": "Английский", "English (auto-generated)": "Английский (созданы автоматически)", @@ -244,13 +205,13 @@ "Burmese": "Бирманский", "Catalan": "Каталонский", "Cebuano": "Себуанский", - "Chinese (Simplified)": "Китайский (упрощенный)", + "Chinese (Simplified)": "Китайский (упрощённый)", "Chinese (Traditional)": "Китайский (традиционный)", "Corsican": "Корсиканский", "Croatian": "Хорватский", "Czech": "Чешский", "Danish": "Датский", - "Dutch": "Нидерландский", + "Dutch": "Голландский", "Esperanto": "Эсперанто", "Estonian": "Эстонский", "Filipino": "Филиппинский", @@ -260,8 +221,8 @@ "Georgian": "Грузинский", "German": "Немецкий", "Greek": "Греческий", - "Gujarati": "Гуджаратский", - "Haitian Creole": "Гаит. креольский", + "Gujarati": "Гуджарати", + "Haitian Creole": "Гаитянский креольский", "Hausa": "Хауса", "Hawaiian": "Гавайский", "Hebrew": "Иврит", @@ -282,7 +243,7 @@ "Kurdish": "Курдский", "Kyrgyz": "Киргизский", "Lao": "Лаосский", - "Latin": "Латинский", + "Latin": "Латынь", "Latvian": "Латышский", "Lithuanian": "Литовский", "Luxembourgish": "Люксембургский", @@ -293,9 +254,9 @@ "Maltese": "Мальтийский", "Maori": "Маори", "Marathi": "Маратхи", - "Mongolian": "Монгольская", + "Mongolian": "Монгольский", "Nepali": "Непальский", - "Norwegian Bokmål": "Норвежский", + "Norwegian Bokmål": "Норвежский букмол", "Nyanja": "Ньянджа", "Pashto": "Пушту", "Persian": "Персидский", @@ -330,47 +291,19 @@ "Vietnamese": "Вьетнамский", "Welsh": "Валлийский", "Western Frisian": "Западнофризский", - "Xhosa": "Коса", + "Xhosa": "Коса (кхоса)", "Yiddish": "Идиш", "Yoruba": "Йоруба", "Zulu": "Зулусский", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` лет", - "": "`x` лет" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` месяцев", - "": "`x` месяцев" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` недель", - "": "`x` недель" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` дней", - "": "`x` дней" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` часов", - "": "`x` часов" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` минут", - "": "`x` минут" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` секунд", - "": "`x` секунд" - }, "Fallback comments: ": "Резервные комментарии: ", "Popular": "Популярное", "Search": "Поиск", "Top": "Топ", "About": "О сайте", "Rating: ": "Рейтинг: ", - "Language: ": "Язык: ", + "preferences_locale_label": "Язык: ", "View as playlist": "Смотреть как плейлист", - "Default": "По-умолчанию", + "Default": "По умолчанию", "Music": "Музыка", "Gaming": "Игры", "News": "Новости", @@ -384,35 +317,201 @@ "`x` marked it with a ❤": "❤ от автора канала \"`x`\"", "Audio mode": "Аудио режим", "Video mode": "Видео режим", - "Videos": "Видео", + "channel_tab_videos_label": "Видео", "Playlists": "Плейлисты", - "Community": "Сообщество", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Текущая версия: " + "channel_tab_community_label": "Сообщество", + "search_filters_sort_option_relevance": "актуальности", + "search_filters_sort_option_rating": "рейтингу", + "search_filters_sort_option_date": "дате загрузки", + "search_filters_sort_option_views": "просмотрам", + "search_filters_type_label": "Тип", + "search_filters_duration_label": "Длительность", + "search_filters_features_label": "Дополнительно", + "search_filters_sort_label": "Сортировать по", + "search_filters_date_option_hour": "Последний час", + "search_filters_date_option_today": "Сегодня", + "search_filters_date_option_week": "Эта неделя", + "search_filters_date_option_month": "Этот месяц", + "search_filters_date_option_year": "Этот год", + "search_filters_type_option_video": "Видео", + "search_filters_type_option_channel": "Канал", + "search_filters_type_option_playlist": "Плейлист", + "search_filters_type_option_movie": "Фильм", + "search_filters_type_option_show": "Сериал", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Субтитры", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Прямой эфир", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Местоположение", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Текущая версия: ", + "next_steps_error_message": "После чего следует попробовать: ", + "next_steps_error_message_refresh": "Обновить", + "next_steps_error_message_go_to_youtube": "Перейти на YouTube", + "search_filters_duration_option_short": "Короткие (< 4 минут)", + "search_filters_duration_option_long": "Длинные (> 20 минут)", + "preferences_quality_dash_option_best": "Наилучшее", + "generic_count_weeks_0": "{{count}} неделя", + "generic_count_weeks_1": "{{count}} недели", + "generic_count_weeks_2": "{{count}} недель", + "English (United Kingdom)": "Английский (Великобритания)", + "English (United States)": "Английский (США)", + "Cantonese (Hong Kong)": "Кантонский (Гонконг)", + "Chinese (Taiwan)": "Китайский (Тайвань)", + "Dutch (auto-generated)": "Голландский (созданы автоматически)", + "German (auto-generated)": "Немецкий (созданы автоматически)", + "Indonesian (auto-generated)": "Индонезийский (созданы автоматически)", + "Italian (auto-generated)": "Итальянский (созданы автоматически)", + "Interlingue": "Окциденталь", + "Russian (auto-generated)": "Русский (созданы автоматически)", + "Spanish (auto-generated)": "Испанский (созданы автоматически)", + "Spanish (Spain)": "Испанский (Испания)", + "Turkish (auto-generated)": "Турецкий (созданы автоматически)", + "Vietnamese (auto-generated)": "Вьетнамский (созданы автоматически)", + "footer_documentation": "Документация", + "adminprefs_modified_source_code_url_label": "Ссылка на репозиторий с измененными исходными кодами", + "none": "ничего", + "videoinfo_watch_on_youTube": "Смотреть на YouTube", + "videoinfo_youTube_embed_link": "Версия для встраивания", + "videoinfo_invidious_embed_link": "Ссылка для встраивания", + "download_subtitles": "Субтитры - `x` (.vtt)", + "user_created_playlists": "`x` созданных плейлистов", + "crash_page_you_found_a_bug": "Похоже, вы нашли ошибку в Invidious!", + "crash_page_before_reporting": "Прежде чем сообщать об ошибке, убедитесь, что вы:", + "crash_page_refresh": "пробовали <a href=\"`x`\"> перезагрузить страницу</a>", + "crash_page_report_issue": "Если ни один вариант не помог, пожалуйста <a href=\"`x`\">откройте новую проблему на GitHub</a> (на английском, пжлста) и приложите следующий текст к вашему сообщению (НЕ переводите его):", + "generic_videos_count_0": "{{count}} видео", + "generic_videos_count_1": "{{count}} видео", + "generic_videos_count_2": "{{count}} видео", + "generic_playlists_count_0": "{{count}} плейлист", + "generic_playlists_count_1": "{{count}} плейлиста", + "generic_playlists_count_2": "{{count}} плейлистов", + "tokens_count_0": "{{count}} токен", + "tokens_count_1": "{{count}} токена", + "tokens_count_2": "{{count}} токенов", + "subscriptions_unseen_notifs_count_0": "{{count}} новое уведомление", + "subscriptions_unseen_notifs_count_1": "{{count}} новых уведомления", + "subscriptions_unseen_notifs_count_2": "{{count}} новых уведомлений", + "comments_view_x_replies_0": "{{count}} ответ", + "comments_view_x_replies_1": "{{count}} ответа", + "comments_view_x_replies_2": "{{count}} ответов", + "generic_count_years_0": "{{count}} год", + "generic_count_years_1": "{{count}} года", + "generic_count_years_2": "{{count}} лет", + "generic_count_minutes_0": "{{count}} минута", + "generic_count_minutes_1": "{{count}} минуты", + "generic_count_minutes_2": "{{count}} минут", + "generic_subscribers_count_0": "{{count}} подписчик", + "generic_subscribers_count_1": "{{count}} подписчика", + "generic_subscribers_count_2": "{{count}} подписчиков", + "generic_views_count_0": "{{count}} просмотр", + "generic_views_count_1": "{{count}} просмотра", + "generic_views_count_2": "{{count}} просмотров", + "French (auto-generated)": "Французский (созданы автоматически)", + "Portuguese (auto-generated)": "Португальский (созданы автоматически)", + "generic_count_days_0": "{{count}} день", + "generic_count_days_1": "{{count}} дня", + "generic_count_days_2": "{{count}} дней", + "preferences_quality_dash_option_auto": "Авто", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "generic_subscriptions_count_0": "{{count}} подписка", + "generic_subscriptions_count_1": "{{count}} подписки", + "generic_subscriptions_count_2": "{{count}} подписок", + "preferences_quality_option_small": "Низкое", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "generic_count_seconds_0": "{{count}} секунда", + "generic_count_seconds_1": "{{count}} секунды", + "generic_count_seconds_2": "{{count}} секунд", + "search_filters_features_option_purchased": "Приобретено", + "videoinfo_started_streaming_x_ago": "Трансляция началась `x` назад", + "crash_page_switch_instance": "пробовали <a href=\"`x`\">использовать другое зеркало</a>", + "crash_page_read_the_faq": "прочли ответы на <a href=\"`x`\">Частые Вопросы (ЧаВо)</a>", + "Chinese": "Китайский", + "Chinese (Hong Kong)": "Китайский (Гонконг)", + "Japanese (auto-generated)": "Японский (созданы автоматически)", + "Chinese (China)": "Китайский (Китай)", + "Korean (auto-generated)": "Корейский (созданы автоматически)", + "generic_count_months_0": "{{count}} месяц", + "generic_count_months_1": "{{count}} месяца", + "generic_count_months_2": "{{count}} месяцев", + "generic_count_hours_0": "{{count}} час", + "generic_count_hours_1": "{{count}} часа", + "generic_count_hours_2": "{{count}} часов", + "Portuguese (Brazil)": "Португальский (Бразилия)", + "footer_source_code": "Исходный код", + "footer_original_source_code": "Оригинальный исходный код", + "footer_modfied_source_code": "Изменённый исходный код", + "user_saved_playlists": "`x` сохранённых плейлистов", + "crash_page_search_issue": "поискали <a href=\"`x`\">похожую проблему на GitHub</a>", + "comments_points_count_0": "{{count}} плюс", + "comments_points_count_1": "{{count}} плюса", + "comments_points_count_2": "{{count}} плюсов", + "Spanish (Mexico)": "Испанский (Мексика)", + "footer_donate_page": "Поддержать проект", + "preferences_quality_option_dash": "DASH (автоматическое качество)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Среднее", + "preferences_quality_dash_label": "Предпочтительное качество для DASH: ", + "preferences_quality_dash_option_worst": "Очень низкое", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "search_filters_features_option_three_sixty": "360°", + "Video unavailable": "Видео недоступно", + "preferences_save_player_pos_label": "Запоминать позицию: ", + "preferences_region_label": "Страна источник ", + "preferences_watch_history_label": "Включить историю просмотров: ", + "search_filters_title": "Фильтр", + "search_filters_duration_option_none": "Любой длины", + "search_filters_type_option_all": "Любого типа", + "search_filters_date_option_none": "Любая дата", + "search_filters_date_label": "Дата загрузки", + "search_message_no_results": "Ничего не найдено.", + "search_message_use_another_instance": " Дополнительно вы можете <a href=\"`x`\">поискать на других зеркалах</a>.", + "search_filters_features_option_vr180": "VR180", + "search_message_change_filters_or_query": "Попробуйте расширить поисковый запрос и/или изменить фильтры.", + "search_filters_duration_option_medium": "Средние (4 - 20 минут)", + "search_filters_apply_button": "Применить фильтры", + "Popular enabled: ": "Популярное включено: ", + "error_video_not_in_playlist": "Запрошенного видео нет в этом плейлисте. <a href=\"`x`\">Нажмите тут, чтобы вернуться к странице плейлиста.</a>", + "channel_tab_playlists_label": "Плейлисты", + "channel_tab_channels_label": "Каналы", + "channel_tab_streams_label": "Стримы", + "channel_tab_shorts_label": "Shorts", + "Music in this video": "Музыка в этом видео", + "Artist: ": "Исполнитель: ", + "Album: ": "Альбом: ", + "Song: ": "Композиция: ", + "Standard YouTube license": "Стандартная лицензия YouTube", + "Channel Sponsor": "Спонсор канала", + "Download is disabled": "Загрузка отключена", + "Import YouTube playlist (.csv)": "Импорт плейлиста YouTube (.csv)", + "channel_tab_releases_label": "Релизы", + "generic_button_delete": "Удалить", + "generic_button_edit": "Редактировать", + "generic_button_save": "Сохранить", + "generic_button_cancel": "Отменить", + "generic_button_rss": "RSS", + "playlist_button_add_items": "Добавить видео", + "channel_tab_podcasts_label": "Подкасты", + "generic_channels_count_0": "{{count}} канал", + "generic_channels_count_1": "{{count}} канала", + "generic_channels_count_2": "{{count}} каналов", + "Import YouTube watch history (.json)": "Импортировать историю просмотра из YouTube (.json)", + "Add to playlist": "Добавить в плейлист", + "Add to playlist: ": "Добавить в плейлист: ", + "Answer": "Ответить", + "Search for videos": "Поиск видео", + "The Popular feed has been disabled by the administrator.": "Лента популярного была отключена администратором.", + "toggle_theme": "Переключатель тем", + "carousel_slide": "Пролистано {{current}} из {{total}}", + "carousel_skip": "Пропустить всё", + "carousel_go_to": "Перейти к странице `x`" } diff --git a/locales/si.json b/locales/si.json index c30ad33c..4637cbd2 100644 --- a/locales/si.json +++ b/locales/si.json @@ -1,418 +1,127 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "LIVE": "සජීව", - "Shared `x` ago": "", - "Unsubscribe": "", - "Subscribe": "", - "View channel on YouTube": "", - "View playlist on YouTube": "", - "newest": "", - "oldest": "", + "generic_views_count": "බැලීම් {{count}}", + "generic_views_count_plural": "බැලීම් {{count}}", + "generic_videos_count": "{{count}} වීඩියෝව", + "generic_videos_count_plural": "වීඩියෝ {{count}}", + "generic_subscribers_count": "ග්රාහකයන් {{count}}", + "generic_subscribers_count_plural": "ග්රාහකයන් {{count}}", + "generic_subscriptions_count": "දායකත්ව {{count}}", + "generic_subscriptions_count_plural": "දායකත්ව {{count}}", + "Shared `x` ago": "`x` පෙර බෙදා ගන්නා ලදී", + "Unsubscribe": "දායක නොවන්න", + "View playlist on YouTube": "YouTube හි ධාවන ලැයිස්තුව බලන්න", + "newest": "අලුත්ම", + "oldest": "පැරණිතම", "popular": "ජනප්රිය", - "last": "", - "Next page": "", - "Previous page": "", - "Clear watch history?": "", - "New password": "", - "New passwords must match": "", - "Cannot change password for Google accounts": "", - "Authorize token?": "", - "Authorize token for `x`?": "", - "Yes": "", - "No": "", - "Import and Export Data": "", - "Import": "", - "Import Invidious data": "", - "Import YouTube subscriptions": "", - "Import FreeTube subscriptions (.db)": "", - "Import NewPipe subscriptions (.json)": "", - "Import NewPipe data (.zip)": "", - "Export": "", - "Export subscriptions as OPML": "", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "", - "Export data as JSON": "", - "Delete account?": "", - "History": "", - "An alternative front-end to YouTube": "", - "JavaScript license information": "", - "source": "", - "Log in": "", - "Log in/register": "", - "Log in with Google": "", - "User ID": "", - "Password": "", - "Time (h:mm:ss):": "", - "Text CAPTCHA": "", - "Image CAPTCHA": "", - "Sign In": "", - "Register": "", - "E-mail": "", - "Google verification code": "", - "Preferences": "", - "Player preferences": "", - "Always loop: ": "", - "Autoplay: ": "", - "Play next by default: ": "", - "Autoplay next video: ": "", - "Listen by default: ": "", - "Proxy videos: ": "", - "Default speed: ": "", - "Preferred video quality: ": "", - "Player volume: ": "", - "Default comments: ": "", - "youtube": "", - "reddit": "", - "Default captions: ": "", - "Fallback captions: ": "", - "Show related videos: ": "", - "Show annotations by default: ": "", - "Automatically extend video description: ": "", - "Visual preferences": "", - "Player style: ": "", - "Dark mode: ": "", - "Theme: ": "", - "dark": "", - "light": "", - "Thin mode: ": "", - "Subscription preferences": "", - "Show annotations by default for subscribed channels: ": "", - "Redirect homepage to feed: ": "", - "Number of videos shown in feed: ": "", - "Sort videos by: ": "", - "published": "", - "published - reverse": "", - "alphabetically": "", - "alphabetically - reverse": "", - "channel name": "", - "channel name - reverse": "", - "Only show latest video from channel: ": "", - "Only show latest unwatched video from channel: ": "", - "Only show unwatched: ": "", - "Only show notifications (if there are any): ": "", - "Enable web notifications": "", - "`x` uploaded a video": "", - "`x` is live": "", - "Data preferences": "", - "Clear watch history": "", - "Import/export data": "", - "Change password": "", - "Manage subscriptions": "", - "Manage tokens": "", - "Watch history": "", - "Delete account": "", - "Administrator preferences": "", - "Default homepage: ": "", - "Feed menu: ": "", - "Top enabled: ": "", - "CAPTCHA enabled: ": "", - "Login enabled: ": "", - "Registration enabled: ": "", - "Report statistics: ": "", - "Save preferences": "", - "Subscription manager": "", - "Token manager": "", - "Token": "", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Import/export": "", - "unsubscribe": "", - "revoke": "", - "Subscriptions": "", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "search": "", - "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", - "Source available here.": "", - "View JavaScript license information.": "", - "View privacy policy.": "", - "Trending": "", - "Public": "", - "Unlisted": "", - "Private": "", - "View all playlists": "", - "Updated `x` ago": "", - "Delete playlist `x`?": "", - "Delete playlist": "", - "Create playlist": "", - "Title": "", - "Playlist privacy": "", - "Editing playlist `x`": "", - "Show more": "", - "Show less": "", - "Watch on YouTube": "", - "Hide annotations": "", - "Show annotations": "", - "Genre: ": "", - "License: ": "", - "Family friendly? ": "", - "Wilson score: ": "", - "Engagement: ": "", - "Whitelisted regions: ": "", - "Blacklisted regions: ": "", - "Shared `x`": "", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Premieres in `x`": "", - "Premieres `x`": "", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", - "View YouTube comments": "", - "View more comments on Reddit": "", - "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "View Reddit comments": "", - "Hide replies": "", - "Show replies": "", - "Incorrect password": "", - "Quota exceeded, try again in a few hours": "", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", - "Invalid TFA code": "", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "", - "Wrong answer": "", - "Erroneous CAPTCHA": "", - "CAPTCHA is a required field": "", - "User ID is a required field": "", - "Password is a required field": "", - "Wrong username or password": "", - "Please sign in using 'Log in with Google'": "", - "Password cannot be empty": "", - "Password cannot be longer than 55 characters": "", - "Please log in": "", - "Invidious Private Feed for `x`": "", - "channel:`x`": "", - "Deleted or invalid channel": "", - "This channel does not exist.": "", - "Could not get channel info.": "", - "Could not fetch comments": "", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` ago": "", - "Load more": "", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Could not create mix.": "", - "Empty playlist": "", - "Not a playlist.": "", - "Playlist does not exist.": "", - "Could not pull trending pages.": "", - "Hidden field \"challenge\" is a required field": "", - "Hidden field \"token\" is a required field": "", - "Erroneous challenge": "", - "Erroneous token": "", - "No such user": "", - "Token is expired, please try again": "", - "English": "", - "English (auto-generated)": "", - "Afrikaans": "", - "Albanian": "", - "Amharic": "", - "Arabic": "", - "Armenian": "", - "Azerbaijani": "", - "Bangla": "", - "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", - "Hungarian": "", - "Icelandic": "", - "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian Bokmål": "", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", - "Russian": "", - "Samoan": "", - "Scottish Gaelic": "", - "Serbian": "", - "Shona": "", - "Sindhi": "", - "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", - "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", - "Swedish": "", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", - "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Fallback comments: ": "", - "Popular": "", - "Search": "", - "Top": "", - "About": "", - "Rating: ": "", - "Language: ": "", - "View as playlist": "", - "Default": "", - "Music": "", - "Gaming": "", - "News": "", - "Movies": "", - "Download": "", - "Download as: ": "", - "%A %B %-d, %Y": "", - "(edited)": "", - "YouTube comment permalink": "", - "permalink": "", - "`x` marked it with a ❤": "", - "Audio mode": "", - "Video mode": "", - "Videos": "", - "Playlists": "", - "Community": "", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "" -}
\ No newline at end of file + "last": "අවසන්", + "Authorize token?": "ටෝකනය අනුමත කරනවා ද?", + "Authorize token for `x`?": "`x` සඳහා ටෝකනය අනුමත කරනවා ද?", + "Yes": "ඔව්", + "Import and Export Data": "දත්ත ආනයනය සහ අපනයනය කිරීම", + "Import": "ආනයන", + "Import Invidious data": "Invidious JSON දත්ත ආයාත කරන්න", + "Import FreeTube subscriptions (.db)": "FreeTube දායකත්වයන් (.db) ආයාත කරන්න", + "Import NewPipe subscriptions (.json)": "NewPipe දායකත්වයන් (.json) ආයාත කරන්න", + "Import NewPipe data (.zip)": "NewPipe දත්ත (.zip) ආයාත කරන්න", + "Export": "අපනයන", + "Export data as JSON": "Invidious දත්ත JSON ලෙස අපනයනය කරන්න", + "Delete account?": "ගිණුම මකාදමනවා ද?", + "History": "ඉතිහාසය", + "An alternative front-end to YouTube": "YouTube සඳහා විකල්ප ඉදිරිපස අන්තයක්", + "source": "මූලාශ්රය", + "Log in/register": "පුරන්න/ලියාපදිංචිවන්න", + "Password": "මුරපදය", + "Time (h:mm:ss):": "වේලාව (h:mm:ss):", + "Sign In": "පුරන්න", + "Preferences": "මනාපයන්", + "preferences_category_player": "වීඩියෝ ධාවක මනාපයන්", + "preferences_video_loop_label": "නැවත නැවතත්: ", + "preferences_autoplay_label": "ස්වයංක්රීය වාදනය: ", + "preferences_continue_label": "මීලඟට වාදනය කරන්න: ", + "preferences_continue_autoplay_label": "මීළඟ වීඩියෝව ස්වයංක්රීයව ධාවනය කරන්න: ", + "preferences_local_label": "Proxy වීඩියෝ: ", + "preferences_watch_history_label": "නැරඹුම් ඉතිහාසය සබල කරන්න: ", + "preferences_speed_label": "පෙරනිමි වේගය: ", + "preferences_quality_option_dash": "DASH (අනුවර්තිත ගුණත්වය)", + "preferences_quality_option_medium": "මධ්යස්ථ", + "preferences_quality_dash_label": "කැමති DASH වීඩියෝ ගුණත්වය: ", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_144p": "144p", + "preferences_volume_label": "ධාවකයේ හඬ: ", + "preferences_comments_label": "පෙරනිමි අදහස්: ", + "youtube": "YouTube", + "reddit": "Reddit", + "invidious": "Invidious", + "preferences_captions_label": "පෙරනිමි උපසිරැසි: ", + "preferences_related_videos_label": "අදාළ වීඩියෝ පෙන්වන්න: ", + "preferences_annotations_label": "අනුසටහන් පෙන්වන්න: ", + "preferences_vr_mode_label": "අන්තර්ක්රියාකාරී අංශක 360 වීඩියෝ (WebGL අවශ්යයි): ", + "preferences_region_label": "අන්තර්ගත රට: ", + "preferences_player_style_label": "වීඩියෝ ධාවක විලාසය: ", + "Dark mode: ": "අඳුරු මාදිලිය: ", + "preferences_dark_mode_label": "තේමාව: ", + "light": "ආලෝකමත්", + "generic_playlists_count": "{{count}} ධාවන ලැයිස්තුව", + "generic_playlists_count_plural": "ධාවන ලැයිස්තු {{count}}", + "LIVE": "සජීව", + "Subscribe": "දායක වන්න", + "View channel on YouTube": "YouTube හි නාලිකාව බලන්න", + "Next page": "ඊළඟ පිටුව", + "Previous page": "පෙර පිටුව", + "Clear watch history?": "නැරඹුම් ඉතිහාසය මකාදමනවා ද?", + "No": "නැත", + "Log in": "පුරන්න", + "New password": "නව මුරපදය", + "Import YouTube subscriptions": "YouTube/OPML දායකත්වයන් ආයාත කරන්න", + "Register": "ලියාපදිංචිවන්න", + "New passwords must match": "නව මුරපද ගැලපිය යුතුය", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML ලෙස දායකත්වයන් අපනයනය කරන්න (NewPipe සහ FreeTube සඳහා)", + "Export subscriptions as OPML": "දායකත්වයන් OPML ලෙස අපනයනය කරන්න", + "JavaScript license information": "JavaScript බලපත්ර තොරතුරු", + "User ID": "පරිශීලක කේතය", + "Text CAPTCHA": "CAPTCHA පෙල", + "Image CAPTCHA": "CAPTCHA රූපය", + "E-mail": "විද්යුත් තැපෑල", + "preferences_quality_label": "කැමති වීඩියෝ ගුණත්වය: ", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_auto": "ස්වයංක්රීය", + "preferences_quality_option_small": "කුඩා", + "preferences_quality_dash_option_best": "හොඳම", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_240p": "240p", + "preferences_extend_desc_label": "වීඩියෝ විස්තරය ස්වයංක්රීයව දිගහරින්න: ", + "preferences_category_visual": "දෘශ්ය මනාපයන්", + "dark": "අඳුරු", + "preferences_category_misc": "විවිධ මනාප", + "preferences_category_subscription": "දායකත්ව මනාප", + "Redirect homepage to feed: ": "මුල් පිටුව පෝෂණය වෙත හරවා යවන්න: ", + "preferences_max_results_label": "සංග්රහයේ පෙන්වන වීඩියෝ ගණන: ", + "preferences_sort_label": "වීඩියෝ වර්ග කරන්න: ", + "alphabetically": "අකාරාදී ලෙස", + "alphabetically - reverse": "අකාරාදී - ආපසු", + "channel name": "නාලිකාවේ නම", + "Only show latest video from channel: ": "නාලිකාවේ නවතම වීඩියෝව පමණක් පෙන්වන්න: ", + "preferences_unseen_only_label": "නොබැලූ පමණක් පෙන්වන්න: ", + "Enable web notifications": "වෙබ් දැනුම්දීම් සබල කරන්න", + "Import/export data": "දත්ත ආනයනය / අපනයනය", + "Change password": "මුරපදය වෙනස් කරන්න", + "Manage subscriptions": "දායකත්ව කළමනාකරණය", + "Manage tokens": "ටෝකන කළමනාකරණය", + "Watch history": "නැරඹුම් ඉතිහාසය", + "Save preferences": "මනාප සුරකින්න", + "Token": "ටෝකනය", + "View privacy policy.": "රහස්යතා ප්රතිපත්තිය බලන්න.", + "Only show latest unwatched video from channel: ": "නාලිකාවේ නවතම නැරඹන නොලද වීඩියෝව පමණක් පෙන්වන්න: ", + "preferences_category_data": "දත්ත මනාප", + "Clear watch history": "නැරඹුම් ඉතිහාසය මකාදැමීම", + "Subscriptions": "දායකත්ව", + "generic_button_rss": "RSS", + "generic_button_save": "සුරකින්න", + "generic_button_cancel": "අවලංගු කරන්න", + "preferences_quality_dash_option_worst": "නරකම" +} diff --git a/locales/sk.json b/locales/sk.json index f74d66db..8add0f57 100644 --- a/locales/sk.json +++ b/locales/sk.json @@ -1,41 +1,31 @@ { - "`x` subscribers.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` subscribers.": "`x` odberateľov.", - "`x` videos.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` videos.": "", - "`x` playlists.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` playlists.": "", "LIVE": "NAŽIVO", - "Shared `x` ago": "", "Unsubscribe": "Zrušiť odber", "Subscribe": "Odoberať", "View channel on YouTube": "Zobraziť kanál na YouTube", - "View playlist on YouTube": "", "newest": "najnovšie", "oldest": "najstaršie", "popular": "populárne", "last": "posledné", "Next page": "Ďalšia strana", "Previous page": "Predchádzajúca strana", - "Clear watch history?": "Vymazať históriu sledovania?", + "Clear watch history?": "Vymazať históriu pozerania?", "New password": "Nové heslo", "New passwords must match": "Nové heslá sa musia zhodovať", - "Cannot change password for Google accounts": "Heslo pre účty Google sa nedá zmeniť", "Authorize token?": "Autorizovať token?", - "Authorize token for `x`?": "", "Yes": "Áno", "No": "Nie", "Import and Export Data": "Import a Export údajov", "Import": "Import", - "Import Invidious data": "Importovať údaje Invidious", - "Import YouTube subscriptions": "Importovať odbery YouTube", + "Import Invidious data": "Importovať JSON údaje Invidious", + "Import YouTube subscriptions": "Importovať odbery YouTube/OPML", "Import FreeTube subscriptions (.db)": "Importovať odbery FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Importovať odbery NewPipe (.json)", "Import NewPipe data (.zip)": "Importovať údaje NewPipe (.zip)", "Export": "Export", "Export subscriptions as OPML": "Exportovať odbery ako OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportovať odbery ako OPML (pre NewPipe a FreeTube)", - "Export data as JSON": "Export údajov ako JSON", + "Export data as JSON": "Exportovať údaje Invidious ako JSON", "Delete account?": "Zrušiť účet?", "History": "História", "An alternative front-end to YouTube": "Alternatívny front-end pre YouTube", @@ -43,7 +33,6 @@ "source": "zdroj", "Log in": "Prihlásiť sa", "Log in/register": "Prihlásiť sa/Registrovať", - "Log in with Google": "Prihlásiť sa pomocou účtu Google", "User ID": "ID používateľa", "Password": "Heslo", "Time (h:mm:ss):": "Čas (h:mm:ss):", @@ -52,38 +41,35 @@ "Sign In": "Prihlásiť sa", "Register": "Registrovať", "E-mail": "E-mail", - "Google verification code": "Overovací kód Google", "Preferences": "Nastavenia", - "Player preferences": "Nastavenia prehrávača", - "Always loop: ": "Vždy opakovať: ", - "Autoplay: ": "Automatické prehrávanie: ", - "Play next by default: ": "", - "Autoplay next video: ": "Automatické prehrávanie nasledujúceho videa: ", - "Listen by default: ": "Predvolene počúvať: ", - "Proxy videos: ": "Proxy videá: ", - "Default speed: ": "Predvolená rýchlosť: ", - "Preferred video quality: ": "Preferovaná kvalita videa: ", - "Player volume: ": "Hlasitosť prehrávača: ", - "Default comments: ": "Predvolené komentáre: ", + "preferences_category_player": "Nastavenia prehrávača", + "preferences_video_loop_label": "Vždy opakovať: ", + "preferences_autoplay_label": "Automatické prehrávanie: ", + "preferences_continue_autoplay_label": "Automatické prehrávanie nasledujúceho videa: ", + "preferences_listen_label": "Predvolene počúvať: ", + "preferences_local_label": "Proxy videá: ", + "preferences_speed_label": "Predvolená rýchlosť: ", + "preferences_quality_label": "Preferovaná kvalita videa: ", + "preferences_volume_label": "Hlasitosť prehrávača: ", + "preferences_comments_label": "Predvolené komentáre: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Predvolené titulky: ", + "preferences_captions_label": "Predvolené titulky: ", "Fallback captions: ": "Náhradné titulky: ", - "Show related videos: ": "Zobraziť súvisiace videá: ", - "Show annotations by default: ": "Predvolene zobraziť anotácie: ", - "Automatically extend video description: ": "", - "Visual preferences": "Vizuálne nastavenia", - "Player style: ": "Štýl prehrávača: ", + "preferences_related_videos_label": "Zobraziť súvisiace videá: ", + "preferences_annotations_label": "Predvolene zobraziť anotácie: ", + "preferences_category_visual": "Vizuálne nastavenia", + "preferences_player_style_label": "Štýl prehrávača: ", "Dark mode: ": "Tmavý režim: ", - "Theme: ": "Téma: ", + "preferences_dark_mode_label": "Téma: ", "dark": "tmavá", "light": "svetlá", - "Thin mode: ": "Tenký režim: ", - "Subscription preferences": "Nastavenia predplatného", - "Show annotations by default for subscribed channels: ": "Predvolene zobraziť anotácie odoberaných kanálov: ", + "preferences_thin_mode_label": "Tenký režim: ", + "preferences_category_subscription": "Nastavenia predplatného", + "preferences_annotations_subscribed_label": "Predvolene zobraziť anotácie odoberaných kanálov: ", "Redirect homepage to feed: ": "Presmerovanie domovskej stránky na informačný kanál: ", - "Number of videos shown in feed: ": "Počet videí zobrazených v informačnom kanáli: ", - "Sort videos by: ": "Zoradiť videá podľa: ", + "preferences_max_results_label": "Počet videí zobrazených v informačnom kanáli: ", + "preferences_sort_label": "Zoradiť videá podľa: ", "published": "zverejnené (od najnovších)", "published - reverse": "zverejnené (od najstarších)", "alphabetically": "abecedne (A-Z)", @@ -92,266 +78,44 @@ "channel name - reverse": "názov kanála (Z-A)", "Only show latest video from channel: ": "Zobraziť iba najnovšie video z kanála: ", "Only show latest unwatched video from channel: ": "Zobraziť iba najnovšie neprehrané video z kanála: ", - "Only show unwatched: ": "Zobraziť iba neprehrané: ", - "Only show notifications (if there are any): ": "Zobraziť iba upozornenia (ak existujú): ", + "preferences_unseen_only_label": "Zobraziť iba neprehrané: ", + "preferences_notifications_only_label": "Zobraziť iba upozornenia (ak existujú): ", "Enable web notifications": "Povoliť webové upozornenia", "`x` uploaded a video": "`x` nahral(a) video", - "`x` is live": "", - "Data preferences": "", - "Clear watch history": "", - "Import/export data": "", - "Change password": "", - "Manage subscriptions": "", - "Manage tokens": "", - "Watch history": "", - "Delete account": "", - "Administrator preferences": "", - "Default homepage: ": "", - "Feed menu: ": "", - "Top enabled: ": "", - "CAPTCHA enabled: ": "", - "Login enabled: ": "", - "Registration enabled: ": "", - "Report statistics: ": "", - "Save preferences": "", - "Subscription manager": "", - "Token manager": "", - "Token": "", - "`x` subscriptions.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` subscriptions.": "", - "`x` tokens.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` tokens.": "", - "Import/export": "", - "unsubscribe": "", - "revoke": "", - "Subscriptions": "", - "`x` unseen notifications.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` unseen notifications.": "", - "search": "", - "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", - "Source available here.": "", - "View JavaScript license information.": "", - "View privacy policy.": "", - "Trending": "", - "Public": "", - "Unlisted": "", - "Private": "", - "View all playlists": "", - "Updated `x` ago": "", - "Delete playlist `x`?": "", - "Delete playlist": "", - "Create playlist": "", - "Title": "", - "Playlist privacy": "", - "Editing playlist `x`": "", - "Show more": "", - "Show less": "", - "Watch on YouTube": "", - "Hide annotations": "", - "Show annotations": "", - "Genre: ": "", - "License: ": "", - "Family friendly? ": "", - "Wilson score: ": "", - "Engagement: ": "", - "Whitelisted regions: ": "", - "Blacklisted regions: ": "", - "Shared `x`": "", - "`x` views.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` views.": "", - "Premieres in `x`": "", - "Premieres `x`": "", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", - "View YouTube comments": "", - "View more comments on Reddit": "", - "View `x` comments.([^.,0-9]|^)1([^.,0-9]|$)": "", - "View `x` comments.": "", - "View Reddit comments": "", - "Hide replies": "", - "Show replies": "", - "Incorrect password": "", - "Quota exceeded, try again in a few hours": "", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", - "Invalid TFA code": "", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "", - "Wrong answer": "", - "Erroneous CAPTCHA": "", - "CAPTCHA is a required field": "", - "User ID is a required field": "", - "Password is a required field": "", - "Wrong username or password": "", - "Please sign in using 'Log in with Google'": "", - "Password cannot be empty": "", - "Password cannot be longer than 55 characters": "", - "Please log in": "", - "Invidious Private Feed for `x`": "", - "channel:`x`": "", - "Deleted or invalid channel": "", - "This channel does not exist.": "", - "Could not get channel info.": "", - "Could not fetch comments": "", - "View `x` replies.([^.,0-9]|^)1([^.,0-9]|$)": "", - "View `x` replies.": "", - "`x` ago": "", - "Load more": "", - "`x` points.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` points.": "", - "Could not create mix.": "", - "Empty playlist": "", - "Not a playlist.": "", - "Playlist does not exist.": "", - "Could not pull trending pages.": "", - "Hidden field \"challenge\" is a required field": "", - "Hidden field \"token\" is a required field": "", - "Erroneous challenge": "", - "Erroneous token": "", - "No such user": "", - "Token is expired, please try again": "", - "English": "", - "English (auto-generated)": "", - "Afrikaans": "", - "Albanian": "", - "Amharic": "", - "Arabic": "", - "Armenian": "", - "Azerbaijani": "", - "Bangla": "", - "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", - "Hungarian": "", - "Icelandic": "", - "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian Bokmål": "", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", - "Russian": "", - "Samoan": "", - "Scottish Gaelic": "", - "Serbian": "", - "Shona": "", - "Sindhi": "", - "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", - "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", - "Swedish": "", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", - "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", - "`x` years.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` years.": "", - "`x` months.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` months.": "", - "`x` weeks.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` weeks.": "", - "`x` days.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` days.": "", - "`x` hours.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` hours.": "", - "`x` minutes.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` minutes.": "", - "`x` seconds.([^.,0-9]|^)1([^.,0-9]|$)": "", - "`x` seconds.": "", - "Fallback comments: ": "", - "Popular": "", - "Search": "", - "Top": "", - "About": "", - "Rating: ": "", - "Language: ": "", - "View as playlist": "", - "Default": "", - "Music": "", - "Gaming": "", - "News": "", - "Movies": "", - "Download": "", - "Download as: ": "", - "%A %B %-d, %Y": "", - "(edited)": "", - "YouTube comment permalink": "", - "permalink": "", - "`x` marked it with a ❤": "", - "Audio mode": "", - "Video mode": "", - "Videos": "", - "Playlists": "", - "Community": "", - "Current version: ": "" -}
\ No newline at end of file + "generic_views_count_0": "{{count}} zhliadnutie", + "generic_views_count_1": "{{count}} zhliadnutia", + "generic_views_count_2": "{{count}} zhliadnutí", + "generic_subscribers_count_0": "{{count}} odberateľ", + "generic_subscribers_count_1": "{{count}} odberatelia", + "generic_subscribers_count_2": "{{count}} odberateľov", + "Shared `x` ago": "Zverejnené pred `x`", + "generic_playlists_count_0": "{{count}} playlist", + "generic_playlists_count_1": "{{count}} playlisty", + "generic_playlists_count_2": "{{count}} playlistov", + "generic_videos_count_0": "{{count}} video", + "generic_videos_count_1": "{{count}} videá", + "generic_videos_count_2": "{{count}} videí", + "generic_subscriptions_count_0": "{{count}} odber", + "generic_subscriptions_count_1": "{{count}} odbery", + "generic_subscriptions_count_2": "{{count}} odberov", + "Authorize token for `x`?": "Autorizovať token pre `x`?", + "View playlist on YouTube": "Zobraziť playlist na YouTube", + "preferences_quality_dash_option_best": "Najlepšia", + "preferences_quality_dash_option_worst": "Najhoršia", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_label": "Preferovaná video kvalita DASH: ", + "preferences_quality_option_dash": "DASH (adaptívna kvalita)", + "preferences_quality_option_small": "Malá", + "preferences_watch_history_label": "Zapnúť históriu pozerania: ", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_144p": "144p", + "preferences_quality_dash_option_2160p": "2160p", + "invidious": "Invidious", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_360p": "360p" +} diff --git a/locales/sl.json b/locales/sl.json new file mode 100644 index 00000000..3803d09c --- /dev/null +++ b/locales/sl.json @@ -0,0 +1,525 @@ +{ + "No": "Ne", + "Subscribe": "Naroči se", + "View playlist on YouTube": "Ogled seznama predvajanja v YouTubu", + "last": "zadnji", + "Next page": "Naslednja stran", + "Previous page": "Prejšnja stran", + "Clear watch history?": "Izbrisati zgodovino ogledov?", + "New password": "Novo geslo", + "New passwords must match": "Nova gesla se morajo ujemati", + "Authorize token?": "Naj odobrim žeton?", + "Yes": "Da", + "Import and Export Data": "Uvoz in izvoz podatkov", + "Import": "Uvozi", + "Import Invidious data": "Uvozi Invidious JSON podatke", + "Import YouTube subscriptions": "Uvozi YouTube/OPML naročnine", + "Import FreeTube subscriptions (.db)": "Uvozi FreeTube (.db) naročnine", + "Import NewPipe data (.zip)": "Uvozi NewPipe (.zip) podatke", + "Export": "Izvozi", + "Export subscriptions as OPML": "Izvozi naročnine kot OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Izvozi naročnine kot OPML (za NewPipe in FreeTube)", + "Log in": "Prijava", + "Log in/register": "Prijava/registracija", + "User ID": "ID uporabnika", + "Password": "Geslo", + "Time (h:mm:ss):": "Čas (h:mm:ss):", + "Text CAPTCHA": "Besedilo CAPTCHA", + "source": "izvorna koda", + "Image CAPTCHA": "Slika CAPTCHA", + "Sign In": "Prijavi se", + "Register": "Registriraj se", + "E-mail": "E-pošta", + "Preferences": "Nastavitve", + "preferences_video_loop_label": "Vedno v zanki: ", + "preferences_autoplay_label": "Samodejno predvajanje: ", + "preferences_continue_autoplay_label": "Samodejno predvajanje naslednjega videoposnetka: ", + "preferences_listen_label": "Privzeto poslušaj: ", + "preferences_local_label": "Proxy za videoposnetke: ", + "preferences_speed_label": "Privzeta hitrost: ", + "preferences_quality_label": "Prednostna kakovost videoposnetka: ", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_best": "najboljša", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_volume_label": "Glasnost predvajalnika: ", + "reddit": "Reddit", + "preferences_player_style_label": "Slog predvajalnika: ", + "dark": "temna", + "light": "svetla", + "preferences_thin_mode_label": "Tanki način: ", + "preferences_automatic_instance_redirect_label": "Samodejna preusmeritev (na redirect.invidious.io): ", + "preferences_annotations_subscribed_label": "Privzeto prikazati opombe za naročene kanale? ", + "Redirect homepage to feed: ": "Preusmeri domačo stran na vir: ", + "preferences_max_results_label": "Število videoposnetkov, prikazanih v viru: ", + "preferences_sort_label": "Razvrsti videoposnetke po: ", + "published": "datumu objave", + "published - reverse": "datumu objave - obratno", + "alphabetically": "abecednem vrstnem redu", + "alphabetically - reverse": "po abecednem vrstnem redu - obratno", + "channel name": "imenu kanala", + "channel name - reverse": "imenu kanala - obratno", + "Only show latest video from channel: ": "Pokaži samo najnovejši videoposnetek iz kanala: ", + "Only show latest unwatched video from channel: ": "Pokaži samo najnovejši še neogledani videoposnetek iz kanala: ", + "preferences_unseen_only_label": "Pokaži samo neogledane: ", + "preferences_notifications_only_label": "Pokaži samo obvestila (če obstajajo): ", + "preferences_category_data": "Nastavitve podatkov", + "Clear watch history": "Počisti zgodovino ogledov", + "Import/export data": "Uvoz/izvoz podatkov", + "Change password": "Spremeni geslo", + "Watch history": "Oglej si zgodovino", + "Delete account": "Izbriši račun", + "preferences_category_admin": "Skrbniške nastavitve", + "preferences_default_home_label": "Privzeta domača stran: ", + "preferences_feed_menu_label": "Meni vira: ", + "Top enabled: ": "Vrh omogočen: ", + "CAPTCHA enabled: ": "CAPTCHA omogočeni: ", + "Login enabled: ": "Prijava je omogočena: ", + "Registration enabled: ": "Registracija je omogočena: ", + "Token manager": "Upravitelj žetonov", + "Token": "Žeton", + "tokens_count_0": "{{count}} žeton", + "tokens_count_1": "{{count}} žetona", + "tokens_count_2": "{{count}} žetoni", + "tokens_count_3": "{{count}} žetonov", + "Import/export": "Uvoz/izvoz", + "unsubscribe": "odjava", + "revoke": "prekliči", + "search": "iskanje", + "Log out": "Odjava", + "Released under the AGPLv3 on Github.": "Objavljeno pod licenco AGPLv3 na GitHubu.", + "Trending": "Trendi", + "Private": "Zasebno", + "View all playlists": "Oglej si vse sezname predvajanja", + "Updated `x` ago": "Posodobljeno pred `x`", + "Delete playlist `x`?": "Brisanje seznama predvajanja `x`?", + "Delete playlist": "Izbriši seznam predvajanja", + "Title": "Naslov", + "Playlist privacy": "Zasebnost seznama predvajanja", + "Editing playlist `x`": "Urejanje seznama predvajanja `x`", + "Show more": "Pokaži več", + "Switch Invidious Instance": "Preklopi Invidious instanco", + "search_message_change_filters_or_query": "Poskusi razširiti iskalno poizvedbo in/ali spremeniti filtre.", + "search_message_use_another_instance": " Lahko tudi <a href=\"`x`\">iščeš v drugi istanci</a>.", + "Wilson score: ": "Wilsonov rezultat: ", + "Engagement: ": "Sodelovanje: ", + "Blacklisted regions: ": "Regije na seznamu nedovoljenih: ", + "Shared `x`": "V skupni rabi od: `x`", + "Premieres `x`": "Premiere `x`", + "View YouTube comments": "Oglej si YouTube komentarje", + "View more comments on Reddit": "Prikaži več komentarjev na Reddit", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Poglej `x` komentar", + "": "Poglej `x` komentarjev" + }, + "Password cannot be empty": "Geslo ne sme biti prazno", + "`x` ago": "`x` nazaj", + "Load more": "Naloži več", + "comments_points_count_0": "{{count}} točka", + "comments_points_count_1": "{{count}} točki", + "comments_points_count_2": "{{count}} točke", + "comments_points_count_3": "{{count}} točk", + "Hidden field \"token\" is a required field": "Skrito polje »žeton« je zahtevano polje", + "Erroneous challenge": "Napačen izziv", + "English": "angleščina", + "English (United States)": "angleščina (Združene države)", + "Albanian": "albanščina", + "Amharic": "amharščina", + "Azerbaijani": "azerbajdžanščina", + "Bangla": "bengalščina", + "Belarusian": "beloruščina", + "Burmese": "birmanščina", + "Cebuano": "cebuanščina", + "Chinese (Hong Kong)": "kitajščina (Hongkong)", + "Chinese (Simplified)": "kitajščina (poenostavljena)", + "Chinese (Taiwan)": "kitajščina (Tajvan)", + "Corsican": "korzijščina", + "Croatian": "hrvaščina", + "Danish": "danščina", + "Dutch": "nizozemščina", + "Estonian": "estonščina", + "Filipino": "filipinščina", + "Finnish": "finščina", + "French": "francoščina", + "French (auto-generated)": "francoščina (samodejno ustvarjeno)", + "Georgian": "gruzinščina", + "German": "nemščina", + "Greek": "grščina", + "Gujarati": "gudžaratščina", + "Haitian Creole": "haitijska kreolščina", + "Hausa": "havščina", + "Hawaiian": "havajščina", + "Hmong": "hmonščina", + "Hungarian": "madžarščina", + "Icelandic": "islandščina", + "Igbo": "igbo", + "Interlingue": "interlingua", + "Italian (auto-generated)": "italijanščina (samodejno ustvarjeno)", + "Japanese": "japonščina", + "Japanese (auto-generated)": "japonščina (samodejno ustvarjeno)", + "Khmer": "kmerščina", + "Korean": "korejščina", + "Korean (auto-generated)": "korejščina (samodejno ustvarjeno)", + "Kurdish": "kurdščina", + "Kannada": "kanadejščina", + "Latvian": "latvijščina", + "Lithuanian": "litovščina", + "Luxembourgish": "luksemburščina", + "Macedonian": "makedonščina", + "Malagasy": "malgaščina", + "Malay": "malajščina", + "Nepali": "nepalščina", + "Norwegian Bokmål": "norveščina bokmal", + "Nyanja": "njanščina", + "Punjabi": "pandžabščina", + "Romanian": "romunščina", + "Russian": "ruščina", + "Samoan": "samoanščina", + "Scottish Gaelic": "škotska galščina", + "Shona": "šonaščina", + "Sundanese": "sudanščina", + "Thai": "tajščina", + "Turkish": "turščina", + "Turkish (auto-generated)": "turščina (samodejno ustvarjeno)", + "Ukrainian": "ukrajinščina", + "Urdu": "urdujščina", + "Telugu": "telugu", + "Vietnamese": "vietnamščina", + "Welsh": "valižanščina", + "Western Frisian": "zahodnofrizijščina", + "Yiddish": "jidiš", + "Yoruba": "joruba", + "Xhosa": "xhosa", + "generic_count_years_0": "{{count}} letom", + "generic_count_years_1": "{{count}} leti", + "generic_count_years_2": "{{count}} leti", + "generic_count_years_3": "{{count}} leti", + "generic_count_days_0": "{{count}} dnevom", + "generic_count_days_1": "{{count}} dnevoma", + "generic_count_days_2": "{{count}} dnevi", + "generic_count_days_3": "{{count}} dnevi", + "generic_count_hours_0": "{{count}} uro", + "generic_count_hours_1": "{{count}} urami", + "generic_count_hours_2": "{{count}} urami", + "generic_count_hours_3": "{{count}} urami", + "generic_count_minutes_0": "{{count}} minuto", + "generic_count_minutes_1": "{{count}} minutami", + "generic_count_minutes_2": "{{count}} minutami", + "generic_count_minutes_3": "{{count}} minutami", + "Search": "Iskanje", + "Top": "Vrh", + "About": "O aplikaciji", + "%A %B %-d, %Y": "%A %-d %B %Y", + "Audio mode": "Avdio način", + "channel_tab_videos_label": "Videoposnetki", + "search_filters_date_label": "Datum nalaganja", + "search_filters_date_option_today": "Danes", + "search_filters_date_option_week": "Ta teden", + "search_filters_type_label": "Vrsta", + "search_filters_type_option_all": "Katerakoli vrsta", + "search_filters_type_option_playlist": "Seznam predvajanja", + "search_filters_features_option_subtitles": "Podnapisi/CC", + "search_filters_features_option_location": "Lokacija", + "footer_donate_page": "Prispevaj", + "footer_documentation": "Dokumentacija", + "footer_original_source_code": "Izvirna izvorna koda", + "none": "ni", + "videoinfo_started_streaming_x_ago": "Začetek pretakanja `x` nazaj", + "videoinfo_watch_on_youTube": "Oglej si v YouTubu", + "user_saved_playlists": "`x` shranjenih seznamov predvajanja", + "Video unavailable": "Video ni na voljo", + "preferences_save_player_pos_label": "Shrani položaj predvajanja: ", + "crash_page_you_found_a_bug": "Videti je, da si v Invidiousu našel hrošča!", + "crash_page_read_the_faq": "prebral/a <a href=\"`x`\">Pogosto zastavljena vprašanja (FAQ)</a>", + "generic_videos_count_0": "{{count}} video", + "generic_videos_count_1": "{{count}} videa", + "generic_videos_count_2": "{{count}} videi", + "generic_videos_count_3": "{{count}} videov", + "generic_views_count_0": "Ogledov: {{count}}", + "generic_views_count_1": "Ogledov: {{count}}", + "generic_views_count_2": "Ogledov: {{count}}", + "generic_views_count_3": "Ogledov: {{count}}", + "generic_playlists_count_0": "{{count}} seznam predvajanja", + "generic_playlists_count_1": "{{count}} seznama predvajanja", + "generic_playlists_count_2": "{{count}} seznami predvajanja", + "generic_playlists_count_3": "{{count}} seznamov predvajanja", + "generic_subscribers_count_0": "{{count}} naročnik", + "generic_subscribers_count_1": "{{count}} naročnika", + "generic_subscribers_count_2": "{{count}} naročniki", + "generic_subscribers_count_3": "{{count}} naročnikov", + "generic_subscriptions_count_0": "{{count}} naročnina", + "generic_subscriptions_count_1": "{{count}} naročnini", + "generic_subscriptions_count_2": "{{count}} naročnine", + "generic_subscriptions_count_3": "{{count}} naročnin", + "LIVE": "V ŽIVO", + "Shared `x` ago": "Deljeno pred `x`", + "View channel on YouTube": "Ogled kanala v YouTubu", + "newest": "najnovejši", + "Unsubscribe": "Odjavi se", + "Authorize token for `x`?": "Odobriti žeton za `x`?", + "Import NewPipe subscriptions (.json)": "Uvozi NewPipe (.json) naročnine", + "History": "Zgodovina", + "JavaScript license information": "Podatki o licenci JavaScript", + "oldest": "najstarejši", + "popular": "priljubljen", + "Export data as JSON": "Izvozi Invidious podatke kot JSON", + "Delete account?": "Izbrisati račun?", + "An alternative front-end to YouTube": "Alternativni vmesnik za YouTube", + "preferences_category_player": "Nastavitve predvajalnika", + "preferences_continue_label": "Privzeto predvajaj naslednjega: ", + "preferences_watch_history_label": "Omogoči zgodovino ogledov: ", + "preferences_quality_option_medium": "srednja", + "preferences_quality_option_dash": "DASH (prilagodljiva kakovost)", + "preferences_quality_option_small": "majhna", + "preferences_quality_dash_option_worst": "najslabša", + "preferences_quality_dash_label": "Prednostna kakovost videoposnetkov DASH: ", + "preferences_comments_label": "Privzeti komentarji: ", + "preferences_quality_dash_option_auto": "samodejna", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_144p": "144p", + "youtube": "YouTube", + "invidious": "Invidious", + "preferences_vr_mode_label": "Interaktivni videoposnetki na 360 stopinj (zahteva WebGL): ", + "preferences_captions_label": "Privzeti napisi: ", + "Fallback captions: ": "Pomožni napisi: ", + "preferences_extend_desc_label": "Samodejno razširi opis videoposnetka: ", + "preferences_related_videos_label": "Prikaži povezane videoposnetke: ", + "preferences_annotations_label": "Privzeto prikaži opombe: ", + "preferences_category_visual": "Vizualne nastavitve", + "preferences_region_label": "Država vsebine: ", + "Dark mode: ": "Temni način: ", + "preferences_dark_mode_label": "Tema: ", + "preferences_category_misc": "Različne nastavitve", + "preferences_category_subscription": "Nastavitve naročnine", + "Unlisted": "Nerazporejeno", + "Enable web notifications": "Omogoči spletna obvestila", + "`x` is live": "`x` je v živo", + "Manage subscriptions": "Upravljaj naročnine", + "Manage tokens": "Upravljaj žetone", + "Subscription manager": "Upravitelj naročnin", + "`x` uploaded a video": "`x` je naložil/a videoposnetek", + "preferences_show_nick_label": "Prikaži vzdevek na vrhu: ", + "search_message_no_results": "Ni zadetkov.", + "Save preferences": "Shrani nastavitve", + "Subscriptions": "Naročnine", + "Report statistics: ": "Poročilo o statistiki: ", + "subscriptions_unseen_notifs_count_0": "{{count}} neogledano obvestilo", + "subscriptions_unseen_notifs_count_1": "{{count}} neogledani obvestili", + "subscriptions_unseen_notifs_count_2": "{{count}} neogledana obvestila", + "subscriptions_unseen_notifs_count_3": "{{count}} neogledanih obvestil", + "View JavaScript license information.": "Oglej si informacije o licenci za JavaScript.", + "Show less": "Pokaži manj", + "Watch on YouTube": "Oglej si v YouTubu", + "Source available here.": "Izvorna koda na voljo tukaj.", + "License: ": "Licenca: ", + "View privacy policy.": "Oglej si pravilnik o zasebnosti.", + "Public": "Javno", + "Create playlist": "Ustvari seznam predvajanja", + "Hide annotations": "Skrij opombe", + "Show annotations": "Pokaži opombe", + "Genre: ": "Žanr: ", + "Family friendly? ": "Družinam prijazno? ", + "Whitelisted regions: ": "Regije na seznamu dovoljenih: ", + "Premieres in `x`": "Premiere v `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Živjo! Izgleda, da imaš izklopljene JavaScripte . Klikni tukaj, če si želiš ogledati komentarje, vendar vedi, da bo lahko nalaganje trajajo nekoliko dlje.", + "Show replies": "Pokaži odgovore", + "Erroneous CAPTCHA": "Napačna CAPTCHA", + "User ID is a required field": "ID uporabnika je obvezno polje", + "Password is a required field": "Geslo je obvezno polje", + "Wrong username or password": "Napačno uporabniško ime ali geslo", + "Password cannot be longer than 55 characters": "Geslo ne sme biti daljše od 55 znakov", + "channel:`x`": "kanal: `x`", + "Could not fetch comments": "Ni bilo mogoče pridobiti komentarjev", + "Could not pull trending pages.": "Ni bilo mogoče povleči trendovskih strani.", + "Please log in": "Prosim, prijavi se", + "Playlist does not exist.": "Seznam predvajanja ne obstaja.", + "Incorrect password": "Napačno geslo", + "View Reddit comments": "Oglej si komentarje na Redditu", + "This channel does not exist.": "Ta kanal ne obstaja.", + "Hide replies": "Skrij odgovore", + "Invidious Private Feed for `x`": "Invidious zasebni vir za `x`", + "Deleted or invalid channel": "Izbrisan ali neveljaven kanal", + "Empty playlist": "Prazen seznam predvajanja", + "No such user": "Ni tega uporabnika", + "Token is expired, please try again": "Žeton je potekel, poskusi znova", + "English (United Kingdom)": "angleščina (Združeno kraljestvo)", + "Wrong answer": "Napačen odgovor", + "CAPTCHA is a required field": "CAPTCHA je obvezno polje", + "Could not get channel info.": "Ni bilo mogoče dobiti informacij o kanalu.", + "comments_view_x_replies_0": "Poglej {{count}} odgovor", + "comments_view_x_replies_1": "Poglej {{count}} odgovora", + "comments_view_x_replies_2": "Poglej {{count}} odgovore", + "comments_view_x_replies_3": "Poglej {{count}} odgovorov", + "Could not create mix.": "Ni bilo mogoče ustvariti mixa.", + "Not a playlist.": "Ni seznam predvajanja.", + "Hidden field \"challenge\" is a required field": "Skrito polje »izziv« je obvezno polje", + "Erroneous token": "Napačen žeton", + "Afrikaans": "afrikanščina", + "Arabic": "arabščina", + "Armenian": "armenščina", + "English (auto-generated)": "angleščina (samodejno ustvarjeno)", + "Bulgarian": "bolgarščina", + "Catalan": "katalonščina", + "Cantonese (Hong Kong)": "kantonščina (Hongkong)", + "Chinese (Traditional)": "kitajščina (tradicionalna)", + "Basque": "baskovščina", + "Czech": "češčina", + "Bosnian": "bosanščina", + "Chinese": "kitajščina", + "Chinese (China)": "kitajščina (Kitajska)", + "Dutch (auto-generated)": "nizozemščina (samodejno ustvarjeno)", + "Esperanto": "esperanto", + "Galician": "galicijščina", + "German (auto-generated)": "nemščina (samodejno ustvarjeno)", + "Hebrew": "hebrejščina", + "Malayalam": "malajalamščina", + "Hindi": "hindijščina", + "Indonesian": "indonezijščina", + "Kazakh": "kazahstanščina", + "Indonesian (auto-generated)": "indonezijščina (samodejno generirano)", + "Irish": "irščina", + "Persian": "perzijščina", + "Slovak": "slovaščina", + "Italian": "italijanščina", + "Maori": "maorščina", + "Portuguese": "portugalščina", + "Javanese": "javanščina", + "Kyrgyz": "kirgiščina", + "Lao": "laoščina", + "Latin": "latinščina", + "Mongolian": "mongolščina", + "Portuguese (auto-generated)": "portugalščina (samodejno ustvarjeno)", + "Sindhi": "sindščina", + "Maltese": "malteščina", + "Marathi": "maratščina", + "Pashto": "paštu", + "Polish": "poljščina", + "Portuguese (Brazil)": "portugalščina (Brazilija)", + "Fallback comments: ": "Nadomestni komentarji: ", + "Gaming": "Igralništvo", + "Russian (auto-generated)": "ruščina (samodejno ustvarjeno)", + "Serbian": "srbščina", + "Sinhala": "singalščina", + "Slovenian": "slovenščina", + "Somali": "somalijščina", + "Spanish": "španščina", + "Southern Sotho": "južni sotho", + "Spanish (auto-generated)": "španščina (samodejno ustvarjeno)", + "Spanish (Mexico)": "španščina (Mehika)", + "Spanish (Latin America)": "španščina (Latinska Amerika)", + "Spanish (Spain)": "španščina (Španija)", + "Tajik": "tadžiščina", + "Tamil": "tamilščina", + "generic_count_weeks_0": "{{count}} tednom", + "generic_count_weeks_1": "{{count}} tedni", + "generic_count_weeks_2": "{{count}} tedni", + "generic_count_weeks_3": "{{count}} tedni", + "Swahili": "svahilščina", + "Swedish": "švedščina", + "Vietnamese (auto-generated)": "vietnamščina (samodejno ustvarjeno)", + "generic_count_months_0": "{{count}} mesecem", + "generic_count_months_1": "{{count}} meseci", + "generic_count_months_2": "{{count}} meseci", + "generic_count_months_3": "{{count}} meseci", + "Uzbek": "uzbeščina", + "Zulu": "zulujščina", + "generic_count_seconds_0": "{{count}} sekundo", + "generic_count_seconds_1": "{{count}} sekundami", + "generic_count_seconds_2": "{{count}} sekundami", + "generic_count_seconds_3": "{{count}} sekundami", + "Popular": "Priljubljeni", + "Music": "Glasba", + "Movies": "Filmi", + "YouTube comment permalink": "Stalna povezava za komentar na YouTubu", + "search_filters_title": "Filtri", + "preferences_locale_label": "Jezik: ", + "Rating: ": "Ocena: ", + "Default": "Privzeto", + "News": "Novice", + "Download as: ": "Prenesi kot: ", + "(edited)": "(urejeno)", + "View as playlist": "Poglej kot seznam predvajanja", + "Download": "Prenesi", + "permalink": "stalna povezava", + "`x` marked it with a ❤": "`x` ga je označil/a z ❤", + "channel_tab_community_label": "Skupnost", + "search_filters_features_option_three_sixty": "360°", + "Video mode": "Video način", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "Playlists": "Seznami predvajanja", + "search_filters_date_option_none": "Katerikoli datum", + "search_filters_date_option_month": "Ta mesec", + "search_filters_date_option_year": "Letos", + "search_filters_type_option_movie": "Film", + "search_filters_duration_option_long": "Dolg (> 20 minut)", + "search_filters_features_label": "Lastnosti", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_hdr": "HDR", + "next_steps_error_message_refresh": "Osveži", + "search_filters_date_option_hour": "Zadnja ura", + "search_filters_features_option_purchased": "Kupljeno", + "search_filters_sort_label": "Razvrsti po", + "search_filters_sort_option_views": "številu ogledov", + "Current version: ": "Trenutna različica: ", + "search_filters_features_option_live": "V živo", + "search_filters_features_option_hd": "HD", + "search_filters_type_option_channel": "Kanal", + "search_filters_type_option_show": "Pokaži", + "search_filters_duration_label": "Trajanje", + "search_filters_duration_option_none": "Poljubno trajanje", + "search_filters_duration_option_short": "Kratek (< 4 minute)", + "search_filters_duration_option_medium": "Srednji (4 - 20 minut)", + "search_filters_features_option_vr180": "VR180", + "search_filters_sort_option_date": "datumu nalaganja", + "search_filters_type_option_video": "Videoposnetek", + "search_filters_sort_option_relevance": "ustreznosti", + "search_filters_sort_option_rating": "oceni", + "search_filters_apply_button": "Uporabi izbrane filtre", + "next_steps_error_message": "Po tem moraš poskusiti: ", + "next_steps_error_message_go_to_youtube": "Pojdi na YouTube", + "footer_source_code": "Izvorna koda", + "footer_modfied_source_code": "Spremenjena izvorna koda", + "user_created_playlists": "`x` ustvarjenih seznamov predvajanja", + "adminprefs_modified_source_code_url_label": "URL do shrambe spremenjene izvorne kode", + "videoinfo_youTube_embed_link": "Vdelaj", + "videoinfo_invidious_embed_link": "Povezava za vdelavo", + "crash_page_switch_instance": "poskušal/a <a href=\"`x`\">uporabiti drugo instanco</a>", + "download_subtitles": "Podnapisi - `x` (.vtt)", + "crash_page_refresh": "poskušal/a <a href=\"`x`\">osvežiti stran</a>", + "crash_page_before_reporting": "Preden prijaviš napako, se prepričaj, da si:", + "crash_page_search_issue": "preiskal/a <a href=\"`x`\">obstoječe težave na GitHubu</a>", + "crash_page_report_issue": "Če nič od navedenega ni pomagalo, prosim <a href=\"`x`\">odpri novo težavo v GitHubu</a> (po možnosti v angleščini) in v svoje sporočilo vključi naslednje besedilo (tega besedila NE prevajaj):", + "Popular enabled: ": "Priljubljeni omogočeni: ", + "error_video_not_in_playlist": "Zahtevani videoposnetek ne obstaja na tem seznamu predvajanja. <a href=\"`x`\">Klikni tukaj za domačo stran seznama predvajanja.</a>", + "channel_tab_playlists_label": "Seznami predvajanja", + "channel_tab_shorts_label": "Kratki videoposnetki", + "channel_tab_channels_label": "Kanali", + "channel_tab_streams_label": "Prenosi v živo", + "Artist: ": "Umetnik/ca: ", + "Music in this video": "Glasba v tem videoposnetku", + "Album: ": "Album: ", + "Song: ": "Pesem: ", + "Standard YouTube license": "Standardna licenca YouTube", + "Channel Sponsor": "Sponzor kanala", + "Download is disabled": "Prenos je onemogočen", + "Import YouTube playlist (.csv)": "Uvoz seznama predvajanja YouTube (.csv)", + "generic_button_delete": "Izbriši", + "generic_button_edit": "Uredi", + "generic_button_save": "Shrani", + "generic_button_cancel": "Prekliči", + "generic_button_rss": "RSS", + "playlist_button_add_items": "Dodaj videoposnetke", + "channel_tab_podcasts_label": "Poddaje", + "channel_tab_releases_label": "Izdaje", + "generic_channels_count_0": "{{count}} kanal", + "generic_channels_count_1": "{{count}} kanala", + "generic_channels_count_2": "{{count}} kanali", + "generic_channels_count_3": "{{count}} kanalov", + "Import YouTube watch history (.json)": "Uvozi zgodovino gledanja YouTube (.json)" +} diff --git a/locales/sq.json b/locales/sq.json new file mode 100644 index 00000000..ea20ce56 --- /dev/null +++ b/locales/sq.json @@ -0,0 +1,496 @@ +{ + "Albanian": "Shqip", + "Amharic": "Amharike", + "Arabic": "Arabisht", + "Armenian": "Armenisht", + "Gujarati": "Gujaratase", + "Haitian Creole": "Kreolase Haiti", + "Hausa": "Hausisht", + "Hawaiian": "Havajane", + "Hebrew": "Hebraisht", + "Hindi": "Indiane", + "Hungarian": "Hungarisht", + "Icelandic": "Islandisht", + "Igbo": "Igboisht", + "Irish": "Irlandisht", + "Javanese": "Xhavanisht", + "Kazakh": "Kazake", + "Khmer": "Khmere", + "Korean": "Koreane", + "Kurdish": "Kurdisht", + "Kyrgyz": "Kirgizisht", + "Sundanese": "Sundaneze", + "Swahili": "Suahilisht", + "Swedish": "Suedisht", + "Tajik": "Taxhike", + "Tamil": "Tamilisht", + "Telugu": "Telugu", + "Vietnamese": "Vietnamisht", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Drejtpërsëdrejti", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Vendndodhja", + "videoinfo_watch_on_youTube": "Shiheni në YouTube", + "videoinfo_youTube_embed_link": "Trupëzojeni", + "videoinfo_invidious_embed_link": "Lidhje Trupëzimi", + "oldest": "më të vjetrat", + "New passwords must match": "Fjalëkalimet e rinj duhet të përputhen me njëri-tjetrin", + "Authorize token?": "Të autorizohet token-i?", + "Authorize token for `x`?": "Të autorizohet token-i për `x`?", + "Log in/register": "Hyni/regjistrohuni", + "User ID": "ID Përdoruesi", + "Password": "Fjalëkalim", + "Time (h:mm:ss):": "Kohë (h:mm:ss):", + "Text CAPTCHA": "CAPTCHA Tekst", + "Image CAPTCHA": "CAPTCHA Figurë", + "Sign In": "Hyni", + "Register": "Regjistrohuni", + "E-mail": "Email", + "Preferences": "Parapëlqime", + "preferences_category_player": "Parapëlqime Lojtësi", + "preferences_autoplay_label": "Vetëluaje: ", + "preferences_continue_label": "Luaj pasuesen, si parazgjedhje: ", + "preferences_continue_autoplay_label": "Vetëluaj videon pasuese: ", + "preferences_listen_label": "Si parazgjedhje, dëgjojeni me: ", + "preferences_speed_label": "Shpejtësi parazgjedhje: ", + "preferences_quality_label": "Cilësi e parapëlqyer për videot: ", + "preferences_quality_option_dash": "DASH (cilësi që përshtatet)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Mesatare", + "preferences_quality_option_small": "E ulët", + "preferences_quality_dash_label": "Cilësi DASH e parapëlqyer për videot: ", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_best": "Më e mira", + "preferences_quality_dash_option_worst": "Më e keqja", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "preferences_volume_label": "Volum lojtësi: ", + "preferences_comments_label": "Komente parazgjedhje: ", + "youtube": "YouTube", + "reddit": "Reddit", + "invidious": "Invidious", + "preferences_captions_label": "Titra parazgjedhje: ", + "preferences_extend_desc_label": "Zgjero automatikisht përshkrimin e videos: ", + "preferences_player_style_label": "Stil lojtësi: ", + "Dark mode: ": "Mënyra e errët: ", + "preferences_dark_mode_label": "Temë: ", + "dark": "e errët", + "light": "e çelët", + "preferences_thin_mode_label": "Mënyrë e hollë: ", + "preferences_category_misc": "Parapëlqime të ndryshme", + "preferences_automatic_instance_redirect_label": "Ridrejtim i automatizuar i instancës (si parazgjedhje, te redirect.invidious.io): ", + "preferences_category_subscription": "Parapëlqime pajtimesh", + "preferences_annotations_subscribed_label": "Të shfaqen, si parazgjedhje, shënime për kanalet e pajtuar? ", + "Redirect homepage to feed: ": "Ridrejtoje faqen hyrëse te prurje: ", + "preferences_max_results_label": "Numër videosh të shfaqura në prurje: ", + "preferences_sort_label": "Renditi videot sipas: ", + "published": "e publikuar", + "alphabetically": "alfabetikisht", + "alphabetically - reverse": "alfabetikisht - së prapthi", + "channel name": "emër kanali", + "Only show latest video from channel: ": "Shfaq vetëm videot më të reja nga kanali: ", + "Only show latest unwatched video from channel: ": "Shfaq vetëm videot më të reja të papara në kanal: ", + "preferences_unseen_only_label": "Shfaq vetëm të paparat: ", + "preferences_notifications_only_label": "Shfaq vetëm njoftime (nëse ka të tilla): ", + "Enable web notifications": "Aktivizoni njoftime web", + "`x` uploaded a video": "`x` ngarkoi një video", + "`x` is live": "`x` funksionon", + "preferences_category_data": "Parapëlqime për të dhënat", + "Clear watch history": "Spastro historik parjesh", + "Import/export data": "Importoni/eksportoni të dhëna", + "Change password": "Ndryshoni fjalëkalimin", + "Manage subscriptions": "Administroni pajtimet", + "Manage tokens": "Administroni token-ë", + "Watch history": "Shihni historikun", + "Delete account": "Fshije llogarinë", + "preferences_category_admin": "Parapëlqime përgjegjësi", + "preferences_default_home_label": "Faqe hyrëse parazgjedhje: ", + "preferences_feed_menu_label": "Menu prurjesh: ", + "Registration enabled: ": "Regjistrim i aktivizuar: ", + "Save preferences": "Ruaji parapëlqimet", + "Token": "Token", + "Subscription manager": "Përgjegjës pajtimesh", + "Token manager": "Përgjegjës token-ësh", + "Import/export": "Importim/eksportim", + "unsubscribe": "shpajtohuni", + "revoke": "shfuqizoje", + "Subscriptions": "Pajtime", + "search": "kërko", + "Log out": "Dilni", + "Released under the AGPLv3 on Github.": "Hedhur në qarkullim në GitHub sipas licencës AGPLv3.", + "Source available here.": "Burimi i passhëm që këtu.", + "View JavaScript license information.": "Shihni hollësi licence JavaScript.", + "View privacy policy.": "Shihni rregulla privatësie.", + "Trending": "Në modë", + "Public": "Publike", + "Unlisted": "Jo në listë", + "Private": "Private", + "View all playlists": "Shihni krejt luajlistat", + "Updated `x` ago": "Përditësuar `x` më parë", + "Delete playlist": "Fshije luajlistën", + "Delete playlist `x`?": "Të fshihet luajlista `x`?", + "Create playlist": "Krijoni luajlistë", + "Title": "Titull", + "Playlist privacy": "Privatësi luajliste", + "Editing playlist `x`": "Po përpunohet luajlista `x`", + "Show more": "Shfaq më tepër", + "Show less": "Shfaq më pak", + "Watch on YouTube": "Shiheni në YouTube", + "Switch Invidious Instance": "Ndërroni Instancë Invidious", + "Hide annotations": "Fshihi shënimet", + "Show annotations": "Shfaq shënime", + "License: ": "Licencë: ", + "Family friendly? ": "E përshtatshme për familje? ", + "Wilson score: ": "Klasifikim Wilson: ", + "Engagement: ": "Angazhim: ", + "Whitelisted regions: ": "Rajone të lejuara: ", + "Premieres `x`": "Premiera `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Njatjeta! Duket sikur keni JavaScript-in të çaktivizuar. Klikoni këtu që të shihni komentet, mbani parasysh se mund të duhet pak më tepër kohë që të ngarkohen.", + "Blacklisted regions: ": "Rajone të palejuara: ", + "Premieres in `x`": "Premiera në `x`", + "Wrong answer": "Përgjigje e gabuar", + "Erroneous CAPTCHA": "CAPTCHA e gabuar", + "CAPTCHA is a required field": "CAPTCHA është fushë e domosdoshme", + "User ID is a required field": "ID-ja e përdoruesit është fushë e domosdoshme", + "Password is a required field": "Fusha e fjalëkalimit është e domosdoshme", + "Wrong username or password": "Emër përdoruesi ose fjalëkalim i gabuar", + "Password cannot be empty": "Fjalëkalimi s’mund të jetë i zbrazët", + "Password cannot be longer than 55 characters": "Fjalëkalimi s’mund të jetë më i gjatë se 55 shenja", + "Please log in": "Ju lutemi, bëni hyrjen", + "Invidious Private Feed for `x`": "Prurje Private Invidious për `x`", + "channel:`x`": "kanal:`x`", + "Deleted or invalid channel": "Kanal i fshirë ose i pavlefshëm", + "This channel does not exist.": "Ky kanal s’ekziston.", + "Could not get channel info.": "S’u morën dot hollësi kanali.", + "Could not fetch comments": "S’u sollën dot komente", + "`x` ago": "`x` më parë", + "Load more": "Ngarko më tepër", + "Empty playlist": "Luajlistë e zbrazët", + "Not a playlist.": "S’është luajlistë.", + "Playlist does not exist.": "Luajlista s’ekziston.", + "Hidden field \"challenge\" is a required field": "Fusha e fshehur “challenge” është fushë e domosdoshme", + "Hidden field \"token\" is a required field": "Fusha e fshehur “token” është fushë e domosdoshme", + "Erroneous token": "Token i gabuar", + "No such user": "S’ka përdorues të tillë", + "Token is expired, please try again": "Token-i ka skaduar, ju lutemi, riprovoni", + "English": "Anglisht", + "English (auto-generated)": "Anglisht (të vetë-prodhuara)", + "Afrikaans": "Afrikaans", + "Azerbaijani": "Azerbajxhanase", + "Bangla": "Bangla", + "Basque": "Baske", + "Burmese": "Burmanisht", + "Catalan": "Katalane", + "Belarusian": "Bjellorusisht", + "Bosnian": "Boshnjake", + "Bulgarian": "Bullgarisht", + "Cebuano": "Cebuano", + "Chinese (Simplified)": "Kineze (E thjeshtuar)", + "Chinese (Traditional)": "Kineze (Tradicionale)", + "Corsican": "Korsikanisht", + "Croatian": "Kroatisht", + "Czech": "Çekisht", + "Danish": "Danisht", + "Dutch": "Holandisht", + "Esperanto": "Esperanto", + "Estonian": "Estonisht", + "Filipino": "Filipineze", + "Finnish": "Finlandisht", + "French": "Frëngjisht", + "Galician": "Galicisht", + "Georgian": "Gjeorgjisht", + "German": "Gjermanisht", + "Greek": "Greqisht", + "Indonesian": "Indonezisht", + "Italian": "Italisht", + "Japanese": "Japonisht", + "Lao": "Laosisht", + "Lithuanian": "Lituanisht", + "Luxembourgish": "Luksemburgisht", + "Latin": "Latinisht", + "Latvian": "Letonisht", + "Macedonian": "Maqedonisht", + "Nyanja": "Nianja", + "Pashto": "Pashtune", + "Persian": "Perisht", + "Polish": "Polonisht", + "Portuguese": "Portugalisht", + "Punjabi": "Panxhabe", + "Romanian": "Rumanisht", + "Russian": "Rusisht", + "Samoan": "Samoanisht", + "Scottish Gaelic": "Galike Skoceze", + "Serbian": "Serbisht", + "Shona": "Shonisht", + "Sindhi": "Sindi", + "Sinhala": "Sinhaleze", + "Slovak": "Slovakisht", + "Slovenian": "Sllovenisht", + "Somali": "Somalisht", + "Southern Sotho": "Sotoishte Jugore", + "Spanish": "Spanjisht", + "Spanish (Latin America)": "Spanjisht (Amerikë Latine)", + "Thai": "Tajlandeze", + "Turkish": "Turqisht", + "Ukrainian": "Ukrainase", + "Urdu": "Urdisht", + "Uzbek": "Uzbeke", + "Welsh": "Uellase", + "Western Frisian": "Frizishte Perëndimore", + "Xhosa": "Xhosa", + "Yiddish": "Jidisht", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "(edited)": "(u përpunua)", + "YouTube comment permalink": "Permalidhje komenti YouTube", + "Audio mode": "Mënyrë për audion", + "Playlists": "Luajlista", + "channel_tab_community_label": "Bashkësi", + "search_filters_sort_option_relevance": "Rëndësi", + "Video mode": "Mënyrë video", + "channel_tab_videos_label": "Video", + "search_filters_sort_option_rating": "Vlerësim", + "search_filters_sort_option_date": "Datë ngarkimi", + "search_filters_sort_option_views": "Numër parjesh", + "search_filters_type_label": "Lloj", + "search_filters_duration_label": "Kohëzgjatje", + "search_filters_features_label": "Veçori", + "search_filters_sort_label": "Renditi Sipas", + "search_filters_date_option_hour": "Orën e fundit", + "search_filters_date_option_today": "Sot", + "search_filters_duration_option_long": "E gjatë (> 20 minuta)", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Titra/CC", + "search_filters_features_option_hdr": "HDR", + "search_filters_date_option_week": "Këtë javë", + "search_filters_date_option_month": "Këtë muaj", + "search_filters_date_option_year": "Këtë vit", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Kanal", + "search_filters_type_option_playlist": "Luajlistë", + "search_filters_type_option_movie": "Film", + "search_filters_type_option_show": "Shfaqe", + "search_filters_duration_option_short": "E shkurtër (< 4 minuta)", + "search_filters_features_option_purchased": "Të blera", + "footer_modfied_source_code": "Kod burim i ndryshuar", + "adminprefs_modified_source_code_url_label": "URL e depos së ndryshuar të kodit burim", + "none": "asnjë", + "videoinfo_started_streaming_x_ago": "Filloi transmetimin `x` më parë", + "LIVE": "DREJTPËRSËDREJTI", + "Shared `x` ago": "Ndarë me të tjerë `x` më parë", + "Unsubscribe": "Shpajtohuni", + "Subscribe": "Pajtomë", + "View channel on YouTube": "Shihni kanalin në YouTube", + "View playlist on YouTube": "Shihni luajlistën në YouTube", + "newest": "më të rejat", + "popular": "popullore", + "last": "e fundit", + "Next page": "Faqja pasuese", + "Previous page": "Faqja e mëparshme", + "Clear watch history?": "Të spastrohet historiku i parjeve?", + "New password": "Fjalëkalim i ri", + "preferences_related_videos_label": "Shfaq video të afërta: ", + "preferences_annotations_label": "Si parazgjedhje, shfaqi shënimet: ", + "preferences_show_nick_label": "Shfaqe nofkën në krye: ", + "CAPTCHA enabled: ": "Me CAPTCHA të aktivizuar: ", + "Login enabled: ": "Me hyrjen të aktivizuar: ", + "Genre: ": "Zhanër: ", + "Could not create mix.": "S’u krijua dot përzierja.", + "Yoruba": "Jorubaisht", + "Zulu": "Zulu", + "Popular": "Popullore", + "Search": "Kërko", + "About": "Mbi", + "Rating: ": "Vlerësim: ", + "preferences_locale_label": "Gjuhë: ", + "View as playlist": "Shiheni si luajlistë", + "Default": "Parazgjedhje", + "Music": "Muzikë", + "Gaming": "Lojëra", + "News": "Lajme", + "Movies": "Filma", + "Download": "Shkarkoje", + "Download as: ": "Shkarkoje si: ", + "permalink": "permalidhje", + "`x` marked it with a ❤": "`x` i është vënë një ❤", + "download_subtitles": "Titra - `x` (.vtt)", + "user_created_playlists": "`x` krijoi luajlista", + "user_saved_playlists": "`x` ruajti luajlista", + "Video unavailable": "Video jo e passhme", + "Yes": "Po", + "No": "Jo", + "Import and Export Data": "Importoni dhe Eksportoni të Dhëna", + "Import": "Importo", + "Import FreeTube subscriptions (.db)": "Importoni pajtime FreeTube (.db)", + "Import NewPipe subscriptions (.json)": "Importoni pajtime NewPipe (.json)", + "Import NewPipe data (.zip)": "Importoni të dhëna NewPipe (.zip)", + "Export": "Eksporto", + "Export subscriptions as OPML": "Eksportoni pajtime si OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Eksportoji pajtimet si OPML (për NewPipe & FreeTube)", + "Delete account?": "Të fshihet llogaria?", + "History": "Historik", + "An alternative front-end to YouTube": "Një front-end alternativ për YouTube-in", + "JavaScript license information": "Hollësi licence JavaScript", + "source": "burim", + "Log in": "Hyni", + "preferences_category_visual": "Parapëlqime pamore", + "preferences_region_label": "Vend lënde: ", + "View YouTube comments": "Shihni komente Youtube", + "View more comments on Reddit": "Shihni më tepër komente në Reddit", + "View `x` comments": { + "([^.,0-9]|^)1([^.,0-9]|$)": "Shihni `x` koment", + "": "Shihni `x` komente" + }, + "View Reddit comments": "Shihni komente Reddit", + "Hide replies": "Fshihi përgjigjet", + "Show replies": "Shfaq përgjigje", + "Incorrect password": "Fjalëkalim i pasaktë", + "Malagasy": "Malagashe", + "Malay": "Malajase", + "Malayalam": "Malajalamase", + "Maltese": "Maltisht", + "Maori": "Maori", + "Marathi": "Marati", + "Mongolian": "Mongolisht", + "Nepali": "Nepaleze", + "Norwegian Bokmål": "Norvegjishte Bokmål", + "search_filters_features_option_three_sixty": "360°", + "Current version: ": "Versioni i tanishëm: ", + "next_steps_error_message": "Pas të cilës duhet të provoni të: ", + "next_steps_error_message_refresh": "Rifreskoje", + "next_steps_error_message_go_to_youtube": "Kaloni në Youtube", + "footer_donate_page": "Dhuroni", + "footer_documentation": "Dokumentim", + "footer_source_code": "Kod burim", + "footer_original_source_code": "Kodim burim origjinal", + "generic_count_hours": "{{count}} orë", + "generic_count_hours_plural": "{{count}} orë", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} video", + "generic_playlists_count": "{{count}} luajlistë", + "generic_playlists_count_plural": "{{count}} luajlista", + "generic_subscribers_count": "{{count}} pajtimtar", + "generic_subscribers_count_plural": "{{count}} pajtimtarë", + "subscriptions_unseen_notifs_count": "{{count}} njoftim që s’është parë", + "subscriptions_unseen_notifs_count_plural": "{{count}} njoftime që s’janë parë", + "comments_view_x_replies": "Shihni {{count}} përgjigje", + "comments_view_x_replies_plural": "Shihni {{count}} përgjigje", + "comments_points_count": "{{count}} pikë", + "comments_points_count_plural": "{{count}} pikë", + "generic_count_years": "{{count}} vit", + "generic_count_years_plural": "{{count}} vjet", + "generic_count_months": "{{count}} muaj", + "generic_count_months_plural": "{{count}} muaj", + "generic_count_weeks": "{{count}} javë", + "generic_count_weeks_plural": "{{count}} javë", + "generic_count_days": "{{count}} ditë", + "generic_count_days_plural": "{{count}} ditë", + "generic_count_minutes": "{{count}} minutë", + "generic_count_minutes_plural": "{{count}} minuta", + "generic_count_seconds": "{{count}} sekondë", + "generic_count_seconds_plural": "{{count}} sekonda", + "crash_page_you_found_a_bug": "Duket sikur gjetët një të metë në Invidious!", + "crash_page_before_reporting": "Para se të njoftoni një të metë, sigurohuni se keni:", + "crash_page_refresh": "provuar të <a href=\"`x`\">rifreskoni faqen</a>", + "crash_page_switch_instance": "provuar të <a href=\"`x`\">përdorni tjetër instancë</a>", + "crash_page_read_the_faq": "lexuar <a href=\"`x`\">Pyetje të Bëra Rëndom (PBR)</a>", + "generic_views_count": "{{count}} parje", + "generic_views_count_plural": "{{count}} parje", + "English (United Kingdom)": "Anglisht (Mbretëri e Bashkuar)", + "English (United States)": "Anglisht (Shtetet e Bashkuara)", + "Cantonese (Hong Kong)": "Kantoneze (Hong Kong)", + "Chinese": "Kinezçe", + "Chinese (China)": "Kinezçe (Kinë)", + "Chinese (Hong Kong)": "Kinezçe (Hong-Kong)", + "Chinese (Taiwan)": "Kinezçe (Tajvan)", + "Dutch (auto-generated)": "Holandisht (e prodhuar automatikisht)", + "French (auto-generated)": "Anglisht (të prodhuara automatikisht)", + "German (auto-generated)": "Gjermanisht (të prodhuara automatikisht)", + "Hmong": "Hmong", + "Indonesian (auto-generated)": "Indonezisht (të prodhuara automatikisht)", + "Interlingue": "Interlingue", + "Italian (auto-generated)": "Italisht (të prodhuara automatikisht)", + "Japanese (auto-generated)": "Japonisht (të prodhuara automatikisht)", + "Korean (auto-generated)": "Koreane (të prodhuara automatikisht)", + "Portuguese (auto-generated)": "Portugalisht (të prodhuara automatikisht)", + "Portuguese (Brazil)": "Portugeze (Brazil)", + "Russian (auto-generated)": "Rusisht (të prodhuara automatikisht)", + "Spanish (auto-generated)": "Spanjisht (të prodhuara automatikisht)", + "Spanish (Mexico)": "Spanjisht (Meksikë)", + "Spanish (Spain)": "Spanjisht (Spanjë)", + "Turkish (auto-generated)": "Turqisht (të prodhuara automatikisht)", + "Vietnamese (auto-generated)": "Vietnamisht (të prodhuara automatikisht)", + "crash_page_search_issue": "kërkuar për <a href=\"`x`\">çështje ekzistuese në GitHub</a>", + "crash_page_report_issue": "Nëse asnjë nga sa më sipër s’ndihmoi, ju lutemi, <a href=\"`x`\">hapni një çështje në GitHub</a> (mundësisht në anglisht) dhe përfshini në mesazhin tuaj tekstin vijues (MOS e përktheni këtë tekst):", + "generic_subscriptions_count": "{{count}} pajtim", + "generic_subscriptions_count_plural": "{{count}} pajtime", + "tokens_count": "{{count}} token", + "tokens_count_plural": "{{count}} tokenë", + "preferences_save_player_pos_label": "Mba mend pozicionin e luajtjes: ", + "Import Invidious data": "Importoni të dhëna JSON Invidious", + "Import YouTube subscriptions": "Importoni pajtime YouTube CSV ose OPML", + "Export data as JSON": "Eksportoji të dhënat Invidious si JSON", + "preferences_vr_mode_label": "Video me ndërveprim 360 gradë (lyp WebGL): ", + "Shared `x`": "Ndarë me të tjerë më `x`", + "search_filters_title": "Filtra", + "Popular enabled: ": "Me populloret të aktivizuara: ", + "error_video_not_in_playlist": "Videoja e kërkuar s’ekziston në këtë luajlistë. <a href=\"`x`\">Klikoni këtu për faqen hyrëse të luajlistës.</a>", + "search_message_use_another_instance": "Mundeni edhe të <a href=\"`x`\">kërkoni në një instancë tjetër</a>.", + "search_filters_date_label": "Datë ngarkimi", + "preferences_watch_history_label": "Aktivizo historik parjesh: ", + "Top enabled: ": "Me kryesueset të aktivizuara: ", + "preferences_video_loop_label": "Përsërite gjithmonë: ", + "search_message_no_results": "S’u gjetën përfundime.", + "Could not pull trending pages.": "S’u morën dot faqet në modë.", + "search_filters_date_option_none": "Çfarëdo date", + "search_message_change_filters_or_query": "Provoni të zgjeroni kërkesën tuaj të kërkimit dhe/ose të ndryshoni filtrat.", + "search_filters_type_option_all": "Çfarëdo lloji", + "search_filters_duration_option_none": "Çfarëdo kohëzgjatjeje", + "search_filters_duration_option_medium": "Mesatare (4 - 20 minuta)", + "search_filters_features_option_vr180": "VR180", + "search_filters_apply_button": "Apliko filtrat e përzgjedhur", + "channel_tab_playlists_label": "Luajlista", + "Artist: ": "Artist: ", + "Album: ": "Album: ", + "channel_tab_channels_label": "Kanale", + "Music in this video": "Muzikë në këtë video", + "channel_tab_shorts_label": "Të shkurtra", + "channel_tab_streams_label": "Transmetime të drejtpërdrejta", + "generic_button_cancel": "Anuloje", + "generic_channels_count": "{{count}} kanal", + "generic_channels_count_plural": "{{count}} kanale", + "generic_button_rss": "RSS", + "generic_button_delete": "Fshije", + "generic_button_save": "Ruaje", + "generic_button_edit": "Përpunoni", + "playlist_button_add_items": "Shtoni video", + "Report statistics: ": "Statistika raportimesh: ", + "Download is disabled": "Shkarkimi është i çaktivizuar", + "Channel Sponsor": "Sponsor Kanali", + "channel_tab_releases_label": "Hedhje në qarkullim", + "Song: ": "Pjesë: ", + "Import YouTube playlist (.csv)": "Importoni luajlistë YouTube (.csv)", + "Standard YouTube license": "Licencë YouTube standarde", + "published - reverse": "publikuar më - së prapthi", + "channel_tab_podcasts_label": "Podcast-e", + "channel name - reverse": "emër kanali - së prapthi", + "Import YouTube watch history (.json)": "Importo historik parjesh YouTube (.json)", + "preferences_local_label": "Video përmes ndërmjetësi: ", + "Fallback captions: ": "Titra nga halli: ", + "Erroneous challenge": "Zgjidhje e gabuar", + "Add to playlist: ": "Shtoje te luajlistë: ", + "Add to playlist": "Shtoje te luajlistë", + "Answer": "Përgjigje", + "Search for videos": "Kërko për video", + "The Popular feed has been disabled by the administrator.": "Prurja Popullore është çaktivizuar nga përgjegjësi.", + "carousel_skip": "Anashkaloje Rrotullamen", + "carousel_slide": "Diapozitiv {{current}} nga {{total}}", + "carousel_go_to": "Kalo te diapozitivi `x`" +} diff --git a/locales/sr.json b/locales/sr.json index 7766f32e..d28b2459 100644 --- a/locales/sr.json +++ b/locales/sr.json @@ -1,418 +1,517 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` пратилаца.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` пратилаца." - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` видео записа.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` видео записа." - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` списака извођења.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` списака извођења." - }, - "LIVE": "УЖИВО", - "Shared `x` ago": "Подељено пре `x`", - "Unsubscribe": "Прекини праћење", - "Subscribe": "Прати", - "View channel on YouTube": "Погледај канал на YouTube-у", - "View playlist on YouTube": "Погледај списак извођења на YouTube-у", - "newest": "најновије", - "oldest": "најстарије", - "popular": "гласовито", - "last": "последње", - "Next page": "Следећа страница", - "Previous page": "Претходна страница", - "Clear watch history?": "Избрисати повест прегледања?", - "New password": "Нова запорка", - "New passwords must match": "Нове запорке морају бити истоветне", - "Cannot change password for Google accounts": "Није могуће променити запорку за Google налоге", - "Authorize token?": "Овласти токен?", - "Authorize token for `x`?": "Овласти токен за `x`?", - "Yes": "Да", - "No": "Не", - "Import and Export Data": "Увоз и извоз података", - "Import": "Увези", - "Import Invidious data": "Увези податке са Invidious-а", - "Import YouTube subscriptions": "Увези праћења са YouTube-а", - "Import FreeTube subscriptions (.db)": "Увези праћења са FreeTube-а (.db)", - "Import NewPipe subscriptions (.json)": "Увези праћења са NewPipe-а (.json)", - "Import NewPipe data (.zip)": "Увези податке са NewPipe-а (.zip)", - "Export": "Извези", - "Export subscriptions as OPML": "Извези праћења као OPML датотеку", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Извези праћења као OPML датотеку (за NewPipe и FreeTube)", - "Export data as JSON": "Извези податке као JSON датотеку", - "Delete account?": "Избрисати рачун?", - "History": "Повест", - "An alternative front-end to YouTube": "Заменски кориснички слој за YouTube", - "JavaScript license information": "Извештај о JavaScript одобрењу", - "source": "извор", - "Log in": "Пријави се", - "Log in/register": "Пријави се/Отвори налог", - "Log in with Google": "Пријави се помоћу Google-а", - "User ID": "Кориснички ИД", - "Password": "Запорка", - "Time (h:mm:ss):": "Време (ч:мм:сс):", - "Text CAPTCHA": "Знаковни CAPTCHA", - "Image CAPTCHA": "Сликовни CAPTCHA", - "Sign In": "Пријава", - "Register": "Отвори налог", - "E-mail": "Е-пошта", - "Google verification code": "Google-ов оверни кôд", - "Preferences": "Подешавања", - "Player preferences": "Подешавања репродуктора", - "Always loop: ": "Увек понављај: ", - "Autoplay: ": "Самопуштање: ", - "Play next by default: ": "Увек подразумевано пуштај следеће: ", - "Autoplay next video: ": "Самопуштање следећег видео записа: ", - "Listen by default: ": "Увек подразумевано укључен само звук: ", - "Proxy videos: ": "Приказ видео записа преко посредника: ", - "Default speed: ": "", - "Preferred video quality: ": "", - "Player volume: ": "", - "Default comments: ": "", - "youtube": "", - "reddit": "", - "Default captions: ": "", - "Fallback captions: ": "", - "Show related videos: ": "", - "Show annotations by default: ": "", - "Automatically extend video description: ": "", - "Visual preferences": "", - "Player style: ": "", - "Dark mode: ": "", - "Theme: ": "", - "dark": "", - "light": "", - "Thin mode: ": "", - "Subscription preferences": "", - "Show annotations by default for subscribed channels: ": "", - "Redirect homepage to feed: ": "", - "Number of videos shown in feed: ": "", - "Sort videos by: ": "", - "published": "", - "published - reverse": "", - "alphabetically": "", - "alphabetically - reverse": "", - "channel name": "", - "channel name - reverse": "", - "Only show latest video from channel: ": "", - "Only show latest unwatched video from channel: ": "", - "Only show unwatched: ": "", - "Only show notifications (if there are any): ": "", - "Enable web notifications": "", - "`x` uploaded a video": "", - "`x` is live": "", - "Data preferences": "", - "Clear watch history": "", - "Import/export data": "", - "Change password": "", - "Manage subscriptions": "", - "Manage tokens": "", - "Watch history": "", - "Delete account": "", - "Administrator preferences": "", - "Default homepage: ": "", - "Feed menu: ": "", - "Top enabled: ": "", - "CAPTCHA enabled: ": "", - "Login enabled: ": "", - "Registration enabled: ": "", - "Report statistics: ": "", - "Save preferences": "", - "Subscription manager": "", - "Token manager": "", - "Token": "", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Import/export": "", - "unsubscribe": "", - "revoke": "", - "Subscriptions": "", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "search": "", - "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", - "Source available here.": "", - "View JavaScript license information.": "", - "View privacy policy.": "", - "Trending": "", - "Public": "", - "Unlisted": "", - "Private": "", - "View all playlists": "", - "Updated `x` ago": "", - "Delete playlist `x`?": "", - "Delete playlist": "", - "Create playlist": "", - "Title": "", - "Playlist privacy": "", - "Editing playlist `x`": "", - "Show more": "", - "Show less": "", - "Watch on YouTube": "", - "Hide annotations": "", - "Show annotations": "", - "Genre: ": "", - "License: ": "", - "Family friendly? ": "", - "Wilson score: ": "", - "Engagement: ": "", - "Whitelisted regions: ": "", - "Blacklisted regions: ": "", - "Shared `x`": "", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Premieres in `x`": "", - "Premieres `x`": "", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "", - "View YouTube comments": "", - "View more comments on Reddit": "", + "LIVE": "UŽIVO", + "Shared `x` ago": "Deljeno pre `x`", + "Unsubscribe": "Prekini praćenje", + "Subscribe": "Zaprati", + "View channel on YouTube": "Pogledaj kanal na YouTube-u", + "View playlist on YouTube": "Pogledaj plejlistu na YouTube-u", + "newest": "najnovije", + "oldest": "najstarije", + "popular": "popularno", + "last": "poslednje", + "Next page": "Sledeća stranica", + "Previous page": "Prethodna stranica", + "Clear watch history?": "Očistiti istoriju gledanja?", + "New password": "Nova lozinka", + "New passwords must match": "Nove lozinke moraju da se podudaraju", + "Authorize token?": "Autorizovati token?", + "Authorize token for `x`?": "Autorizovati token za `x`?", + "Yes": "Da", + "No": "Ne", + "Import and Export Data": "Uvoz i izvoz podataka", + "Import": "Uvezi", + "Import Invidious data": "Uvezi Invidious JSON podatke", + "Import YouTube subscriptions": "Uvezi YouTube CSV ili OPML praćenja", + "Import FreeTube subscriptions (.db)": "Uvezi FreeTube praćenja (.db)", + "Import NewPipe subscriptions (.json)": "Uvezi NewPipe praćenja (.json)", + "Import NewPipe data (.zip)": "Uvezi NewPipe podatke (.zip)", + "Export": "Izvezi", + "Export subscriptions as OPML": "Izvezi praćenja kao OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Izvezi praćenja kao OPML (za NewPipe i FreeTube)", + "Export data as JSON": "Izvezi Invidious podatke kao JSON", + "Delete account?": "Izbrisati nalog?", + "History": "Istorija", + "An alternative front-end to YouTube": "Alternativni front-end za YouTube", + "JavaScript license information": "Informacije o JavaScript licenci", + "source": "izvor", + "Log in": "Prijava", + "Log in/register": "Prijava/registracija", + "User ID": "ID korisnika", + "Password": "Lozinka", + "Time (h:mm:ss):": "Vreme (č:mm:ss):", + "Text CAPTCHA": "Tekst CAPTCHA", + "Image CAPTCHA": "Slika CAPTCHA", + "Sign In": "Prijava", + "Register": "Registracija", + "E-mail": "Imejl", + "Preferences": "Podešavanja", + "preferences_category_player": "Podešavanja plejera", + "preferences_video_loop_label": "Uvek ponavljaj: ", + "preferences_autoplay_label": "Automatski pusti: ", + "preferences_continue_label": "Podrazumevano pusti sledeće: ", + "preferences_continue_autoplay_label": "Automatski pusti sledeći video snimak: ", + "preferences_listen_label": "Podrazumevano uključi samo zvuk: ", + "preferences_local_label": "Proksi video snimci: ", + "Playlist privacy": "Privatnost plejliste", + "Editing playlist `x`": "Izmenjivanje plejliste `x`", + "Playlist does not exist.": "Plejlista ne postoji.", + "Erroneous challenge": "Pogrešan izazov", + "Maltese": "Malteški", + "Download": "Preuzmi", + "Download as: ": "Preuzeti kao: ", + "Bangla": "Bengalski", + "preferences_quality_dash_label": "Preferirani DASH kvalitet video snimka: ", + "Token manager": "Upravljanje tokenima", + "Token": "Token", + "Import/export": "Uvoz/izvoz", + "revoke": "opozovi", + "search": "pretraga", + "Log out": "Odjava", + "Source available here.": "Izvorni kôd je dostupan ovde.", + "Trending": "U trendu", + "Updated `x` ago": "Ažurirano pre `x`", + "Delete playlist `x`?": "Izbrisati plejlistu `x`?", + "Create playlist": "Napravi plejlistu", + "Show less": "Prikaži manje", + "Switch Invidious Instance": "Promeni Invidious instancu", + "Hide annotations": "Sakrij napomene", + "User ID is a required field": "ID korisnika je obavezno polje", + "Wrong username or password": "Pogrešno korisničko ime ili lozinka", + "Please log in": "Molimo, prijavite se", + "channel:`x`": "kanal:`x`", + "Could not fetch comments": "Nije moguće prikupiti komentare", + "Could not create mix.": "Nije moguće napraviti miks.", + "Empty playlist": "Prazna plejlista", + "Not a playlist.": "Nije plejlista.", + "Could not pull trending pages.": "Nije moguće povući stranice „U trendu“.", + "Token is expired, please try again": "Token je istekao, pokušajte ponovo", + "English (auto-generated)": "Engleski (automatski generisano)", + "Afrikaans": "Afrikans", + "Albanian": "Albanski", + "Armenian": "Jermenski", + "Azerbaijani": "Azerbejdžanski", + "Basque": "Baskijski", + "Bosnian": "Bosanski", + "Bulgarian": "Bugarski", + "Burmese": "Burmanski", + "Catalan": "Katalonski", + "Cebuano": "Cebuanski", + "Chinese (Traditional)": "Kineski (Tradicionalni)", + "Corsican": "Korzikanski", + "Danish": "Danski", + "Kannada": "Kanada", + "Kazakh": "Kazaški", + "Russian": "Ruski", + "Scottish Gaelic": "Škotski Gelski", + "Sinhala": "Sinhalski", + "Slovak": "Slovački", + "Spanish": "Španski", + "Spanish (Latin America)": "Španski (Latinska Amerika)", + "Sundanese": "Sundanski", + "Swedish": "Švedski", + "Tajik": "Tadžički", + "Telugu": "Telugu", + "Turkish": "Turski", + "Ukrainian": "Ukrajinski", + "Urdu": "Urdu", + "Uzbek": "Uzbečki", + "Vietnamese": "Vijetnamski", + "Rating: ": "Ocena: ", + "View as playlist": "Pogledaj kao plejlistu", + "Default": "Podrazumevano", + "Gaming": "Video igre", + "Movies": "Filmovi", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "(edited)": "(izmenjeno)", + "YouTube comment permalink": "Trajni link YouTube komentara", + "Audio mode": "Režim audio snimka", + "Playlists": "Plejliste", + "search_filters_sort_option_relevance": "Relevantnost", + "search_filters_sort_option_rating": "Ocena", + "search_filters_sort_option_date": "Datum otpremanja", + "search_filters_sort_option_views": "Broj pregleda", + "`x` marked it with a ❤": "`x` je označio/la sa ❤", + "search_filters_duration_label": "Trajanje", + "search_filters_features_label": "Karakteristike", + "search_filters_date_option_hour": "Poslednji sat", + "search_filters_date_option_week": "Ove nedelje", + "search_filters_date_option_month": "Ovog meseca", + "search_filters_date_option_year": "Ove godine", + "search_filters_type_option_video": "Video snimak", + "search_filters_type_option_playlist": "Plejlista", + "search_filters_type_option_movie": "Film", + "search_filters_duration_option_long": "Dugo (> 20 minuta)", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_hdr": "HDR", + "next_steps_error_message": "Nakon toga treba da pokušate da: ", + "next_steps_error_message_go_to_youtube": "Odete na YouTube", + "footer_documentation": "Dokumentacija", + "preferences_region_label": "Država sadržaja: ", + "preferences_player_style_label": "Stil plejera: ", + "preferences_dark_mode_label": "Tema: ", + "light": "svetla", + "preferences_thin_mode_label": "Kompaktni režim: ", + "preferences_category_misc": "Ostala podešavanja", + "preferences_automatic_instance_redirect_label": "Automatsko preusmeravanje instance (povratak na redirect.invidious.io): ", + "alphabetically - reverse": "abecedno - obrnuto", + "Enable web notifications": "Omogući veb obaveštenja", + "`x` is live": "`x` je uživo", + "Manage tokens": "Upravljaj tokenima", + "Watch history": "Istorija gledanja", + "preferences_feed_menu_label": "Fid meni: ", + "preferences_show_nick_label": "Prikaži nadimke na vrhu: ", + "CAPTCHA enabled: ": "CAPTCHA omogućena: ", + "Registration enabled: ": "Registracija omogućena: ", + "Subscription manager": "Upravljanje praćenjima", + "Wilson score: ": "Vilsonova ocena: ", + "Engagement: ": "Angažovanje: ", + "Whitelisted regions: ": "Dostupni regioni: ", + "Shared `x`": "Deljeno `x`", + "Premieres in `x`": "Premijera u `x`", + "Premieres `x`": "Premijera `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hej! Izgleda da ste isključili JavaScript. Kliknite ovde da biste videli komentare, imajte na umu da će možda potrajati malo duže da se učitaju.", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "View Reddit comments": "", - "Hide replies": "", - "Show replies": "", - "Incorrect password": "", - "Quota exceeded, try again in a few hours": "", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", - "Invalid TFA code": "", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "", - "Wrong answer": "", - "Erroneous CAPTCHA": "", - "CAPTCHA is a required field": "", - "User ID is a required field": "", - "Password is a required field": "", - "Wrong username or password": "", - "Please sign in using 'Log in with Google'": "", - "Password cannot be empty": "", - "Password cannot be longer than 55 characters": "", - "Please log in": "", - "Invidious Private Feed for `x`": "", - "channel:`x`": "", - "Deleted or invalid channel": "", - "This channel does not exist.": "", - "Could not get channel info.": "", - "Could not fetch comments": "", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` ago": "", - "Load more": "", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "Could not create mix.": "", - "Empty playlist": "", - "Not a playlist.": "", - "Playlist does not exist.": "", - "Could not pull trending pages.": "", - "Hidden field \"challenge\" is a required field": "", - "Hidden field \"token\" is a required field": "", - "Erroneous challenge": "", - "Erroneous token": "", - "No such user": "", - "Token is expired, please try again": "", - "English": "", - "English (auto-generated)": "", - "Afrikaans": "", - "Albanian": "", - "Amharic": "", - "Arabic": "", - "Armenian": "", - "Azerbaijani": "", - "Bangla": "", - "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", - "Hungarian": "", - "Icelandic": "", - "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian Bokmål": "", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", - "Russian": "", - "Samoan": "", - "Scottish Gaelic": "", - "Serbian": "", - "Shona": "", - "Sindhi": "", - "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", - "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", - "Swedish": "", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", - "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "", - "": "" + "([^.,0-9]|^)1([^.,0-9]|$)": "Pogledaj `x` komentar", + "": "Pogledaj`x` komentara" }, - "Fallback comments: ": "", - "Popular": "", - "Search": "", - "Top": "", - "About": "", - "Rating: ": "", - "Language: ": "", - "View as playlist": "", - "Default": "", - "Music": "", - "Gaming": "", - "News": "", - "Movies": "", - "Download": "", - "Download as: ": "", - "%A %B %-d, %Y": "", - "(edited)": "", - "YouTube comment permalink": "", - "permalink": "", - "`x` marked it with a ❤": "", - "Audio mode": "", - "Video mode": "", - "Videos": "", - "Playlists": "", - "Community": "", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "" -}
\ No newline at end of file + "View Reddit comments": "Pogledaj Reddit komentare", + "CAPTCHA is a required field": "CAPTCHA je obavezno polje", + "Croatian": "Hrvatski", + "Estonian": "Estonski", + "Filipino": "Filipinski", + "French": "Francuski", + "Galician": "Galicijski", + "German": "Nemački", + "Greek": "Grčki", + "Hausa": "Hausa", + "Italian": "Italijanski", + "Khmer": "Kmerski", + "Kurdish": "Kurdski", + "Kyrgyz": "Kirgiski", + "Latvian": "Letonski", + "Lithuanian": "Litvanski", + "Macedonian": "Makedonski", + "Malagasy": "Malgaški", + "Malay": "Malajski", + "Marathi": "Maratski", + "Mongolian": "Mongolski", + "Norwegian Bokmål": "Norveški Bokmal", + "Nyanja": "Nijandža", + "Pashto": "Paštunski", + "Persian": "Persijski", + "Punjabi": "Pandžapski", + "Romanian": "Rumunski", + "Welsh": "Velški", + "Western Frisian": "Zapadnofrizijski", + "Fallback comments: ": "Rezervni komentari: ", + "Popular": "Popularno", + "Search": "Pretraga", + "About": "O sajtu", + "footer_source_code": "Izvorni kôd", + "footer_original_source_code": "Originalni izvorni kôd", + "preferences_related_videos_label": "Prikaži srodne video snimke: ", + "preferences_annotations_label": "Podrazumevano prikaži napomene: ", + "preferences_extend_desc_label": "Automatski proširi opis video snimka: ", + "preferences_vr_mode_label": "Interaktivni video snimci od 360 stepeni (zahteva WebGl): ", + "preferences_category_visual": "Vizuelna podešavanja", + "preferences_captions_label": "Podrazumevani titlovi: ", + "Music": "Muzika", + "search_filters_type_label": "Vrsta", + "Tamil": "Tamilski", + "Save preferences": "Sačuvaj podešavanja", + "Only show latest unwatched video from channel: ": "Prikaži samo najnoviji neodgledani video snimak sa kanala: ", + "Xhosa": "Kosa (Khosa)", + "search_filters_type_option_channel": "Kanal", + "Hungarian": "Mađarski", + "Maori": "Maorski", + "Manage subscriptions": "Upravljaj praćenjima", + "Hindi": "Hindi", + "`x` ago": "pre `x`", + "Import/export data": "Uvezi/Izvezi podatke", + "`x` uploaded a video": "`x` je otpremio/la video snimak", + "Delete account": "Izbriši nalog", + "preferences_default_home_label": "Podrazumevana početna stranica: ", + "Serbian": "Srpski", + "License: ": "Licenca: ", + "search_filters_features_option_live": "Uživo", + "Report statistics: ": "Izveštavaj statistike: ", + "Only show latest video from channel: ": "Prikaži samo najnoviji video snimak sa kanala: ", + "channel name - reverse": "ime kanala - obrnuto", + "Could not get channel info.": "Nije moguće prikupiti informacije o kanalu.", + "View privacy policy.": "Pogledaj politiku privatnosti.", + "Change password": "Promeni lozinku", + "Malayalam": "Malajalamski", + "View more comments on Reddit": "Pogledaj više komentara na Reddit-u", + "Portuguese": "Portugalski", + "View YouTube comments": "Pogledaj YouTube komentare", + "published - reverse": "objavljeno - obrnuto", + "Dutch": "Holandski", + "preferences_volume_label": "Jačina zvuka plejera: ", + "preferences_locale_label": "Jezik: ", + "adminprefs_modified_source_code_url_label": "URL adresa do repozitorijuma izmenjenog izvornog koda", + "channel_tab_community_label": "Zajednica", + "Video mode": "Režim video snimka", + "Fallback captions: ": "Rezervni titlovi: ", + "Private": "Privatno", + "alphabetically": "abecedno", + "No such user": "Ne postoji korisnik", + "Subscriptions": "Praćenja", + "search_filters_date_option_today": "Danas", + "Finnish": "Finski", + "Lao": "Laoski", + "Login enabled: ": "Prijava omogućena: ", + "Shona": "Šona", + "search_filters_features_option_location": "Lokacija", + "Load more": "Učitaj više", + "Released under the AGPLv3 on Github.": "Objavljeno pod licencom AGPLv3 na GitHub-u.", + "Slovenian": "Slovenački", + "View JavaScript license information.": "Pogledaj informacije o JavaScript licenci.", + "Chinese (Simplified)": "Kineski (Pojednostavljeni)", + "preferences_comments_label": "Podrazumevani komentari: ", + "Incorrect password": "Netačna lozinka", + "Show replies": "Prikaži odgovore", + "Invidious Private Feed for `x`": "Invidious privatni fid za `x`", + "Watch on YouTube": "Gledaj na YouTube-u", + "Wrong answer": "Pogrešan odgovor", + "preferences_quality_label": "Preferirani kvalitet video snimka: ", + "Hide replies": "Sakrij odgovore", + "Erroneous CAPTCHA": "Pogrešna CAPTCHA", + "Erroneous token": "Pogrešan token", + "Czech": "Češki", + "Latin": "Latinski", + "channel_tab_videos_label": "Video snimci", + "search_filters_features_option_four_k": "4К", + "footer_donate_page": "Doniraj", + "English": "Engleski", + "Arabic": "Arapski", + "Unlisted": "Po pozivu", + "Hidden field \"challenge\" is a required field": "Skriveno polje „izazov“ je obavezno polje", + "Hidden field \"token\" is a required field": "Skriveno polje „token“ je obavezno polje", + "Georgian": "Gruzijski", + "Hawaiian": "Havajski", + "Hebrew": "Hebrejski", + "Icelandic": "Islandski", + "Igbo": "Igbo", + "Japanese": "Japanski", + "Javanese": "Javanski", + "Sindhi": "Sindi", + "Swahili": "Suvali", + "Yiddish": "Jidiš", + "Zulu": "Zulu", + "search_filters_features_option_subtitles": "Titlovi/Skriveni titlovi", + "Password cannot be longer than 55 characters": "Lozinka ne može biti duža od 55 znakova", + "This channel does not exist.": "Ovaj kanal ne postoji.", + "Belarusian": "Beloruski", + "Gujarati": "Gudžarati", + "Haitian Creole": "Haićanski Kreolski", + "Somali": "Somalijski", + "Top": "Top", + "footer_modfied_source_code": "Izmenjeni izvorni kôd", + "preferences_category_subscription": "Podešavanja praćenja", + "preferences_annotations_subscribed_label": "Podrazumevano prikazati napomene za kanale koje pratite? ", + "preferences_max_results_label": "Broj video snimaka prikazanih u fidu: ", + "preferences_sort_label": "Sortiraj video snimke po: ", + "preferences_unseen_only_label": "Prikaži samo neodgledano: ", + "preferences_notifications_only_label": "Prikaži samo obaveštenja (ako ih ima): ", + "preferences_category_data": "Podešavanja podataka", + "Clear watch history": "Očisti istoriju gledanja", + "preferences_category_admin": "Podešavanja administratora", + "published": "objavljeno", + "search_filters_sort_label": "Sortiranje po", + "search_filters_type_option_show": "Emisija", + "search_filters_duration_option_short": "Kratko (< 4 minuta)", + "Current version: ": "Trenutna verzija: ", + "Top enabled: ": "Top omogućeno: ", + "Public": "Javno", + "Delete playlist": "Izbriši plejlistu", + "Title": "Naslov", + "Show annotations": "Prikaži napomene", + "Password cannot be empty": "Lozinka ne može biti prazna", + "Deleted or invalid channel": "Izbrisan ili nevažeći kanal", + "Esperanto": "Esperanto", + "Hmong": "Hmong", + "Luxembourgish": "Luksemburški", + "Nepali": "Nepalski", + "Samoan": "Samoanski", + "News": "Vesti", + "permalink": "trajni link", + "Password is a required field": "Lozinka je obavezno polje", + "Amharic": "Amharski", + "Indonesian": "Indonezijski", + "Irish": "Irski", + "Korean": "Korejski", + "Southern Sotho": "Južni Soto", + "Thai": "Tajski", + "preferences_speed_label": "Podrazumevana brzina: ", + "Dark mode: ": "Tamni režim: ", + "dark": "tamna", + "Redirect homepage to feed: ": "Preusmeri početnu stranicu na fid: ", + "channel name": "ime kanala", + "View all playlists": "Pogledaj sve plejliste", + "Show more": "Prikaži više", + "Genre: ": "Žanr: ", + "Family friendly? ": "Pogodno za porodicu? ", + "next_steps_error_message_refresh": "Osvežite", + "youtube": "YouTube", + "reddit": "Reddit", + "unsubscribe": "prekini praćenje", + "Blacklisted regions: ": "Nedostupni regioni: ", + "Polish": "Poljski", + "Yoruba": "Joruba", + "search_filters_title": "Filteri", + "Korean (auto-generated)": "Korejski (automatski generisano)", + "search_filters_features_option_three_sixty": "360°", + "preferences_quality_dash_option_worst": "Najgore", + "channel_tab_podcasts_label": "Podkasti", + "preferences_save_player_pos_label": "Sačuvaj poziciju reprodukcije: ", + "Spanish (Mexico)": "Španski (Meksiko)", + "generic_subscriptions_count_0": "{{count}} praćenje", + "generic_subscriptions_count_1": "{{count}} praćenja", + "generic_subscriptions_count_2": "{{count}} praćenja", + "search_filters_apply_button": "Primeni izabrane filtere", + "Download is disabled": "Preuzimanje je onemogućeno", + "comments_points_count_0": "{{count}} poen", + "comments_points_count_1": "{{count}} poena", + "comments_points_count_2": "{{count}} poena", + "preferences_quality_dash_option_2160p": "2160p", + "German (auto-generated)": "Nemački (automatski generisano)", + "Japanese (auto-generated)": "Japanski (automatski generisano)", + "preferences_quality_option_medium": "Srednje", + "search_message_change_filters_or_query": "Pokušajte da proširite upit za pretragu i/ili promenite filtere.", + "crash_page_before_reporting": "Pre nego što prijavite grešku, uverite se da ste:", + "preferences_quality_dash_option_best": "Najbolje", + "Channel Sponsor": "Sponzor kanala", + "generic_videos_count_0": "{{count}} video snimak", + "generic_videos_count_1": "{{count}} video snimka", + "generic_videos_count_2": "{{count}} video snimaka", + "videoinfo_started_streaming_x_ago": "Započeto strimovanje pre `x`", + "videoinfo_youTube_embed_link": "Ugrađeno", + "channel_tab_streams_label": "Strimovi uživo", + "playlist_button_add_items": "Dodaj video snimke", + "generic_count_minutes_0": "{{count}} minut", + "generic_count_minutes_1": "{{count}} minuta", + "generic_count_minutes_2": "{{count}} minuta", + "preferences_quality_dash_option_720p": "720p", + "preferences_watch_history_label": "Omogući istoriju gledanja: ", + "user_saved_playlists": "Sačuvanih plejlista: `x`", + "Spanish (Spain)": "Španski (Španija)", + "invidious": "Invidious", + "crash_page_refresh": "pokušali da <a href=\"`x`\">osvežite stranicu</a>", + "Chinese (Hong Kong)": "Kineski (Hong Kong)", + "Artist: ": "Izvođač: ", + "generic_count_months_0": "{{count}} mesec", + "generic_count_months_1": "{{count}} meseca", + "generic_count_months_2": "{{count}} meseci", + "search_message_use_another_instance": "Takođe, možete <a href=\"`x`\">pretraživati na drugoj instanci</a>.", + "generic_subscribers_count_0": "{{count}} pratilac", + "generic_subscribers_count_1": "{{count}} pratioca", + "generic_subscribers_count_2": "{{count}} pratilaca", + "download_subtitles": "Titlovi - `x` (.vtt)", + "generic_button_save": "Sačuvaj", + "crash_page_search_issue": "pretražili <a href=\"`x`\">postojeće izveštaje o problemima na GitHub-u</a>", + "generic_button_cancel": "Otkaži", + "none": "nijedno", + "English (United States)": "Engleski (Sjedinjene Američke Države)", + "subscriptions_unseen_notifs_count_0": "{{count}} neviđeno obaveštenje", + "subscriptions_unseen_notifs_count_1": "{{count}} neviđena obaveštenja", + "subscriptions_unseen_notifs_count_2": "{{count}} neviđenih obaveštenja", + "Album: ": "Album: ", + "preferences_quality_option_dash": "DASH (adaptivni kvalitet)", + "preferences_quality_dash_option_1080p": "1080p", + "Video unavailable": "Video snimak nedostupan", + "tokens_count_0": "{{count}} token", + "tokens_count_1": "{{count}} tokena", + "tokens_count_2": "{{count}} tokena", + "Chinese (China)": "Kineski (Kina)", + "Italian (auto-generated)": "Italijanski (automatski generisano)", + "channel_tab_shorts_label": "Shorts", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_360p": "360p", + "search_message_no_results": "Nisu pronađeni rezultati.", + "channel_tab_releases_label": "Izdanja", + "preferences_quality_dash_option_144p": "144p", + "Interlingue": "Interlingva", + "Song: ": "Pesma: ", + "generic_channels_count_0": "{{count}} kanal", + "generic_channels_count_1": "{{count}} kanala", + "generic_channels_count_2": "{{count}} kanala", + "Chinese (Taiwan)": "Kineski (Tajvan)", + "Turkish (auto-generated)": "Turski (automatski generisano)", + "Indonesian (auto-generated)": "Indonezijski (automatski generisano)", + "Portuguese (auto-generated)": "Portugalski (automatski generisano)", + "generic_count_years_0": "{{count}} godina", + "generic_count_years_1": "{{count}} godine", + "generic_count_years_2": "{{count}} godina", + "videoinfo_invidious_embed_link": "Ugrađeni link", + "Popular enabled: ": "Popularno omogućeno: ", + "Spanish (auto-generated)": "Španski (automatski generisano)", + "preferences_quality_option_small": "Malo", + "English (United Kingdom)": "Engleski (Ujedinjeno Kraljevstvo)", + "channel_tab_playlists_label": "Plejliste", + "generic_button_edit": "Izmeni", + "generic_playlists_count_0": "{{count}} plejlista", + "generic_playlists_count_1": "{{count}} plejliste", + "generic_playlists_count_2": "{{count}} plejlista", + "preferences_quality_option_hd720": "HD720", + "search_filters_features_option_purchased": "Kupljeno", + "search_filters_date_option_none": "Bilo koji datum", + "preferences_quality_dash_option_auto": "Automatski", + "Cantonese (Hong Kong)": "Kantonski (Hong Kong)", + "crash_page_report_issue": "Ako ništa od gorenavedenog nije pomoglo, <a href=\"`x`\">otvorite novi izveštaj o problemu na GitHub-u</a> (po mogućnosti na engleskom) i uključite sledeći tekst u svoju poruku (NE prevodite taj tekst):", + "crash_page_switch_instance": "pokušali da <a href=\"`x`\">koristite drugu instancu</a>", + "generic_count_weeks_0": "{{count}} nedelja", + "generic_count_weeks_1": "{{count}} nedelje", + "generic_count_weeks_2": "{{count}} nedelja", + "videoinfo_watch_on_youTube": "Gledaj na YouTube-u", + "Music in this video": "Muzika u ovom video snimku", + "generic_button_rss": "RSS", + "preferences_quality_dash_option_4320p": "4320p", + "generic_count_hours_0": "{{count}} sat", + "generic_count_hours_1": "{{count}} sata", + "generic_count_hours_2": "{{count}} sati", + "French (auto-generated)": "Francuski (automatski generisano)", + "crash_page_read_the_faq": "pročitali <a href=\"`x`\">Često Postavljana Pitanja (ČPP)</a>", + "user_created_playlists": "Napravljenih plejlista: `x`", + "channel_tab_channels_label": "Kanali", + "search_filters_type_option_all": "Bilo koja vrsta", + "Russian (auto-generated)": "Ruski (automatski generisano)", + "preferences_quality_dash_option_480p": "480p", + "comments_view_x_replies_0": "Pogledaj {{count}} odgovor", + "comments_view_x_replies_1": "Pogledaj {{count}} odgovora", + "comments_view_x_replies_2": "Pogledaj {{count}} odgovora", + "Portuguese (Brazil)": "Portugalski (Brazil)", + "search_filters_features_option_vr180": "VR180", + "error_video_not_in_playlist": "Traženi video snimak ne postoji na ovoj plejlisti. <a href=\"`x`\">Kliknite ovde za početnu stranicu plejliste.</a>", + "Dutch (auto-generated)": "Holandski (automatski generisano)", + "generic_count_days_0": "{{count}} dan", + "generic_count_days_1": "{{count}} dana", + "generic_count_days_2": "{{count}} dana", + "Vietnamese (auto-generated)": "Vijetnamski (automatski generisano)", + "search_filters_duration_option_none": "Bilo koje trajanje", + "preferences_quality_dash_option_240p": "240p", + "Chinese": "Kineski", + "generic_button_delete": "Izbriši", + "Import YouTube playlist (.csv)": "Uvezi YouTube plejlistu (.csv)", + "Standard YouTube license": "Standardna YouTube licenca", + "search_filters_duration_option_medium": "Srednje (4 - 20 minuta)", + "generic_count_seconds_0": "{{count}} sekunda", + "generic_count_seconds_1": "{{count}} sekunde", + "generic_count_seconds_2": "{{count}} sekundi", + "search_filters_date_label": "Datum otpremanja", + "crash_page_you_found_a_bug": "Izgleda da ste pronašli grešku u Invidious-u!", + "generic_views_count_0": "{{count}} pregled", + "generic_views_count_1": "{{count}} pregleda", + "generic_views_count_2": "{{count}} pregleda", + "Import YouTube watch history (.json)": "Uvezi YouTube istoriju gledanja (.json)", + "The Popular feed has been disabled by the administrator.": "Administrator je onemogućio fid „Popularno“.", + "Add to playlist: ": "Dodajte na plejlistu: ", + "Add to playlist": "Dodaj na plejlistu", + "carousel_slide": "Slajd {{current}} od {{total}}", + "carousel_go_to": "Idi na slajd `x`", + "Answer": "Odgovor", + "Search for videos": "Pretražite video snimke", + "carousel_skip": "Preskoči karusel", + "toggle_theme": "Подеси тему" +} diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index 77465501..483e7fc4 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -1,340 +1,517 @@ { - "`x` subscribers.": "%(count)s пратилац.", - "`x` videos.": "`x` видеа.", - "`x` playlists.": "`x` плејлиста/е.", "LIVE": "УЖИВО", - "Shared `x` ago": "Објављено пре `x`", - "Unsubscribe": "Прекините праћење", - "Subscribe": "Пратите", - "View channel on YouTube": "Погледајте канал на YouTube-у", - "View playlist on YouTube": "Погледајте плејлисту на YouTube-у", + "Shared `x` ago": "Дељено пре `x`", + "Unsubscribe": "Прекини праћење", + "Subscribe": "Запрати", + "View channel on YouTube": "Погледај канал на YouTube-у", + "View playlist on YouTube": "Погледај плејлисту на YouTube-у", "newest": "најновије", "oldest": "најстарије", "popular": "популарно", "last": "последње", - "Next page": "Следећа страна", - "Previous page": "Претходна страна", - "Clear watch history?": "Обришите историју прегледања?", + "Next page": "Следећа страница", + "Previous page": "Претходна страница", + "Clear watch history?": "Очистити историју гледања?", "New password": "Нова лозинка", - "New passwords must match": "Нове лозинке се морају поклапати", - "Cannot change password for Google accounts": "Није могуће променити лозинку за Google налоге", - "Authorize token?": "Овластите токен?", - "Authorize token for `x`?": "Овластите токен за `x`?", + "New passwords must match": "Нове лозинке морају да се подударају", + "Authorize token?": "Ауторизовати токен?", + "Authorize token for `x`?": "Ауторизовати токен за `x`?", "Yes": "Да", "No": "Не", "Import and Export Data": "Увоз и извоз података", - "Import": "Увезите", - "Import Invidious data": "Увезите Invidious податке", - "Import YouTube subscriptions": "Увезите праћења са YouTube-а", - "Import FreeTube subscriptions (.db)": "Увезите праћења са FreeTube-а (.db)", - "Import NewPipe subscriptions (.json)": "Увезите праћења са NewPipe-а (.json)", - "Import NewPipe data (.zip)": "Увезите NewPipe податке (.zip)", - "Export": "Извезите", - "Export subscriptions as OPML": "Извезите праћења у OPML формату", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Извезите праћења у OPML формату (за NewPipe и FreeTube )", - "Export data as JSON": "Изветизе податке у JSON формату", - "Delete account?": "Избришите налог?", + "Import": "Увези", + "Import Invidious data": "Увези Invidious JSON податке", + "Import YouTube subscriptions": "Увези YouTube CSV или OPML праћења", + "Import FreeTube subscriptions (.db)": "Увези FreeTube праћења (.db)", + "Import NewPipe subscriptions (.json)": "Увези NewPipe праћења (.json)", + "Import NewPipe data (.zip)": "Увези NewPipe податке (.zip)", + "Export": "Извези", + "Export subscriptions as OPML": "Извези праћења као OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Извези праћења као OPML (за NewPipe и FreeTube)", + "Export data as JSON": "Извези Invidious податке као JSON", + "Delete account?": "Избрисати налог?", "History": "Историја", - "An alternative front-end to YouTube": "Алтернативни фронтенд за YouTube", - "JavaScript license information": "Извештај о JavaScript лиценци", + "An alternative front-end to YouTube": "Алтернативни фронт-енд за YouTube", + "JavaScript license information": "Информације о JavaScript лиценци", "source": "извор", - "Log in": "Пријавите се", - "Log in/register": "Пријавите се/направите налог", - "Log in with Google": "Пријавите се помоћу Google-а", - "User ID": "ИД корисника", + "Log in": "Пријава", + "Log in/register": "Пријава/регистрација", + "User ID": "ID корисника", "Password": "Лозинка", - "Time (h:mm:ss):": "Колико је сати? (ч:мм:сс):", - "Text CAPTCHA": "Текстуална CAPTCHA", - "Image CAPTCHA": "Сликовна CAPTCHA", - "Sign In": "Пријавите се", - "Register": "Направите налог", - "E-mail": "Е-пошта", - "Google verification code": "Google верификациони кôд", + "Time (h:mm:ss):": "Време (ч:мм:сс):", + "Text CAPTCHA": "Текст CAPTCHA", + "Image CAPTCHA": "Слика CAPTCHA", + "Sign In": "Пријава", + "Register": "Регистрација", + "E-mail": "Имејл", "Preferences": "Подешавања", - "Player preferences": "Подешавања видео плејера", - "Always loop: ": "Увек понављај: ", - "Autoplay: ": "Аутоматско пуштање: ", - "Play next by default: ": "Увек пуштај следеће: ", - "Autoplay next video: ": "Аутоматско пуштање следећег видеа: ", - "Listen by default: ": "Режим слушања као подразумевано: ", - "Proxy videos: ": "Пуштање видеа кроз прокси сервер: ", - "Default speed: ": "Подразумевана брзина репродукције: ", - "Preferred video quality: ": "Претпостављени квалитет видеа: ", - "Player volume: ": "Јачина звука: ", - "Default comments: ": "Подразумевани коментари: ", - "youtube": "са YouTube-а", - "reddit": "са редита", - "Default captions: ": "Подразумевани титлови: ", - "Fallback captions: ": "Алтернативни титлови: ", - "Show related videos: ": "Прикажи сличне видее: ", - "Show annotations by default: ": "Увек приказуј анотације: ", - "Automatically extend video description: ": "", - "Visual preferences": "Подешавања изгледа", - "Player style: ": "Стил плејера: ", + "preferences_category_player": "Подешавања плејера", + "preferences_video_loop_label": "Увек понављај: ", + "preferences_autoplay_label": "Аутоматски пусти: ", + "preferences_continue_label": "Подразумевано пусти следеће: ", + "preferences_continue_autoplay_label": "Аутоматски пусти следећи видео снимак: ", + "preferences_listen_label": "Подразумевано укључи само звук: ", + "preferences_local_label": "Прокси видео снимци: ", + "preferences_speed_label": "Подразумевана брзина: ", + "preferences_quality_label": "Преферирани квалитет видео снимка: ", + "preferences_volume_label": "Јачина звука плејера: ", + "preferences_comments_label": "Подразумевани коментари: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Подразумевани титлови: ", + "Fallback captions: ": "Резервни титлови: ", + "preferences_related_videos_label": "Прикажи сродне видео снимке: ", + "preferences_annotations_label": "Подразумевано прикажи напомене: ", + "preferences_category_visual": "Визуелна подешавања", + "preferences_player_style_label": "Стил плејера: ", "Dark mode: ": "Тамни режим: ", - "Theme: ": "Тема: ", + "preferences_dark_mode_label": "Тема: ", "dark": "тамна", "light": "светла", - "Thin mode: ": "Узани режим: ", - "Subscription preferences": "Подешавања о праћењима", - "Show annotations by default for subscribed channels: ": "Увек приказуј анотације за канале које пратим: ", - "Redirect homepage to feed: ": "Прикажи праћења као почетну страницу: ", - "Number of videos shown in feed: ": "Количина приказаних видеа на доводу: ", - "Sort videos by: ": "Сортирај према: ", - "published": "датуму објављивања", - "published - reverse": "датуму објављивања - обрнуто", - "alphabetically": "алфабету", - "alphabetically - reverse": "алфабету - обрнуто", - "channel name": "називу канала", - "channel name - reverse": "називу канала - обрнуто", - "Only show latest video from channel: ": "Прикажи само најновији видео са канала: ", - "Only show latest unwatched video from channel: ": "Прикажи само најновији негледани видео са канала: ", - "Only show unwatched: ": "Прикажи само негледано: ", - "Only show notifications (if there are any): ": "Прикажи само обавештења (ако их има): ", - "Enable web notifications": "Укључи обавештења преко претраживача", - "`x` uploaded a video": "`x`је објавио/ла видео", - "`x` is live": "`x` емитује уживо", - "Data preferences": "Подешавања о подацима", - "Clear watch history": "Обришите историју прегледања", - "Import/export data": "Увезите или извезите податке", - "Change password": "Промените лозинку", - "Manage subscriptions": "Управљајте праћењима", - "Manage tokens": "Управљајте токенима", - "Watch history": "Историја прегледања", - "Delete account": "Избришите налог", - "Administrator preferences": "Подешавања администратора", - "Default homepage: ": "Подразумевана главна страница: ", - "Feed menu: ": "Мени довода: ", - "Top enabled: ": "", - "CAPTCHA enabled: ": "CAPTCHA укључена?: ", - "Login enabled: ": "Пријава укључена?: ", - "Registration enabled: ": "Регистрација укључена?: ", - "Report statistics: ": "", + "preferences_thin_mode_label": "Компактни режим: ", + "preferences_category_subscription": "Подешавања праћења", + "preferences_annotations_subscribed_label": "Подразумевано приказати напомене за канале које пратите? ", + "Redirect homepage to feed: ": "Преусмери почетну страницу на фид: ", + "preferences_max_results_label": "Број видео снимака приказаних у фиду: ", + "preferences_sort_label": "Сортирај видео снимке по: ", + "published": "објављено", + "published - reverse": "објављено - обрнуто", + "alphabetically": "абецедно", + "alphabetically - reverse": "абецедно - обрнуто", + "channel name": "име канала", + "channel name - reverse": "име канала - обрнуто", + "Only show latest video from channel: ": "Прикажи само најновији видео снимак са канала: ", + "Only show latest unwatched video from channel: ": "Прикажи само најновији неодгледани видео снимак са канала: ", + "preferences_unseen_only_label": "Прикажи само недогледано: ", + "preferences_notifications_only_label": "Прикажи само обавештења (ако их има): ", + "Enable web notifications": "Омогући веб обавештења", + "`x` uploaded a video": "`x` је отпремио/ла видео снимак", + "`x` is live": "`x` је уживо", + "preferences_category_data": "Подешавања података", + "Clear watch history": "Очисти историју гледања", + "Import/export data": "Увези/Извези податке", + "Change password": "Промени лозинку", + "Manage subscriptions": "Управљај праћењима", + "Manage tokens": "Управљај токенима", + "Watch history": "Историја гледања", + "Delete account": "Избриши налог", + "preferences_category_admin": "Подешавања администратора", + "preferences_default_home_label": "Подразумевана почетна страница: ", + "preferences_feed_menu_label": "Фид мени: ", + "CAPTCHA enabled: ": "CAPTCHA омогућена: ", + "Login enabled: ": "Пријава омогућена: ", + "Registration enabled: ": "Регистрација омогућена: ", "Save preferences": "Сачувај подешавања", "Subscription manager": "Управљање праћењима", "Token manager": "Управљање токенима", "Token": "Токен", - "`x` subscriptions.": "`x`праћења.", - "`x` tokens.": "`x`токена.", - "Import/export": "Увези/извези", - "unsubscribe": "укини праћење", + "Import/export": "Увоз/извоз", + "unsubscribe": "прекини праћење", "revoke": "опозови", "Subscriptions": "Праћења", - "`x` unseen notifications.": "`x` непрочитаних обавештења.", "search": "претрага", - "Log out": "Одјавите се", - "Released under the AGPLv3 by Omar Roth.": "Издао Омар Рот (Omar Roth) под условима AGPLv3 лиценце.", - "Source available here.": "Изворни код доступан овде.", - "View JavaScript license information.": "Прикажи информације о JavaScript лиценци.", - "View privacy policy.": "Прикажи извештај о приватности.", + "Log out": "Одјава", + "Source available here.": "Изворни кôд је доступан овде.", + "View JavaScript license information.": "Погледај информације о JavaScript лиценци.", + "View privacy policy.": "Погледај политику приватности.", "Trending": "У тренду", "Public": "Јавно", "Unlisted": "По позиву", "Private": "Приватно", - "View all playlists": "Прикажи све плејлисте", + "View all playlists": "Погледај све плејлисте", "Updated `x` ago": "Ажурирано пре `x`", - "Delete playlist `x`?": "Избриши плејлисту `x`?", + "Delete playlist `x`?": "Избрисати плејлисту `x`?", "Delete playlist": "Избриши плејлисту", "Create playlist": "Направи плејлисту", "Title": "Наслов", - "Playlist privacy": "Видљивост плејлисте", - "Editing playlist `x`": "Уређујете плејлисту `x`", - "Show more": "", - "Show less": "", - "Watch on YouTube": "Гледајте на YouTube-у", - "Hide annotations": "Сакриј анотације", - "Show annotations": "Прикажи анотације", + "Playlist privacy": "Приватност плејлисте", + "Editing playlist `x`": "Измењивање плејлисте `x`", + "Watch on YouTube": "Гледај на YouTube-у", + "Hide annotations": "Сакриј напомене", + "Show annotations": "Прикажи напомене", "Genre: ": "Жанр: ", "License: ": "Лиценца: ", - "Family friendly? ": "", - "Wilson score: ": "", "Engagement: ": "Ангажовање: ", - "Whitelisted regions: ": "Дозвољене области: ", - "Blacklisted regions: ": "Забрањене области: ", - "Shared `x`": "", - "`x` views.": "`x` прегледа.", - "Premieres in `x`": "Емитује се уживо за `x`", - "Premieres `x`": "", - "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Здраво! Изгледа да је искључен JavaScript. Кликните овде да бисте приказали коментаре. Требаће мало дуже да се учитају.", - "View YouTube comments": "Прикажи коментаре са YouTube-а", - "View more comments on Reddit": "Прикажи још коментара на Reddit-у", - "View `x` comments.": "", - "View Reddit comments": "Прикажи коментаре са Reddit-а", + "Whitelisted regions: ": "Доступни региони: ", + "Blacklisted regions: ": "Недоступни региони: ", + "Premieres in `x`": "Премијера у `x`", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Хеј! Изгледа да сте искључили JavaScript. Кликните овде да бисте видели коментаре, имајте на уму да ће можда потрајати мало дуже да се учитају.", + "View YouTube comments": "Погледај YouTube коментаре", + "View more comments on Reddit": "Погледај више коментара на Reddit-у", + "View Reddit comments": "Погледај Reddit коментаре", "Hide replies": "Сакриј одговоре", "Show replies": "Прикажи одговоре", - "Incorrect password": "Неисправна лозинка", - "Quota exceeded, try again in a few hours": "", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "", - "Invalid TFA code": "", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "", - "Wrong answer": "", - "Erroneous CAPTCHA": "", - "CAPTCHA is a required field": "", - "User ID is a required field": "", - "Password is a required field": "", - "Wrong username or password": "", - "Please sign in using 'Log in with Google'": "", - "Password cannot be empty": "", - "Password cannot be longer than 55 characters": "", - "Please log in": "", - "Invidious Private Feed for `x`": "", - "channel:`x`": "", - "Deleted or invalid channel": "", - "This channel does not exist.": "", - "Could not get channel info.": "", - "Could not fetch comments": "", - "View `x` replies.": "", - "`x` ago": "", - "Load more": "", - "`x` points.": "", - "Could not create mix.": "", - "Empty playlist": "", - "Not a playlist.": "", - "Playlist does not exist.": "", - "Could not pull trending pages.": "", - "Hidden field \"challenge\" is a required field": "", - "Hidden field \"token\" is a required field": "", - "Erroneous challenge": "", - "Erroneous token": "", - "No such user": "", - "Token is expired, please try again": "", - "English": "", - "English (auto-generated)": "", - "Afrikaans": "", - "Albanian": "", - "Amharic": "", - "Arabic": "", - "Armenian": "", - "Azerbaijani": "", - "Bangla": "", - "Basque": "", - "Belarusian": "", - "Bosnian": "", - "Bulgarian": "", - "Burmese": "", - "Catalan": "", - "Cebuano": "", - "Chinese (Simplified)": "", - "Chinese (Traditional)": "", - "Corsican": "", - "Croatian": "", - "Czech": "", - "Danish": "", - "Dutch": "", - "Esperanto": "", - "Estonian": "", - "Filipino": "", - "Finnish": "", - "French": "", - "Galician": "", - "Georgian": "", - "German": "", - "Greek": "", - "Gujarati": "", - "Haitian Creole": "", - "Hausa": "", - "Hawaiian": "", - "Hebrew": "", - "Hindi": "", - "Hmong": "", - "Hungarian": "", - "Icelandic": "", - "Igbo": "", - "Indonesian": "", - "Irish": "", - "Italian": "", - "Japanese": "", - "Javanese": "", - "Kannada": "", - "Kazakh": "", - "Khmer": "", - "Korean": "", - "Kurdish": "", - "Kyrgyz": "", - "Lao": "", - "Latin": "", - "Latvian": "", - "Lithuanian": "", - "Luxembourgish": "", - "Macedonian": "", - "Malagasy": "", - "Malay": "", - "Malayalam": "", - "Maltese": "", - "Maori": "", - "Marathi": "", - "Mongolian": "", - "Nepali": "", - "Norwegian Bokmål": "", - "Nyanja": "", - "Pashto": "", - "Persian": "", - "Polish": "", - "Portuguese": "", - "Punjabi": "", - "Romanian": "", - "Russian": "", - "Samoan": "", - "Scottish Gaelic": "", - "Serbian": "", - "Shona": "", - "Sindhi": "", - "Sinhala": "", - "Slovak": "", - "Slovenian": "", - "Somali": "", - "Southern Sotho": "", - "Spanish": "", - "Spanish (Latin America)": "", - "Sundanese": "", - "Swahili": "", - "Swedish": "", - "Tajik": "", - "Tamil": "", - "Telugu": "", - "Thai": "", - "Turkish": "", - "Ukrainian": "", - "Urdu": "", - "Uzbek": "", - "Vietnamese": "", - "Welsh": "", - "Western Frisian": "", - "Xhosa": "", - "Yiddish": "", - "Yoruba": "", - "Zulu": "", - "`x` years.": "", - "`x` months.": "", - "`x` weeks.": "", - "`x` days.": "", - "`x` hours.": "", - "`x` minutes.": "", - "`x` seconds.": "", - "Fallback comments: ": "", - "Popular": "", - "Search": "", - "Top": "", - "About": "", - "Rating: ": "", - "Language: ": "", - "View as playlist": "", - "Default": "", - "Music": "", - "Gaming": "", - "News": "", - "Movies": "", - "Download": "", - "Download as: ": "", - "%A %B %-d, %Y": "", - "(edited)": "", - "YouTube comment permalink": "", - "permalink": "", - "`x` marked it with a ❤": "", - "Audio mode": "", - "Video mode": "", - "Videos": "", - "Playlists": "", - "Community": "", - "Current version: ": "Тренутна верзија: " -}
\ No newline at end of file + "Incorrect password": "Нетачна лозинка", + "Current version: ": "Тренутна верзија: ", + "Wilson score: ": "Вилсонова оцена: ", + "Burmese": "Бурмански", + "preferences_quality_dash_label": "Преферирани DASH квалитет видео снимка: ", + "Erroneous token": "Погрешан токен", + "CAPTCHA is a required field": "CAPTCHA је обавезно поље", + "No such user": "Не постоји корисник", + "Chinese (Traditional)": "Кинески (Традиционални)", + "adminprefs_modified_source_code_url_label": "URL адреса до репозиторијума измењеног изворног кода", + "Lao": "Лаоски", + "Czech": "Чешки", + "Kannada": "Канада", + "Polish": "Пољски", + "Cebuano": "Цебуански", + "preferences_show_nick_label": "Прикажи надимке на врху: ", + "Report statistics: ": "Извештавај статистике: ", + "Show more": "Прикажи више", + "Wrong answer": "Погрешан одговор", + "Hidden field \"token\" is a required field": "Скривено поље „токен“ је обавезно поље", + "English": "Енглески", + "Albanian": "Албански", + "Amharic": "Амхарски", + "Azerbaijani": "Азербејџански", + "Basque": "Баскијски", + "Belarusian": "Белоруски", + "Chinese (Simplified)": "Кинески (Поједностављени)", + "Croatian": "Хрватски", + "Dutch": "Холандски", + "Esperanto": "Есперанто", + "Finnish": "Фински", + "French": "Француски", + "Georgian": "Грузијски", + "Greek": "Грчки", + "Hausa": "Хауса", + "search_filters_type_option_video": "Видео снимак", + "search_filters_type_option_playlist": "Плејлиста", + "search_filters_type_option_movie": "Филм", + "search_filters_duration_option_long": "Дуго (> 20 минута)", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_live": "Уживо", + "search_filters_features_option_location": "Локација", + "next_steps_error_message": "Након тога би требало да покушате да: ", + "footer_donate_page": "Донирај", + "footer_documentation": "Документација", + "footer_modfied_source_code": "Измењени изворни кôд", + "preferences_region_label": "Држава садржаја: ", + "preferences_category_misc": "Остала подешавања", + "User ID is a required field": "ID корисника је обавезно поље", + "Password is a required field": "Лозинка је обавезно поље", + "Wrong username or password": "Погрешно корисничко име или лозинка", + "Password cannot be empty": "Лозинка не може бити празна", + "Password cannot be longer than 55 characters": "Лозинка не може бити дужа од 55 знакова", + "Invidious Private Feed for `x`": "Invidious приватни фид за `x`", + "Deleted or invalid channel": "Избрисан или неважећи канал", + "This channel does not exist.": "Овај канал не постоји.", + "Could not create mix.": "Није могуће направити микс.", + "Empty playlist": "Празна плејлиста", + "Not a playlist.": "Није плејлиста.", + "Playlist does not exist.": "Плејлиста не постоји.", + "Could not pull trending pages.": "Није могуће повући странице „У тренду“.", + "Hidden field \"challenge\" is a required field": "Скривено поље „изазов“ је обавезно поље", + "Telugu": "Телугу", + "Turkish": "Турски", + "Urdu": "Урду", + "Western Frisian": "Западнофризијски", + "Xhosa": "Коса (Кхоса)", + "Yiddish": "Јидиш", + "Hawaiian": "Хавајски", + "Hmong": "Хмонг", + "Hungarian": "Мађарски", + "Igbo": "Игбо", + "Javanese": "Јавански", + "Khmer": "Кмерски", + "Kyrgyz": "Киргиски", + "Macedonian": "Македонски", + "Maori": "Маорски", + "Marathi": "Маратски", + "Nepali": "Непалски", + "Norwegian Bokmål": "Норвешки Бокмал", + "Nyanja": "Нијанџа", + "Russian": "Руски", + "Scottish Gaelic": "Шкотски Гелски", + "Shona": "Шона", + "Slovak": "Словачки", + "Spanish (Latin America)": "Шпански (Латинска Америка)", + "Sundanese": "Сундански", + "Swahili": "Сували", + "Tajik": "Таџички", + "Search": "Претрага", + "Rating: ": "Оцена: ", + "Default": "Подразумевано", + "News": "Вести", + "Download": "Преузми", + "(edited)": "(измењено)", + "`x` marked it with a ❤": "`x` је означио/ла са ❤", + "Audio mode": "Режим аудио снимка", + "channel_tab_videos_label": "Видео снимци", + "search_filters_sort_option_views": "Број прегледа", + "search_filters_features_label": "Карактеристике", + "search_filters_date_option_today": "Данас", + "%A %B %-d, %Y": "%A %B %-d, %Y", + "preferences_locale_label": "Језик: ", + "Persian": "Персијски", + "View `x` comments": { + "": "Погледај `x` коментара", + "([^.,0-9]|^)1([^.,0-9]|$)": "Погледај `x` коментар" + }, + "search_filters_type_option_channel": "Канал", + "Haitian Creole": "Хаићански Креолски", + "Armenian": "Јерменски", + "next_steps_error_message_go_to_youtube": "Одете на YouTube", + "Indonesian": "Индонезијски", + "preferences_vr_mode_label": "Интерактивни видео снимци од 360 степени (захтева WebGL): ", + "Switch Invidious Instance": "Промени Invidious инстанцу", + "Portuguese": "Португалски", + "search_filters_date_option_week": "Ове недеље", + "search_filters_type_option_show": "Емисија", + "Fallback comments: ": "Резервни коментари: ", + "search_filters_features_option_hdr": "HDR", + "About": "О сајту", + "Kazakh": "Казашки", + "Shared `x`": "Дељено `x`", + "Playlists": "Плејлисте", + "Yoruba": "Јоруба", + "Erroneous challenge": "Погрешан изазов", + "Danish": "Дански", + "Could not get channel info.": "Није могуће прикупити информације о каналу.", + "search_filters_features_option_hd": "HD", + "Slovenian": "Словеначки", + "Load more": "Учитај више", + "German": "Немачки", + "Luxembourgish": "Луксембуршки", + "Mongolian": "Монголски", + "Latvian": "Летонски", + "channel:`x`": "канал:`x`", + "Southern Sotho": "Јужни Сото", + "Popular": "Популарно", + "Gujarati": "Гуџарати", + "search_filters_date_option_year": "Ове године", + "Irish": "Ирски", + "YouTube comment permalink": "Трајни линк YouTube коментара", + "Malagasy": "Малгашки", + "Token is expired, please try again": "Токен је истекао, покушајте поново", + "search_filters_duration_option_short": "Кратко (< 4 минута)", + "Samoan": "Самоански", + "Tamil": "Тамилски", + "Ukrainian": "Украјински", + "permalink": "трајни линк", + "Pashto": "Паштунски", + "channel_tab_community_label": "Заједница", + "Sindhi": "Синди", + "Could not fetch comments": "Није могуће прикупити коментаре", + "Bangla": "Бенгалски", + "Uzbek": "Узбечки", + "Lithuanian": "Литвански", + "Icelandic": "Исландски", + "Thai": "Тајски", + "search_filters_date_option_month": "Овог месеца", + "search_filters_type_label": "Врста", + "search_filters_date_option_hour": "Последњи сат", + "Spanish": "Шпански", + "search_filters_sort_option_date": "Датум отпремања", + "View as playlist": "Погледај као плејлисту", + "search_filters_sort_option_relevance": "Релевантност", + "Estonian": "Естонски", + "Sinhala": "Синхалски", + "Corsican": "Корзикански", + "Filipino": "Филипински", + "Gaming": "Видео игре", + "Movies": "Филмови", + "search_filters_sort_option_rating": "Оцена", + "Top enabled: ": "Топ омогућено: ", + "Released under the AGPLv3 on Github.": "Објављено под лиценцом AGPLv3 на GitHub-у.", + "Afrikaans": "Африканс", + "preferences_automatic_instance_redirect_label": "Аутоматско преусмеравање инстанце (повратак на redirect.invidious.io): ", + "Please log in": "Молимо, пријавите се", + "English (auto-generated)": "Енглески (аутоматски генерисано)", + "Hindi": "Хинди", + "Italian": "Италијански", + "Malayalam": "Малајаламски", + "Punjabi": "Панџапски", + "Somali": "Сомалијски", + "Vietnamese": "Вијетнамски", + "Welsh": "Велшки", + "Zulu": "Зулу", + "Maltese": "Малтешки", + "Swedish": "Шведски", + "Music": "Музика", + "Download as: ": "Преузети као: ", + "search_filters_duration_label": "Трајање", + "search_filters_sort_label": "Сортирање по", + "search_filters_features_option_subtitles": "Титлови/Скривени титлови", + "preferences_extend_desc_label": "Аутоматски прошири опис видео снимка: ", + "Show less": "Прикажи мање", + "Family friendly? ": "Погодно за породицу? ", + "Premieres `x`": "Премијера `x`", + "Bosnian": "Босански", + "Catalan": "Каталонски", + "Japanese": "Јапански", + "Latin": "Латински", + "next_steps_error_message_refresh": "Освежите", + "footer_original_source_code": "Оригинални изворни кôд", + "Romanian": "Румунски", + "Serbian": "Српски", + "Top": "Топ", + "Video mode": "Режим видео снимка", + "footer_source_code": "Изворни кôд", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_four_k": "4K", + "Erroneous CAPTCHA": "Погрешна CAPTCHA", + "`x` ago": "пре `x`", + "Arabic": "Арапски", + "Bulgarian": "Бугарски", + "Galician": "Галицијски", + "Hebrew": "Хебрејски", + "Korean": "Корејски", + "Kurdish": "Курдски", + "Malay": "Малајски", + "search_filters_title": "Филтери", + "Korean (auto-generated)": "Корејски (аутоматски генерисано)", + "search_filters_features_option_three_sixty": "360°", + "preferences_quality_dash_option_worst": "Најгоре", + "channel_tab_podcasts_label": "Подкасти", + "preferences_save_player_pos_label": "Сачувај позицију репродукције: ", + "Spanish (Mexico)": "Шпански (Мексико)", + "generic_subscriptions_count_0": "{{count}} праћење", + "generic_subscriptions_count_1": "{{count}} праћења", + "generic_subscriptions_count_2": "{{count}} праћења", + "search_filters_apply_button": "Примени изабране филтере", + "Download is disabled": "Преузимање је онемогућено", + "comments_points_count_0": "{{count}} поен", + "comments_points_count_1": "{{count}} поена", + "comments_points_count_2": "{{count}} поена", + "preferences_quality_dash_option_2160p": "2160p", + "German (auto-generated)": "Немачки (аутоматски генерисано)", + "Japanese (auto-generated)": "Јапански (аутоматски генерисано)", + "preferences_quality_option_medium": "Средње", + "search_message_change_filters_or_query": "Покушајте да проширите упит за претрагу и/или промените филтере.", + "crash_page_before_reporting": "Пре него што пријавите грешку, уверите се да сте:", + "preferences_quality_dash_option_best": "Најбоље", + "Channel Sponsor": "Спонзор канала", + "generic_videos_count_0": "{{count}} видео снимак", + "generic_videos_count_1": "{{count}} видео снимка", + "generic_videos_count_2": "{{count}} видео снимака", + "videoinfo_started_streaming_x_ago": "Започето стримовање пре `x`", + "videoinfo_youTube_embed_link": "Уграђено", + "channel_tab_streams_label": "Стримови уживо", + "playlist_button_add_items": "Додај видео снимке", + "generic_count_minutes_0": "{{count}} минут", + "generic_count_minutes_1": "{{count}} минута", + "generic_count_minutes_2": "{{count}} минута", + "preferences_quality_dash_option_720p": "720p", + "preferences_watch_history_label": "Омогући историју гледања: ", + "user_saved_playlists": "Сачуваних плејлиста: `x`", + "Spanish (Spain)": "Шпански (Шпанија)", + "invidious": "Invidious", + "crash_page_refresh": "покушали да <a href=\"`x`\">освежите страницу</a>", + "Chinese (Hong Kong)": "Кинески (Хонг Конг)", + "Artist: ": "Извођач: ", + "generic_count_months_0": "{{count}} месец", + "generic_count_months_1": "{{count}} месеца", + "generic_count_months_2": "{{count}} месеци", + "search_message_use_another_instance": "Такође, можете <a href=\"`x`\">претраживати на другој инстанци</a>.", + "generic_subscribers_count_0": "{{count}} пратилац", + "generic_subscribers_count_1": "{{count}} пратиоца", + "generic_subscribers_count_2": "{{count}} пратилаца", + "download_subtitles": "Титлови - `x` (.vtt)", + "generic_button_save": "Сачувај", + "crash_page_search_issue": "претражили <a href=\"`x`\">постојеће извештаје о проблемима на GitHub-у</a>", + "generic_button_cancel": "Откажи", + "none": "ниједно", + "English (United States)": "Енглески (Сједињене Америчке Државе)", + "subscriptions_unseen_notifs_count_0": "{{count}} невиђено обавештење", + "subscriptions_unseen_notifs_count_1": "{{count}} невиђена обавештења", + "subscriptions_unseen_notifs_count_2": "{{count}} невиђених обавештења", + "Album: ": "Албум: ", + "preferences_quality_option_dash": "DASH (адаптивни квалитет)", + "preferences_quality_dash_option_1080p": "1080p", + "Video unavailable": "Видео снимак недоступан", + "tokens_count_0": "{{count}} токен", + "tokens_count_1": "{{count}} токена", + "tokens_count_2": "{{count}} токена", + "Chinese (China)": "Кинески (Кина)", + "Italian (auto-generated)": "Италијански (аутоматски генерисано)", + "channel_tab_shorts_label": "Shorts", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_360p": "360p", + "search_message_no_results": "Нису пронађени резултати.", + "channel_tab_releases_label": "Издања", + "preferences_quality_dash_option_144p": "144p", + "Interlingue": "Интерлингва", + "Song: ": "Песма: ", + "generic_channels_count_0": "{{count}} канал", + "generic_channels_count_1": "{{count}} канала", + "generic_channels_count_2": "{{count}} канала", + "Chinese (Taiwan)": "Кинески (Тајван)", + "Turkish (auto-generated)": "Турски (аутоматски генерисано)", + "Indonesian (auto-generated)": "Индонезијски (аутоматски генерисано)", + "Portuguese (auto-generated)": "Португалски (аутоматски генерисано)", + "generic_count_years_0": "{{count}} година", + "generic_count_years_1": "{{count}} године", + "generic_count_years_2": "{{count}} година", + "videoinfo_invidious_embed_link": "Уграђени линк", + "Popular enabled: ": "Популарно омогућено: ", + "Spanish (auto-generated)": "Шпански (аутоматски генерисано)", + "preferences_quality_option_small": "Мало", + "English (United Kingdom)": "Енглески (Уједињено Краљевство)", + "channel_tab_playlists_label": "Плејлисте", + "generic_button_edit": "Измени", + "generic_playlists_count_0": "{{count}} плејлиста", + "generic_playlists_count_1": "{{count}} плејлисте", + "generic_playlists_count_2": "{{count}} плејлиста", + "preferences_quality_option_hd720": "HD720", + "search_filters_features_option_purchased": "Купљено", + "search_filters_date_option_none": "Било који датум", + "preferences_quality_dash_option_auto": "Аутоматски", + "Cantonese (Hong Kong)": "Кантонски (Хонг Конг)", + "crash_page_report_issue": "Ако ништа од горенаведеног није помогло, <a href=\"`x`\">отворите нови извештај о проблему на GitHub-у</a> (по могућности на енглеском) и укључите следећи текст у своју поруку (НЕ преводите тај текст):", + "crash_page_switch_instance": "покушали да <a href=\"`x`\">користите другу инстанцу</a>", + "generic_count_weeks_0": "{{count}} недеља", + "generic_count_weeks_1": "{{count}} недеље", + "generic_count_weeks_2": "{{count}} недеља", + "videoinfo_watch_on_youTube": "Гледај на YouTube-у", + "Music in this video": "Музика у овом видео снимку", + "generic_button_rss": "RSS", + "preferences_quality_dash_option_4320p": "4320p", + "generic_count_hours_0": "{{count}} сат", + "generic_count_hours_1": "{{count}} сата", + "generic_count_hours_2": "{{count}} сати", + "French (auto-generated)": "Француски (аутоматски генерисано)", + "crash_page_read_the_faq": "прочитали <a href=\"`x`\">Често Постављана Питања (ЧПП)</a>", + "user_created_playlists": "Направљених плејлиста: `x`", + "channel_tab_channels_label": "Канали", + "search_filters_type_option_all": "Било која врста", + "Russian (auto-generated)": "Руски (аутоматски генерисано)", + "preferences_quality_dash_option_480p": "480p", + "comments_view_x_replies_0": "Погледај {{count}} одговор", + "comments_view_x_replies_1": "Погледај {{count}} одговора", + "comments_view_x_replies_2": "Погледај {{count}} одговора", + "Portuguese (Brazil)": "Португалски (Бразил)", + "search_filters_features_option_vr180": "VR180", + "error_video_not_in_playlist": "Тражени видео снимак не постоји на овој плејлисти. <a href=\"`x`\">Кликните овде за почетну страницу плејлисте.</a>", + "Dutch (auto-generated)": "Холандски (аутоматски генерисано)", + "generic_count_days_0": "{{count}} дан", + "generic_count_days_1": "{{count}} дана", + "generic_count_days_2": "{{count}} дана", + "Vietnamese (auto-generated)": "Вијетнамски (аутоматски генерисано)", + "search_filters_duration_option_none": "Било које трајање", + "preferences_quality_dash_option_240p": "240p", + "Chinese": "Кинески", + "generic_button_delete": "Избриши", + "Import YouTube playlist (.csv)": "Увези YouTube плејлисту (.csv)", + "Standard YouTube license": "Стандардна YouTube лиценца", + "search_filters_duration_option_medium": "Средње (4 - 20 минута)", + "generic_count_seconds_0": "{{count}} секунда", + "generic_count_seconds_1": "{{count}} секунде", + "generic_count_seconds_2": "{{count}} секунди", + "search_filters_date_label": "Датум отпремања", + "crash_page_you_found_a_bug": "Изгледа да сте пронашли грешку у Invidious-у!", + "generic_views_count_0": "{{count}} преглед", + "generic_views_count_1": "{{count}} прегледа", + "generic_views_count_2": "{{count}} прегледа", + "Import YouTube watch history (.json)": "Увези YouTube историју гледањa (.json)", + "toggle_theme": "Укључи тему", + "Add to playlist": "Додај на плејлисту", + "Answer": "Одговор", + "Search for videos": "Претражите видео снимке", + "carousel_go_to": "Иди на слајд `x`", + "Add to playlist: ": "Додајте на плејлисту: ", + "carousel_skip": "Прескочи карусел", + "The Popular feed has been disabled by the administrator.": "Администратор је онемогућио фид „Популарно“.", + "carousel_slide": "Слајд {{current}} од {{total}}" +} diff --git a/locales/sv-SE.json b/locales/sv-SE.json index 5a0ffbd2..f1313a4d 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -1,16 +1,4 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` prenumeranter", - "": "`x` prenumeranter" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` videor", - "": "`x` videor" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` spellistor", - "": "`x` spellistor" - }, "LIVE": "LIVE", "Shared `x` ago": "Delad `x` sedan", "Unsubscribe": "Avprenumerera", @@ -26,22 +14,21 @@ "Clear watch history?": "Töm visningshistorik?", "New password": "Nytt lösenord", "New passwords must match": "Nya lösenord måste stämma överens", - "Cannot change password for Google accounts": "Kan inte ändra lösenord på Google-konton", "Authorize token?": "Auktorisera åtkomsttoken?", "Authorize token for `x`?": "Auktorisera åtkomsttoken för `x`?", "Yes": "Ja", "No": "Nej", "Import and Export Data": "Importera och exportera data", "Import": "Importera", - "Import Invidious data": "Importera Invidious-data", - "Import YouTube subscriptions": "Importera YouTube-prenumerationer", + "Import Invidious data": "Importera Invidious JSON data", + "Import YouTube subscriptions": "Importera YouTube CSV eller OPML prenumerationer", "Import FreeTube subscriptions (.db)": "Importera FreeTube-prenumerationer (.db)", "Import NewPipe subscriptions (.json)": "Importera NewPipe-prenumerationer (.json)", "Import NewPipe data (.zip)": "Importera NewPipe-data (.zip)", "Export": "Exportera", "Export subscriptions as OPML": "Exportera prenumerationer som OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Exportera prenumerationer som OPML (för NewPipe och FreeTube)", - "Export data as JSON": "Exportera data som JSON", + "Export data as JSON": "Exportera Invidious data som JSON", "Delete account?": "Radera konto?", "History": "Historik", "An alternative front-end to YouTube": "Ett alternativt gränssnitt till YouTube", @@ -49,7 +36,6 @@ "source": "källa", "Log in": "Logga in", "Log in/register": "Logga in/registrera", - "Log in with Google": "Logga in med Google", "User ID": "Användar-ID", "Password": "Lösenord", "Time (h:mm:ss):": "Tid (h:mm:ss):", @@ -58,38 +44,39 @@ "Sign In": "Inloggning", "Register": "Registrera", "E-mail": "E-post", - "Google verification code": "Google-bekräftelsekod", "Preferences": "Inställningar", - "Player preferences": "Spelarinställningar", - "Always loop: ": "Loopa alltid: ", - "Autoplay: ": "Autouppspelning: ", - "Play next by default: ": "Spela nästa som förval: ", - "Autoplay next video: ": "Autouppspela nästa video: ", - "Listen by default: ": "Lyssna som förval: ", - "Proxy videos: ": "Proxy:a videor: ", - "Default speed: ": "Förvald hastighet: ", - "Preferred video quality: ": "Föredragen videokvalitet: ", - "Player volume: ": "Volym: ", - "Default comments: ": "Förvalda kommentarer: ", + "preferences_category_player": "Spelarinställningar", + "preferences_video_loop_label": "Loopa alltid: ", + "preferences_autoplay_label": "Autouppspelning: ", + "preferences_continue_label": "Spela nästa som förval: ", + "preferences_continue_autoplay_label": "Autouppspela nästa video: ", + "preferences_listen_label": "Lyssna som förval: ", + "preferences_local_label": "Proxy:a videor: ", + "preferences_speed_label": "Förvald hastighet: ", + "preferences_quality_label": "Föredragen videokvalitet: ", + "preferences_volume_label": "Volym: ", + "preferences_comments_label": "Förvalda kommentarer: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Förvalda undertexter: ", + "preferences_captions_label": "Förvalda undertexter: ", "Fallback captions: ": "Ersättningsundertexter: ", - "Show related videos: ": "Visa relaterade videor? ", - "Show annotations by default: ": "Visa länkar-i-videon som förval? ", - "Automatically extend video description: ": "", - "Visual preferences": "Visuella inställningar", - "Player style: ": "Spelarstil: ", + "preferences_related_videos_label": "Visa relaterade videor? ", + "preferences_annotations_label": "Visa länkar-i-videon som förval? ", + "preferences_extend_desc_label": "Förläng videobeskrivning automatiskt: ", + "preferences_vr_mode_label": "Interaktiva 360-gradervideos (kräver WebGL): ", + "preferences_category_visual": "Visuella inställningar", + "preferences_player_style_label": "Spelarstil: ", "Dark mode: ": "Mörkt läge: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "Mörkt", "light": "Ljust", - "Thin mode: ": "Lättviktigt läge: ", - "Subscription preferences": "Prenumerationsinställningar", - "Show annotations by default for subscribed channels: ": "Visa länkar-i-videor som förval för kanaler som prenumereras på? ", + "preferences_thin_mode_label": "Lättviktigt läge: ", + "preferences_category_misc": "Övriga inställningar", + "preferences_category_subscription": "Prenumerationsinställningar", + "preferences_annotations_subscribed_label": "Visa länkar-i-videor som förval för kanaler som prenumereras på? ", "Redirect homepage to feed: ": "Omdirigera hemsida till flöde: ", - "Number of videos shown in feed: ": "Antal videor att visa i flödet: ", - "Sort videos by: ": "Sortera videor: ", + "preferences_max_results_label": "Antal videor att visa i flödet: ", + "preferences_sort_label": "Sortera videor: ", "published": "publicering", "published - reverse": "publicering - omvänd", "alphabetically": "alfabetiskt", @@ -98,12 +85,12 @@ "channel name - reverse": "kanalnamn - omvänd", "Only show latest video from channel: ": "Visa bara senaste videon från kanal: ", "Only show latest unwatched video from channel: ": "Visa bara senaste osedda videon från kanal: ", - "Only show unwatched: ": "Visa bara osedda: ", - "Only show notifications (if there are any): ": "Visa endast aviseringar (om det finns några): ", + "preferences_unseen_only_label": "Visa bara osedda: ", + "preferences_notifications_only_label": "Visa endast aviseringar (om det finns några): ", "Enable web notifications": "Slå på aviseringar", "`x` uploaded a video": "`x` laddade upp en video", "`x` is live": "`x` sänder live", - "Data preferences": "Datainställningar", + "preferences_category_data": "Datainställningar", "Clear watch history": "Töm visningshistorik", "Import/export data": "Importera/Exportera data", "Change password": "Byt lösenord", @@ -111,9 +98,10 @@ "Manage tokens": "Hantera åtkomst-tokens", "Watch history": "Visningshistorik", "Delete account": "Radera konto", - "Administrator preferences": "Administratörsinställningar", - "Default homepage: ": "Förvald hemsida: ", - "Feed menu: ": "Flödesmeny: ", + "preferences_category_admin": "Administratörsinställningar", + "preferences_default_home_label": "Förvald hemsida: ", + "preferences_feed_menu_label": "Flödesmeny: ", + "preferences_show_nick_label": "Visa smeknamn överst: ", "Top enabled: ": "Topp påslaget? ", "CAPTCHA enabled: ": "CAPTCHA påslaget? ", "Login enabled: ": "Inloggning påslaget? ", @@ -123,25 +111,12 @@ "Subscription manager": "Prenumerationshanterare", "Token manager": "Åtkomst-token-hanterare", "Token": "Åtkomst-token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` prenumerationer", - "": "`x` prenumerationer" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` åtkomst-token", - "": "`x` åtkomst-token" - }, "Import/export": "Importera/exportera", "unsubscribe": "avprenumerera", "revoke": "återkalla", "Subscriptions": "Prenumerationer", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` osedda aviseringar", - "": "`x` osedda aviseringar" - }, "search": "sök", "Log out": "Logga ut", - "Released under the AGPLv3 by Omar Roth.": "Utgiven under AGPLv3-licens av Omar Roth.", "Source available here.": "Källkod tillgänglig här.", "View JavaScript license information.": "Visa JavaScript-licensinformation.", "View privacy policy.": "Visa privatlivspolicy.", @@ -157,9 +132,10 @@ "Title": "Titel", "Playlist privacy": "Privatläge på spellista", "Editing playlist `x`": "Redigerer spellistan `x`", - "Show more": "", - "Show less": "", + "Show more": "Visa mer", + "Show less": "Visa mindre", "Watch on YouTube": "Titta på YouTube", + "Switch Invidious Instance": "Byt Invidious Instans", "Hide annotations": "Dölj länkar-i-video", "Show annotations": "Visa länkar-i-video", "Genre: ": "Genre: ", @@ -170,53 +146,36 @@ "Whitelisted regions: ": "Vitlistade regioner: ", "Blacklisted regions: ": "Svartlistade regioner: ", "Shared `x`": "Delade `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visningar", - "": "`x` visningar" - }, "Premieres in `x`": "Premiär om `x`", "Premieres `x`": "Premiär av `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Hej. Det ser ut som att du har JavaScript avstängt. Klicka här för att visa kommentarer, ha i åtanke att nedladdning tar längre tid.", "View YouTube comments": "Visa YouTube-kommentarer", "View more comments on Reddit": "Visa flera kommentarer på Reddit", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Visa `x` kommentarer", + "([^.,0-9]|^)1([^.,0-9]|$)": "Visa `x` kommentar", "": "Visa `x` kommentarer" }, "View Reddit comments": "Visa Reddit-kommentarer", "Hide replies": "Dölj svar", "Show replies": "Visa svar", "Incorrect password": "Fel lösenord", - "Quota exceeded, try again in a few hours": "Kvoten överskriden, försök igen om ett par timmar", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Kunde inte logga in, försäkra dig om att tvåfaktors-autentisering (Authenticator eller SMS) är påslagen.", - "Invalid TFA code": "Ogiltig tvåfaktor-kod", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Inloggning misslyckades. Detta kan vara för att tvåfaktors-autentisering inte är påslaget på ditt konto.", "Wrong answer": "Fel svar", "Erroneous CAPTCHA": "Ogiltig CAPTCHA", "CAPTCHA is a required field": "CAPTCHA är ett obligatoriskt fält", "User ID is a required field": "Användar-ID är ett obligatoriskt fält", "Password is a required field": "Lösenord är ett obligatoriskt fält", "Wrong username or password": "Ogiltigt användarnamn eller lösenord", - "Please sign in using 'Log in with Google'": "Logga in genom \"Google-inloggning\"", "Password cannot be empty": "Lösenordet kan inte vara tomt", "Password cannot be longer than 55 characters": "Lösenordet kan inte vara längre än 55 tecken", - "Please log in": "Logga in", + "Please log in": "Snälla logga in", "Invidious Private Feed for `x`": "Ogiltig privat flöde för `x`", "channel:`x`": "kanal `x`", "Deleted or invalid channel": "Raderad eller ogiltig kanal", "This channel does not exist.": "Denna kanal finns inte.", "Could not get channel info.": "Kunde inte hämta kanalinfo.", "Could not fetch comments": "Kunde inte hämta kommentarer", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Visa `x` svar", - "": "Visa `x` svar" - }, "`x` ago": "`x` sedan", "Load more": "Ladda fler", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` poäng", - "": "`x` poäng" - }, "Could not create mix.": "Kunde inte skapa mix.", "Empty playlist": "Spellistan är tom", "Not a playlist.": "Ogiltig spellista.", @@ -334,41 +293,13 @@ "Yiddish": "Jiddisch", "Yoruba": "Yoruba", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` år", - "": "`x` år" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` månader", - "": "`x` månader" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` veckor", - "": "`x` veckor" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dagar", - "": "`x` dagar" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` timmar", - "": "`x` timmar" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuter", - "": "`x` minuter" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` sekunder", - "": "`x` sekunder" - }, "Fallback comments: ": "Fallback-kommentarer: ", "Popular": "Populärt", - "Search": "", + "Search": "Sök", "Top": "Topp", "About": "Om", "Rating: ": "Betyg: ", - "Language: ": "Språk: ", + "preferences_locale_label": "Språk: ", "View as playlist": "Visa som spellista", "Default": "Förvalt", "Music": "Musik", @@ -380,39 +311,190 @@ "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(redigerad)", "YouTube comment permalink": "Permanent YouTube-länk till innehållet", - "permalink": "permalänk", - "`x` marked it with a ❤": "`x` lämnade ett ❤", + "permalink": "permanent länk", + "`x` marked it with a ❤": "`x` markerade det med ett ❤", "Audio mode": "Ljudläge", "Video mode": "Videoläge", - "Videos": "Videor", + "channel_tab_videos_label": "Videor", "Playlists": "Spellistor", - "Community": "Gemenskap", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Nuvarande version: " -}
\ No newline at end of file + "channel_tab_community_label": "Gemenskap", + "search_filters_sort_option_relevance": "Relevans", + "search_filters_sort_option_rating": "Rankning", + "search_filters_sort_option_date": "Uppladdnings datum", + "search_filters_sort_option_views": "Visningar", + "search_filters_type_label": "Typ", + "search_filters_duration_label": "Varaktighet", + "search_filters_features_label": "Funktioner", + "search_filters_sort_label": "Sortera efter", + "search_filters_date_option_hour": "Senaste timmen", + "search_filters_date_option_today": "Idag", + "search_filters_date_option_week": "Denna vecka", + "search_filters_date_option_month": "Denna månad", + "search_filters_date_option_year": "Detta år", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Kanal", + "search_filters_type_option_playlist": "Spellista", + "search_filters_type_option_movie": "Film", + "search_filters_type_option_show": "Serie", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Undertexter/CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Live", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Plats", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Nuvarande version: ", + "next_steps_error_message_refresh": "Uppdatera", + "next_steps_error_message_go_to_youtube": "Gå till Youtube", + "Released under the AGPLv3 on Github.": "Publicerad under AGPLv3 på GitHub.", + "footer_source_code": "Källkod", + "search_filters_duration_option_long": "Lång (> 20 minuter)", + "footer_documentation": "Dokumentation", + "search_filters_duration_option_short": "Kort (< 4 minuter)", + "search_filters_title": "Filter", + "Korean (auto-generated)": "Koreanska (auto-genererad)", + "search_filters_features_option_three_sixty": "360°", + "preferences_quality_dash_option_worst": "Sämst", + "channel_tab_podcasts_label": "Podcaster", + "preferences_save_player_pos_label": "Spara uppspelningsposition: ", + "Spanish (Mexico)": "Spanska (Mexiko)", + "preferences_region_label": "Innehållsland: ", + "generic_subscriptions_count": "{{count}} prenumeration", + "generic_subscriptions_count_plural": "{{count}} prenumerationer", + "search_filters_apply_button": "Använd valda filter", + "Download is disabled": "Nedladdning är inaktiverad", + "comments_points_count": "{{count}} poäng", + "comments_points_count_plural": "{{count}} poäng", + "preferences_quality_dash_option_2160p": "2160p", + "German (auto-generated)": "Tyska (auto-genererad)", + "Japanese (auto-generated)": "Japanska (auto-genererad)", + "preferences_quality_option_medium": "Medium", + "footer_donate_page": "Donera", + "search_message_change_filters_or_query": "Prova att bredda din sökfråga och/eller ändra filtren.", + "crash_page_before_reporting": "Innan du rapporterar en bugg, se till att du har:", + "preferences_quality_dash_option_best": "Bäst", + "Channel Sponsor": "Kanal Sponsor", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videor", + "videoinfo_started_streaming_x_ago": "Började sända `x` sedan", + "videoinfo_youTube_embed_link": "Bädda in", + "channel_tab_streams_label": "Livesändningar", + "playlist_button_add_items": "Lägg till videor", + "generic_count_minutes": "{{count}}minut", + "generic_count_minutes_plural": "{{count}}minuter", + "preferences_quality_dash_option_720p": "720p", + "preferences_watch_history_label": "Aktivera visningshistorik: ", + "user_saved_playlists": "`x` sparade spellistor", + "Spanish (Spain)": "Spanska (Spanien)", + "invidious": "Invidious", + "crash_page_refresh": "försökte <a href=\"`x`\">uppdatera sidan</a>", + "Chinese (Hong Kong)": "Kinesiska (Hong Kong)", + "Artist: ": "Artist: ", + "generic_count_months": "{{count}}månad", + "generic_count_months_plural": "{{count}}månader", + "search_message_use_another_instance": "Du kan också <a href=\"`x`\">söka på en annan instans</a>.", + "generic_subscribers_count": "{{count}} prenumerant", + "generic_subscribers_count_plural": "{{count}} prenumeranter", + "download_subtitles": "Undertexter - `x` (.vtt)", + "generic_button_save": "Spara", + "crash_page_search_issue": "sökte efter <a href=\"`x`\">befintliga problem på GitHub</a>", + "generic_button_cancel": "Avbryt", + "none": "ingen", + "English (United States)": "English (Förenta staterna)", + "subscriptions_unseen_notifs_count": "{{count}}osedd notifikation", + "subscriptions_unseen_notifs_count_plural": "{{count}}osedda notifikationer", + "Album: ": "Album: ", + "preferences_quality_option_dash": "DASH (adaptiv kvalitet)", + "preferences_quality_dash_option_1080p": "1080p", + "Video unavailable": "Video inte tillgänglig", + "tokens_count": "{{count}}nyckel", + "tokens_count_plural": "{{count}}nycklar", + "Chinese (China)": "Kinesiska (Kina)", + "Italian (auto-generated)": "Italienska (auto-genererad)", + "channel_tab_shorts_label": "Shorts", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_360p": "360p", + "search_message_no_results": "Inga resultat hittades.", + "channel_tab_releases_label": "Releaser", + "preferences_quality_dash_option_144p": "144p", + "Interlingue": "Interlingue (auto-genererad)", + "Song: ": "Låt: ", + "generic_channels_count": "{{count}} kanal", + "generic_channels_count_plural": "{{count}} kanaler", + "Chinese (Taiwan)": "Kinesiska (Taiwan)", + "preferences_quality_dash_label": "Önskad DASH-videokvalitet: ", + "adminprefs_modified_source_code_url_label": "URL till modifierad källkodslager", + "Turkish (auto-generated)": "Turkiska (auto-genererad)", + "Indonesian (auto-generated)": "Indonesiska (auto-genererad)", + "Portuguese (auto-generated)": "Portugisiska (auto-genererad)", + "generic_count_years": "{{count}}år", + "generic_count_years_plural": "{{count}}år", + "videoinfo_invidious_embed_link": "Bädda in länk", + "Popular enabled: ": "Populär aktiverad: ", + "Spanish (auto-generated)": "Spanska (auto-genererad)", + "preferences_quality_option_small": "Liten", + "English (United Kingdom)": "Engelska (Storbritannien)", + "channel_tab_playlists_label": "Spellistor", + "generic_button_edit": "Redigera", + "generic_playlists_count": "{{count}} spellista", + "generic_playlists_count_plural": "{{count}} spellistor", + "preferences_quality_option_hd720": "HD720p", + "search_filters_features_option_purchased": "Köpt", + "search_filters_date_option_none": "Vilket datum som helst", + "preferences_quality_dash_option_auto": "Auto", + "Cantonese (Hong Kong)": "Katonesiska (Hong Kong)", + "crash_page_report_issue": "Om inget av ovanstående hjälpte, vänligen <a href=\"`x`\">öppna ett nytt nummer på GitHub</a> (helst på engelska) och inkludera följande text i ditt meddelande (översätt INTE den texten):", + "crash_page_switch_instance": "försökte <a href=\"`x`\">använda en annan instans</a>", + "generic_count_weeks": "{{count}}vecka", + "generic_count_weeks_plural": "{{count}}veckor", + "videoinfo_watch_on_youTube": "Titta på YouTube", + "Music in this video": "Musik i denna video", + "footer_modfied_source_code": "Modifierad källkod", + "generic_button_rss": "RSS", + "preferences_quality_dash_option_4320p": "4320p", + "generic_count_hours": "{{count}}timme", + "generic_count_hours_plural": "{{count}}timmar", + "French (auto-generated)": "Franska (auto-genererad)", + "crash_page_read_the_faq": "läs <a href=\"`x`\">Vanliga frågor (FAQ)</a>", + "user_created_playlists": "`x` skapade spellistor", + "channel_tab_channels_label": "Kanaler", + "search_filters_type_option_all": "Vilken typ som helst", + "Russian (auto-generated)": "Ryska (auto-genererad)", + "preferences_quality_dash_option_480p": "480p", + "comments_view_x_replies": "Se {{count}} svar", + "comments_view_x_replies_plural": "Se {{count}} svar", + "footer_original_source_code": "Ursprunglig källkod", + "Portuguese (Brazil)": "Portugisiska (Brasilien)", + "search_filters_features_option_vr180": "VR180", + "error_video_not_in_playlist": "Den begärda videon finns inte i den här spellistan. <a href=\"`x`\">Klicka här för startsidan för spellistan.</a>", + "Dutch (auto-generated)": "Nederländska (auto-genererad)", + "generic_count_days": "{{count}}dag", + "generic_count_days_plural": "{{count}}dagar", + "Vietnamese (auto-generated)": "Vietnamesiska (auto-genererad)", + "search_filters_duration_option_none": "Vilken varaktighet som helst", + "preferences_quality_dash_option_240p": "240p", + "Chinese": "Kinesiska", + "preferences_automatic_instance_redirect_label": "Automatisk instansomdirigering (återgång till redirect.invidious.io): ", + "generic_button_delete": "Radera", + "Import YouTube playlist (.csv)": "Importera YouTube spellista (.csv)", + "next_steps_error_message": "Därefter bör du försöka: ", + "Standard YouTube license": "Standard YouTube licens", + "Import YouTube watch history (.json)": "Importera YouTube visningshistorik (.json)", + "search_filters_duration_option_medium": "Medium (4 - 20 minuter)", + "generic_count_seconds": "{{count}}sekund", + "generic_count_seconds_plural": "{{count}}sekunder", + "search_filters_date_label": "Uppladdningsdatum", + "crash_page_you_found_a_bug": "Det verkar som att du har hittat en bugg i Invidious!", + "generic_views_count": "{{count}} visning", + "generic_views_count_plural": "{{count}} visningar", + "toggle_theme": "Växla tema", + "Add to playlist": "Lägg till i spellista", + "Add to playlist: ": "Lägg till i spellista: ", + "Answer": "Svara", + "Search for videos": "Sök efter videor", + "The Popular feed has been disabled by the administrator.": "Det populära flödet har inaktiverats av administratören.", + "carousel_slide": "Bildspel {{current}} av {{total}}", + "carousel_skip": "Hoppa över karusellen", + "carousel_go_to": "Gå till bildspel `x`" +} diff --git a/locales/tk.json b/locales/tk.json new file mode 100644 index 00000000..798ea6ce --- /dev/null +++ b/locales/tk.json @@ -0,0 +1,7 @@ +{ + "Add to playlist": "Aýdym sanawyna goş", + "Add to playlist: ": "Pleýliste goş: ", + "Answer": "Jogap", + "Search for videos": "Wideo gözläň", + "The Popular feed has been disabled by the administrator.": "Trende bolan administrator tarapyndan ýapyldy." +} diff --git a/locales/tr.json b/locales/tr.json index 53c2a8c6..282cbf88 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -1,147 +1,124 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abone.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` abone." - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` video." - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` oynatma listesi.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` oynatma listesi." - }, "LIVE": "CANLI", - "Shared `x` ago": "`x` önce paylaşıldı", - "Unsubscribe": "Abonelikten çık", - "Subscribe": "Abone ol", - "View channel on YouTube": "Kanalı YouTube'da görüntüle", - "View playlist on YouTube": "Oynatma listesini YouTube'da görüntüle", - "newest": "en yeni", - "oldest": "en eski", - "popular": "popüler", - "last": "son", - "Next page": "Sonraki sayfa", - "Previous page": "Önceki sayfa", + "Shared `x` ago": "`x` Önce Paylaşıldı", + "Unsubscribe": "Abonelikten Çık", + "Subscribe": "Abone Ol", + "View channel on YouTube": "Kanalı YouTube'da Görüntüle", + "View playlist on YouTube": "Oynatma Listesini YouTube'da Görüntüle", + "newest": "En Yeni", + "oldest": "En Eski", + "popular": "Popüler", + "last": "Son", + "Next page": "Sonraki Sayfa", + "Previous page": "Önceki Sayfa", "Clear watch history?": "İzleme geçmişi temizlensin mi?", - "New password": "Yeni parola", - "New passwords must match": "Yeni parolalar eşleşmek zorunda", - "Cannot change password for Google accounts": "Google hesapları için parola değiştirilemez", + "New password": "Yeni Parola", + "New passwords must match": "Yeni Parolalar Eşleşmek Zorunda", "Authorize token?": "Belirteç yetkilendirilsin mi?", "Authorize token for `x`?": "`x` için belirteç yetkilendirilsin mi?", "Yes": "Evet", "No": "Hayır", "Import and Export Data": "Verileri İçe ve Dışa Aktar", - "Import": "İçe aktar", - "Import Invidious data": "İnvidious verilerini içe aktar", - "Import YouTube subscriptions": "YouTube aboneliklerini içe aktar", - "Import FreeTube subscriptions (.db)": "FreeTube aboneliklerini içe aktar (.db)", - "Import NewPipe subscriptions (.json)": "NewPipe aboneliklerini içe aktar (.json)", - "Import NewPipe data (.zip)": "NewPipe verilerini içe aktar (.zip)", - "Export": "Dışa aktar", - "Export subscriptions as OPML": "Abonelikleri OPML olarak dışa aktar", - "Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonelikleri OPML olarak dışa aktar (NewPipe ve FreeTube için)", - "Export data as JSON": "Verileri JSON olarak dışa aktar", + "Import": "İçe Aktar", + "Import Invidious data": "Invidious JSON Verilerini İçe Aktar", + "Import YouTube subscriptions": "YouTube CSV veya OPML Aboneliklerini İçe Aktar", + "Import FreeTube subscriptions (.db)": "FreeTube Aboneliklerini İçe Aktar (.db)", + "Import NewPipe subscriptions (.json)": "NewPipe Aboneliklerini İçe Aktar (.json)", + "Import NewPipe data (.zip)": "NewPipe Verilerini İçe Aktar (.zip)", + "Export": "Dışa Aktar", + "Export subscriptions as OPML": "Abonelikleri OPML Olarak Dışa Aktar", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Abonelikleri OPML Olarak Dışa Aktar (NewPipe ve FreeTube İçin)", + "Export data as JSON": "İnvidious Verilerini JSON Olarak Dışa Aktar", "Delete account?": "Hesap silinsin mi?", "History": "Geçmiş", - "An alternative front-end to YouTube": "YouTube için alternatif bir ön-yüz", - "JavaScript license information": "JavaScript lisans bilgileri", - "source": "kaynak", - "Log in": "Oturum aç", - "Log in/register": "Oturum aç/kayıt ol", - "Log in with Google": "Google ile oturum aç", - "User ID": "Kullanıcı kimliği", + "An alternative front-end to YouTube": "YouTube İçin Alternatif Bir Ön-Yüz", + "JavaScript license information": "JavaScript Lisans Bilgileri", + "source": "Kaynak", + "Log in": "Oturum Aç", + "Log in/register": "Oturum Aç/Kayıt Ol", + "User ID": "Kullanıcı Kimliği", "Password": "Parola", "Time (h:mm:ss):": "Zaman (h:mm:ss):", "Text CAPTCHA": "Metin CAPTCHA", "Image CAPTCHA": "Resim CAPTCHA", "Sign In": "Oturum Aç", "Register": "Kayıt Ol", - "E-mail": "E-posta", - "Google verification code": "Google doğrulama kodu", + "E-mail": "E-Posta", "Preferences": "Tercihler", - "Player preferences": "Oynatıcı tercihleri", - "Always loop: ": "Sürekli döngü: ", - "Autoplay: ": "Otomatik oynat: ", - "Play next by default: ": "Öntanımlı olarak sonrakini oynat: ", - "Autoplay next video: ": "Sonraki videoyu otomatik oynat: ", - "Listen by default: ": "Öntanımlı olarak dinle: ", - "Proxy videos: ": "Videoları proxy'le: ", - "Default speed: ": "Öntanımlı hız: ", - "Preferred video quality: ": "Tercih edilen video kalitesi: ", - "Player volume: ": "Oynatıcı ses seviyesi: ", - "Default comments: ": "Öntanımlı yorumlar: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "Öntanımlı altyazılar: ", - "Fallback captions: ": "Yedek altyazılar: ", - "Show related videos: ": "İlgili videoları göster: ", - "Show annotations by default: ": "Öntanımlı olarak ek açıklamaları göster: ", - "Automatically extend video description: ": "", - "Visual preferences": "Görsel tercihler", - "Player style: ": "Oynatıcı biçimi: ", - "Dark mode: ": "Karanlık mod: ", - "Theme: ": "Tema: ", - "dark": "karanlık", - "light": "aydınlık", - "Thin mode: ": "İnce mod: ", - "Subscription preferences": "Abonelik tercihleri", - "Show annotations by default for subscribed channels: ": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ", - "Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ", - "Number of videos shown in feed: ": "Akışta gösterilen video sayısı: ", - "Sort videos by: ": "Videoları sıralama kriteri: ", - "published": "yayınlandı", - "published - reverse": "yayınlandı - ters", - "alphabetically": "alfabetik olarak", - "alphabetically - reverse": "alfabetik olarak - ters", - "channel name": "kanal adı", - "channel name - reverse": "kanal adı - ters", - "Only show latest video from channel: ": "Sadece kanaldaki en son videoyu göster: ", - "Only show latest unwatched video from channel: ": "Sadece kanaldaki en son izlenmemiş videoyu göster: ", - "Only show unwatched: ": "Sadece izlenmemişleri göster: ", - "Only show notifications (if there are any): ": "Sadece bildirimleri göster (eğer varsa): ", - "Enable web notifications": "Ağ bildirimlerini etkinleştir", - "`x` uploaded a video": "`x` bir video yükledi", - "`x` is live": "`x` canlı yayında", - "Data preferences": "Veri tercihleri", - "Clear watch history": "İzleme geçmişini temizle", - "Import/export data": "Verileri içe/dışa aktar", - "Change password": "Parolayı değiştir", - "Manage subscriptions": "Abonelikleri yönet", - "Manage tokens": "Belirteçleri yönet", - "Watch history": "İzleme geçmişi", - "Delete account": "Hesap silme", - "Administrator preferences": "Yönetici tercihleri", - "Default homepage: ": "Öntanımlı ana sayfa: ", - "Feed menu: ": "Akış menüsü: ", - "Top enabled: ": "Top etkin: ", - "CAPTCHA enabled: ": "CAPTCHA etkin: ", - "Login enabled: ": "Oturum açma etkin: ", - "Registration enabled: ": "Kayıt olma etkin: ", - "Report statistics: ": "Rapor istatistikleri: ", - "Save preferences": "Tercihleri kaydet", - "Subscription manager": "Abonelik yöneticisi", - "Token manager": "Belirteç yöneticisi", + "preferences_category_player": "Oynatıcı Tercihleri", + "preferences_video_loop_label": "Sürekli Döngü: ", + "preferences_autoplay_label": "Otomatik Oynat: ", + "preferences_continue_label": "Öntanımlı Olarak Sonrakini Oynat: ", + "preferences_continue_autoplay_label": "Sonraki Videoyu Otomatik Oynat: ", + "preferences_listen_label": "Öntanımlı Olarak Dinle: ", + "preferences_local_label": "Videolara Proxy Uygula: ", + "preferences_speed_label": "Öntanımlı Hız: ", + "preferences_quality_label": "Tercih Edilen Video Kalitesi: ", + "preferences_volume_label": "Oynatıcı Ses Seviyesi: ", + "preferences_comments_label": "Öntanımlı Yorumlar: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Öntanımlı Altyazılar: ", + "Fallback captions: ": "Yedek Altyazılar: ", + "preferences_related_videos_label": "İlgili Videoları Göster: ", + "preferences_annotations_label": "Öntanımlı Olarak Ek Açıklamaları Göster: ", + "preferences_extend_desc_label": "Video Açıklamasını Otomatik Olarak Genişlet: ", + "preferences_vr_mode_label": "Etkileşimli 360 Derece Videolar (WebGL Gerektirir): ", + "preferences_category_visual": "Görsel Tercihler", + "preferences_player_style_label": "Oynatıcı Biçimi: ", + "Dark mode: ": "Koyu Mod: ", + "preferences_dark_mode_label": "Tema: ", + "dark": "Koyu", + "light": "Açık", + "preferences_thin_mode_label": "İnce Mod: ", + "preferences_category_misc": "Çeşitli Tercihler", + "preferences_automatic_instance_redirect_label": "Otomatik Örnek Yeniden Yönlendirmesi (Yedek: redirect.invidious.io): ", + "preferences_category_subscription": "Abonelik Tercihleri", + "preferences_annotations_subscribed_label": "Abone Olunan Kanallar İçin Ek Açıklamaları Öntanımlı Olarak Göster: ", + "Redirect homepage to feed: ": "Ana Sayfayı Akışa Yönlendir: ", + "preferences_max_results_label": "Akışta Gösterilen Video Sayısı: ", + "preferences_sort_label": "Videoları Sıralama Kriteri: ", + "published": "Yayınlandı", + "published - reverse": "Yayınlandı - Ters", + "alphabetically": "Alfabetik Olarak", + "alphabetically - reverse": "Alfabetik Olarak - Ters", + "channel name": "Kanal Adı", + "channel name - reverse": "Kanal Adı - Ters", + "Only show latest video from channel: ": "Sadece Kanaldaki En Son Videoyu Göster: ", + "Only show latest unwatched video from channel: ": "Sadece Kanaldaki En Son İzlenmemiş Videoyu Göster: ", + "preferences_unseen_only_label": "Sadece İzlenmemişleri Göster: ", + "preferences_notifications_only_label": "Sadece Bildirimleri Göster (Eğer Varsa): ", + "Enable web notifications": "Ağ Bildirimlerini Etkinleştir", + "`x` uploaded a video": "`x` Bir Video Yükledi", + "`x` is live": "`x` Canlı Yayında", + "preferences_category_data": "Veri Tercihleri", + "Clear watch history": "İzleme Geçmişini Temizle", + "Import/export data": "Verileri İçe/Dışa Aktar", + "Change password": "Parolayı Değiştir", + "Manage subscriptions": "Abonelikleri Yönet", + "Manage tokens": "Belirteçleri Yönet", + "Watch history": "İzleme Geçmişi", + "Delete account": "Hesap Silme", + "preferences_category_admin": "Yönetici Tercihleri", + "preferences_default_home_label": "Öntanımlı Ana Sayfa: ", + "preferences_feed_menu_label": "Akış Menüsü: ", + "preferences_show_nick_label": "Takma Adı Üstte Göster: ", + "Top enabled: ": "Top Etkin: ", + "CAPTCHA enabled: ": "CAPTCHA Etkin: ", + "Login enabled: ": "Oturum Açma Etkin: ", + "Registration enabled: ": "Kayıt Olma Etkin: ", + "Report statistics: ": "Rapor İstatistikleri: ", + "Save preferences": "Tercihleri Kaydet", + "Subscription manager": "Abonelik Yöneticisi", + "Token manager": "Belirteç Yöneticisi", "Token": "Belirteç", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonelik.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` abonelik." - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` belirteç.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` belirteç." - }, - "Import/export": "İçe/dışa aktar", - "unsubscribe": "abonelikten çık", - "revoke": "geri al", + "Import/export": "İçe/Dışa Aktar", + "unsubscribe": "Abonelikten Çık", + "revoke": "Geri Al", "Subscriptions": "Abonelikler", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` okunmamış bildirim.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` okunmamış bildirim." - }, - "search": "ara", - "Log out": "Çıkış yap", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth tarafından AGPLv3 altında yayımlandı.", + "search": "Ara", + "Log out": "Çıkış Yap", + "Released under the AGPLv3 on Github.": "GitHub'da AGPLv3 altında yayınlandı.", "Source available here.": "Kaynak kodları burada bulunabilir.", "View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.", "View privacy policy.": "Gizlilik politikasını görüntüle.", @@ -149,87 +126,71 @@ "Public": "Genel", "Unlisted": "Listelenmemiş", "Private": "Özel", - "View all playlists": "Tüm oynatma listelerini görüntüle", - "Updated `x` ago": "`x` önce güncellendi", + "View all playlists": "Tüm Oynatma Listelerini Görüntüle", + "Updated `x` ago": "`x` Önce Güncellendi", "Delete playlist `x`?": "`x` oynatma listesi silinsin mi?", - "Delete playlist": "Oynatma listesini sil", - "Create playlist": "Oynatma listesi oluştur", + "Delete playlist": "Oynatma Listesini Sil", + "Create playlist": "Oynatma Listesi Oluştur", "Title": "Başlık", - "Playlist privacy": "Oynatma listesi gizliliği", - "Editing playlist `x`": "`x` oynatma listesi düzenleniyor", - "Show more": "", - "Show less": "", - "Watch on YouTube": "YouTube'da izle", - "Hide annotations": "Ek açıklamaları gizle", - "Show annotations": "Ek açıklamaları göster", + "Playlist privacy": "Oynatma Listesi Gizliliği", + "Editing playlist `x`": "`x` Oynatma Listesi Düzenleniyor", + "Show more": "Daha Fazla Göster", + "Show less": "Daha Az Göster", + "Watch on YouTube": "YouTube'da İzle", + "Switch Invidious Instance": "Invidious Örneğini Değiştir", + "Hide annotations": "Ek Açıklamaları Gizle", + "Show annotations": "Ek Açıklamaları Göster", "Genre: ": "Tür: ", "License: ": "Lisans: ", "Family friendly? ": "Aile için uygun mu? ", - "Wilson score: ": "Wilson puanı: ", - "Engagement: ": "İzleyenlerin oy verme oranı: ", - "Whitelisted regions: ": "Beyaz listeye alınan bölgeler: ", - "Blacklisted regions: ": "Kara listeye alınan bölgeler: ", - "Shared `x`": "`x` paylaşıldı", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` görüntüleme.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` görüntüleme." - }, - "Premieres in `x`": "`x`içinde ilk gösterim", - "Premieres `x`": "`x` ilk gösterim", + "Wilson score: ": "Wilson Puanı: ", + "Engagement: ": "İzleyenlerin Oy Verme Oranı: ", + "Whitelisted regions: ": "Beyaz Listeye Alınan Bölgeler: ", + "Blacklisted regions: ": "Kara Listeye Alınan Bölgeler: ", + "Shared `x`": "`x` Paylaşıldı", + "Premieres in `x`": "`x`İçinde İlk Gösterim", + "Premieres `x`": "`x` İlk Gösterim", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Merhaba! JavaScript'i kapatmış gibi görünüyorsun. Yorumları görüntülemek için buraya tıkla, yüklenmelerinin biraz uzun sürebileceğini unutma.", - "View YouTube comments": "YouTube yorumlarını görüntüle", - "View more comments on Reddit": "Reddit'te daha fazla yorum görüntüle", + "View YouTube comments": "YouTube Yorumlarını Görüntüle", + "View more comments on Reddit": "Reddit'te Daha Fazla Yorum Görüntüle", "View `x` comments": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yorumu görüntüle.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` yorumu görüntüle." + "([^.,0-9]|^)1([^.,0-9]|$)": "`x` Yorumu Görüntüle", + "": "`x` Yorumu Görüntüle" }, - "View Reddit comments": "Reddit yorumlarını görüntüle", - "Hide replies": "Cevapları gizle", - "Show replies": "Cevapları göster", - "Incorrect password": "Yanlış parola", - "Quota exceeded, try again in a few hours": "Kota aşıldı, birkaç saat içinde tekrar deneyin", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Oturum açılamadı, iki faktörlü kimlik doğrulamanın (Authenticator ya da SMS) açık olduğundan emin olun.", - "Invalid TFA code": "Geçersiz TFA kodu", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Giriş başarısız. Bunun nedeni, hesabınız için iki faktörlü kimlik doğrulamanın açık olmaması olabilir.", - "Wrong answer": "Yanlış cevap", + "View Reddit comments": "Reddit Yorumlarını Görüntüle", + "Hide replies": "Cevapları Gizle", + "Show replies": "Cevapları Göster", + "Incorrect password": "Yanlış Parola", + "Wrong answer": "Yanlış Cevap", "Erroneous CAPTCHA": "Hatalı CAPTCHA", - "CAPTCHA is a required field": "CAPTCHA zorunlu bir alandır", - "User ID is a required field": "Kullanıcı kimliği zorunlu bir alandır", - "Password is a required field": "Parola zorunlu bir alandır", - "Wrong username or password": "Yanlış kullanıcı adı ya da parola", - "Please sign in using 'Log in with Google'": "Lütfen 'Google ile giriş yap' seçeneğini kullanarak oturum açın", - "Password cannot be empty": "Parola boş olamaz", - "Password cannot be longer than 55 characters": "Parola 55 karakterden uzun olamaz", - "Please log in": "Lütfen oturum açın", - "Invidious Private Feed for `x`": "`x` için İnvidious Özel Akışı", - "channel:`x`": "kanal:`x`", - "Deleted or invalid channel": "Silinmiş ya da geçersiz kanal", + "CAPTCHA is a required field": "CAPTCHA Zorunlu Bir Alandır", + "User ID is a required field": "Kullanıcı Kimliği Zorunlu Bir Alandır", + "Password is a required field": "Parola Zorunlu Bir Alandır", + "Wrong username or password": "Yanlış Kullanıcı Adı ya da Parola", + "Password cannot be empty": "Parola Boş Olamaz", + "Password cannot be longer than 55 characters": "Parola 55 Karakterden Uzun Olamaz", + "Please log in": "Lütfen Oturum Açın", + "Invidious Private Feed for `x`": "`x` İçin Invidious Özel Akışı", + "channel:`x`": "Kanal:`x`", + "Deleted or invalid channel": "Silinmiş ya da Geçersiz Kanal", "This channel does not exist.": "Bu kanal mevcut değil.", "Could not get channel info.": "Kanal bilgisi alınamadı.", - "Could not fetch comments": "Yorumlar alınamadı", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yanıtı görüntüle.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` yanıtı görüntüle." - }, - "`x` ago": "`x` önce", - "Load more": "Daha fazla yükle", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` puan.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` puan." - }, + "Could not fetch comments": "Yorumlar Alınamadı", + "`x` ago": "`x` Önce", + "Load more": "Daha Fazla Yükle", "Could not create mix.": "Mix oluşturulamadı.", - "Empty playlist": "Boş oynatma listesi", + "Empty playlist": "Boş Oynatma Listesi", "Not a playlist.": "Oynatma listesi değil.", "Playlist does not exist.": "Oynatma listesi mevcut değil.", "Could not pull trending pages.": "Trend sayfaları alınamıyor.", - "Hidden field \"challenge\" is a required field": "Gizli alan \"challenge\" zorunlu bir alandır", - "Hidden field \"token\" is a required field": "\"belirteç\" gizli alanı zorunlu bir alandır", - "Erroneous challenge": "Hatalı challenge", - "Erroneous token": "Hatalı belirteç", - "No such user": "Böyle bir kullanıcı yok", - "Token is expired, please try again": "Belirtecin süresi doldu, lütfen tekrar deneyin", + "Hidden field \"challenge\" is a required field": "Gizli Alan \"Challenge\" Zorunlu Bir Alandır", + "Hidden field \"token\" is a required field": "\"Belirteç\" Gizli Alanı Zorunlu Bir Alandır", + "Erroneous challenge": "Hatalı Challenge", + "Erroneous token": "Hatalı Belirteç", + "No such user": "Böyle Bir Kullanıcı Yok", + "Token is expired, please try again": "Belirtecin Süresi Doldu, Lütfen Tekrar Deneyin", "English": "İngilizce", - "English (auto-generated)": "İngilizce (otomatik oluşturuldu)", + "English (auto-generated)": "İngilizce (Otomatik Oluşturuldu)", "Afrikaans": "Afrikanca", "Albanian": "Arnavutça", "Amharic": "Amharca", @@ -250,7 +211,7 @@ "Croatian": "Hırvatça", "Czech": "Çekçe", "Danish": "Danca", - "Dutch": "Flemenkçe", + "Dutch": "Felemenkçe", "Esperanto": "Esperanto", "Estonian": "Estonca", "Filipino": "Filipince", @@ -261,9 +222,9 @@ "German": "Almanca", "Greek": "Yunanca", "Gujarati": "Guceratça", - "Haitian Creole": "Haiti Creole dili", + "Haitian Creole": "Haiti Creole Dili", "Hausa": "Hausaca", - "Hawaiian": "Hawaii dili", + "Hawaiian": "Hawaii Dili", "Hebrew": "İbranice", "Hindi": "Hintçe", "Hmong": "Hmong", @@ -275,7 +236,7 @@ "Italian": "İtalyanca", "Japanese": "Japonca", "Javanese": "Cava dili", - "Kannada": "Kannada dili", + "Kannada": "Kannada Dili", "Kazakh": "Kazakça", "Khmer": "Kmerce", "Korean": "Korece", @@ -289,10 +250,10 @@ "Macedonian": "Makedonca", "Malagasy": "Malgaşça", "Malay": "Malayca", - "Malayalam": "Malayalam dili", + "Malayalam": "Malayalam Dili", "Maltese": "Maltaca", - "Maori": "Maori dili", - "Marathi": "Marati dili", + "Maori": "Maori Dili", + "Marathi": "Marati Dili", "Mongolian": "Moğolca", "Nepali": "Nepalce", "Norwegian Bokmål": "Norveççe Bokmål", @@ -301,19 +262,19 @@ "Persian": "Farsça", "Polish": "Lehçe", "Portuguese": "Portekizce", - "Punjabi": "Pencap dili", + "Punjabi": "Pencap Dili", "Romanian": "Rumence", "Russian": "Rusça", - "Samoan": "Samoa dili", + "Samoan": "Samoa Dili", "Scottish Gaelic": "İskoç Galcesi", "Serbian": "Sırpça", - "Shona": "Şona dili", + "Shona": "Şona Dili", "Sindhi": "Sintçe", "Sinhala": "Seylanca", "Slovak": "Slovakça", "Slovenian": "Slovence", "Somali": "Somalice", - "Southern Sotho": "Güney Sotho dili", + "Southern Sotho": "Güney Sotho Dili", "Spanish": "İspanyolca", "Spanish (Latin America)": "İspanyolca (Latin Amerika)", "Sundanese": "Sundaca", @@ -321,7 +282,7 @@ "Swedish": "İsveççe", "Tajik": "Tacikçe", "Tamil": "Tamilce", - "Telugu": "Telugu dili", + "Telugu": "Telugu Dili", "Thai": "Tayca", "Turkish": "Türkçe", "Ukrainian": "Ukraynaca", @@ -330,89 +291,210 @@ "Vietnamese": "Vietnamca", "Welsh": "Galce", "Western Frisian": "Batı Frizcesi", - "Xhosa": "Xhosa dili", + "Xhosa": "Xhosa Dili", "Yiddish": "Yiddiş", - "Yoruba": "Yoruba dili", + "Yoruba": "Yoruba Dili", "Zulu": "Zuluca", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` yıl.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` yıl." - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ay.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` ay." - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hafta.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` hafta." - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gün.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` gün." - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saat.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` saat." - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dakika.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` dakika." - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` saniye.([^.,0-9]|^)1([^.,0-9]|$)", - "": "`x` saniye." - }, - "Fallback comments: ": "Yedek yorumlar: ", + "Fallback comments: ": "Yedek Yorumlar: ", "Popular": "Popüler", "Search": "Ara", "Top": "Enler", "About": "Hakkında", "Rating: ": "Değerlendirme: ", - "Language: ": "Dil: ", - "View as playlist": "Oynatma listesi olarak görüntüle", + "preferences_locale_label": "Dil: ", + "View as playlist": "Oynatma Listesi Olarak Görüntüle", "Default": "Öntanımlı", "Music": "Müzik", "Gaming": "Oyun", "News": "Haberler", "Movies": "Filmler", "Download": "İndir", - "Download as: ": "Şu şekilde indir: ", + "Download as: ": "Şu Şekilde İndir: ", "%A %B %-d, %Y": "%A %B %-d, %Y", - "(edited)": "(düzenlendi)", - "YouTube comment permalink": "YouTube yorumu kalıcı linki", - "permalink": "kalıcı link", - "`x` marked it with a ❤": "`x` ❤ ile işaretledi", - "Audio mode": "Ses modu", - "Video mode": "Video modu", - "Videos": "Videolar", - "Playlists": "Oynatma listeleri", - "Community": "Topluluk", - "relevance": "ilgi", - "rating": "değerlendirme", - "date": "tarih", - "views": "görüntüleme", - "content_type": "içerik_türü", - "duration": "süre", - "features": "özellikler", - "sort": "sırala", - "hour": "saat", - "today": "bugün", - "week": "hafta", - "month": "ay", - "year": "yıl", - "video": "video", - "channel": "kanal", - "playlist": "oynatma listesi", - "movie": "film", - "show": "gösteri", - "hd": "HD", - "subtitles": "alt yazılar", - "creative_commons": "Creative Commons", - "3d": "3B", - "live": "canlı", - "4k": "4K", - "location": "konum", - "hdr": "HDR", - "filter": "filtrele", - "Current version: ": "Şu anki sürüm: " -}
\ No newline at end of file + "(edited)": "(Düzenlendi)", + "YouTube comment permalink": "YouTube Yorumu Kalıcı Linki", + "permalink": "Kalıcı Link", + "`x` marked it with a ❤": "`x` ❤ İle İşaretledi", + "Audio mode": "Ses Modu", + "Video mode": "Video Modu", + "channel_tab_videos_label": "Videolar", + "Playlists": "Oynatma Listeleri", + "channel_tab_community_label": "Topluluk", + "search_filters_sort_option_relevance": "İlgi", + "search_filters_sort_option_rating": "Değerlendirme", + "search_filters_sort_option_date": "Yükleme tarihi", + "search_filters_sort_option_views": "Görüntüleme Sayısı", + "search_filters_type_label": "Tür", + "search_filters_duration_label": "Süre", + "search_filters_features_label": "Özellikler", + "search_filters_sort_label": "Sıralama Ölçütü", + "search_filters_date_option_hour": "Son saat", + "search_filters_date_option_today": "Bugün", + "search_filters_date_option_week": "Bu Hafta", + "search_filters_date_option_month": "Bu Ay", + "search_filters_date_option_year": "Bu Yıl", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Kanal", + "search_filters_type_option_playlist": "Oynatma Listesi", + "search_filters_type_option_movie": "Film", + "search_filters_type_option_show": "Gösteri", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Alt Yazılar", + "search_filters_features_option_c_commons": "Yaratıcı", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Canlı", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Konum", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Şu Anki Sürüm: ", + "next_steps_error_message": "Bundan Sonra Şunları Denemelisiniz: ", + "next_steps_error_message_refresh": "Yenile", + "next_steps_error_message_go_to_youtube": "YouTube'a Git", + "search_filters_duration_option_short": "Kısa (4 Dakikadan Az)", + "search_filters_duration_option_long": "Uzun (20 Dakikadan Fazla)", + "footer_documentation": "Belgelendirme", + "footer_source_code": "Kaynak Kodları", + "footer_original_source_code": "Orijinal Kaynak Kodları", + "footer_modfied_source_code": "Değiştirilmiş kaynak kodları", + "adminprefs_modified_source_code_url_label": "Değiştirilmiş Kaynak Kodları Deposunun URL'si", + "footer_donate_page": "Bağış Yap", + "preferences_region_label": "İçerik Ülkesi: ", + "preferences_quality_dash_label": "Tercih Edilen DASH Video Kalitesi: ", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_best": "En İyi", + "preferences_quality_dash_option_worst": "En Kötü", + "preferences_quality_dash_option_4320p": "4320P", + "preferences_quality_dash_option_2160p": "2160P", + "preferences_quality_dash_option_480p": "480P", + "preferences_quality_dash_option_360p": "360P", + "preferences_quality_dash_option_240p": "240P", + "preferences_quality_dash_option_144p": "144P", + "invidious": "Invidious", + "none": "Yok", + "videoinfo_started_streaming_x_ago": "`x` Önce Yayına Başladı", + "videoinfo_youTube_embed_link": "Entegre Et", + "videoinfo_invidious_embed_link": "Bağlantıyı Entegre Et", + "user_created_playlists": "`x` Oluşturulan Oynatma Listeleri", + "user_saved_playlists": "`x` Kaydedilen Oynatma Listeleri", + "preferences_quality_option_small": "Küçük", + "preferences_quality_dash_option_720p": "720P", + "preferences_quality_option_medium": "Orta", + "preferences_quality_dash_option_1440p": "1440P", + "preferences_quality_dash_option_1080p": "1080P", + "Video unavailable": "Video Kullanılamıyor", + "preferences_quality_option_dash": "DASH (Uyarlanabilir Kalite)", + "preferences_quality_dash_option_auto": "Otomatik", + "search_filters_features_option_purchased": "Satın Alınan", + "search_filters_features_option_three_sixty": "360°", + "videoinfo_watch_on_youTube": "YouTube'da İzle", + "download_subtitles": "Alt Yazılar - `x` (.vtt)", + "preferences_save_player_pos_label": "Oynatma Konumunu Kaydet: ", + "generic_views_count": "{{count}} Görüntülenme", + "generic_views_count_plural": "{{count}} Görüntülenme", + "generic_subscribers_count": "{{count}} Abone", + "generic_subscribers_count_plural": "{{count}} Abone", + "generic_subscriptions_count": "{{count}} Abonelik", + "generic_subscriptions_count_plural": "{{count}} Abonelik", + "subscriptions_unseen_notifs_count": "{{count}} Okunmamış Bildirim", + "subscriptions_unseen_notifs_count_plural": "{{count}} Okunmamış Bildirim", + "comments_points_count": "{{count}} Puan", + "comments_points_count_plural": "{{count}} Puan", + "generic_count_hours": "{{count}} Saat", + "generic_count_hours_plural": "{{count}} Saat", + "generic_count_minutes": "{{count}} Dakika", + "generic_count_minutes_plural": "{{count}} Dakika", + "generic_count_seconds": "{{count}} Saniye", + "generic_count_seconds_plural": "{{count}} Saniye", + "generic_playlists_count": "{{count}} Oynatma Listesi", + "generic_playlists_count_plural": "{{count}} Oynatma Listesi", + "tokens_count": "{{count}} Belirteç", + "tokens_count_plural": "{{count}} Belirteç", + "comments_view_x_replies": "{{count}} Yanıtı Görüntüle", + "comments_view_x_replies_plural": "{{count}} Yanıtı Görüntüle", + "generic_count_years": "{{count}} Yıl", + "generic_count_years_plural": "{{count}} Yıl", + "generic_count_months": "{{count}} Ay", + "generic_count_months_plural": "{{count}} Ay", + "generic_count_days": "{{count}} Gün", + "generic_count_days_plural": "{{count}} Gün", + "generic_videos_count": "{{count}} Video", + "generic_videos_count_plural": "{{count}} Video", + "generic_count_weeks": "{{count}} Hafta", + "generic_count_weeks_plural": "{{count}} Hafta", + "crash_page_you_found_a_bug": "Görünüşe göre Invidious'ta bir hata buldunuz!", + "crash_page_before_reporting": "Bir hatayı bildirmeden önce, şunları yaptığınızdan emin olun:", + "crash_page_refresh": "<a href=\"`x`\">Sayfayı Yenilemeye</a> Çalıştınız", + "crash_page_switch_instance": "<a href=\"`x`\">Başka Bir Örnek Kullanmaya</a> Çalıştınız", + "crash_page_read_the_faq": "<a href=\"`x`\">Sık Sorulan Soruları (SSS)</a> Okudunuz", + "crash_page_search_issue": "<a href=\"`x`\">GitHub'daki Sorunlarda</a> Aradınız", + "crash_page_report_issue": "Yukarıdakilerin hiçbiri yardımcı olmadıysa, lütfen <a href=\"`x`\">GitHub'da yeni bir sorun açın</a> (Tercihen İngilizce) ve mesajınıza aşağıdaki metni ekleyin (Bu metni ÇEVİRMEYİN):", + "English (United Kingdom)": "İngilizce (Birleşik Krallık)", + "Chinese": "Çince", + "Interlingue": "İnterlingue", + "Italian (auto-generated)": "İtalyanca (Otomatik Oluşturuldu)", + "Japanese (auto-generated)": "Japonca (Otomatik Oluşturuldu)", + "Portuguese (Brazil)": "Portekizce (Brezilya)", + "Russian (auto-generated)": "Rusça (Otomatik Oluşturuldu)", + "Spanish (auto-generated)": "İspanyolca (Otomatik Oluşturuldu)", + "Spanish (Mexico)": "İspanyolca (Meksika)", + "English (United States)": "İngilizce (ABD)", + "Cantonese (Hong Kong)": "Kantonca (Hong Kong)", + "Chinese (Taiwan)": "Çince (Tayvan)", + "Dutch (auto-generated)": "Felemenkçe (Otomatik Oluşturuldu)", + "Indonesian (auto-generated)": "Endonezyaca (Otomatik Oluşturuldu)", + "Chinese (Hong Kong)": "Çince (Hong Kong)", + "French (auto-generated)": "Fransızca (Otomatik Oluşturuldu)", + "Korean (auto-generated)": "Korece (Otomatik Oluşturuldu)", + "Turkish (auto-generated)": "Türkçe (Otomatik Oluşturuldu)", + "Chinese (China)": "Çince (Çin)", + "German (auto-generated)": "Almanca (Otomatik Oluşturuldu)", + "Portuguese (auto-generated)": "Portekizce (Otomatik Oluşturuldu)", + "Spanish (Spain)": "İspanyolca (İspanya)", + "Vietnamese (auto-generated)": "Vietnamca (Otomatik Oluşturuldu)", + "preferences_watch_history_label": "İzleme Geçmişini Etkinleştir: ", + "search_message_use_another_instance": "Ayrıca <a href=\"`x`\">başka bir örnekte arayabilirsiniz</a>.", + "search_filters_type_option_all": "Herhangi Bir Tür", + "search_filters_duration_option_none": "Herhangi Bir Süre", + "search_message_no_results": "Sonuç bulunamadı.", + "search_filters_date_label": "Yükleme Tarihi", + "search_filters_apply_button": "Seçili Filtreleri Uygula", + "search_filters_date_option_none": "Herhangi Bir Tarih", + "search_filters_duration_option_medium": "Orta (4 - 20 Dakika)", + "search_filters_features_option_vr180": "VR180", + "search_filters_title": "Filtreler", + "search_message_change_filters_or_query": "Arama sorgunuzu genişletmeyi ve/veya filtreleri değiştirmeyi deneyin.", + "Popular enabled: ": "Popüler Etkin: ", + "error_video_not_in_playlist": "İstenen video bu oynatma listesinde yok. <a href=\"`x`\">Oynatma listesi ana sayfası için buraya tıklayın.</a>", + "channel_tab_channels_label": "Kanallar", + "channel_tab_shorts_label": "Kısa Çekimler", + "channel_tab_streams_label": "Canlı Yayınlar", + "channel_tab_playlists_label": "Oynatma Listeleri", + "Album: ": "Albüm: ", + "Music in this video": "Bu videodaki müzik", + "Artist: ": "Sanatçı: ", + "Channel Sponsor": "Kanal Sponsoru", + "Song: ": "Şarkı: ", + "Standard YouTube license": "Standart YouTube lisansı", + "Download is disabled": "İndirme devre dışı", + "Import YouTube playlist (.csv)": "YouTube Oynatma Listesini İçe Aktar (.csv)", + "generic_button_delete": "Sil", + "generic_button_edit": "Düzenle", + "generic_button_save": "Kaydet", + "generic_button_cancel": "İptal", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "Yayınlar", + "playlist_button_add_items": "Video ekle", + "channel_tab_podcasts_label": "Podcast'ler", + "generic_channels_count": "{{count}} kanal", + "generic_channels_count_plural": "{{count}} kanal", + "Import YouTube watch history (.json)": "YouTube İzleme Geçmişini İçe Aktar (.json)", + "toggle_theme": "Temayı Değiştir", + "Add to playlist": "Oynatma listesine ekle", + "Add to playlist: ": "Oynatma listesine ekle: ", + "Answer": "Yanıt", + "Search for videos": "Video ara", + "carousel_slide": "Sunum {{current}} / {{total}}", + "carousel_skip": "Kayar menüyü atla", + "carousel_go_to": "`x` sunumuna git", + "The Popular feed has been disabled by the administrator.": "Popüler akışı yönetici tarafından devre dışı bırakıldı." +} diff --git a/locales/uk.json b/locales/uk.json index 0795612e..64329032 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -1,18 +1,6 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` підписників", - "": "`x` підписників" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` відео", - "": "`x` відео" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "списки відтворення `x`", - "": "списки відтворення `x`" - }, - "LIVE": "ПРЯМИЙ ЕФІР", - "Shared `x` ago": "Розміщено `x` назад", + "LIVE": "НАЖИВО", + "Shared `x` ago": "Розміщено `x` тому", "Unsubscribe": "Відписатися", "Subscribe": "Підписатися", "View channel on YouTube": "Подивитися канал на YouTube", @@ -26,70 +14,66 @@ "Clear watch history?": "Очистити історію переглядів?", "New password": "Новий пароль", "New passwords must match": "Нові паролі не співпадають", - "Cannot change password for Google accounts": "Змінити пароль обліківки Google неможливо", "Authorize token?": "Авторизувати токен?", "Authorize token for `x`?": "Авторизувати токен для `x`?", "Yes": "Так", "No": "Ні", "Import and Export Data": "Імпорт і експорт даних", "Import": "Імпорт", - "Import Invidious data": "Імпортувати дані Invidious", - "Import YouTube subscriptions": "Імпортувати підписки з YouTube", + "Import Invidious data": "Імпортувати JSON-дані Invidious", + "Import YouTube subscriptions": "Імпортувати підписки YouTube з CSV чи OPML", "Import FreeTube subscriptions (.db)": "Імпортувати підписки з FreeTube (.db)", "Import NewPipe subscriptions (.json)": "Імпортувати підписки з NewPipe (.json)", "Import NewPipe data (.zip)": "Імпортувати дані з NewPipe (.zip)", "Export": "Експорт", "Export subscriptions as OPML": "Експортувати підписки у форматі OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "Експортувати підписки у форматі OPML (для NewPipe та FreeTube)", - "Export data as JSON": "Експортувати дані у форматі JSON", - "Delete account?": "Видалити обліківку?", + "Export data as JSON": "Експортувати дані Invidious у форматі JSON", + "Delete account?": "Видалити обліковий запис?", "History": "Історія", "An alternative front-end to YouTube": "Альтернативний фронтенд до YouTube", "JavaScript license information": "Інформація щодо ліцензій JavaScript", "source": "джерело", "Log in": "Увійти", "Log in/register": "Увійти або зареєструватися", - "Log in with Google": "Увійти через Google", "User ID": "ID користувача", "Password": "Пароль", - "Time (h:mm:ss):": "Час (г:мм:сс):", - "Text CAPTCHA": "Текст капчі", - "Image CAPTCHA": "Зображення капчі", + "Time (h:mm:ss):": "Час (г:хх:сс):", + "Text CAPTCHA": "Текст CAPTCHA", + "Image CAPTCHA": "Зображення CAPTCHA", "Sign In": "Увійти", "Register": "Зареєструватися", "E-mail": "Електронна пошта", - "Google verification code": "Код підтвердження Google", "Preferences": "Налаштування", - "Player preferences": "Налаштування програвача", - "Always loop: ": "Завжди повторювати: ", - "Autoplay: ": "Автовідтворення: ", - "Play next by default: ": "Завжди вмикати наступне відео: ", - "Autoplay next video: ": "Автовідтворення наступного відео: ", - "Listen by default: ": "Режим «тільки звук» як усталений: ", - "Proxy videos: ": "Програвати відео через проксі? ", - "Default speed: ": "Усталена швидкість відео: ", - "Preferred video quality: ": "Пріорітетна якість відео: ", - "Player volume: ": "Гучність відео: ", - "Default comments: ": "Джерело коментарів: ", + "preferences_category_player": "Налаштування програвача", + "preferences_video_loop_label": "Завжди повторювати: ", + "preferences_autoplay_label": "Автовідтворення: ", + "preferences_continue_label": "Завжди вмикати наступне відео: ", + "preferences_continue_autoplay_label": "Автовідтворення наступного відео: ", + "preferences_listen_label": "Режим «тільки звук» як усталений: ", + "preferences_local_label": "Відтворення відео через проксі: ", + "preferences_speed_label": "Усталена швидкість відео: ", + "preferences_quality_label": "Пріорітетна якість відео: ", + "preferences_volume_label": "Гучність відео: ", + "preferences_comments_label": "Джерело коментарів: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Основна мова субтитрів: ", + "preferences_captions_label": "Основна мова субтитрів: ", "Fallback captions: ": "Запасна мова субтитрів: ", - "Show related videos: ": "Показувати схожі відео? ", - "Show annotations by default: ": "Завжди показувати анотації? ", - "Automatically extend video description: ": "", - "Visual preferences": "Налаштування сайту", - "Player style: ": "Стиль програвача: ", - "Dark mode: ": "Темне оформлення: ", - "Theme: ": "Тема: ", - "dark": "темна", + "preferences_related_videos_label": "Показувати схожі відео: ", + "preferences_annotations_label": "Завжди показувати анотації: ", + "preferences_category_visual": "Налаштування сайту", + "preferences_player_style_label": "Стиль програвача: ", + "Dark mode: ": "Темний режим: ", + "preferences_dark_mode_label": "Тема: ", + "dark": "Темна", "light": "Світла", - "Thin mode: ": "Полегшене оформлення: ", - "Subscription preferences": "Налаштування підписок", - "Show annotations by default for subscribed channels: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ", + "preferences_thin_mode_label": "Полегшене оформлення: ", + "preferences_category_subscription": "Налаштування підписок", + "preferences_annotations_subscribed_label": "Завжди показувати анотації у відео каналів, на які ви підписані? ", "Redirect homepage to feed: ": "Показувати відео з каналів, на які підписані, як головну сторінку: ", - "Number of videos shown in feed: ": "Кількість відео з каналів, на які підписані, у потоці: ", - "Sort videos by: ": "Сортувати відео: ", + "preferences_max_results_label": "Кількість відео з каналів, на які підписані, у потоці: ", + "preferences_sort_label": "Сортувати відео: ", "published": "за датою розміщення", "published - reverse": "за датою розміщення в зворотному порядку", "alphabetically": "за абеткою", @@ -98,12 +82,12 @@ "channel name - reverse": "за назвою каналу в зворотному порядку", "Only show latest video from channel: ": "Показувати тільки останнє відео з каналів: ", "Only show latest unwatched video from channel: ": "Показувати тільки непереглянуті відео з каналів: ", - "Only show unwatched: ": "Показувати тільки непереглянуті відео: ", - "Only show notifications (if there are any): ": "Показувати лише сповіщення, якщо вони є: ", + "preferences_unseen_only_label": "Показувати тільки непереглянуті відео: ", + "preferences_notifications_only_label": "Показувати лише сповіщення, якщо вони є: ", "Enable web notifications": "Ввімкнути сповіщення в браузері", "`x` uploaded a video": "`x` розмістив відео", "`x` is live": "`x` у прямому ефірі", - "Data preferences": "Налаштування даних", + "preferences_category_data": "Налаштування даних", "Clear watch history": "Очистити історію переглядів", "Import/export data": "Імпорт і експорт даних", "Change password": "Змінити пароль", @@ -111,37 +95,24 @@ "Manage tokens": "Керувати токенами", "Watch history": "Історія переглядів", "Delete account": "Видалити обліківку", - "Administrator preferences": "Адміністраторські налаштування", - "Default homepage: ": "Усталена домашня сторінка: ", - "Feed menu: ": "Меню потоку з відео: ", - "Top enabled: ": "Увімкнути топ відео? ", - "CAPTCHA enabled: ": "Увімкнути капчу? ", - "Login enabled: ": "Увімкнути авторизацію? ", - "Registration enabled: ": "Увімкнути реєстрацію? ", - "Report statistics: ": "Повідомляти статистику? ", + "preferences_category_admin": "Адміністраторські налаштування", + "preferences_default_home_label": "Усталена домашня сторінка: ", + "preferences_feed_menu_label": "Меню потоку з відео: ", + "Top enabled: ": "Увімкнути топ відео: ", + "CAPTCHA enabled: ": "Увімкнути CAPTCHA: ", + "Login enabled: ": "Увімкнути вхід: ", + "Registration enabled: ": "Увімкнути реєстрацію: ", + "Report statistics: ": "Повідомляти статистику: ", "Save preferences": "Зберегти налаштування", "Subscription manager": "Менеджер підписок", "Token manager": "Менеджер токенів", "Token": "Токен", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` підписка / підписок / підписки", - "": "`x` підписка / підписок / підписки" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` токенів", - "": "`x` токенів" - }, "Import/export": "Імпорт і експорт", "unsubscribe": "відписатися", "revoke": "скасувати", "Subscriptions": "Підписки", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` непереглянуте сповіщення / непереглянутих сповіщень / непереглянутих сповіщення", - "": "`x` непереглянуте сповіщення / непереглянутих сповіщень / непереглянутих сповіщення" - }, "search": "пошук", "Log out": "Вийти", - "Released under the AGPLv3 by Omar Roth.": "Реалізовано Омаром Ротом за ліцензією AGPLv3.", "Source available here.": "Програмний код доступний тут.", "View JavaScript license information.": "Переглянути інформацію щодо ліцензії JavaScript.", "View privacy policy.": "Переглянути політику приватності.", @@ -151,14 +122,12 @@ "Private": "Особистий", "View all playlists": "Переглянути всі списки відтворення", "Updated `x` ago": "Оновлено `x` тому", - "Delete playlist `x`?": "Видалити список відтворення \"x\"?", + "Delete playlist `x`?": "Видалити список відтворення `x`?", "Delete playlist": "Видалити список відтворення", "Create playlist": "Створити список відтворення", "Title": "Заголовок", "Playlist privacy": "Конфіденційність списку відтворення", - "Editing playlist `x`": "Редагування списку відтворення \"x\"", - "Show more": "", - "Show less": "", + "Editing playlist `x`": "Редагування списку відтворення `x`", "Watch on YouTube": "Дивитися на YouTube", "Hide annotations": "Приховати анотації", "Show annotations": "Показати анотації", @@ -170,11 +139,7 @@ "Whitelisted regions: ": "Доступно у регіонах: ", "Blacklisted regions: ": "Недоступно у регіонах: ", "Shared `x`": "Розміщено `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` переглядів", - "": "`x` переглядів" - }, - "Premieres in `x`": "Прем’єра через `x`", + "Premieres in `x`": "Прем’єра за `x`", "Premieres `x`": "Прем’єра `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Схоже, у вас відключений JavaScript. Щоб побачити коментарі, натисніть сюда, але майте на увазі, що вони можуть завантажуватися трохи довше.", "View YouTube comments": "Переглянути коментарі з YouTube", @@ -187,36 +152,23 @@ "Hide replies": "Сховати відповіді", "Show replies": "Показати відповіді", "Incorrect password": "Неправильний пароль", - "Quota exceeded, try again in a few hours": "Ліміт перевищено, спробуйте знову за декілька годин", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "Не вдається увійти. Перевірте, чи не ввімкнена двофакторна аутентифікація (за кодом чи смс).", - "Invalid TFA code": "Неправильний код двофакторної аутентифікації", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "Не вдається увійти. Це може бути через те, що у вашій обліківці не ввімкнена двофакторна аутентифікація.", "Wrong answer": "Неправильна відповідь", "Erroneous CAPTCHA": "Неправильна капча", - "CAPTCHA is a required field": "Необхідно пройти капчу", + "CAPTCHA is a required field": "Необхідно пройти CAPTCHA", "User ID is a required field": "Необхідно ввести ID користувача", "Password is a required field": "Необхідно ввести пароль", "Wrong username or password": "Неправильний логін чи пароль", - "Please sign in using 'Log in with Google'": "Будь ласка, натисніть «Увійти через Google»", "Password cannot be empty": "Пароль не може бути порожнім", "Password cannot be longer than 55 characters": "Пароль не може бути довшим за 55 знаків", "Please log in": "Будь ласка, увійдіть", - "Invidious Private Feed for `x`": "Приватний поток відео Invidious для `x`", + "Invidious Private Feed for `x`": "Приватний потік відео Invidious для `x`", "channel:`x`": "канал: `x`", "Deleted or invalid channel": "Канал видалено або не знайдено", "This channel does not exist.": "Такого каналу не існує.", "Could not get channel info.": "Не вдається отримати інформацію щодо цього каналу.", "Could not fetch comments": "Не вдається завантажити коментарі", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "Переглянути `x` відповідь / відповідей / відповіді", - "": "Переглянути `x` відповідь / відповідей / відповіді" - }, "`x` ago": "`x` тому", "Load more": "Завантажити більше", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` очко / очок / очка", - "": "`x` очко / очок / очка" - }, "Could not create mix.": "Не вдається створити мікс.", "Empty playlist": "Плейлист порожній", "Not a playlist.": "Недійсний плейлист.", @@ -229,7 +181,7 @@ "No such user": "Недопустиме ім’я користувача", "Token is expired, please try again": "Термін дії токена закінчився, спробуйте пізніше", "English": "Англійська", - "English (auto-generated)": "Англійська (сгенеровано автоматично)", + "English (auto-generated)": "Англійська (автогенератор)", "Afrikaans": "Африкаанс", "Albanian": "Албанська", "Amharic": "Амхарська", @@ -315,7 +267,7 @@ "Somali": "Сомалійська", "Southern Sotho": "Сесото (південна сото)", "Spanish": "Іспанська", - "Spanish (Latin America)": "Испанська (Латинська Америка)", + "Spanish (Latin America)": "Іспанська (Латинська Америка)", "Sundanese": "Сунданська", "Swahili": "Суахілі", "Swedish": "Шведська", @@ -334,41 +286,12 @@ "Yiddish": "Їдиш", "Yoruba": "Йоруба", "Zulu": "Зулу", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` років", - "": "`x` років" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` місяців", - "": "`x` місяців" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` тижнів", - "": "`x` тижнів" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` днів", - "": "`x` днів" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` годин", - "": "`x` годин" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` хвилин", - "": "`x` хвилин" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` секунд", - "": "`x` секунд" - }, "Fallback comments: ": "Резервні коментарі: ", "Popular": "Популярне", - "Search": "", "Top": "Топ", "About": "Про сайт", "Rating: ": "Рейтинг: ", - "Language: ": "Мова: ", + "preferences_locale_label": "Мова: ", "View as playlist": "Дивитися як плейлист", "Default": "Усталено", "Music": "Музика", @@ -384,35 +307,211 @@ "`x` marked it with a ❤": "❤ цьому від каналу `x`", "Audio mode": "Аудіорежим", "Video mode": "Відеорежим", - "Videos": "Відео", + "channel_tab_videos_label": "Відео", "Playlists": "Плейлисти", - "Community": "Спільнота", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "Поточна версія: " + "channel_tab_community_label": "Спільнота", + "Current version: ": "Поточна версія: ", + "generic_views_count_0": "{{count}} перегляд", + "generic_views_count_1": "{{count}} перегляди", + "generic_views_count_2": "{{count}} переглядів", + "generic_videos_count_0": "{{count}} відео", + "generic_videos_count_1": "{{count}} відео", + "generic_videos_count_2": "{{count}} відео", + "generic_playlists_count_0": "{{count}} список відтворення", + "generic_playlists_count_1": "{{count}} списки відтворення", + "generic_playlists_count_2": "{{count}} списків відтворення", + "generic_subscribers_count_0": "{{count}} стежить", + "generic_subscribers_count_1": "{{count}} стежать", + "generic_subscribers_count_2": "{{count}} стежать", + "generic_subscriptions_count_0": "{{count}} підписка", + "generic_subscriptions_count_1": "{{count}} підписки", + "generic_subscriptions_count_2": "{{count}} підписок", + "tokens_count_0": "{{count}} токен", + "tokens_count_1": "{{count}} токени", + "tokens_count_2": "{{count}} токенів", + "subscriptions_unseen_notifs_count_0": "{{count}} нове сповіщення", + "subscriptions_unseen_notifs_count_1": "{{count}} нові сповіщення", + "subscriptions_unseen_notifs_count_2": "{{count}} нових сповіщень", + "comments_view_x_replies_0": "Переглянути {{count}} відповідь", + "comments_view_x_replies_1": "Переглянути {{count}} відповіді", + "comments_view_x_replies_2": "Переглянути {{count}} відповідей", + "generic_count_years_0": "{{count}} рік", + "generic_count_years_1": "{{count}} роки", + "generic_count_years_2": "{{count}} років", + "generic_count_weeks_0": "{{count}} тиждень", + "generic_count_weeks_1": "{{count}} тижні", + "generic_count_weeks_2": "{{count}} тижнів", + "generic_count_days_0": "{{count}} день", + "generic_count_days_1": "{{count}} дні", + "generic_count_days_2": "{{count}} днів", + "generic_count_hours_0": "{{count}} годину", + "generic_count_hours_1": "{{count}} години", + "generic_count_hours_2": "{{count}} годин", + "crash_page_switch_instance": "спробуйте <a href=\"`x`\">використати інший сервер</a>", + "crash_page_read_the_faq": "прочитайте <a href=\"`x`\">часті питання (ЧаП)</a>", + "crash_page_search_issue": "перегляньте <a href=\"`x`\">наявні обговорення на GitHub</a>", + "crash_page_report_issue": "Якщо нічого не допомогло, просимо <a href=\"`x`\">створити обговорення на GitHub</a> (бажано англійською), додавши наступний текст у повідомлення (НЕ перекладайте цього тексту):", + "Chinese (Hong Kong)": "Китайська (Гонконг)", + "Cantonese (Hong Kong)": "Кантонська (Гонконг)", + "Chinese": "Китайська", + "Chinese (China)": "Китайська (Китай)", + "Interlingue": "Інтерлінгва", + "Italian (auto-generated)": "Італійська (автогенератор)", + "Turkish (auto-generated)": "Турецька (автогенератор)", + "Vietnamese (auto-generated)": "В'єтнамська (автогенератор)", + "user_created_playlists": "Створено списків відтворення: `x`", + "user_saved_playlists": "Збережено списків відтворення: `x`", + "Video unavailable": "Відео недоступне", + "preferences_watch_history_label": "Історія переглядів: ", + "preferences_quality_dash_label": "Бажана DASH-якість відео: ", + "preferences_quality_dash_option_144p": "144p", + "preferences_vr_mode_label": "Взаємодія з 360-градусними відео (потребує WebGL): ", + "Released under the AGPLv3 on Github.": "Випущено під AGPLv3 на GitHub.", + "English (United Kingdom)": "Англійська (Сполучене Королівство)", + "English (United States)": "Англійська (США)", + "French (auto-generated)": "Французька (автогенератор)", + "German (auto-generated)": "Німецька (автогенератор)", + "Portuguese (auto-generated)": "Португальська (автогенератор)", + "Portuguese (Brazil)": "Португальська (Бразилія)", + "Russian (auto-generated)": ":^)", + "Spanish (auto-generated)": "Іспанська (автогенератор)", + "Spanish (Mexico)": "Іспанська (Мексика)", + "Spanish (Spain)": "Іспанська (Іспанія)", + "next_steps_error_message_go_to_youtube": "Перейти до YouTube", + "footer_donate_page": "Підтримати", + "footer_documentation": "Документація", + "footer_source_code": "Джерельний код", + "footer_original_source_code": "Оригінал джерельного коду", + "footer_modfied_source_code": "Змінений джерельний код", + "adminprefs_modified_source_code_url_label": "URL-адреса репозиторію зміненого джерельного коду", + "none": "нема", + "videoinfo_started_streaming_x_ago": "Трансляцію розпочато `x` тому", + "crash_page_you_found_a_bug": "Схоже, ви знайшли ваду в Invidious!", + "crash_page_before_reporting": "Перш ніж прозвітувати про ваду:", + "crash_page_refresh": "спробуйте <a href=\"`x`\">оновити сторінку</a>", + "preferences_quality_dash_option_auto": "Авто", + "preferences_quality_dash_option_best": "Найкраща", + "preferences_quality_dash_option_worst": "Найгірша", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_save_player_pos_label": "Зберегти позицію відтворення: ", + "preferences_show_nick_label": "Псевдонім угорі: ", + "Show more": "Докладніше", + "next_steps_error_message": "Після чого спробуйте: ", + "next_steps_error_message_refresh": "Оновити сторінку", + "Search": "Пошук", + "preferences_extend_desc_label": "Автоматично розгортати опис відео: ", + "preferences_category_misc": "Різноманітні параметри", + "Show less": "Коротше", + "preferences_quality_option_small": "Низька", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_option_medium": "Середня", + "preferences_quality_dash_option_4320p": "4320p", + "invidious": "Invidious", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_360p": "360p", + "preferences_region_label": "Ваша країна: ", + "preferences_quality_option_dash": "DASH (змінна якість)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_automatic_instance_redirect_label": "Автоматична зміна сервера (redirect.invidious.io як резерв): ", + "Switch Invidious Instance": "Інший сервер Invidious", + "preferences_quality_dash_option_480p": "480p", + "Chinese (Taiwan)": "Китайська (Тайвань)", + "Dutch (auto-generated)": "Нідерландська (автогенератор)", + "Indonesian (auto-generated)": "Індонезійська (автогенератор)", + "Japanese (auto-generated)": "Японська (автогенератор)", + "Korean (auto-generated)": "Корейська (автогенератор)", + "generic_count_months_0": "{{count}} місяць", + "generic_count_months_1": "{{count}} місяці", + "generic_count_months_2": "{{count}} місяців", + "videoinfo_youTube_embed_link": "Вкласти", + "generic_count_minutes_0": "{{count}} хвилину", + "generic_count_minutes_1": "{{count}} хвилини", + "generic_count_minutes_2": "{{count}} хвилин", + "generic_count_seconds_0": "{{count}} секунду", + "generic_count_seconds_1": "{{count}} секунди", + "generic_count_seconds_2": "{{count}} секунд", + "videoinfo_watch_on_youTube": "Переглянути на YouTube", + "videoinfo_invidious_embed_link": "Вкласти посилання", + "download_subtitles": "Субтитри — `x` (.vtt)", + "comments_points_count_0": "{{count}} пункт", + "comments_points_count_1": "{{count}} пункти", + "comments_points_count_2": "{{count}} пунктів", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_location": "Геомітка", + "search_filters_duration_option_none": "Будь-які", + "search_filters_features_option_hd": "HD", + "search_message_change_filters_or_query": "Спробуйте ширший запит і/або інші фільтри.", + "search_filters_type_option_all": "Будь-що", + "search_filters_type_option_movie": "Фільм", + "search_filters_type_option_show": "Шоу", + "search_filters_duration_label": "Тривалість", + "search_filters_duration_option_short": "Короткі (до 4 хвилин)", + "search_message_no_results": "Результатів не знайдено.", + "search_filters_date_label": "Дата вивантаження", + "search_filters_date_option_none": "Будь-яка дата", + "search_filters_date_option_today": "Сьогодні", + "search_filters_date_option_week": "Цей тиждень", + "search_filters_type_label": "Тип", + "search_filters_type_option_channel": "Канал", + "search_message_use_another_instance": "Можете також <a href=\"`x`\">пошукати на іншому сервері</a>.", + "search_filters_title": "Фільтри", + "search_filters_date_option_hour": "Остання година", + "search_filters_date_option_month": "Цей місяць", + "search_filters_date_option_year": "Цей рік", + "search_filters_type_option_video": "Відео", + "search_filters_type_option_playlist": "Добірка", + "search_filters_duration_option_medium": "Середні (4–20 хвилин)", + "search_filters_duration_option_long": "Довгі (понад 20 хвилин)", + "search_filters_features_label": "Особливості", + "search_filters_features_option_live": "Наживо", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_subtitles": "Субтитри", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_hdr": "HDR", + "search_filters_sort_label": "Спершу", + "search_filters_sort_option_date": "Дата вивантаження", + "search_filters_apply_button": "Застосувати фільтри", + "search_filters_features_option_vr180": "VR180", + "search_filters_features_option_purchased": "Придбано", + "search_filters_sort_option_relevance": "Відповідні", + "search_filters_sort_option_rating": "Рейтингові", + "search_filters_sort_option_views": "Популярні", + "Popular enabled: ": "Популярне ввімкнено: ", + "error_video_not_in_playlist": "Запитуваного відео в цьому списку відтворення не існує. <a href=\"`x`\">Клацніть тут, щоб переглянути домашню сторінку списку відтворення.</a>", + "channel_tab_shorts_label": "Shorts", + "channel_tab_streams_label": "Прямі трансляції", + "channel_tab_playlists_label": "Добірки", + "channel_tab_channels_label": "Канали", + "Music in this video": "Музика в цьому відео", + "Artist: ": "Виконавець: ", + "Album: ": "Альбом: ", + "Song: ": "Пісня: ", + "Channel Sponsor": "Спонсор каналу", + "Standard YouTube license": "Стандартна ліцензія YouTube", + "Download is disabled": "Завантаження вимкнено", + "Import YouTube playlist (.csv)": "Імпорт списку відтворення YouTube (.csv)", + "channel_tab_podcasts_label": "Подкасти", + "playlist_button_add_items": "Додати відео", + "generic_button_cancel": "Скасувати", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "Випуски", + "generic_button_delete": "Видалити", + "generic_button_edit": "Змінити", + "generic_button_save": "Зберегти", + "generic_channels_count_0": "{{count}} канал", + "generic_channels_count_1": "{{count}} канали", + "generic_channels_count_2": "{{count}} каналів", + "Import YouTube watch history (.json)": "Імпортувати історію переглядів YouTube (.json)", + "toggle_theme": "Перемкнути тему", + "Add to playlist": "Додати до списку відтворення", + "Add to playlist: ": "Додати до списку відтворення: ", + "Answer": "Відповідь", + "Search for videos": "Шукати відео", + "The Popular feed has been disabled by the administrator.": "Стрічка Популярні вимкнена адміністратором.", + "carousel_slide": "Слайд {{current}} з {{total}}", + "carousel_skip": "Пропустити карусель", + "carousel_go_to": "Перейти до слайда `x`" } diff --git a/locales/vi.json b/locales/vi.json new file mode 100644 index 00000000..229f8fa9 --- /dev/null +++ b/locales/vi.json @@ -0,0 +1,483 @@ +{ + "generic_videos_count_0": "{{count}} video", + "generic_subscribers_count_0": "{{count}} người đăng ký", + "LIVE": "TRỰC TIẾP", + "Shared `x` ago": "Đã chia sẻ `x` trước", + "Unsubscribe": "Hủy đăng ký", + "Subscribe": "Đăng ký", + "View channel on YouTube": "Xem kênh trên YouTube", + "View playlist on YouTube": "Xem danh sách phát trên YouTube", + "newest": "Mới nhất", + "oldest": "Cũ nhất", + "popular": "Phổ biến", + "last": "cuối cùng", + "Next page": "Trang tiếp theo", + "Previous page": "Trang trước", + "Clear watch history?": "Xóa lịch sử xem?", + "New password": "Mật khẩu mới", + "New passwords must match": "Mật khẩu mới phải khớp", + "Authorize token?": "Cấp phép mã thông báo?", + "Authorize token for `x`?": "Cấp phép mã thông báo cho `x`?", + "Yes": "Có", + "No": "Không", + "Import and Export Data": "Nhập và xuất dữ liệu", + "Import": "Nhập", + "Import Invidious data": "Nhập dữ liệu Invidious dưới dạng JSON", + "Import YouTube subscriptions": "Nhập các kênh đã đăng ký từ YouTube/OPML", + "Import FreeTube subscriptions (.db)": "Nhập các kênh đã đăng ký từ FreeTube (.db)", + "Import NewPipe subscriptions (.json)": "Nhập các kênh đã đăng ký từ NewPipe (.json)", + "Import NewPipe data (.zip)": "Nhập dữ liệu từ NewPipe (.zip)", + "Export": "Xuất", + "Export subscriptions as OPML": "Xuất các kênh đã đăng ký dưới dạng OPML", + "Export subscriptions as OPML (for NewPipe & FreeTube)": "Xuất các kênh đã đăng ký dưới dạng OPML (cho NewPipe & FreeTube)", + "Export data as JSON": "Xuất dữ liệu Invidious dưới dạng JSON", + "Delete account?": "Xóa tài khoản?", + "History": "Lịch sử", + "An alternative front-end to YouTube": "Giao diện thay thế cho YouTube", + "JavaScript license information": "Thông tin giấy phép JavaScript", + "source": "nguồn", + "Log in": "Đăng nhập", + "Log in/register": "Đăng nhập / đăng ký", + "User ID": "Mã nhận dạng người dùng", + "Password": "Mật khẩu", + "Time (h:mm:ss):": "Thời gian (h:mm:ss):", + "Text CAPTCHA": "CAPTCHA dạng chữ", + "Image CAPTCHA": "CAPTCHA dạng ảnh", + "Sign In": "Đăng nhập", + "Register": "Đăng ký", + "E-mail": "E-mail", + "Preferences": "Cài đặt", + "preferences_category_player": "Tùy chọn trình phát video", + "preferences_video_loop_label": "Luôn lặp lại: ", + "preferences_autoplay_label": "Tự động phát: ", + "preferences_continue_label": "Phát kế tiếp theo mặc định: ", + "preferences_continue_autoplay_label": "Tự động phát video tiếp theo: ", + "preferences_listen_label": "Nghe theo mặc định: ", + "preferences_local_label": "Máy chủ sử lý video: ", + "preferences_speed_label": "Tốc độ mặc định: ", + "preferences_quality_label": "Chất lượng video: ", + "preferences_volume_label": "Âm lượng video: ", + "preferences_comments_label": "Nhận xét mặc định: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "Phụ đề mặc định: ", + "Fallback captions: ": "Phụ đề dự phòng: ", + "preferences_related_videos_label": "Hiển thị các video có liên quan: ", + "preferences_annotations_label": "Hiển thị chú thích theo mặc định: ", + "preferences_extend_desc_label": "Tự động mở rộng phần mô tả của video: ", + "preferences_vr_mode_label": "Video 360 độ tương tác (yêu cầu WebGL): ", + "preferences_category_visual": "Tùy chọn hình ảnh", + "preferences_player_style_label": "Phong cách trình phát: ", + "Dark mode: ": "Chế độ tối: ", + "preferences_dark_mode_label": "Chủ đề: ", + "dark": "tối", + "light": "sáng", + "preferences_thin_mode_label": "Chế độ mỏng: ", + "preferences_category_misc": "Tùy chọn khác", + "preferences_automatic_instance_redirect_label": "Tự động chuyển hướng phiên bản (dự phòng về redirect.invidious.io): ", + "preferences_category_subscription": "Tùy chọn đăng ký", + "preferences_annotations_subscribed_label": "Hiển thị chú thích theo mặc định cho các kênh đã đăng ký: ", + "Redirect homepage to feed: ": "Chuyển hướng trang chủ đến nguồn cấp dữ liệu: ", + "preferences_max_results_label": "Số lượng video được hiển thị trong nguồn cấp dữ liệu: ", + "preferences_sort_label": "Sắp xếp video theo: ", + "published": "được phát hành", + "published - reverse": "đã xuất bản - đảo ngược", + "alphabetically": "Thứ tự (A - Z)", + "alphabetically - reverse": "Thứ tự (Z - A)", + "channel name": "Tên kênh (A - Z)", + "channel name - reverse": "Tên kênh (Z - A)", + "Only show latest video from channel: ": "Chỉ hiển thị video mới nhất từ kênh: ", + "Only show latest unwatched video from channel: ": "Chỉ hiển thị video chưa xem mới nhất từ kênh: ", + "preferences_unseen_only_label": "Chỉ hiển thị các video chưa từng xem: ", + "preferences_notifications_only_label": "Chỉ hiển thị thông báo (nếu có): ", + "Enable web notifications": "Bật thông báo web", + "`x` uploaded a video": "`x` đã tải lên một video", + "`x` is live": "`x` đang phát trực tiếp", + "preferences_category_data": "Tùy chọn dữ liệu", + "Clear watch history": "Xóa lịch sử xem", + "Import/export data": "Nhập / xuất dữ liệu", + "Change password": "Đổi mật khẩu", + "Manage subscriptions": "Quản lý các mục đăng kí", + "Manage tokens": "Quản lý mã thông báo", + "Watch history": "Xem lịch sử", + "Delete account": "Xóa tài khoản", + "preferences_category_admin": "Tùy chọn quản trị viên", + "preferences_default_home_label": "Trang chủ mặc định: ", + "preferences_feed_menu_label": "Menu nguồn cấp dữ liệu: ", + "preferences_show_nick_label": "Hiển thị biệt hiệu ở trên cùng: ", + "Top enabled: ": "Đã bật hàng đầu: ", + "CAPTCHA enabled: ": "Đã bật CAPTCHA: ", + "Login enabled: ": "Đã bật đăng nhập: ", + "Registration enabled: ": "Đã bật đăng ký: ", + "Report statistics: ": "Báo cáo thống kê: ", + "Save preferences": "Lưu tùy chọn", + "Subscription manager": "Người quản lý đăng ký", + "Token manager": "Trình quản lý mã thông báo", + "Token": "Mã thông báo", + "search": "tìm kiếm", + "Log out": "Đăng xuất", + "Source available here.": "Nguồn có sẵn ở đây.", + "View JavaScript license information.": "Xem thông tin giấy phép JavaScript.", + "View privacy policy.": "Xem chính sách bảo mật.", + "Trending": "Xu hướng", + "Public": "Công khai", + "Unlisted": "Không công khai", + "Private": "Riêng tư", + "View all playlists": "Xem tất cả danh sách phát", + "Updated `x` ago": "Đã cập nhật` x` trước", + "Delete playlist `x`?": "Xóa danh sách phát` x`?", + "Delete playlist": "Xóa danh sách phát", + "Create playlist": "Tạo danh sách phát", + "Title": "Tiêu đề", + "Playlist privacy": "Bảo mật danh sách phát", + "Editing playlist `x`": "Chỉnh sửa danh sách phát` x`", + "Show more": "Hiển thị thêm", + "Show less": "Hiển thị ít hơn", + "Watch on YouTube": "Xem trên YouTube", + "Switch Invidious Instance": "Chuyển phiên bản Invidious", + "Hide annotations": "Ẩn chú thích", + "Show annotations": "Hiển thị chú thích", + "Genre: ": "Thể loại: ", + "License: ": "Giấy phép: ", + "Family friendly? ": "Thân thiện với gia đình? ", + "Wilson score: ": "Điểm số Wilson: ", + "Engagement: ": "Hôn ước: ", + "Whitelisted regions: ": "Các vùng nằm trong danh sách trắng: ", + "Blacklisted regions: ": "Các vùng nằm trong danh sách đen: ", + "Shared `x`": "Chia sẻ` x`", + "View Reddit comments": "Xem bình luận trên Reddit", + "Hide replies": "Ẩn phản hồi", + "Show replies": "Hiển thị phản hồi", + "Incorrect password": "Mật khẩu không chính xác", + "Wrong answer": "Câu trả lời sai", + "Erroneous CAPTCHA": "CAPTCHA bị lỗi", + "CAPTCHA is a required field": "CAPTCHA là trường bắt buộc", + "User ID is a required field": "User ID là trường bắt buộc", + "Password is a required field": "Mật khẩu là trường bắt buộc", + "Wrong username or password": "Tên người dùng hoặc mật khẩu sai", + "Password cannot be empty": "Mật khẩu không được để trống", + "Password cannot be longer than 55 characters": "Mật khẩu không được dài hơn 55 ký tự", + "Please log in": "Xin vui lòng đăng nhập", + "Invidious Private Feed for `x`": "Nguồn cấp dữ liệu riêng tư Invidious cho` x`", + "channel:`x`": "kênh:` x`", + "Deleted or invalid channel": "Kênh đã xóa hoặc không hợp lệ", + "This channel does not exist.": "Kênh này không tồn tại.", + "Could not get channel info.": "Không thể tải thông tin kênh.", + "Could not fetch comments": "Không thể tìm nạp nhận xét", + "Could not create mix.": "Không thể tạo kết hợp.", + "Empty playlist": "Danh sách phát trống", + "Not a playlist.": "Không phải danh sách phát.", + "Playlist does not exist.": "Danh sách phát không tồn tại.", + "Could not pull trending pages.": "Không thể kéo các trang thịnh hành.", + "Hidden field \"challenge\" is a required field": "Trường ẩn \"challenge\" là trường bắt buộc", + "Hidden field \"token\" is a required field": "Trường ẩn \"token\" là trường bắt buộc", + "Erroneous challenge": "Thử thách sai", + "Erroneous token": "Mã thông báo bị lỗi", + "No such user": "Không có người dùng như vậy", + "Token is expired, please try again": "Token đã hết hạn, vui lòng thử lại", + "English": "Tiếng Anh", + "English (auto-generated)": "Tiếng Anh (auto-generated))", + "Afrikaans": "Tiếng Afrikaans", + "Albanian": "Tiếng Albania", + "Amharic": "Amharic", + "Arabic": "Tiếng Ả Rập", + "Armenian": "Tiếng Armenia", + "Azerbaijani": "Tiếng Azerbaijan", + "Bangla": "Tiếng Bengal", + "Basque": "Tiếng Basque", + "Belarusian": "Tiếng Belarus", + "Bosnian": "Tiếng Bosnia", + "Bulgarian": "Tiếng Bungari", + "Burmese": "Tiếng Miến Điện", + "Catalan": "Tiếng Catalan", + "Cebuano": "Tiếng Cebu", + "Chinese (Simplified)": "Tiếng Trung (Giản thể)", + "Chinese (Traditional)": "Tiếng Trung (Phồn thể)", + "Corsican": "Tiếng Corse", + "Croatian": "Tiếng Croatia", + "Czech": "Tiếng Séc", + "Danish": "Tiếng Đan Mạch", + "Dutch": "Tiếng Hà Lan", + "Esperanto": "Quốc tế ngữ", + "Estonian": "Tiếng Estonia", + "Filipino": "Tiếng Philippines", + "Finnish": "Tiếng Phần Lan", + "French": "Tiếng Pháp", + "Galician": "Tiếng Galicia", + "Georgian": "Tiếng Georgia", + "German": "Tiếng Đức", + "Greek": "Tiếng Hy Lạp", + "Gujarati": "Tiếng Gujarat", + "Haitian Creole": "Tiếng Creole (Haiti)", + "Hausa": "Tiếng Hausa", + "Hawaiian": "Tiếng Hawaii", + "Hebrew": "Tiếng Do Thái", + "Hindi": "Tiếng Hindi", + "Hmong": "Tiếng Hmong", + "Hungarian": "Tiếng Hungary", + "Icelandic": "Tiếng Iceland", + "Igbo": "Tiếng Igbo", + "Indonesian": "Tiếng Indonesia", + "Irish": "Tiếng Ireland", + "Italian": "Tiếng Ý", + "Japanese": "Tiếng Nhật", + "Javanese": "Tiếng Java", + "Kannada": "Tiếng Kannada", + "Kazakh": "Tiếng Kazakh", + "Khmer": "Tiếng Khmer", + "Korean": "Tiếng Hàn", + "Kurdish": "Tiếng Kurd", + "Kyrgyz": "Tiếng Kyrgyz", + "Lao": "Tiếng Lào", + "Latin": "Tiếng Latin", + "Latvian": "Tiếng Latvia", + "Lithuanian": "Tiếng Litva", + "Luxembourgish": "Tiếng Luxembourg", + "Macedonian": "Tiếng Macedonian", + "Malagasy": "Tiếng Malagasy", + "Malay": "Tiếng Mã Lai", + "Malayalam": "Tiếng Malayalam", + "Maltese": "Tiếng Malta", + "Maori": "Tiếng Maori", + "Marathi": "Tiếng Marathi", + "Mongolian": "Tiếng Mông Cổ", + "Nepali": "Tiếng Nepal", + "Norwegian Bokmål": "Tiếng Na Uy (Bokmål)", + "Nyanja": "Tiếng Chewa / Nyanja", + "Pashto": "Tiếng Pashtun", + "Persian": "Tiếng Ba Tư", + "Polish": "Tiếng Ba Lan", + "Portuguese": "Tiếng Bồ Đào Nha", + "Punjabi": "Tiếng Punjab", + "Romanian": "Tiếng Rumani", + "Russian": "Tiếng Nga", + "Samoan": "Tiếng Samoa", + "Scottish Gaelic": "Tiếng Gaelic (Scotland)", + "Serbian": "Tiếng Serbia", + "Shona": "Tiếng Shona", + "Sindhi": "Tiếng Sindh", + "Sinhala": "Tiếng Sinhala", + "Slovak": "Tiếng Slovak", + "Slovenian": "Tiếng Slovenia", + "Somali": "Tiếng Somali", + "Southern Sotho": "Southern Sotho", + "Spanish": "Tiếng Tây Ban Nha", + "Spanish (Latin America)": "Tiếng Tây Ban Nha (Mỹ Latinh)", + "Sundanese": "Tiếng Sundan", + "Swahili": "Tiếng Swahili", + "Swedish": "Tiếng Thụy Điển", + "Tajik": "Tiếng Tajik", + "Tamil": "Tiếng Tamil", + "Telugu": "Tiếng Telugu", + "Thai": "Tiếng Thái", + "Turkish": "Tiếng Thổ Nhĩ Kỳ", + "Ukrainian": "Tiếng Ukraina", + "Urdu": "Tiếng Urdu", + "Uzbek": "Tiếng Uzbek", + "Vietnamese": "Tiếng Việt", + "Welsh": "Tiếng Wales", + "Western Frisian": "Tiếng Tây Frisia", + "Xhosa": "Tiếng Nam Phi", + "Yiddish": "Tiếng Yiddish", + "Yoruba": "Tiếng Yoruba", + "Zulu": "Tiếng Zulu", + "Fallback comments: ": "Nhận xét dự phòng: ", + "Popular": "Phổ biến", + "Search": "Tìm kiếm", + "Top": "Hàng đầu", + "About": "Giới thiệu", + "Rating: ": "Xếp hạng: ", + "preferences_locale_label": "Ngôn ngữ: ", + "View as playlist": "Xem dưới dạng danh sách phát", + "Default": "Mặc định", + "Music": "Âm nhạc", + "Gaming": "Trò chơi", + "News": "Tin tức", + "Movies": "Phim", + "Download": "Tải xuống", + "Download as: ": "Tải xuống dưới dạng: ", + "%A %B %-d, %Y": "% A% B% -d,% Y", + "(edited)": "(đã chỉnh sửa)", + "YouTube comment permalink": "Liên kết cố định nhận xét trên YouTube", + "permalink": "liên kết cố định", + "`x` marked it with a ❤": "` x` đã đánh dấu nó bằng một ❤", + "Audio mode": "Chế độ audio", + "Video mode": "Chế độ video", + "channel_tab_videos_label": "Video", + "Playlists": "Danh sách phát", + "channel_tab_community_label": "Cộng đồng", + "search_filters_sort_option_relevance": "Liên quan", + "search_filters_sort_option_rating": "Xếp hạng", + "search_filters_sort_option_date": "Ngày tải lên", + "search_filters_sort_option_views": "Lượt xem", + "search_filters_type_label": "Thể loại", + "search_filters_duration_label": "Thời lượng", + "search_filters_features_label": "Đặc điểm", + "search_filters_sort_label": "Sắp xếp theo", + "search_filters_date_option_hour": "Một giờ qua", + "search_filters_date_option_today": "Hôm nay", + "search_filters_date_option_week": "Tuần này", + "search_filters_date_option_month": "Tháng này", + "search_filters_date_option_year": "Năm này", + "search_filters_type_option_video": "video", + "search_filters_type_option_channel": "Kênh", + "search_filters_type_option_playlist": "Danh sách phát", + "search_filters_type_option_movie": "Phim", + "search_filters_type_option_show": "Hiện", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Phụ đề", + "search_filters_features_option_c_commons": "Giấy phép Creative Commons", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "Trực tiếp", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "Vị trí", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "Phiên bản hiện tại: ", + "search_filters_title": "Bộ lọc", + "generic_playlists_count": "{{count}} danh sách phát", + "generic_views_count": "{{count}} lượt xem", + "View `x` comments": { + "": "Xem `x` bình luận", + "([^.,0-9]|^)1([^.,0-9]|$)": "Hiển thị `x`bình luận" + }, + "Song: ": "Ca khúc: ", + "Premieres in `x`": "Trình chiếu ở `x`", + "preferences_quality_dash_option_worst": "Tệ nhất", + "preferences_watch_history_label": "Bật lịch sử video đã xem ", + "preferences_quality_option_hd720": "HD720", + "unsubscribe": "hủy đăng kí", + "revoke": "gỡ bỏ", + "preferences_quality_dash_label": "Chất lượng video DASH ", + "preferences_quality_dash_option_auto": "Tự động", + "Subscriptions": "Thuê bao", + "View YouTube comments": "Hiển thị bình luận từ YouTube", + "View more comments on Reddit": "Hiển thị thêm bình luận từ Reddit", + "Music in this video": "Nhạc trong video này", + "Artist: ": "Nghệ sĩ: ", + "Premieres `x`": "Phát lần đầu `x`", + "preferences_region_label": "Nội dung theo quốc gia ", + "search_message_change_filters_or_query": "Thử mở rộng nội dung tìm kiếm hoặc thay đổi bộ lọc.", + "preferences_quality_option_small": "Thấp", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "preferences_quality_dash_option_240p": "240p", + "Import/export": "Nhập/Xuất", + "preferences_quality_dash_option_4320p": "4320p (8K)", + "preferences_quality_option_dash": "DASH (tự tối ưu chất lượng)", + "generic_subscriptions_count_0": "{{count}} người đăng kí", + "preferences_quality_dash_option_1440p": "1440p (2K)", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_2160p": "2160p (4K)", + "search_message_no_results": "Tìm kiếm không có kết quả.", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_option_medium": "Trung bình", + "Load more": "Tải thêm", + "comments_points_count_0": "{{count}} điểm", + "Import YouTube playlist (.csv)": "Nhập các danh sách phát từ YouTube (.csv)", + "preferences_quality_dash_option_best": "Tốt nhất", + "preferences_quality_dash_option_360p": "360p", + "subscriptions_unseen_notifs_count_0": "{{count}} thông báo chưa đọc", + "Released under the AGPLv3 on Github.": "Phát hành dưới giấy phép AGPLv3 trên GitHub.", + "search_message_use_another_instance": " Bạn cũng có thể tìm kiếm <a href=\"`x`\"> ở một phiên bản khác</a>.", + "Standard YouTube license": "Giấy phép YouTube thông thường", + "Album: ": "Album: ", + "preferences_save_player_pos_label": "Lưu vị trí xem: ", + "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "Xin chào! Có vẻ như bạn đã tắt JavaScript. Bấm vào đây để xem bình luận, lưu ý rằng thời gian tải có thể lâu hơn.", + "Chinese (China)": "Tiếng Trung (Trung Quốc)", + "generic_button_cancel": "Hủy", + "Chinese": "Tiếng Trung", + "generic_button_delete": "Xóa", + "Korean (auto-generated)": "Tiếng Hàn (được tạo tự động)", + "search_filters_features_option_three_sixty": "360°", + "channel_tab_podcasts_label": "Podcast", + "Spanish (Mexico)": "Tiếng Tây Ban Nha (Mexico)", + "search_filters_apply_button": "Áp dụng các mục đã chọn", + "Download is disabled": "Tải xuống đã bị vô hiệu hóa.", + "next_steps_error_message_go_to_youtube": "Đi đến YouTube", + "German (auto-generated)": "Tiếng Đức (được tạo tự động)", + "Japanese (auto-generated)": "Tiếng Nhật (được tạo tự động)", + "footer_donate_page": "Ủng hộ", + "crash_page_before_reporting": "Trước khi báo cáo lỗi, hãy chắc chắn rằng bạn đã:", + "Channel Sponsor": "Nhà tài trợ của kênh", + "videoinfo_started_streaming_x_ago": "Đã bắt đầu phát sóng `x` trước", + "videoinfo_youTube_embed_link": "Nhúng", + "channel_tab_streams_label": "Phát trực tiếp", + "playlist_button_add_items": "Thêm video", + "generic_count_minutes_0": "{{count}} phút", + "user_saved_playlists": "`x` danh sách phát đã lưu", + "Spanish (Spain)": "Tiếng Tây Ban Nha (Tây Ban Nha)", + "crash_page_refresh": "Đã thử <a href=\"`x`\">tải lại trang</a>", + "Chinese (Hong Kong)": "Tiếng Trung (Hồng Kông)", + "generic_count_months_0": "{{count}} tháng", + "download_subtitles": "Phụ đề - `x` (.vtt)", + "generic_button_save": "Lưu", + "crash_page_search_issue": "Tìm <a href=\"`x`\">lỗi có sẵn trên GitHub</a>", + "none": "không", + "English (United States)": "Tiếng Anh (Mỹ)", + "next_steps_error_message_refresh": "Tải lại", + "Video unavailable": "Video không có sẵn", + "footer_source_code": "Mã nguồn", + "search_filters_duration_option_short": "Ngắn (< 4 phút)", + "search_filters_duration_option_long": "Dài (> 20 phút)", + "tokens_count_0": "{{count}} mã thông báo", + "Italian (auto-generated)": "Tiếng Ý (được tạo tự động)", + "channel_tab_shorts_label": "Shorts", + "channel_tab_releases_label": "Mới tải lên", + "`x` ago": "`x` trước", + "Interlingue": "Tiếng Khoa học Quốc tế", + "generic_channels_count_0": "{{count}} kênh", + "Chinese (Taiwan)": "Tiếng Trung (Đài Loan)", + "adminprefs_modified_source_code_url_label": "URL tới kho lưu trữ mã nguồn đã sửa đổi", + "Turkish (auto-generated)": "Tiếng Thổ Nhĩ Kỳ (được tạo tự động)", + "Indonesian (auto-generated)": "Tiếng Indonesia (được tạo tự động)", + "Portuguese (auto-generated)": "Tiếng Bồ Đào Nha (được tạo tự động)", + "generic_count_years_0": "{{count}} năm", + "videoinfo_invidious_embed_link": "Liên kết nhúng", + "Popular enabled: ": "Đã bật phổ biến: ", + "Spanish (auto-generated)": "Tiếng Tây Ban Nha (được tạo tự động)", + "English (United Kingdom)": "Tiếng Anh Anh", + "channel_tab_playlists_label": "Danh sách phát", + "generic_button_edit": "Sửa", + "search_filters_features_option_purchased": "Đã mua", + "search_filters_date_option_none": "Mọi thời điểm", + "Cantonese (Hong Kong)": "Tiếng Quảng Châu (Hồng Kông)", + "crash_page_report_issue": "Nếu các điều trên không giúp được, xin hãy <a href=\"`x`\">tạo vấn đề mới trên GitHub</a> (ưu tiên tiếng Anh) và đính kèm đoạn chữ sau trong nội dung (giữ nguyên KHÔNG dịch):", + "crash_page_switch_instance": "Đã thử <a href=\"`x`\">dùng một phiên bản khác</a>", + "generic_count_weeks_0": "{{count}} tuần", + "videoinfo_watch_on_youTube": "Xem trên YouTube", + "footer_modfied_source_code": "Mã nguồn đã chỉnh sửa", + "generic_button_rss": "RSS", + "generic_count_hours_0": "{{count}} giờ", + "French (auto-generated)": "Tiếng Pháp (được tạo tự động)", + "crash_page_read_the_faq": "Đọc <a href=\"`x`\">Hỏi đáp thường gặp (FAQ)</a>", + "user_created_playlists": "`x` danh sách phát đã tạo", + "channel_tab_channels_label": "Kênh", + "search_filters_type_option_all": "Mọi thể loại", + "Russian (auto-generated)": "Tiếng Nga (được tạo tự động)", + "comments_view_x_replies_0": "Xem {{count}} lượt trả lời", + "footer_original_source_code": "Mã nguồn gốc", + "Portuguese (Brazil)": "Tiếng Bồ Đào Nha (Brazil)", + "search_filters_features_option_vr180": "VR180", + "error_video_not_in_playlist": "Video không tồn tại trong danh sách phát. <a href=\"`x`\">Bấm để trở về trang chủ của danh sách phát.</a>", + "Dutch (auto-generated)": "Tiếng Hà Lan (được tạo tự động)", + "generic_count_days_0": "{{count}} ngày", + "Vietnamese (auto-generated)": "Tiếng Việt (được tạo tự động)", + "search_filters_duration_option_none": "Mọi thời lượng", + "footer_documentation": "Tài liệu", + "next_steps_error_message": "Bạn có thể thử: ", + "Import YouTube watch history (.json)": "Nhập lịch sử xem từ YouTube (.json)", + "search_filters_duration_option_medium": "Trung bình (4 - 20 phút)", + "generic_count_seconds_0": "{{count}} giây", + "search_filters_date_label": "Ngày tải lên", + "crash_page_you_found_a_bug": "Có vẻ như bạn đã tìm ra lỗi trong Indivious!", + "Add to playlist": "Thêm vào danh sách phát", + "Add to playlist: ": "Thêm vào danh sách phát: ", + "Answer": "Trả lời", + "toggle_theme": "Bật/tắt diện mạo", + "carousel_slide": "Trang {{current}} trên tổng {{total}} trang", + "carousel_skip": "Bỏ qua Carousel", + "carousel_go_to": "Đi tới trang `x`", + "Search for videos": "Tìm kiếm video", + "The Popular feed has been disabled by the administrator.": "Bảng tin phổ biến đã bị tắt bởi ban quản lý." +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 8bd49845..776c5ddb 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1,16 +1,9 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 位订阅者", - "": "`x` 位订阅者" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个视频", - "": "`x` 个视频" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个播放列表", - "": "`x` 个播放列表" - }, + "generic_views_count_0": "{{count}} 播放", + "generic_videos_count_0": "{{count}} 个视频", + "generic_playlists_count_0": "{{count}} 个播放列表", + "generic_subscribers_count_0": "{{count}} 位订阅者", + "generic_subscriptions_count_0": "{{count}} 个订阅", "LIVE": "直播", "Shared `x` ago": "`x` 前分享", "Unsubscribe": "取消订阅", @@ -20,28 +13,27 @@ "newest": "最新", "oldest": "最老", "popular": "时下流行", - "last": "", + "last": "上一个", "Next page": "下一页", "Previous page": "上一页", "Clear watch history?": "清除观看历史?", "New password": "新密码", "New passwords must match": "新密码必须匹配", - "Cannot change password for Google accounts": "无法为 Google 账户更改密码", "Authorize token?": "授权令牌?", "Authorize token for `x`?": "`x` 的授权令牌?", "Yes": "是", "No": "否", "Import and Export Data": "导入与导出数据", "Import": "导入", - "Import Invidious data": "导入 Invidious 数据", - "Import YouTube subscriptions": "导入 YouTube 订阅", + "Import Invidious data": "导入 Invidious JSON 数据", + "Import YouTube subscriptions": "导入 YouTube CSV 或 OPML 订阅", "Import FreeTube subscriptions (.db)": "导入 FreeTube 订阅 (.db)", "Import NewPipe subscriptions (.json)": "导入 NewPipe 订阅 (.json)", "Import NewPipe data (.zip)": "导入 NewPipe 数据 (.zip)", "Export": "导出", "Export subscriptions as OPML": "导出订阅到 OPML 格式", "Export subscriptions as OPML (for NewPipe & FreeTube)": "导出订阅到 OPML 格式(用于 NewPipe 及 FreeTube)", - "Export data as JSON": "导出数据为 JSON 格式", + "Export data as JSON": "导出 Invidious 数据为 JSON 格式", "Delete account?": "删除账户?", "History": "历史", "An alternative front-end to YouTube": "另一个 YouTube 前端", @@ -49,7 +41,6 @@ "source": "source", "Log in": "登录", "Log in/register": "登录/注册", - "Log in with Google": "使用 Google 账户登录", "User ID": "用户 ID", "Password": "密码", "Time (h:mm:ss):": "时间 (h:mm:ss):", @@ -58,38 +49,40 @@ "Sign In": "登录", "Register": "注册", "E-mail": "E-mail", - "Google verification code": "Google 验证代码", "Preferences": "偏好设置", - "Player preferences": "播放器偏好设置", - "Always loop: ": "始终循环: ", - "Autoplay: ": "自动播放: ", - "Play next by default: ": "默认自动播放下一个视频: ", - "Autoplay next video: ": "自动播放下一个视频: ", - "Listen by default: ": "默认只听声音: ", - "Proxy videos: ": "是否代理视频: ", - "Default speed: ": "默认速度: ", - "Preferred video quality: ": "视频质量偏好: ", - "Player volume: ": "播放器音量: ", - "Default comments: ": "默认评论源: ", + "preferences_category_player": "播放器偏好设置", + "preferences_video_loop_label": "始终循环: ", + "preferences_autoplay_label": "自动播放: ", + "preferences_continue_label": "默认自动播放下一个视频: ", + "preferences_continue_autoplay_label": "自动播放下一个视频: ", + "preferences_listen_label": "默认只听声音: ", + "preferences_local_label": "是否代理视频: ", + "preferences_speed_label": "默认速度: ", + "preferences_quality_label": "视频质量偏好: ", + "preferences_volume_label": "播放器音量: ", + "preferences_comments_label": "默认评论源: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "默认字幕语言: ", + "preferences_captions_label": "默认字幕语言: ", "Fallback captions: ": "后备字幕语言: ", - "Show related videos: ": "是否显示相关视频: ", - "Show annotations by default: ": "是否默认显示视频注释: ", - "Automatically extend video description: ": "", - "Visual preferences": "视觉选项", - "Player style: ": "播放器样式: ", + "preferences_related_videos_label": "是否显示相关视频: ", + "preferences_annotations_label": "是否默认显示视频注释: ", + "preferences_extend_desc_label": "自动展开视频描述: ", + "preferences_vr_mode_label": "互动式 360 度视频 (需要 WebGL): ", + "preferences_category_visual": "视觉选项", + "preferences_player_style_label": "播放器样式: ", "Dark mode: ": "深色模式: ", - "Theme: ": "主题: ", + "preferences_dark_mode_label": "主题: ", "dark": "暗色", "light": "亮色", - "Thin mode: ": "窄页模式: ", - "Subscription preferences": "订阅设置", - "Show annotations by default for subscribed channels: ": "默认情况下显示已订阅频道的注释: ", + "preferences_thin_mode_label": "窄页模式: ", + "preferences_category_misc": "其他选项", + "preferences_automatic_instance_redirect_label": "自动实例重定向 (回退到redirect.invidious.io): ", + "preferences_category_subscription": "订阅设置", + "preferences_annotations_subscribed_label": "默认情况下显示已订阅频道的注释: ", "Redirect homepage to feed: ": "跳转主页到 feed: ", - "Number of videos shown in feed: ": "Feed 中显示的视频数量: ", - "Sort videos by: ": "视频排序方式: ", + "preferences_max_results_label": "Feed 中显示的视频数量: ", + "preferences_sort_label": "视频排序方式: ", "published": "发布时间", "published - reverse": "发布时间(反向)", "alphabetically": "字母序", @@ -98,12 +91,12 @@ "channel name - reverse": "频道名称(反向)", "Only show latest video from channel: ": "只显示频道的最新视频: ", "Only show latest unwatched video from channel: ": "只显示频道的最新未看过视频: ", - "Only show unwatched: ": "只显示未看过的视频: ", - "Only show notifications (if there are any): ": "只显示通知 (如果有的话): ", + "preferences_unseen_only_label": "只显示未看过的视频: ", + "preferences_notifications_only_label": "只显示通知 (如果有的话): ", "Enable web notifications": "启用浏览器通知", "`x` uploaded a video": "`x` 上传了视频", "`x` is live": "`x` 正在直播", - "Data preferences": "数据选项", + "preferences_category_data": "数据选项", "Clear watch history": "清除观看历史", "Import/export data": "导入/导出数据", "Change password": "更改密码", @@ -111,9 +104,10 @@ "Manage tokens": "管理令牌", "Watch history": "观看历史", "Delete account": "删除账户", - "Administrator preferences": "管理员选项", - "Default homepage: ": "默认主页: ", - "Feed menu: ": "Feed 菜单: ", + "preferences_category_admin": "管理员选项", + "preferences_default_home_label": "默认主页: ", + "preferences_feed_menu_label": "Feed 菜单: ", + "preferences_show_nick_label": "在顶部显示昵称: ", "Top enabled: ": "是否启用“热门视频”页: ", "CAPTCHA enabled: ": "是否启用验证码: ", "Login enabled: ": "是否启用登录: ", @@ -123,25 +117,15 @@ "Subscription manager": "订阅管理器", "Token manager": "令牌管理器", "Token": "令牌", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个订阅", - "": "`x` 个订阅" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个令牌", - "": "`x` 个令牌" - }, + "tokens_count_0": "{{count}} 个令牌", "Import/export": "导入/导出", "unsubscribe": "取消订阅", "revoke": "吊销", "Subscriptions": "订阅", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 条未读通知", - "": "`x` 条未读通知" - }, + "subscriptions_unseen_notifs_count_0": "{{count}} 条未读通知", "search": "搜索", "Log out": "登出", - "Released under the AGPLv3 by Omar Roth.": "由 Omar Roth 开发,以 AGPLv3 授权。", + "Released under the AGPLv3 on Github.": "依据 AGPLv3 许可证发布于 GitHub。", "Source available here.": "源码可在此查看。", "View JavaScript license information.": "查看 JavaScript 协议信息。", "View privacy policy.": "查看隐私政策。", @@ -157,9 +141,10 @@ "Title": "标题", "Playlist privacy": "播放列表隐私设置", "Editing playlist `x`": "正在编辑播放列表 `x`", - "Show more": "", - "Show less": "", + "Show more": "显示更多", + "Show less": "显示较少", "Watch on YouTube": "在 YouTube 观看", + "Switch Invidious Instance": "切换 Invidious 实例", "Hide annotations": "隐藏注释", "Show annotations": "显示注释", "Genre: ": "风格: ", @@ -170,10 +155,6 @@ "Whitelisted regions: ": "白名单地区: ", "Blacklisted regions: ": "黑名单地区: ", "Shared `x`": "`x`发布", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 播放", - "": "`x` 播放" - }, "Premieres in `x`": "首映于 `x` 后", "Premieres `x`": "首映于 `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "你好!看起来你关闭了 JavaScript。点击这里阅读评论。注意它们加载的时间可能会稍长。", @@ -187,17 +168,12 @@ "Hide replies": "隐藏回复", "Show replies": "显示回复", "Incorrect password": "密码错误", - "Quota exceeded, try again in a few hours": "已超出限额,请于几小时后重试", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "无法登录。请确认你的短信或验证器的二步验证已打开。", - "Invalid TFA code": "无效的二步验证码", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "登录失败。可能是因为二步验证未打开。", "Wrong answer": "错误的回复", "Erroneous CAPTCHA": "验证码错误", "CAPTCHA is a required field": "验证码必填", "User ID is a required field": "用户名必填", "Password is a required field": "密码必填", "Wrong username or password": "用户名或密码错误", - "Please sign in using 'Log in with Google'": "请通过谷歌账户登录", "Password cannot be empty": "密码不能为空", "Password cannot be longer than 55 characters": "密码长度不能大于 55", "Please log in": "请登录", @@ -207,16 +183,10 @@ "This channel does not exist.": "频道不存在。", "Could not get channel info.": "无法获取频道信息。", "Could not fetch comments": "无法获取评论", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "查看 `x` 条回复", - "": "查看 `x` 条回复" - }, + "comments_view_x_replies_0": "查看 {{count}} 条回复", "`x` ago": "`x` 前", "Load more": "加载更多", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 分", - "": "`x` 分" - }, + "comments_points_count_0": "{{count}} 分", "Could not create mix.": "无法创建合集。", "Empty playlist": "空播放列表", "Not a playlist.": "非播放列表。", @@ -334,41 +304,20 @@ "Yiddish": "意第绪语", "Yoruba": "约鲁巴语", "Zulu": "祖鲁语", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 年", - "": "`x` 年" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 月", - "": "`x` 月" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 周", - "": "`x` 周" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天", - "": "`x` 天" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 小时", - "": "`x` 小时" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 分钟", - "": "`x` 分钟" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 秒", - "": "`x` 秒" - }, + "generic_count_years_0": "{{count}} 年", + "generic_count_months_0": "{{count}} 月", + "generic_count_weeks_0": "{{count}} 周", + "generic_count_days_0": "{{count}} 天", + "generic_count_hours_0": "{{count}} 小时", + "generic_count_minutes_0": "{{count}} 分钟", + "generic_count_seconds_0": "{{count}} 秒", "Fallback comments: ": "后备评论: ", "Popular": "热门频道", - "Search": "", + "Search": "搜索", "Top": "热门视频", "About": "关于", "Rating: ": "评分: ", - "Language: ": "语言: ", + "preferences_locale_label": "语言: ", "View as playlist": "作为播放列表查看", "Default": "默认", "Music": "音乐", @@ -384,35 +333,151 @@ "`x` marked it with a ❤": "`x` 为此加 ❤", "Audio mode": "音频模式", "Video mode": "视频模式", - "Videos": "视频", + "channel_tab_videos_label": "视频", "Playlists": "播放列表", - "Community": "社区", - "relevance": "", - "rating": "", - "date": "", - "views": "", - "content_type": "", - "duration": "", - "features": "", - "sort": "", - "hour": "", - "today": "", - "week": "", - "month": "", - "year": "", - "video": "", - "channel": "", - "playlist": "", - "movie": "", - "show": "", - "hd": "", - "subtitles": "", - "creative_commons": "", - "3d": "", - "live": "", - "4k": "", - "location": "", - "hdr": "", - "filter": "", - "Current version: ": "当前版本: " -}
\ No newline at end of file + "channel_tab_community_label": "社区", + "search_filters_sort_option_relevance": "相关度", + "search_filters_sort_option_rating": "评分", + "search_filters_sort_option_date": "上传日期", + "search_filters_sort_option_views": "观看次数", + "search_filters_type_label": "类型", + "search_filters_duration_label": "持续时间", + "search_filters_features_label": "功能", + "search_filters_sort_label": "排序依据", + "search_filters_date_option_hour": "上个小时", + "search_filters_date_option_today": "今日", + "search_filters_date_option_week": "本周", + "search_filters_date_option_month": "本月", + "search_filters_date_option_year": "今年", + "search_filters_type_option_video": "视频", + "search_filters_type_option_channel": "频道", + "search_filters_type_option_playlist": "播放列表", + "search_filters_type_option_movie": "电影", + "search_filters_type_option_show": "真人秀", + "search_filters_features_option_hd": "高清", + "search_filters_features_option_subtitles": "字幕", + "search_filters_features_option_c_commons": "creative_commons 许可", + "search_filters_features_option_three_d": "3d", + "search_filters_features_option_live": "直播", + "search_filters_features_option_four_k": "4k", + "search_filters_features_option_location": "位置", + "search_filters_features_option_hdr": "hdr", + "Current version: ": "当前版本: ", + "next_steps_error_message": "在此之后你应尝试: ", + "next_steps_error_message_refresh": "刷新", + "next_steps_error_message_go_to_youtube": "转到 YouTube", + "search_filters_duration_option_short": "短(少于4分钟)", + "search_filters_duration_option_long": "长(多于 20 分钟)", + "footer_documentation": "文档", + "footer_source_code": "源代码", + "footer_modfied_source_code": "修改的源代码", + "adminprefs_modified_source_code_url_label": "更改的源代码仓库网址", + "footer_original_source_code": "原始源代码", + "footer_donate_page": "捐赠", + "preferences_region_label": "内容国家: ", + "preferences_quality_dash_label": "首选 DASH 视频分辨率: ", + "crash_page_you_found_a_bug": "你似乎找到了 Invidious 的一个 bug!", + "crash_page_before_reporting": "报告 bug 之前,请确保你已经:", + "crash_page_refresh": "试着 <a href=\"`x`\">刷新页面</a>", + "crash_page_switch_instance": "试着<a href=\"`x`\">使用另一个实例</a>", + "crash_page_read_the_faq": "阅读<a href=\"`x`\">常见问题</a>", + "crash_page_search_issue": "搜索过 <a href=\"`x`\">GitHub 上的现有 issue</a>", + "crash_page_report_issue": "如果以上这些都没用的话,请<a href=\"`x`\">在 Github 上新开一个 issue</a>(最好用英语撰写),并在你的消息中包含以下文本(不要翻译该文本):", + "videoinfo_invidious_embed_link": "嵌入链接", + "download_subtitles": "字幕 - `x` (.vtt)", + "preferences_quality_dash_option_360p": "360p", + "videoinfo_watch_on_youTube": "在 YouTube 上观看", + "videoinfo_youTube_embed_link": "嵌入的", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_worst": "最差", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_144p": "144p", + "preferences_quality_option_medium": "中等", + "preferences_quality_option_small": "小", + "preferences_quality_dash_option_auto": "自动", + "preferences_quality_option_dash": "DASH (自适应画质)", + "preferences_quality_dash_option_best": "最佳", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "invidious": "Invidious", + "videoinfo_started_streaming_x_ago": "`x` 前开始播放", + "user_created_playlists": "`x` 创建了播放列表", + "user_saved_playlists": "`x` 保存了播放列表", + "Video unavailable": "视频不可用", + "search_filters_features_option_purchased": "已购买", + "search_filters_features_option_three_sixty": "360°", + "none": "无", + "preferences_save_player_pos_label": "保存播放位置: ", + "Spanish (Mexico)": "西班牙语 (墨西哥)", + "Portuguese (auto-generated)": "葡萄牙语 (自动生成)", + "Portuguese (Brazil)": "葡萄牙语 (巴西)", + "English (United Kingdom)": "英语 (英国)", + "English (United States)": "英语 (美国)", + "Chinese": "中文", + "Chinese (China)": "中文 (中国)", + "Chinese (Hong Kong)": "中文 (中国香港)", + "Chinese (Taiwan)": "中文 (中国台湾)", + "German (auto-generated)": "德语 (自动生成)", + "Indonesian (auto-generated)": "印尼语 (自动生成)", + "Interlingue": "国际语", + "Italian (auto-generated)": "意大利语 (自动生成)", + "Japanese (auto-generated)": "日语 (自动生成)", + "Korean (auto-generated)": "韩语 (自动生成)", + "Russian (auto-generated)": "俄语 (自动生成)", + "Spanish (auto-generated)": "西班牙语 (自动生成)", + "Vietnamese (auto-generated)": "越南语 (自动生成)", + "Cantonese (Hong Kong)": "粤语 (中国香港)", + "Dutch (auto-generated)": "荷兰语 (自动生成)", + "French (auto-generated)": "法语 (自动生成)", + "Turkish (auto-generated)": "土耳其语 (自动生成)", + "Spanish (Spain)": "西班牙语 (西班牙)", + "preferences_watch_history_label": "启用观看历史: ", + "search_message_use_another_instance": "你也可以 <a href=\"`x`\">在另一实例上搜索</a>。", + "search_filters_title": "过滤器", + "search_filters_date_label": "上传日期", + "search_filters_apply_button": "应用所选过滤器", + "search_message_no_results": "没找到结果。", + "search_filters_duration_option_medium": "中等(4-20 分钟)", + "search_filters_date_option_none": "任意日期", + "search_message_change_filters_or_query": "尝试扩大你的搜索查询和/或更改过滤器。", + "search_filters_duration_option_none": "任意时长", + "search_filters_type_option_all": "任意类型", + "search_filters_features_option_vr180": "VR180", + "Popular enabled: ": "已启用流行度: ", + "error_video_not_in_playlist": "此播放列表中不存在请求的视频。 <a href=\"`x`\">单击析出查看播放列表主页。</a>", + "Music in this video": "此视频中的音乐", + "channel_tab_playlists_label": "播放列表", + "Artist: ": "艺术家: ", + "channel_tab_streams_label": "直播", + "Album: ": "专辑: ", + "channel_tab_shorts_label": "短视频", + "channel_tab_channels_label": "频道", + "Song: ": "歌曲: ", + "Channel Sponsor": "频道赞助者", + "Standard YouTube license": "标准 YouTube 许可证", + "Download is disabled": "已禁用下载", + "Import YouTube playlist (.csv)": "导入 YouTube 播放列表(.csv)", + "Import YouTube watch history (.json)": "导入 YouTube 观看历史(.json)", + "generic_button_cancel": "取消", + "playlist_button_add_items": "添加视频", + "generic_button_delete": "删除", + "channel_tab_podcasts_label": "播客", + "generic_button_edit": "编辑", + "generic_button_save": "保存", + "generic_button_rss": "RSS", + "channel_tab_releases_label": "公告", + "generic_channels_count_0": "{{count}} 个频道", + "toggle_theme": "切换主题", + "Add to playlist": "添加到播放列表", + "Add to playlist: ": "添加到播放列表: ", + "Answer": "响应", + "Search for videos": "搜索视频", + "The Popular feed has been disabled by the administrator.": "“流行”源已被管理员禁用。", + "carousel_slide": "当前为第 {{current}} 张图,共 {{total}} 张图", + "carousel_skip": "跳过图集", + "carousel_go_to": "转到图 `x`" +} diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 044647be..1e17deb6 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1,16 +1,9 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱者", - "": "`x` 個訂閱者" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 部影片", - "": "`x` 部影片" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 播放清單", - "": "`x` 播放清單。" - }, + "generic_views_count_0": "{{count}} 次檢視", + "generic_videos_count_0": "{{count}} 部影片", + "generic_playlists_count_0": "{{count}} 播放清單", + "generic_subscribers_count_0": "{{count}} 個訂閱者", + "generic_subscriptions_count_0": "{{count}} 個訂閱", "LIVE": "直播", "Shared `x` ago": "`x` 前分享", "Unsubscribe": "取消訂閱", @@ -26,22 +19,21 @@ "Clear watch history?": "清除觀看歷史?", "New password": "新密碼", "New passwords must match": "新密碼必須符合", - "Cannot change password for Google accounts": "無法變更 Google 帳號的密碼", "Authorize token?": "授權 token?", "Authorize token for `x`?": "`x` 的授權 token?", "Yes": "是", "No": "否", "Import and Export Data": "匯入與匯出資料", "Import": "匯入", - "Import Invidious data": "匯入 Invidious 資料", - "Import YouTube subscriptions": "匯入 YouTube 訂閱", + "Import Invidious data": "匯入 Invidious JSON 資料", + "Import YouTube subscriptions": "匯入 YouTube CSV 或 OPML 訂閱", "Import FreeTube subscriptions (.db)": "匯入 FreeTube 訂閱 (.db)", "Import NewPipe subscriptions (.json)": "匯入 NewPipe 訂閱 (.json)", "Import NewPipe data (.zip)": "匯入 NewPipe 資料 (.zip)", "Export": "匯出", "Export subscriptions as OPML": "將訂閱匯出為 OPML", "Export subscriptions as OPML (for NewPipe & FreeTube)": "將訂閱匯出為 OPML(供 NewPipe 與 FreeTube 使用)", - "Export data as JSON": "將 JSON 匯出為 JSON", + "Export data as JSON": "將 Invidious 資料匯出為 JSON", "Delete account?": "刪除帳號?", "History": "歷史", "An alternative front-end to YouTube": "一個 YouTube 的替代前端", @@ -49,7 +41,6 @@ "source": "來源", "Log in": "登入", "Log in/register": "登入/註冊", - "Log in with Google": "使用 Google 登入", "User ID": "使用者 ID", "Password": "密碼", "Time (h:mm:ss):": "時間 (h:mm:ss):", @@ -58,38 +49,40 @@ "Sign In": "登入", "Register": "註冊", "E-mail": "電子郵件", - "Google verification code": "Google 驗證碼", "Preferences": "偏好設定", - "Player preferences": "播放器偏好設定", - "Always loop: ": "總是循環播放: ", - "Autoplay: ": "自動播放: ", - "Play next by default: ": "預設播放下一部: ", - "Autoplay next video: ": "自動播放下一部影片: ", - "Listen by default: ": "預設聆聽: ", - "Proxy videos: ": "代理影片: ", - "Default speed: ": "預設速度: ", - "Preferred video quality: ": "偏好的影片畫質: ", - "Player volume: ": "播放器音量: ", - "Default comments: ": "預設留言: ", - "youtube": "youtube", - "reddit": "reddit", - "Default captions: ": "預設字幕: ", + "preferences_category_player": "播放器偏好設定", + "preferences_video_loop_label": "總是循環播放: ", + "preferences_autoplay_label": "自動播放: ", + "preferences_continue_label": "預設播放下一部: ", + "preferences_continue_autoplay_label": "自動播放下一部影片: ", + "preferences_listen_label": "預設聆聽: ", + "preferences_local_label": "代理影片: ", + "preferences_speed_label": "預設速度: ", + "preferences_quality_label": "偏好的影片畫質: ", + "preferences_volume_label": "播放器音量: ", + "preferences_comments_label": "預設留言: ", + "youtube": "YouTube", + "reddit": "Reddit", + "preferences_captions_label": "預設字幕: ", "Fallback captions: ": "汰退字幕: ", - "Show related videos: ": "顯示相關的影片: ", - "Show annotations by default: ": "預設顯示註釋: ", - "Automatically extend video description: ": "", - "Visual preferences": "視覺偏好設定", - "Player style: ": "播放器樣式: ", + "preferences_related_videos_label": "顯示相關的影片: ", + "preferences_annotations_label": "預設顯示註釋: ", + "preferences_extend_desc_label": "自動展開影片描述: ", + "preferences_vr_mode_label": "互動式 360 度影片(需要 WebGL): ", + "preferences_category_visual": "視覺偏好設定", + "preferences_player_style_label": "播放器樣式: ", "Dark mode: ": "深色模式: ", - "Theme: ": "佈景主題: ", + "preferences_dark_mode_label": "佈景主題: ", "dark": "深色", "light": "淺色", - "Thin mode: ": "精簡模式: ", - "Subscription preferences": "訂閱偏好設定", - "Show annotations by default for subscribed channels: ": "預設為已訂閱的頻道顯示註釋: ", + "preferences_thin_mode_label": "精簡模式: ", + "preferences_category_misc": "其他偏好設定", + "preferences_automatic_instance_redirect_label": "自動站台重新導向(汰退至 redirect.invidious.io): ", + "preferences_category_subscription": "訂閱偏好設定", + "preferences_annotations_subscribed_label": "預設為已訂閱的頻道顯示註釋: ", "Redirect homepage to feed: ": "重新導向首頁至 feed: ", - "Number of videos shown in feed: ": "顯示在 feed 中的影片數量: ", - "Sort videos by: ": "以此種方式排序影片: ", + "preferences_max_results_label": "顯示在 feed 中的影片數量: ", + "preferences_sort_label": "以此種方式排序影片: ", "published": "已發佈", "published - reverse": "已發佈 - 反向", "alphabetically": "字母", @@ -98,12 +91,12 @@ "channel name - reverse": "頻道名稱 - 反向", "Only show latest video from channel: ": "僅顯示從頻道而來的最新影片: ", "Only show latest unwatched video from channel: ": "僅顯示從頻道而來的未觀看影片: ", - "Only show unwatched: ": "僅顯示未觀看的: ", - "Only show notifications (if there are any): ": "僅顯示通知(如果有的話): ", + "preferences_unseen_only_label": "僅顯示未觀看的: ", + "preferences_notifications_only_label": "僅顯示通知(如果有的話): ", "Enable web notifications": "啟用網路通知", "`x` uploaded a video": "`x` 上傳了一部影片", "`x` is live": "`x` 正在直播", - "Data preferences": "資料偏好設定", + "preferences_category_data": "資料偏好設定", "Clear watch history": "清除觀看歷史", "Import/export data": "匯入/匯出資料", "Change password": "變更密碼", @@ -111,9 +104,10 @@ "Manage tokens": "管理 tokens", "Watch history": "觀看歷史", "Delete account": "刪除帳號", - "Administrator preferences": "管理員偏好設定", - "Default homepage: ": "預設首頁: ", - "Feed menu: ": "Feed 選單: ", + "preferences_category_admin": "管理員偏好設定", + "preferences_default_home_label": "預設首頁: ", + "preferences_feed_menu_label": "Feed 選單: ", + "preferences_show_nick_label": "在頂部顯示暱稱: ", "Top enabled: ": "頂部啟用: ", "CAPTCHA enabled: ": "CAPTCHA 啟用: ", "Login enabled: ": "啟用登入: ", @@ -123,25 +117,15 @@ "Subscription manager": "訂閱管理員", "Token manager": "Token 管理員", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱", - "": "`x` 個訂閱" - }, - "`x` tokens": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token", - "": "`x` 個存取金鑰" - }, + "tokens_count_0": "{{count}} 個存取金鑰", "Import/export": "匯入/匯出", "unsubscribe": "取消訂閱", "revoke": "撤銷", "Subscriptions": "訂閱", - "`x` unseen notifications": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個未讀的通知", - "": "`x` 個未讀的通知" - }, + "subscriptions_unseen_notifs_count_0": "{{count}} 個未讀的通知", "search": "搜尋", "Log out": "登出", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth 以 AGPLv3 釋出。", + "Released under the AGPLv3 on Github.": "在 GitHub 上以 AGPLv3 釋出。", "Source available here.": "原始碼在此提供。", "View JavaScript license information.": "檢視 JavaScript 授權條款資訊。", "View privacy policy.": "檢視隱私權政策。", @@ -157,9 +141,10 @@ "Title": "標題", "Playlist privacy": "播放清單隱私", "Editing playlist `x`": "已編輯播放清單 `x`", - "Show more": "", - "Show less": "", + "Show more": "顯示更多", + "Show less": "顯示較少", "Watch on YouTube": "在 YouTube 上觀看", + "Switch Invidious Instance": "切換 Invidious 站台", "Hide annotations": "隱藏註釋", "Show annotations": "顯示註釋", "Genre: ": "風格: ", @@ -170,10 +155,6 @@ "Whitelisted regions: ": "白名單區域: ", "Blacklisted regions: ": "黑名單區域: ", "Shared `x`": "`x` 發佈", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 次檢視", - "": "`x` 次檢視" - }, "Premieres in `x`": "首映於 `x`", "Premieres `x`": "首映於 `x`", "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.": "嗨!看來您將 JavaScript 關閉了。點擊這裡以檢視留言,請注意,它們可能需要比較長的時間載入。", @@ -181,23 +162,18 @@ "View more comments on Reddit": "在 Reddit 上檢視更多留言", "View `x` comments": { "([^.,0-9]|^)1([^.,0-9]|$)": "檢視 `x` 則留言", - "": "檢視 `x` 則留言。" + "": "檢視 `x` 則留言" }, "View Reddit comments": "檢視 Reddit 留言", "Hide replies": "隱藏回覆", "Show replies": "顯示回覆", "Incorrect password": "不正確的密碼", - "Quota exceeded, try again in a few hours": "超過限額,請在幾個小時後再試一次", - "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.": "無法登入,請確定雙因素驗證(驗證器或簡訊)已開啟。", - "Invalid TFA code": "無效的 TFA 代碼", - "Login failed. This may be because two-factor authentication is not turned on for your account.": "登入失敗。這可能是因為您的帳號未開啟雙因素驗證的關係。", "Wrong answer": "錯誤的答案", "Erroneous CAPTCHA": "錯誤的 CAPTCHA", "CAPTCHA is a required field": "CAPTCHA 為必填欄位", "User ID is a required field": "使用者 ID 為必填欄位", "Password is a required field": "密碼為必填欄位", "Wrong username or password": "錯誤的使用者名稱或密碼", - "Please sign in using 'Log in with Google'": "請使用「以 Google 登入」來登入", "Password cannot be empty": "密碼不能為空", "Password cannot be longer than 55 characters": "密碼不能長於55個字元", "Please log in": "請登入", @@ -207,16 +183,10 @@ "This channel does not exist.": "此頻道不存在。", "Could not get channel info.": "無法取得頻道資訊。", "Could not fetch comments": "無法擷取留言", - "View `x` replies": { - "([^.,0-9]|^)1([^.,0-9]|$)": "檢視 `x` 則回覆", - "": "檢視 `x` 則回覆" - }, + "comments_view_x_replies_0": "檢視 {{count}} 則回覆", "`x` ago": "`x` 以前", "Load more": "載入更多", - "`x` points": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 點", - "": "`x` 點" - }, + "comments_points_count_0": "{{count}} 點", "Could not create mix.": "無法建立混合。", "Empty playlist": "空的播放清單", "Not a playlist.": "不是播放清單。", @@ -334,41 +304,20 @@ "Yiddish": "意第緒語", "Yoruba": "約魯巴語", "Zulu": "祖魯語", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 年", - "": "`x` 年" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 月", - "": "`x` 月" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 週", - "": "`x` 週" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天", - "": "`x` 天" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 小時", - "": "`x` 小時" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 天", - "": "`x` 分鐘" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 秒", - "": "`x` 秒" - }, + "generic_count_years_0": "{{count}} 年", + "generic_count_months_0": "{{count}} 月", + "generic_count_weeks_0": "{{count}} 週", + "generic_count_days_0": "{{count}} 天", + "generic_count_hours_0": "{{count}} 小時", + "generic_count_minutes_0": "{{count}} 分鐘", + "generic_count_seconds_0": "{{count}} 秒", "Fallback comments: ": "汰退留言: ", "Popular": "熱門頻道", "Search": "搜尋", "Top": "熱門影片", "About": "關於", "Rating: ": "評分: ", - "Language: ": "語言: ", + "preferences_locale_label": "語言: ", "View as playlist": "以播放清單檢視", "Default": "預設值", "Music": "音樂", @@ -384,35 +333,151 @@ "`x` marked it with a ❤": "`x` 為此標記 ❤", "Audio mode": "音訊模式", "Video mode": "視訊模式", - "Videos": "影片", + "channel_tab_videos_label": "影片", "Playlists": "播放清單", - "Community": "社群", - "relevance": "關聯", - "rating": "評分", - "date": "日期", - "views": "檢視", - "content_type": "內容類型", - "duration": "時長", - "features": "特色", - "sort": "排序", - "hour": "小時", - "today": "今天", - "week": "週", - "month": "月", - "year": "年", - "video": "影片", - "channel": "頻道", - "playlist": "播放清單", - "movie": "電影", - "show": "秀", - "hd": "HD", - "subtitles": "字幕", - "creative_commons": "創用 CC", - "3d": "3D", - "live": "直播", - "4k": "4K", - "location": "位置", - "hdr": "HDR", - "filter": "篩選條件", - "Current version: ": "目前版本: " -}
\ No newline at end of file + "channel_tab_community_label": "社群", + "search_filters_sort_option_relevance": "關聯", + "search_filters_sort_option_rating": "評分", + "search_filters_sort_option_date": "上傳日期", + "search_filters_sort_option_views": "檢視", + "search_filters_type_label": "內容類型", + "search_filters_duration_label": "時長", + "search_filters_features_label": "特色", + "search_filters_sort_label": "排序", + "search_filters_date_option_hour": "最後一小時", + "search_filters_date_option_today": "今天", + "search_filters_date_option_week": "週", + "search_filters_date_option_month": "月", + "search_filters_date_option_year": "年", + "search_filters_type_option_video": "影片", + "search_filters_type_option_channel": "頻道", + "search_filters_type_option_playlist": "播放清單", + "search_filters_type_option_movie": "電影", + "search_filters_type_option_show": "秀", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "字幕", + "search_filters_features_option_c_commons": "創用 CC", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_live": "直播", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_location": "位置", + "search_filters_features_option_hdr": "HDR", + "Current version: ": "目前版本: ", + "next_steps_error_message": "之後您應該嘗試: ", + "next_steps_error_message_refresh": "重新整理", + "next_steps_error_message_go_to_youtube": "到 YouTube", + "search_filters_duration_option_short": "短(小於4分鐘)", + "search_filters_duration_option_long": "長(多於20分鐘)", + "footer_documentation": "文件", + "footer_source_code": "原始碼", + "footer_original_source_code": "原本的原始碼", + "footer_modfied_source_code": "修改後的原始碼", + "adminprefs_modified_source_code_url_label": "修改後的原始碼倉庫 URL", + "footer_donate_page": "捐款", + "preferences_region_label": "內容國家: ", + "preferences_quality_dash_label": "偏好的 DASH 影片品質: ", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_dash_option_worst": "最差", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", + "invidious": "Invidious", + "search_filters_features_option_purchased": "已購買", + "search_filters_features_option_three_sixty": "360°", + "none": "無", + "videoinfo_started_streaming_x_ago": "`x` 前開始串流", + "videoinfo_watch_on_youTube": "在 YouTube 上觀看", + "videoinfo_youTube_embed_link": "嵌入", + "videoinfo_invidious_embed_link": "嵌入連結", + "download_subtitles": "字幕 - `x` (.vtt)", + "user_created_playlists": "`x` 已建立的播放清單", + "user_saved_playlists": "`x` 已儲存的播放清單", + "Video unavailable": "影片不可用", + "preferences_quality_option_small": "小", + "preferences_quality_option_dash": "DASH(主動調整品質)", + "preferences_quality_option_medium": "中等", + "preferences_quality_dash_option_auto": "自動", + "preferences_quality_dash_option_best": "最佳", + "preferences_save_player_pos_label": "儲存播放位置: ", + "crash_page_you_found_a_bug": "看來您在 Invidious 中發現了一隻臭蟲!", + "crash_page_refresh": "嘗試過<a href=\"`x`\">重新整理頁面</a>", + "crash_page_switch_instance": "嘗試<a href=\"`x`\">使用其他站台</a>", + "crash_page_read_the_faq": "閱讀<a href=\"`x`\">常見問題解答 (FAQ)</a>", + "crash_page_search_issue": "搜尋 <a href=\"`x`\">GitHub 上既有的問題</a>", + "crash_page_report_issue": "若以上的動作都沒有幫到忙,請<a href=\"`x`\">在 GitHub 上開啟新的議題</a>(請盡量使用英文)並在您的訊息中包含以下文字(不要翻譯文字):", + "crash_page_before_reporting": "在回報臭蟲之前,請確保您有:", + "English (United Kingdom)": "英文(英國)", + "English (United States)": "英文(美國)", + "Cantonese (Hong Kong)": "粵語(香港)", + "Chinese": "中文", + "Chinese (China)": "中文(中國)", + "Chinese (Taiwan)": "中文(台灣)", + "Dutch (auto-generated)": "荷蘭語(自動產生)", + "German (auto-generated)": "德語(自動產生)", + "Korean (auto-generated)": "韓語(自動產生)", + "Russian (auto-generated)": "俄語(自動產生)", + "Spanish (auto-generated)": "西班牙語(自動產生)", + "Spanish (Mexico)": "西班牙語(墨西哥)", + "Spanish (Spain)": "西班牙語(西班牙)", + "Turkish (auto-generated)": "土耳其語(自動產生)", + "French (auto-generated)": "法語(自動產生)", + "Vietnamese (auto-generated)": "越南語(自動產生)", + "Interlingue": "西方國際語", + "Chinese (Hong Kong)": "中文(香港)", + "Italian (auto-generated)": "義大利語(自動產生)", + "Indonesian (auto-generated)": "印尼語(自動產生)", + "Portuguese (Brazil)": "葡萄牙語(巴西)", + "Japanese (auto-generated)": "日語(自動產生)", + "Portuguese (auto-generated)": "葡萄牙語(自動產生)", + "preferences_watch_history_label": "啟用觀看紀錄: ", + "search_message_change_filters_or_query": "嘗試擴大您的查詢字詞與/或變更過濾條件。", + "search_filters_apply_button": "套用選定的過濾條件", + "search_message_no_results": "找不到結果。", + "search_filters_duration_option_none": "任何時長", + "search_filters_duration_option_medium": "中等(4到20分鐘)", + "search_filters_features_option_vr180": "VR180", + "search_message_use_another_instance": "您也可以<a href=\"`x`\">在其他站台上搜尋</a>。", + "search_filters_title": "過濾條件", + "search_filters_date_label": "上傳日期", + "search_filters_type_option_all": "任何類型", + "search_filters_date_option_none": "任何日期", + "Popular enabled: ": "已啟用人氣: ", + "error_video_not_in_playlist": "此播放清單不存在請求的影片。<a href=\"`x`\">點擊此處檢視播放清單首頁。</a>", + "channel_tab_shorts_label": "短片", + "channel_tab_playlists_label": "播放清單", + "channel_tab_channels_label": "頻道", + "channel_tab_streams_label": "直播", + "Artist: ": "藝術家: ", + "Album: ": "專輯: ", + "Music in this video": "此影片中的音樂", + "Channel Sponsor": "頻道贊助者", + "Song: ": "歌曲: ", + "Standard YouTube license": "標準 YouTube 授權條款", + "Download is disabled": "已停用下載", + "Import YouTube playlist (.csv)": "匯入 YouTube 播放清單 (.csv)", + "Import YouTube watch history (.json)": "匯入 YouTube 觀看歷史 (.json)", + "generic_button_cancel": "取消", + "generic_button_edit": "編輯", + "generic_button_save": "儲存", + "generic_button_rss": "RSS", + "generic_button_delete": "刪除", + "playlist_button_add_items": "新增影片", + "channel_tab_podcasts_label": "Podcast", + "channel_tab_releases_label": "發布", + "generic_channels_count_0": "{{count}} 個頻道", + "toggle_theme": "切換佈景主題", + "Add to playlist": "新增至播放清單", + "Add to playlist: ": "新增至播放清單: ", + "Answer": "答案", + "Search for videos": "搜尋影片", + "carousel_slide": "第 {{current}} 張投影片,共 {{total}} 張", + "carousel_skip": "略過輪播", + "carousel_go_to": "跳到投影片 `x`", + "The Popular feed has been disabled by the administrator.": "熱門 feed 已被管理員停用。" +} diff --git a/mocks b/mocks new file mode 160000 +Subproject b55d58dea94f7144ff0205857dfa70ec14eaa87 diff --git a/screenshots/02_preferences.png b/screenshots/02_preferences.png Binary files differindex bca77802..d562100d 100644 --- a/screenshots/02_preferences.png +++ b/screenshots/02_preferences.png diff --git a/screenshots/05_preferences.png b/screenshots/05_preferences.png Binary files differindex ea7f142f..f1227a66 100644 --- a/screenshots/05_preferences.png +++ b/screenshots/05_preferences.png diff --git a/scripts/deploy-database.sh b/scripts/deploy-database.sh new file mode 100755 index 00000000..fa24b8f0 --- /dev/null +++ b/scripts/deploy-database.sh @@ -0,0 +1,60 @@ +#!/bin/sh + +# +# Parameters +# + +interactive=true + +if [ "$1" = "--no-interactive" ]; then + interactive=false +fi + +# +# Enable and start Postgres +# + +sudo systemctl start postgresql.service +sudo systemctl enable postgresql.service + +# +# Create databse and user +# + +if [ "$interactive" = "true" ]; then + sudo -u postgres -- createuser -P kemal + sudo -u postgres -- createdb -O kemal invidious +else + # Generate a DB password + if [ -z "$POSTGRES_PASS" ]; then + echo "Generating database password" + POSTGRES_PASS=$(tr -dc 'A-Za-z0-9.;!?{[()]}\\/' < /dev/urandom | head -c16) + fi + + # hostname:port:database:username:password + echo "Writing .pgpass" + echo "127.0.0.1:*:invidious:kemal:${POSTGRES_PASS}" > "$HOME/.pgpass" + + sudo -u postgres -- psql -c "CREATE USER kemal WITH PASSWORD '$POSTGRES_PASS';" + sudo -u postgres -- psql -c "CREATE DATABASE invidious WITH OWNER kemal;" + sudo -u postgres -- psql -c "GRANT ALL ON DATABASE invidious TO kemal;" +fi + + +# +# Instructions for modification of pg_hba.conf +# + +if [ "$interactive" = "true" ]; then + echo + echo "-------------" + echo " NOTICE " + echo "-------------" + echo + echo "Make sure that your postgreSQL's pg_hba.conf file contains the follwong" + echo "lines before previous 'host' configurations:" + echo + echo "host invidious kemal 127.0.0.1/32 md5" + echo "host invidious kemal ::1/128 md5" + echo +fi diff --git a/scripts/fetch-player-dependencies.cr b/scripts/fetch-player-dependencies.cr new file mode 100755 index 00000000..813e4ce4 --- /dev/null +++ b/scripts/fetch-player-dependencies.cr @@ -0,0 +1,164 @@ +require "http" +require "yaml" +require "digest/sha1" +require "option_parser" +require "colorize" + +# Taken from https://crystal-lang.org/api/1.1.1/OptionParser.html +minified = false +OptionParser.parse do |parser| + parser.banner = "Usage: Fetch VideoJS dependencies [arguments]" + parser.on("-m", "--minified", "Use minified versions of VideoJS dependencies (performance and bandwidth benefit)") { minified = true } + + parser.on("-h", "--help", "Show this help") do + puts parser + exit + end + + parser.invalid_option do |flag| + STDERR.puts "ERROR: #{flag} is not a valid option." + STDERR.puts parser + exit(1) + end +end + +required_dependencies = File.open("videojs-dependencies.yml") do |file| + YAML.parse(file).as_h +end + +def update_versions_yaml(required_dependencies, minified, dep_name) + File.open("assets/videojs/#{dep_name}/versions.yml", "w") do |io| + YAML.build(io) do |builder| + builder.mapping do + # Versions + builder.scalar "version" + builder.scalar "#{required_dependencies[dep_name]["version"]}" + + builder.scalar "minified" + builder.scalar minified + end + end + end +end + +# The first step is to check which dependencies we'll need to install. +# If the version we have requested in `videojs-dependencies.yml` is the +# same as what we've installed, we shouldn't do anything. Likewise, if it's +# different or the requested dependency just isn't present, then it needs to be +# installed. + +# Since we can't know when videojs-youtube-annotations is updated, we'll just always fetch +# a new copy each time. +dependencies_to_install = [] of String + +required_dependencies.keys.each do |dep| + dep = dep.to_s + path = "assets/videojs/#{dep}" + # Check for missing dependencies + if !Dir.exists?(path) + Dir.mkdir(path) + dependencies_to_install << dep + else + config = File.open("#{path}/versions.yml") do |file| + YAML.parse(file).as_h + end + + if config["version"].as_s != required_dependencies[dep]["version"].as_s || config["minified"].as_bool != minified + `rm -rf #{path}/*.js #{path}/*.css` + dependencies_to_install << dep + end + end +end + +# Now we begin the fun part of installing the dependencies. +# But first we'll setup a temp directory to store the plugins +tmp_dir_path = "#{Dir.tempdir}/invidious-videojs-dep-install" +Dir.mkdir(tmp_dir_path) if !Dir.exists? tmp_dir_path + +channel = Channel(String | Exception).new + +dependencies_to_install.each do |dep| + spawn do + dep_name = dep + download_path = "#{tmp_dir_path}/#{dep}" + dest_path = "assets/videojs/#{dep}" + + HTTP::Client.get("https://registry.npmjs.org/#{dep}/-/#{dep}-#{required_dependencies[dep]["version"]}.tgz") do |response| + Dir.mkdir(download_path) + data = response.body_io.gets_to_end + File.write("#{download_path}/package.tgz", data) + + # https://github.com/iv-org/invidious/pull/2397#issuecomment-922375908 + if `sha1sum #{download_path}/package.tgz`.split(" ")[0] != required_dependencies[dep]["shasum"] + raise Exception.new("Checksum for '#{dep}' failed") + end + end + + # Unless we install an external dependency, crystal provides no way of extracting a tarball. + # Thus we'll go ahead and call a system command. + `tar -vzxf '#{download_path}/package.tgz' -C '#{download_path}'` + raise "Extraction for #{dep} failed" if !$?.success? + + # Would use File.rename in the following steps but for some reason it just doesn't work here. + # Video.js itself is structured slightly differently + dep = "video" if dep == "video.js" + + # This dep nests everything under an additional JS or CSS folder + if dep == "silvermine-videojs-quality-selector" + js_path = "js/" + + # It also stores their quality selector as `quality-selector.css` + `mv #{download_path}/package/dist/css/quality-selector.css #{dest_path}/quality-selector.css` + else + js_path = "" + end + + # Would use File.rename but for some reason it just doesn't work here. + if minified && File.exists?("#{download_path}/package/dist/#{js_path}#{dep}.min.js") + `mv #{download_path}/package/dist/#{js_path}#{dep}.min.js #{dest_path}/#{dep}.js` + else + `mv #{download_path}/package/dist/#{js_path}#{dep}.js #{dest_path}/#{dep}.js` + end + + # Fetch CSS which isn't guaranteed to exist + # + # Also, video JS changes structure here once again... + dep = "video-js" if dep == "video" + + # VideoJS marker uses a dot on the CSS files. + dep = "videojs.markers" if dep == "videojs-markers" + + if File.exists?("#{download_path}/package/dist/#{dep}.css") + if minified && File.exists?("#{download_path}/package/dist/#{dep}.min.css") + `mv #{download_path}/package/dist/#{dep}.min.css #{dest_path}/#{dep}.css` + else + `mv #{download_path}/package/dist/#{dep}.css #{dest_path}/#{dep}.css` + end + end + + # Update/create versions file for the dependency + update_versions_yaml(required_dependencies, minified, dep_name) + + channel.send(dep_name) + rescue ex + channel.send(ex) + end +end + +if dependencies_to_install.empty? + puts "#{"Player".colorize(:blue)} #{"dependencies".colorize(:green)} are satisfied" +else + puts "#{"Resolving".colorize(:green)} #{"player".colorize(:blue)} dependencies" + dependencies_to_install.size.times do + result = channel.receive + + if result.is_a? Exception + raise result + end + + puts "#{"Fetched".colorize(:green)} #{result.colorize(:blue)}" + end +end + +# Cleanup +`rm -rf #{tmp_dir_path}` diff --git a/scripts/git/pre-commit b/scripts/git/pre-commit new file mode 100644 index 00000000..4460b670 --- /dev/null +++ b/scripts/git/pre-commit @@ -0,0 +1,17 @@ +# Useful precomit hooks +# Please see https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks for instructions on installation. + +# 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. +changed_cr_files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.cr$') +if [ ! -z "$changed_cr_files" ]; then + if [ -x bin/crystal ]; then + # use bin/crystal wrapper when available to run local compiler build + bin/crystal tool format $changed_cr_files >&2 + else + crystal tool format $changed_cr_files >&2 + fi + + git add $changed_cr_files +fi diff --git a/scripts/install-dependencies.sh b/scripts/install-dependencies.sh new file mode 100755 index 00000000..1e67bdaf --- /dev/null +++ b/scripts/install-dependencies.sh @@ -0,0 +1,174 @@ +#!/bin/sh +# +# Script that installs the various dependencies of invidious +# +# Dependencies: +# - crystal => Language in which Invidious is developed +# - postgres => Database server +# - git => required to clone Invidious +# - librsvg2-bin => For login captcha (provides 'rsvg-convert') +# +# - libssl-dev => Used by Crystal's SSL module (standard library) +# - libxml2-dev => Used by Crystal's XML module (standard library) +# - libyaml-dev => Used by Crystal's YAML module (standard library) +# - libgmp-dev => Used by Crystal's BigNumbers module (standard library) +# - libevent-dev => Used by crystal's internal scheduler (?) +# - libpcre3-dev => Used by Crystal's regex engine (?) +# +# - libsqlite3-dev => Used to open .db files from NewPipe exports +# - zlib1g-dev => TBD +# - libreadline-dev => TBD +# +# +# Tested on: +# - OpenSUSE Leap 15.3 + +# +# Load system details +# + +if [ -e /etc/os-release ]; then + . /etc/os-release +elif [ -e /usr/lib/os-release ]; then + . /usr/lib/os-release +else + echo "Unsupported Linux system" + exit 2 +fi + +# +# Some variables +# + +repo_base_url="https://download.opensuse.org/repositories/devel:/languages:/crystal/" +repo_end_url="devel:languages:crystal.repo" + +apt_gpg_key="/usr/share/keyrings/crystal.gpg" +apt_list_file="/etc/apt/sources.list.d/crystal.list" + +yum_repo_file="/etc/yum.repos.d/crystal.repo" + +# +# Major install functions +# + +make_repo_url() { + echo "${repo_base_url}/${1}/${repo_end_url}" +} + + +install_apt() { + repo="$1" + + echo "Adding Crystal repository" + + curl -fsSL "${repo_base_url}/${repo}/Release.key" \ + | gpg --dearmor \ + | sudo tee "${apt_gpg_key}" > /dev/null + + echo "deb [signed-by=${apt_gpg_key}] ${repo_base_url}/${repo}/ /" \ + | sudo tee "$apt_list_file" + + sudo apt-get update + + sudo apt-get install --yes --no-install-recommends \ + libssl-dev libxml2-dev libyaml-dev libgmp-dev libevent-dev \ + libpcre3-dev libreadline-dev libsqlite3-dev zlib1g-dev \ + crystal postgresql-13 git librsvg2-bin make +} + +install_yum() { + repo=$(make_repo_url "$1") + + echo "Adding Crystal repository" + + cat << END | sudo tee "${yum_repo_file}" > /dev/null +[crystal] +name=Crystal +type=rpm-md +baseurl=${repo}/ +gpgcheck=1 +gpgkey=${repo}/repodata/repomd.xml.key +enabled=1 +END + + sudo yum -y install \ + openssl-devel libxml2-devel libyaml-devel gmp-devel \ + readline-devel sqlite-devel \ + crystal postgresql postgresql-server git librsvg2-tools make +} + +install_pacman() { + # TODO: find an alternative to --no-confirm? + sudo pacman -S --no-confirm \ + base-devel librsvg postgresql crystal +} + +install_zypper() +{ + repo=$(make_repo_url "$1") + + echo "Adding Crystal repository" + sudo zypper --non-interactive addrepo -f "$repo" + + sudo zypper --non-interactive --gpg-auto-import-keys install --no-recommends \ + libopenssl-devel libxml2-devel libyaml-devel gmp-devel libevent-devel \ + pcre-devel readline-devel sqlite3-devel zlib-devel \ + crystal postgresql postgresql-server git rsvg-convert make +} + + +# +# System-specific logic +# + +case "$ID" in + archlinux) install_pacman;; + + centos) install_dnf "CentOS_${VERSION_ID}";; + + debian) + case "$VERSION_CODENAME" in + sid) install_apt "Debian_Unstable";; + bookworm) install_apt "Debian_Testing";; + *) install_apt "Debian_${VERSION_ID}";; + esac + ;; + + fedora) + if [ "$VERSION" == *"Prerelease"* ]; then + install_dnf "Fedora_Rawhide" + else + install_dnf "Fedora_${VERSION}" + fi + ;; + + opensuse-leap) install_zypper "openSUSE_Leap_${VERSION}";; + + opensuse-tumbleweed) install_zypper "openSUSE_Tumbleweed";; + + rhel) install_dnf "RHEL_${VERSION_ID}";; + + ubuntu) + # Small workaround for recently released 22.04 + case "$VERSION_ID" in + 22.04) install_apt "xUbuntu_21.04";; + *) install_apt "xUbuntu_${VERSION_ID}";; + esac + ;; + + *) + # Try to match on ID_LIKE instead + # Not guaranteed to 100% work + case "$ID_LIKE" in + archlinux) install_pacman;; + centos) install_dnf "CentOS_${VERSION_ID}";; + debian) install_apt "Debian_${VERSION_ID}";; + *) + echo "Error: distribution ${CODENAME} is not supported" + echo "Please install dependencies manually" + exit 2 + ;; + esac + ;; +esac @@ -1,42 +1,54 @@ version: 2.0 shards: + ameba: + git: https://github.com/crystal-ameba/ameba.git + version: 1.6.1 + + athena-negotiation: + git: https://github.com/athena-framework/negotiation.git + version: 0.1.1 + + backtracer: + git: https://github.com/sija/backtracer.cr.git + version: 1.2.2 + db: git: https://github.com/crystal-lang/crystal-db.git - version: 0.10.0 + version: 0.13.1 exception_page: git: https://github.com/crystal-loot/exception_page.git - version: 0.1.4 + version: 0.2.2 + + http_proxy: + git: https://github.com/mamantoha/http_proxy.git + version: 0.10.3 kemal: git: https://github.com/kemalcr/kemal.git - version: 0.27.0 + version: 1.1.2 kilt: git: https://github.com/jeromegn/kilt.git - version: 0.4.0 - - lsquic: - git: https://github.com/iv-org/lsquic.cr.git - version: 2.18.1-1 + version: 0.6.1 pg: git: https://github.com/will/crystal-pg.git - version: 0.23.1 - - pool: - git: https://github.com/ysbaddaden/pool.git - version: 0.2.3 + version: 0.28.0 protodec: - git: https://github.com/omarroth/protodec.git - version: 0.1.3 + git: https://github.com/iv-org/protodec.git + version: 0.1.5 radix: git: https://github.com/luislavena/radix.git - version: 0.3.9 + version: 0.4.1 + + spectator: + git: https://github.com/icy-arctic-fox/spectator.git + version: 0.10.6 sqlite3: git: https://github.com/crystal-lang/crystal-sqlite3.git - version: 0.18.0 + version: 0.21.0 @@ -1,34 +1,48 @@ name: invidious -version: 0.20.1 +version: 2.20241110.0-dev authors: - - Omar Roth <omarroth@protonmail.com> - - Invidous team + - Invidious team <contact@invidious.io> + - Contributors! -targets: - invidious: - main: src/invidious.cr +description: | + Invidious is an alternative front-end to YouTube dependencies: pg: github: will/crystal-pg - version: ~> 0.23.1 + version: ~> 0.28.0 sqlite3: github: crystal-lang/crystal-sqlite3 - version: ~> 0.18.0 + version: ~> 0.21.0 kemal: github: kemalcr/kemal - version: ~> 0.27.0 - pool: - github: ysbaddaden/pool - version: ~> 0.2.3 + version: ~> 1.1.2 + kilt: + github: jeromegn/kilt + version: ~> 0.6.1 protodec: - github: omarroth/protodec - version: ~> 0.1.3 - lsquic: - github: iv-org/lsquic.cr - version: ~> 2.18.1-1 + github: iv-org/protodec + version: ~> 0.1.5 + athena-negotiation: + github: athena-framework/negotiation + version: ~> 0.1.1 + http_proxy: + github: mamantoha/http_proxy + version: ~> 0.10.3 -crystal: 0.36.1 +development_dependencies: + spectator: + github: icy-arctic-fox/spectator + version: ~> 0.10.4 + ameba: + github: crystal-ameba/ameba + version: ~> 1.6.1 + +crystal: ">= 1.10.0, < 2.0.0" license: AGPLv3 + +repository: https://github.com/iv-org/invidious +homepage: https://invidious.io +documentation: https://docs.invidious.io diff --git a/spec/helpers/vtt/builder_spec.cr b/spec/helpers/vtt/builder_spec.cr new file mode 100644 index 00000000..dc1f4613 --- /dev/null +++ b/spec/helpers/vtt/builder_spec.cr @@ -0,0 +1,87 @@ +require "../../spec_helper.cr" + +MockLines = ["Line 1", "Line 2"] +MockLinesWithEscapableCharacter = ["<Line 1>", "&Line 2>", '\u200E' + "Line\u200F 3", "\u00A0Line 4"] + +Spectator.describe "WebVTT::Builder" do + it "correctly builds a vtt file" do + result = WebVTT.build do |vtt| + 2.times do |i| + vtt.cue( + Time::Span.new(seconds: i), + Time::Span.new(seconds: i + 1), + MockLines[i] + ) + end + end + + expect(result).to eq([ + "WEBVTT", + "", + "00:00:00.000 --> 00:00:01.000", + "Line 1", + "", + "00:00:01.000 --> 00:00:02.000", + "Line 2", + "", + "", + ].join('\n')) + end + + it "correctly builds a vtt file with setting fields" do + setting_fields = { + "Kind" => "captions", + "Language" => "en", + } + + result = WebVTT.build(setting_fields) do |vtt| + 2.times do |i| + vtt.cue( + Time::Span.new(seconds: i), + Time::Span.new(seconds: i + 1), + MockLines[i] + ) + end + end + + expect(result).to eq([ + "WEBVTT", + "Kind: captions", + "Language: en", + "", + "00:00:00.000 --> 00:00:01.000", + "Line 1", + "", + "00:00:01.000 --> 00:00:02.000", + "Line 2", + "", + "", + ].join('\n')) + end + + it "properly escapes characters" do + result = WebVTT.build do |vtt| + 4.times do |i| + vtt.cue(Time::Span.new(seconds: i), Time::Span.new(seconds: i + 1), MockLinesWithEscapableCharacter[i]) + end + end + + expect(result).to eq([ + "WEBVTT", + "", + "00:00:00.000 --> 00:00:01.000", + "<Line 1>", + "", + "00:00:01.000 --> 00:00:02.000", + "&Line 2>", + "", + "00:00:02.000 --> 00:00:03.000", + "‎Line‏ 3", + "", + "00:00:03.000 --> 00:00:04.000", + " Line 4", + "", + "", + ].join('\n')) + end +end diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr deleted file mode 100644 index ed3a3d48..00000000 --- a/spec/helpers_spec.cr +++ /dev/null @@ -1,141 +0,0 @@ -require "kemal" -require "openssl/hmac" -require "pg" -require "protodec/utils" -require "spec" -require "yaml" -require "../src/invidious/helpers/*" -require "../src/invidious/channels" -require "../src/invidious/comments" -require "../src/invidious/playlists" -require "../src/invidious/search" -require "../src/invidious/trending" -require "../src/invidious/users" - -CONFIG = Config.from_yaml(File.open("config/config.example.yml")) - -describe "Helper" do - describe "#produce_channel_videos_url" do - it "correctly produces url for requesting page `x` of a channel's videos" do - produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw").should eq("/browse_ajax?continuation=4qmFsgI8EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4&gl=US&hl=en") - - produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", sort_by: "popular").should eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4R0FFPQ%3D%3D&gl=US&hl=en") - - produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", page: 20).should eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUE9PQ%3D%3D&gl=US&hl=en") - - produce_channel_videos_url(ucid: "UC-9-kyTW8ZkZNDHQJ6FgpwQ", page: 20, sort_by: "popular").should eq("/browse_ajax?continuation=4qmFsgJAEhhVQy05LWt5VFc4WmtaTkRIUUo2Rmdwd1EaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUJnQg%3D%3D&gl=US&hl=en") - end - end - - describe "#produce_channel_search_continuation" do - it "correctly produces token for searching a specific channel" do - produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "", 100).should eq("4qmFsgJqEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWnpaV0Z5WTJnd0FUZ0JZQUY2QkVkS2IxaTRBUUE9WgCaAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D") - - produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "По ожиशुपतिरपि子而時ஸ்றீனி", 0).should eq("4qmFsgKoARIYVUNYdXFTQmxIQUU2WHcteWVKQTBUdW53GiBFZ1p6WldGeVkyZ3dBVGdCWUFGNkJFZEJRVDI0QVFBPVo-0J_QviDQvtC20LjgpLbgpYHgpKrgpKTgpL_gpLDgpKrgpL_lrZDogIzmmYLgrrjgr43grrHgr4Dgrqngrr-aAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D") - end - end - - describe "#produce_channel_playlists_url" do - it "correctly produces a /browse_ajax URL with the given UCID and cursor" do - produce_channel_playlists_url("UCCj956IF62FbT7Gouszaj9w", "AIOkY9EQpi_gyn1_QrFuZ1reN81_MMmI1YmlBblw8j7JHItEFG5h7qcJTNd4W9x5Quk_CVZ028gW").should eq("/browse_ajax?continuation=4qmFsgLNARIYVUNDajk1NklGNjJGYlQ3R291c3phajl3GrABRWdsd2JHRjViR2x6ZEhNd0FqZ0JZQUZxQUxnQkFIcG1VVlZzVUdFeGF6VlNWa1ozWVZZNWJtVlhOSGhZTVVaNVVtNVdZVTFZU214VWFtZDRXREF4VG1KVmEzaFhWekZ6VVcxS2MyUjZhSEZPTUhCSlUxaFNSbEpyWXpGaFJHUjRXVEJ3VlZSdFVUQldlbXcwVGxaR01XRXhPVVJXYkc5M1RXcG9ibFozSUFFWUF3PT0%3D&gl=US&hl=en") - end - end - - describe "#produce_playlist_continuation" do - it "correctly produces ctoken for requesting index `x` of a playlist" do - produce_playlist_continuation("UUCla9fZca4I7KagBtgRGnOw", 100).should eq("4qmFsgJNEhpWTFVVQ2xhOWZaY2E0STdLYWdCdGdSR25PdxoUQ0FGNkJsQlVPa05IVVElM0QlM0SaAhhVVUNsYTlmWmNhNEk3S2FnQnRnUkduT3c%3D") - - produce_playlist_continuation("UCCla9fZca4I7KagBtgRGnOw", 200).should eq("4qmFsgJLEhpWTFVVQ2xhOWZaY2E0STdLYWdCdGdSR25PdxoSQ0FKNkIxQlVPa05OWjBJJTNEmgIYVVVDbGE5ZlpjYTRJN0thZ0J0Z1JHbk93") - - produce_playlist_continuation("PL55713C70BA91BD6E", 100).should eq("4qmFsgJBEhRWTFBMNTU3MTNDNzBCQTkxQkQ2RRoUQ0FGNkJsQlVPa05IVVElM0QlM0SaAhJQTDU1NzEzQzcwQkE5MUJENkU%3D") - end - end - - describe "#produce_search_params" do - it "correctly produces token for searching with specified filters" do - produce_search_params.should eq("CAASAhABSAA%3D") - - produce_search_params(sort: "upload_date", content_type: "video").should eq("CAISAhABSAA%3D") - - produce_search_params(content_type: "playlist").should eq("CAASAhADSAA%3D") - - produce_search_params(sort: "date", content_type: "video", features: ["hd", "cc", "purchased", "hdr"]).should eq("CAISCxABIAEwAUgByAEBSAA%3D") - - produce_search_params(content_type: "channel").should eq("CAASAhACSAA%3D") - end - end - - describe "#produce_comment_continuation" do - it "correctly produces a continuation token for comments" do - produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA").should eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") - - produce_comment_continuation("_cE8xSu6swE", "ADSJ_i1yz21HI4xrtsYXVC-2_kfZ6kx1yjYQumXAAxqH3CAd7ZxKxfLdZS1__fqhCtOASRbbpSBGH_tH1J96Dxux-Qfjk-lUbupMqv08Q3aHzGu7p70VoUMHhI2-GoJpnbpmcOxkGzeIuenRS_ym2Y8fkDowhqLPFgsS0n4djnZ2UmC17F3Ch3N1S1UYf1ZVOc991qOC1iW9kJDzyvRQTWCPsJUPneSaAKW-Rr97pdesOkR4i8cNvHZRnQKe2HEfsvlJOb2C3lF1dJBfJeNfnQYeh5hv6_fZN7bt3-JL1Xk3Qc9NXNxmmbDpwAC_yFR8dthFfUJdyIO9Nu1D79MLYeR-H5HxqUJokkJiGIz4lTE_CXXbhAI").should eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyiQMK8wJBRFNKX2kxeXoyMUhJNHhydHNZWFZDLTJfa2ZaNmt4MXlqWVF1bVhBQXhxSDNDQWQ3WnhLeGZMZFpTMV9fZnFoQ3RPQVNSYmJwU0JHSF90SDFKOTZEeHV4LVFmamstbFVidXBNcXYwOFEzYUh6R3U3cDcwVm9VTUhoSTItR29KcG5icG1jT3hrR3plSXVlblJTX3ltMlk4ZmtEb3docUxQRmdzUzBuNGRqbloyVW1DMTdGM0NoM04xUzFVWWYxWlZPYzk5MXFPQzFpVzlrSkR6eXZSUVRXQ1BzSlVQbmVTYUFLVy1Scjk3cGRlc09rUjRpOGNOdkhaUm5RS2UySEVmc3ZsSk9iMkMzbEYxZEpCZkplTmZuUVllaDVodjZfZlpON2J0My1KTDFYazNRYzlOWE54bW1iRHB3QUNfeUZSOGR0aEZmVUpkeUlPOU51MUQ3OU1MWWVSLUg1SHhxVUpva2tKaUdJejRsVEVfQ1hYYmhBSSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") - - produce_comment_continuation("29-q7YnyUmY", "").should eq("EkMSCzI5LXE3WW55VW1ZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iCzI5LXE3WW55VW1ZMAAoFA%3D%3D") - - produce_comment_continuation("CvFH_6DNRCY", "").should eq("EkMSC0N2RkhfNkROUkNZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iC0N2RkhfNkROUkNZMAAoFA%3D%3D") - end - end - - describe "#produce_comment_reply_continuation" do - it "correctly produces a continuation token for replies to a given comment" do - produce_comment_reply_continuation("cIHQWOoJeag", "UCq6VFHwMzcMXbuKyG7SQYIg", "Ugx1IP_wGVv3WtGWcdV4AaABAg").should eq("EiYSC2NJSFFXT29KZWFnwAEByAEB4AEBogINKP___________wFAABgGMk0aSxIaVWd4MUlQX3dHVnYzV3RHV2NkVjRBYUFCQWciAggAKhhVQ3E2VkZId016Y01YYnVLeUc3U1FZSWcyC2NJSFFXT29KZWFnQAFICg%3D%3D") - - produce_comment_reply_continuation("cIHQWOoJeag", "UCq6VFHwMzcMXbuKyG7SQYIg", "Ugza62y_TlmTu9o2RfF4AaABAg").should eq("EiYSC2NJSFFXT29KZWFnwAEByAEB4AEBogINKP___________wFAABgGMk0aSxIaVWd6YTYyeV9UbG1UdTlvMlJmRjRBYUFCQWciAggAKhhVQ3E2VkZId016Y01YYnVLeUc3U1FZSWcyC2NJSFFXT29KZWFnQAFICg%3D%3D") - - produce_comment_reply_continuation("_cE8xSu6swE", "UC1AZY74-dGVPe6bfxFwwEMg", "UgyBUaRGHB9Jmt1dsUZ4AaABAg").should eq("EiYSC19jRTh4U3U2c3dFwAEByAEB4AEBogINKP___________wFAABgGMk0aSxIaVWd5QlVhUkdIQjlKbXQxZHNVWjRBYUFCQWciAggAKhhVQzFBWlk3NC1kR1ZQZTZiZnhGd3dFTWcyC19jRTh4U3U2c3dFQAFICg%3D%3D") - end - end - - describe "#produce_channel_community_continuation" do - it "correctly produces a continuation token for a channel community" do - produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4").should eq("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D") - produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd3cE9NQmVwWEdjclhsUHg2WjRBYUFCQ1FIZGgDKAA%3D").should eq("4qmFsgJmEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTNE") - - produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKAA%3D").should eq("4qmFsgJmEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTNE") - produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqA-cOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKGM%3D").should eq("4qmFsgKXFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvoTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUzRA%3D%3D") - end - end - - describe "#extract_channel_community_cursor" do - it "correctly extracts a community cursor from a given continuation" do - extract_channel_community_cursor("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D").should eq("Egljb21tdW5pdHk=") - extract_channel_community_cursor("4qmFsgJoEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D").should eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVnd3BPTUJlcFhHY3JYbFB4Nlo0QWFBQkNRKAA=") - - extract_channel_community_cursor("4qmFsgJoEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D").should eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVneUUyNjVta1JJNnBPbkttZ2w0QWFBQkNRKAA=") - extract_channel_community_cursor("4qmFsgKZFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvwTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUyNTNE").should eq("Egljb21tdW5pdHm4AQCqA-kOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIhIcVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1E9PUhkaAMoYw==") - end - end - - describe "#extract_plid" do - it "correctly extracts playlist ID from trending URL" do - extract_plid("/feed/trending?bp=4gIuCggvbS8wNHJsZhIiUExGZ3F1TG5MNTlhbVBud2pLbmNhZUp3MDYzZlU1M3Q0cA%3D%3D").should eq("PLFgquLnL59amPnwjKncaeJw063fU53t4p") - extract_plid("/feed/trending?bp=4gIvCgkvbS8wYnp2bTISIlBMaUN2Vkp6QnVwS2tDaFNnUDdGWFhDclo2aEp4NmtlTm0%3D").should eq("PLiCvVJzBupKkChSgP7FXXCrZ6hJx6keNm") - extract_plid("/feed/trending?bp=4gIuCggvbS8wNWpoZxIiUEwzWlE1Q3BOdWxRbUtPUDNJekdsYWN0V1c4dklYX0hFUA%3D%3D").should eq("PL3ZQ5CpNulQmKOP3IzGlactWW8vIX_HEP") - extract_plid("/feed/trending?bp=4gIuCggvbS8wMnZ4bhIiUEx6akZiYUZ6c21NUnFhdEJnVTdPeGNGTkZhQ2hqTkVERA%3D%3D").should eq("PLzjFbaFzsmMRqatBgU7OxcFNFaChjNEDD") - end - end - - describe "#sign_token" do - it "correctly signs a given hash" do - token = { - "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "expires" => 1554680038, - "scopes" => [ - ":notifications", - ":subscriptions/*", - "GET:tokens*", - ], - "signature" => "f__2hS20th8pALF305PJFK-D2aVtvefNnQheILHD2vU=", - } - sign_token("SECRET_KEY", token).should eq(token["signature"]) - - token = { - "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "scopes" => [":notifications", "POST:subscriptions/*"], - "signature" => "fNvXoT0MRAL9eE6lTE33CEg8HitYJDOL9a22rSN2Ihg=", - } - sign_token("SECRET_KEY", token).should eq(token["signature"]) - end - end -end diff --git a/spec/i18next_plurals_spec.cr b/spec/i18next_plurals_spec.cr new file mode 100644 index 00000000..dcd0f5ec --- /dev/null +++ b/spec/i18next_plurals_spec.cr @@ -0,0 +1,231 @@ +require "spectator" +require "../src/invidious/helpers/i18next.cr" + +Spectator.configure do |config| + config.fail_blank + config.randomize +end + +def resolver + I18next::Plurals::RESOLVER +end + +FORM_TESTS = { + "ach" => I18next::Plurals::PluralForms::Single_gt_one, + "ar" => I18next::Plurals::PluralForms::Special_Arabic, + "be" => I18next::Plurals::PluralForms::Dual_Slavic, + "cy" => I18next::Plurals::PluralForms::Special_Welsh, + "fr" => I18next::Plurals::PluralForms::Special_French_Portuguese, + "en" => I18next::Plurals::PluralForms::Single_not_one, + "es" => I18next::Plurals::PluralForms::Special_Spanish_Italian, + "ga" => I18next::Plurals::PluralForms::Special_Irish, + "gd" => I18next::Plurals::PluralForms::Special_Scottish_Gaelic, + "he" => I18next::Plurals::PluralForms::Special_Hebrew, + "hr" => I18next::Plurals::PluralForms::Special_Hungarian_Serbian, + "is" => I18next::Plurals::PluralForms::Special_Icelandic, + "it" => I18next::Plurals::PluralForms::Special_Spanish_Italian, + "jv" => I18next::Plurals::PluralForms::Special_Javanese, + "kw" => I18next::Plurals::PluralForms::Special_Cornish, + "lt" => I18next::Plurals::PluralForms::Special_Lithuanian, + "lv" => I18next::Plurals::PluralForms::Special_Latvian, + "mk" => I18next::Plurals::PluralForms::Special_Macedonian, + "mnk" => I18next::Plurals::PluralForms::Special_Mandinka, + "mt" => I18next::Plurals::PluralForms::Special_Maltese, + "or" => I18next::Plurals::PluralForms::Special_Odia, + "pl" => I18next::Plurals::PluralForms::Special_Polish_Kashubian, + "pt" => I18next::Plurals::PluralForms::Special_French_Portuguese, + "pt-PT" => I18next::Plurals::PluralForms::Single_gt_one, + "pt-BR" => I18next::Plurals::PluralForms::Special_French_Portuguese, + "ro" => I18next::Plurals::PluralForms::Special_Romanian, + "sk" => I18next::Plurals::PluralForms::Special_Czech_Slovak, + "sl" => I18next::Plurals::PluralForms::Special_Slovenian, + "su" => I18next::Plurals::PluralForms::None, + "sr" => I18next::Plurals::PluralForms::Special_Hungarian_Serbian, +} + +SUFFIX_TESTS = { + "ach" => [ + {num: 0, suffix: ""}, + {num: 1, suffix: ""}, + {num: 10, suffix: "_plural"}, + ], + "ar" => [ + {num: 0, suffix: "_0"}, + {num: 1, suffix: "_1"}, + {num: 2, suffix: "_2"}, + {num: 3, suffix: "_3"}, + {num: 4, suffix: "_3"}, + {num: 104, suffix: "_3"}, + {num: 11, suffix: "_4"}, + {num: 99, suffix: "_4"}, + {num: 199, suffix: "_4"}, + {num: 100, suffix: "_5"}, + ], + "be" => [ + {num: 0, suffix: "_2"}, + {num: 1, suffix: "_0"}, + {num: 5, suffix: "_2"}, + ], + "cy" => [ + {num: 0, suffix: "_2"}, + {num: 1, suffix: "_0"}, + {num: 3, suffix: "_2"}, + {num: 8, suffix: "_3"}, + ], + "en" => [ + {num: 0, suffix: "_plural"}, + {num: 1, suffix: ""}, + {num: 10, suffix: "_plural"}, + ], + "es" => [ + {num: 0, suffix: "_2"}, + {num: 1, suffix: "_0"}, + {num: 10, suffix: "_2"}, + {num: 6_000_000, suffix: "_1"}, + ], + "fr" => [ + {num: 0, suffix: "_0"}, + {num: 1, suffix: "_0"}, + {num: 10, suffix: "_2"}, + {num: 4_000_000, suffix: "_1"}, + {num: 6_260_000, suffix: "_2"}, + ], + "ga" => [ + {num: 1, suffix: "_0"}, + {num: 2, suffix: "_1"}, + {num: 3, suffix: "_2"}, + {num: 7, suffix: "_3"}, + {num: 11, suffix: "_4"}, + ], + "gd" => [ + {num: 1, suffix: "_0"}, + {num: 2, suffix: "_1"}, + {num: 3, suffix: "_2"}, + {num: 20, suffix: "_3"}, + ], + "he" => [ + {num: 0, suffix: "_3"}, + {num: 1, suffix: "_0"}, + {num: 2, suffix: "_1"}, + {num: 3, suffix: "_3"}, + {num: 20, suffix: "_2"}, + {num: 21, suffix: "_3"}, + {num: 30, suffix: "_2"}, + {num: 100, suffix: "_2"}, + {num: 101, suffix: "_3"}, + ], + "is" => [ + {num: 1, suffix: ""}, + {num: 2, suffix: "_plural"}, + ], + "jv" => [ + {num: 0, suffix: "_0"}, + {num: 1, suffix: "_1"}, + ], + "kw" => [ + {num: 1, suffix: "_0"}, + {num: 2, suffix: "_1"}, + {num: 3, suffix: "_2"}, + {num: 4, suffix: "_3"}, + ], + "lt" => [ + {num: 1, suffix: "_0"}, + {num: 2, suffix: "_1"}, + {num: 10, suffix: "_2"}, + ], + "lv" => [ + {num: 1, suffix: "_0"}, + {num: 2, suffix: "_1"}, + {num: 0, suffix: "_2"}, + ], + "mk" => [ + {num: 1, suffix: ""}, + {num: 2, suffix: "_plural"}, + {num: 0, suffix: "_plural"}, + {num: 11, suffix: "_plural"}, + {num: 21, suffix: ""}, + {num: 31, suffix: ""}, + {num: 311, suffix: "_plural"}, + ], + "mnk" => [ + {num: 0, suffix: "_0"}, + {num: 1, suffix: "_1"}, + {num: 2, suffix: "_2"}, + ], + "mt" => [ + {num: 1, suffix: "_0"}, + {num: 2, suffix: "_1"}, + {num: 11, suffix: "_2"}, + {num: 20, suffix: "_3"}, + ], + "or" => [ + {num: 2, suffix: "_1"}, + {num: 1, suffix: "_0"}, + ], + "pl" => [ + {num: 0, suffix: "_2"}, + {num: 1, suffix: "_0"}, + {num: 5, suffix: "_2"}, + ], + "pt-BR" => [ + {num: 0, suffix: "_0"}, + {num: 1, suffix: "_0"}, + {num: 10, suffix: "_2"}, + {num: 42, suffix: "_2"}, + {num: 9_000_000, suffix: "_1"}, + ], + "pt-PT" => [ + {num: 0, suffix: ""}, + {num: 1, suffix: ""}, + {num: 10, suffix: "_plural"}, + {num: 9_000_000, suffix: "_plural"}, + ], + "ro" => [ + {num: 0, suffix: "_1"}, + {num: 1, suffix: "_0"}, + {num: 20, suffix: "_2"}, + ], + "sk" => [ + {num: 0, suffix: "_2"}, + {num: 1, suffix: "_0"}, + {num: 5, suffix: "_2"}, + ], + "sl" => [ + {num: 5, suffix: "_0"}, + {num: 1, suffix: "_1"}, + {num: 2, suffix: "_2"}, + {num: 3, suffix: "_3"}, + ], + "su" => [ + {num: 0, suffix: "_0"}, + {num: 1, suffix: "_0"}, + {num: 10, suffix: "_0"}, + ], + "sr" => [ + {num: 1, suffix: "_0"}, + {num: 51, suffix: "_0"}, + {num: 32, suffix: "_1"}, + {num: 100, suffix: "_2"}, + {num: 100_000, suffix: "_2"}, + ], +} + +Spectator.describe "i18next_Plural_Resolver" do + describe "get_plural_form" do + sample FORM_TESTS do |locale, form| + it "returns the right plural form for locale '#{locale}'" do + expect(resolver.get_plural_form(locale)).to eq(form) + end + end + end + + describe "get_suffix" do + sample SUFFIX_TESTS do |locale, tests| + it "returns the right suffix for locale '#{locale}'" do + tests.each do |d| + expect(resolver.get_suffix(locale, d[:num])).to eq(d[:suffix]) + end + end + end + end +end diff --git a/spec/invidious/hashtag_spec.cr b/spec/invidious/hashtag_spec.cr new file mode 100644 index 00000000..abc81225 --- /dev/null +++ b/spec/invidious/hashtag_spec.cr @@ -0,0 +1,109 @@ +require "../parsers_helper.cr" + +Spectator.describe Invidious::Hashtag do + it "parses richItemRenderer containers (test 1)" do + # Enable mock + test_content = load_mock("hashtag/martingarrix_page1") + videos, _ = extract_items(test_content) + + expect(typeof(videos)).to eq(Array(SearchItem)) + expect(videos.size).to eq(60) + + # + # Random video check 1 + # + expect(typeof(videos[11])).to eq(SearchItem) + + video_11 = videos[11].as(SearchVideo) + + expect(video_11.id).to eq("06eSsOWcKYA") + expect(video_11.title).to eq("Martin Garrix - Live @ Tomorrowland 2018") + + expect(video_11.ucid).to eq("UC5H_KXkPbEsGs0tFt8R35mA") + expect(video_11.author).to eq("Martin Garrix") + expect(video_11.author_verified).to be_true + + expect(video_11.published).to be_close(Time.utc - 3.years, 1.second) + expect(video_11.length_seconds).to eq((56.minutes + 41.seconds).total_seconds.to_i32) + expect(video_11.views).to eq(40_504_893) + + expect(video_11.badges.live_now?).to be_false + expect(video_11.badges.premium?).to be_false + expect(video_11.premiere_timestamp).to be_nil + + # + # Random video check 2 + # + expect(typeof(videos[35])).to eq(SearchItem) + + video_35 = videos[35].as(SearchVideo) + + expect(video_35.id).to eq("b9HpOAYjY9I") + expect(video_35.title).to eq("Martin Garrix feat. Mike Yung - Dreamer (Official Video)") + + expect(video_35.ucid).to eq("UC5H_KXkPbEsGs0tFt8R35mA") + expect(video_35.author).to eq("Martin Garrix") + expect(video_35.author_verified).to be_true + + expect(video_35.published).to be_close(Time.utc - 3.years, 1.second) + expect(video_35.length_seconds).to eq((3.minutes + 14.seconds).total_seconds.to_i32) + expect(video_35.views).to eq(30_790_049) + + expect(video_35.badges.live_now?).to be_false + expect(video_35.badges.premium?).to be_false + expect(video_35.premiere_timestamp).to be_nil + end + + it "parses richItemRenderer containers (test 2)" do + # Enable mock + test_content = load_mock("hashtag/martingarrix_page2") + videos, _ = extract_items(test_content) + + expect(typeof(videos)).to eq(Array(SearchItem)) + expect(videos.size).to eq(60) + + # + # Random video check 1 + # + expect(typeof(videos[41])).to eq(SearchItem) + + video_41 = videos[41].as(SearchVideo) + + expect(video_41.id).to eq("qhstH17zAjs") + expect(video_41.title).to eq("Martin Garrix Radio - Episode 391") + + expect(video_41.ucid).to eq("UC5H_KXkPbEsGs0tFt8R35mA") + expect(video_41.author).to eq("Martin Garrix") + expect(video_41.author_verified).to be_true + + expect(video_41.published).to be_close(Time.utc - 2.months, 1.second) + expect(video_41.length_seconds).to eq((1.hour).total_seconds.to_i32) + expect(video_41.views).to eq(63_240) + + expect(video_41.badges.live_now?).to be_false + expect(video_41.badges.premium?).to be_false + expect(video_41.premiere_timestamp).to be_nil + + # + # Random video check 2 + # + expect(typeof(videos[48])).to eq(SearchItem) + + video_48 = videos[48].as(SearchVideo) + + expect(video_48.id).to eq("lqGvW0NIfdc") + expect(video_48.title).to eq("Martin Garrix SENTIO Full Album Mix by Sakul") + + expect(video_48.ucid).to eq("UC3833PXeLTS6yRpwGMQpp4Q") + expect(video_48.author).to eq("SAKUL") + expect(video_48.author_verified).to be_false + + expect(video_48.published).to be_close(Time.utc - 3.weeks, 1.second) + expect(video_48.length_seconds).to eq((35.minutes + 46.seconds).total_seconds.to_i32) + expect(video_48.views).to eq(68_704) + + expect(video_48.badges.live_now?).to be_false + expect(video_48.badges.premium?).to be_false + expect(video_48.premiere_timestamp).to be_nil + end +end diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr new file mode 100644 index 00000000..9fbb6d6f --- /dev/null +++ b/spec/invidious/helpers_spec.cr @@ -0,0 +1,56 @@ +require "../spec_helper" + +CONFIG = Config.from_yaml(File.open("config/config.example.yml")) + +Spectator.describe "Helper" do + describe "#produce_channel_search_continuation" do + it "correctly produces token for searching a specific channel" do + expect(produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "", 100)).to eq("4qmFsgJqEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWnpaV0Z5WTJnd0FUZ0JZQUY2QkVkS2IxaTRBUUE9WgCaAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D") + + expect(produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "По ожиशुपतिरपि子而時ஸ்றீனி", 0)).to eq("4qmFsgKoARIYVUNYdXFTQmxIQUU2WHcteWVKQTBUdW53GiBFZ1p6WldGeVkyZ3dBVGdCWUFGNkJFZEJRVDI0QVFBPVo-0J_QviDQvtC20LjgpLbgpYHgpKrgpKTgpL_gpLDgpKrgpL_lrZDogIzmmYLgrrjgr43grrHgr4Dgrqngrr-aAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D") + end + end + + describe "#produce_channel_community_continuation" do + it "correctly produces a continuation token for a channel community" do + expect(produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4")).to eq("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D") + expect(produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd3cE9NQmVwWEdjclhsUHg2WjRBYUFCQ1FIZGgDKAA%3D")).to eq("4qmFsgJmEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTNE") + + expect(produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqAyQaIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKAA%3D")).to eq("4qmFsgJmEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaSkVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTNE") + expect(produce_channel_community_continuation("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "Egljb21tdW5pdHm4AQCqA-cOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIBIaVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1FIZGgDKGM%3D")).to eq("4qmFsgKXFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvoTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUzRA%3D%3D") + end + end + + describe "#extract_channel_community_cursor" do + it "correctly extracts a community cursor from a given continuation" do + expect(extract_channel_community_cursor("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D")).to eq("Egljb21tdW5pdHk=") + expect(extract_channel_community_cursor("4qmFsgJoEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2QzY0U5TlFtVndXRWRqY2xoc1VIZzJXalJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D")).to eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVnd3BPTUJlcFhHY3JYbFB4Nlo0QWFBQkNRKAA=") + + expect(extract_channel_community_cursor("4qmFsgJoEhhVQy1sSEpaUjNHcXhtMjRfVmRfQUo1WXcaTEVnbGpiMjF0ZFc1cGRIbTRBUUNxQXlRYUlCSWFWV2Q1UlRJMk5XMXJVa2syY0U5dVMyMW5iRFJCWVVGQ1ExRklaR2dES0FBJTI1M0Q%3D")).to eq("Egljb21tdW5pdHm4AQCqAyQaIEhkaAMSGlVneUUyNjVta1JJNnBPbkttZ2w0QWFBQkNRKAA=") + expect(extract_channel_community_cursor("4qmFsgKZFBIYVUMtbEhKWlIzR3F4bTI0X1ZkX0FKNVl3GvwTRWdsamIyMXRkVzVwZEhtNEFRQ3FBLWNPQ3NBT1VWVlNWRk5zT1hCTldFWXhZbFZhYmxGWGFHRmlXRkpOVFc1V00xWkhTWGRQVlU1RVdUTm9lRkpXV2xWalJXUkdWVEJPYTFwclRrdGpWVW95V2pCWmVtTkVaSFJQVjJjd1YxaFdiVkp0YUZWUFdGSndWakphVVU0eFRYbE5SV1JhWWxad1NWRlVhM2RsYWs1cFRVVjBkbGw2UWtSVk1scHNXSHBvVkZkVWJIRlNSMG8xWWtSa00xa3lkRWhNVlRWd1dERkNkRmRYT1VoalIwWjZaRU14YmxkVmNFaFVNamt6VW0xc2FHUlhTa1ZpVm1SNlpGaHdkMVF4VG5wT01FNTRUVzVLVWxveFFsQmtNRTVRVTFWV1ZXTXliSGxOYkZadlVWVjBObFZJWkZWaE1WVjVVek5XVW1KSFJsZGtSbXN6VTFkS2QxcFZVbGxWTWtaRlZHMWFWMVpzUm5WVU1HaHNaRmQwVDAxc1ZuZFRiR2QzVGtod2VWZERNVkJUUlVwaFYyNUdOazVZYjNkWU1XUkNWVEZuTWxsdE9EQlBSbWhJVjIwNVdsUXdOVzFZTWpWMVVsVktUV051Y0hOTlNHUjVZMWhLYUZsdFVrVmtibEpZWkcxS2MxRlZhSEZVVjNCd1RrYzVSMXBVUWtWbGJHdzJaSHBTTTJGSVNsQlRTRkpvWWpKR2JWTXdOVEJpVjFweFYycENTVk5XV25aVGFscFJUMFJvY2xWR1ZtaGlhMXA1VkZoc2FXRlRNVkJqUkVaWlYxZFNURmRFUmtaalNIQjBaVWhzZVdGdFJYZE5SMUptVGtoT1dtRkVWbFpUYlZaMVpVVmtSVTFYUmxGaGJVNHhWRVJhYms0d2REVlNTR3hIVTJ4c1QxVkVRbEpYUjFaTFRVaEdNMVV3V2tKVFNFNW9Xa1JXUTJOWFpITmFNRnBxWVcxU1QxWXhaRmxoTURWT1ZVWlNTRlpXVmt0UmVrWlNZVmhvZGxVeFRtMVNWMUV3VFVkc2RXTkVXWGxQVjFZd1VqTmtjR0ZWY0VWVE0wNDBZVVphZG1SWGJISmhibGt5WkZkRmVsTkhXWHBVVjNoTVlVUkNhMkpJUlRGU2JsSjRXbXM0TlUxWGJHdE9NMHBIWWpCR2VVNHhaRkpOTVU1cVlrWkNkMDVyWkU5alYxSnFUMWhHUkdJeU5VNVhhazVUVWxoa2VtRnNVWFJPYkd0NFVXeGtVRTE2WkdGYVJUbHhWR3RhWmxJd2VFaFJiWFJOV1hwQ1dFOUdVak5PTUhCc1lWaHdTMlJ0U2xaa01teEdUVmhDYlZOSVdrZGtWVEZKWTBNeGJGZFlTa1ZaTTBWMFpGUk9XV1J0VmxGbFYzaGhZbFZLTW1WcmVHWlVNR3hPVTJ4YVNsUkZUbEJaTVZwRVVqRndkMVJIWkZoWk1taElZVlZLYWt4VVNtRmFiWGQwVTFSTmVGSkVXa2hsU0doWVRraE9NVTFHWkdoT01qRkNWbFZuTkdORlRsSlhTR3gyV1c1U2NXTlVhSFpYV0d4S1QxZDBUVlJYYzNsUk1XYzBVbTV3VTJKRVZqQmxSR3hwVFc1dk1WUllhRXRrZWxFeFkyMDFTMUpIU21aa2FtaG1UbGhPV21SR1l6UmphazVGVlZka01scFhWbk5SV0VKeVpXNU9kRnBIY0VsalZHaFdZekZzWmt4V2EzZFJWVGt5VFZaVk1tSXlNVE5WZVRGTFZFVXhlRkl3VWxkUmJtYzBWRWRzVGxwR1ZrdGpWbXh6VGtaR2ExVXdhekZhYkUwd1pVaHNSazVXWjNsV1IwWmFZekphZGxZeWFIUlBSRnB6VGpOQ1QxZEhSbkJpTUhoVVZEQmtNbVJ1WkZWT01scHRWbTA1ZFdJd1JURlpWa1p1WWxkS05tSXdNVU5hTW5nMlZHa3hTazU2YkhoWFYzQkpWR3Q0UkZZd1ZsbFVNMDVwVFZjd2VtUkhjM2xVVldONlRWVktjVlJIZEVsTlZXZzFZbXRLUW1WcmJGTlZNbmN6WkVWS1VsSkdPVWxOVldSeVpFUnNiRkpyYUhWWWVrSlhaVWhHYkUxclRUQmxWRTQwWVZVMVQxcEZjR3BWTWtwRlpGTXhXVmRJVGpOVE1uaFdWakp3WVZnelZYUlhiR2N3Wkc1T1NFMXFVWHBZTWxKSFRWaFNWMWt4V2taUk1sWndVMjVPZFZsWFRrZFZlazV3VlVkNGIyRlViRFZTUlZwNFlWaHNiRlJxYkcxYVJXeFlWRlpDTVZGV2JHOU9NRWwzVFc1S1YxSlVWalJrUkVKTFpHNW9ibUpHWkhoU1ZsWTFaV3BqTUZJeWVHbFplbVJJVm1reGVGcEVTbWxhTW5oRlpHeGtjVlJ1U2paTlZFWldVa1JXYW1WSVFsRmtSazVEVm10U1UyUklUbFJhU0doV1pHMDVXRTlWVWtOaFdFWXdUbTFrU0ZSdGIzUk5WMXBOWWxoU2VWSldUbkpoUldoSVZEQjBTVTB5Vmt4VWJGWjJWMVZHTmxKVE1ESmlhbEpaWWtSS2RGRlVWbkpoUlZKNFdtcGplRnB0Y0VSTlIwMDFVbXBrTTJRd05XMVZSWGQ1WVVaQ1psRXdXakZTYkVVelkwZG9SazVJU2taWk1XeFRUV3M1ZDJSWFJuaGlSekZyWWpCVk1tSXhXa0phUnpreVUyeG5lRlpXT1hOaU1EVldXa1V4UkZKNlFqQmpXR2hwVmpCVk1sZFlZM3BUVXpGNFVWY3hhMVJ1WkVKUlZHUnZXVlpGTkdOc1NUQmFWVWwwVW14YWNWZEVUbkpYYXpWTFkyMWFSazlIVm5kUmJXeHFVakIwYmxSRlpFWlZSM042WXpKT2Nsa3dTVE5sVmxaWlZFZGtjRTFZUWtkaU1IQXlaVlUxYUdSVlpGZFZibEpRWVZoYVFsWnRkSFpTYTBVelRGVTFTazFYYUZKUk1VcE1WMmt4U1dKNldreGpXRWt4Wkd0U1RXSnNPVmRVYTBaRlZtcEtabU13VWxGV1YzZ3dVVEowVEZSc2JESmFNMmd4WkZWT1NWa3piRVZPUlVwUlpWVXhNVlJFUW5wT01Wb3dXREkxTVdSclZtbFVNVTU0VGtSa1VrNXJWalZpTUVwUlRVWkdObVI2UmxKU1IyUnhZMVUxZVZnd05UQmpNRGt4V20xNFIyTlVWakJsUmtKR1QxZEdWbUZYZUZKVE1FWllZbGR3UWxWVmJITk9WbWd3WkVSWmRHRkZSbFZpTVd4bVVqRldjMUV5Y0cxV1ZrSlFWMGhrY0ZWUlBUMGFJQklhVldkNVJUSTJOVzFyVWtrMmNFOXVTMjFuYkRSQllVRkNRMUZJWkdnREtHTSUyNTNE")).to eq("Egljb21tdW5pdHm4AQCqA-kOCsAOUVVSVFNsOXBNWEYxYlVablFXaGFiWFJNTW5WM1ZHSXdPVU5EWTNoeFJWWlVjRWRGVTBOa1prTktjVUoyWjBZemNEZHRPV2cwV1hWbVJtaFVPWFJwVjJaUU4xTXlNRWRaYlZwSVFUa3dlak5pTUV0dll6QkRVMlpsWHpoVFdUbHFSR0o1YkRkM1kydEhMVTVwWDFCdFdXOUhjR0Z6ZEMxbldVcEhUMjkzUm1saGRXSkViVmR6ZFhwd1QxTnpOME54TW5KUloxQlBkME5QU1VWVWMybHlNbFZvUVV0NlVIZFVhMVV5UzNWUmJHRldkRmszU1dKd1pVUllVMkZFVG1aV1ZsRnVUMGhsZFd0T01sVndTbGd3TkhweVdDMVBTRUphV25GNk5Yb3dYMWRCVTFnMlltODBPRmhIV205WlQwNW1YMjV1UlVKTWNucHNNSGR5Y1hKaFltUkVkblJYZG1Kc1FVaHFUV3BwTkc5R1pUQkVlbGw2ZHpSM2FISlBTSFJoYjJGbVMwNTBiV1pxV2pCSVNWWnZTalpRT0RoclVGVmhia1p5VFhsaWFTMVBjREZZV1dSTFdERkZjSHB0ZUhseWFtRXdNR1JmTkhOWmFEVlZTbVZ1ZUVkRU1XRlFhbU4xVERabk4wdDVSSGxHU2xsT1VEQlJXR1ZLTUhGM1UwWkJTSE5oWkRWQ2NXZHNaMFpqYW1ST1YxZFlhMDVOVUZSSFZWVktRekZSYVhodlUxTm1SV1EwTUdsdWNEWXlPV1YwUjNkcGFVcEVTM040YUZadmRXbHJhblkyZFdFelNHWXpUV3hMYURCa2JIRTFSblJ4Wms4NU1XbGtOM0pHYjBGeU4xZFJNMU5qYkZCd05rZE9jV1JqT1hGRGIyNU5Xak5TUlhkemFsUXRObGt4UWxkUE16ZGFaRTlxVGtaZlIweEhRbXRNWXpCWE9GUjNOMHBsYVhwS2RtSlZkMmxGTVhCbVNIWkdkVTFJY0MxbFdYSkVZM0V0ZFROWWRtVlFlV3hhYlVKMmVreGZUMGxOU2xaSlRFTlBZMVpEUjFwd1RHZFhZMmhIYVVKakxUSmFabXd0U1RNeFJEWkhlSGhYTkhOMU1GZGhOMjFCVlVnNGNFTlJXSGx2WW5ScWNUaHZXWGxKT1d0TVRXc3lRMWc0Um5wU2JEVjBlRGxpTW5vMVRYaEtkelExY201S1JHSmZkamhmTlhOWmRGYzRjak5FVVdkMlpXVnNRWEJyZW5OdFpHcEljVGhWYzFsZkxWa3dRVTkyTVZVMmIyMTNVeTFLVEUxeFIwUldRbmc0VEdsTlpGVktjVmxzTkZGa1UwazFabE0wZUhsRk5WZ3lWR0ZaYzJadlYyaHRPRFpzTjNCT1dHRnBiMHhUVDBkMmRuZFVOMlptVm05dWIwRTFZVkZuYldKNmIwMUNaMng2VGkxSk56bHhXV3BJVGt4RFYwVllUM05pTVcwemRHc3lUVWN6TVVKcVRHdElNVWg1YmtKQmVrbFNVMnczZEVKUlJGOUlNVWRyZERsbFJraHVYekJXZUhGbE1rTTBlVE40YVU1T1pFcGpVMkpFZFMxWVdITjNTMnhWVjJwYVgzVXRXbGcwZG5OSE1qUXpYMlJHTVhSV1kxWkZRMlZwU25OdVlXTkdVek5wVUd4b2FUbDVSRVp4YVhsbFRqbG1aRWxYVFZCMVFWbG9OMEl3TW5KV1JUVjRkREJLZG5obmJGZHhSVlY1ZWpjMFIyeGlZemRIVmkxeFpESmlaMnhFZGxkcVRuSjZNVEZWUkRWamVIQlFkRk5DVmtSU2RITlRaSGhWZG05WE9VUkNhWEYwTm1kSFRtb3RNV1pNYlhSeVJWTnJhRWhIVDB0SU0yVkxUbFZ2V1VGNlJTMDJialJZYkRKdFFUVnJhRVJ4WmpjeFptcERNR001UmpkM2QwNW1VRXd5YUZCZlEwWjFSbEUzY0doRk5ISkZZMWxTTWs5d2RXRnhiRzFrYjBVMmIxWkJaRzkyU2xneFZWOXNiMDVWWkUxRFJ6QjBjWGhpVjBVMldYY3pTUzF4UVcxa1RuZEJRVGRvWVZFNGNsSTBaVUl0UmxacVdETnJXazVLY21aRk9HVndRbWxqUjB0blRFZEZVR3N6YzJOclkwSTNlVlZZVEdkcE1YQkdiMHAyZVU1aGRVZFdVblJQYVhaQlZtdHZSa0UzTFU1Sk1XaFJRMUpMV2kxSWJ6WkxjWEkxZGtSTWJsOVdUa0ZFVmpKZmMwUlFWV3gwUTJ0TFRsbDJaM2gxZFVOSVkzbEVORUpRZVUxMVREQnpOMVowWDI1MWRrVmlUMU54TkRkUk5rVjViMEpRTUZGNmR6RlJSR2RxY1U1eVgwNTBjMDkxWm14R2NUVjBlRkJGT1dGVmFXeFJTMEZYYldwQlVVbHNOVmgwZERZdGFFRlViMWxmUjFWc1EycG1WVkJQV0hkcFVRPT0aIhIcVWd5RTI2NW1rUkk2cE9uS21nbDRBYUFCQ1E9PUhkaAMoYw==") + end + end + + describe "#sign_token" do + it "correctly signs a given hash" do + token = { + "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "expires" => 1554680038, + "scopes" => [ + ":notifications", + ":subscriptions/*", + "GET:tokens*", + ], + "signature" => "f__2hS20th8pALF305PJFK-D2aVtvefNnQheILHD2vU=", + } + expect(sign_token("SECRET_KEY", token)).to eq(token["signature"]) + + token = { + "session" => "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "scopes" => [":notifications", "POST:subscriptions/*"], + "signature" => "fNvXoT0MRAL9eE6lTE33CEg8HitYJDOL9a22rSN2Ihg=", + } + expect(sign_token("SECRET_KEY", token)).to eq(token["signature"]) + end + end +end diff --git a/spec/invidious/search/iv_filters_spec.cr b/spec/invidious/search/iv_filters_spec.cr new file mode 100644 index 00000000..3cefafa1 --- /dev/null +++ b/spec/invidious/search/iv_filters_spec.cr @@ -0,0 +1,370 @@ +require "../../../src/invidious/search/filters" + +require "http/params" +require "spectator" + +Spectator.configure do |config| + config.fail_blank + config.randomize +end + +FEATURES_TEXT = { + Invidious::Search::Filters::Features::Live => "live", + Invidious::Search::Filters::Features::FourK => "4k", + Invidious::Search::Filters::Features::HD => "hd", + Invidious::Search::Filters::Features::Subtitles => "subtitles", + Invidious::Search::Filters::Features::CCommons => "commons", + Invidious::Search::Filters::Features::ThreeSixty => "360", + Invidious::Search::Filters::Features::VR180 => "vr180", + Invidious::Search::Filters::Features::ThreeD => "3d", + Invidious::Search::Filters::Features::HDR => "hdr", + Invidious::Search::Filters::Features::Location => "location", + Invidious::Search::Filters::Features::Purchased => "purchased", +} + +Spectator.describe Invidious::Search::Filters do + # ------------------- + # Decode (legacy) + # ------------------- + + describe "#from_legacy_filters" do + it "Decodes channel: filter" do + query = "test channel:UC123456 request" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new) + expect(chan).to eq("UC123456") + expect(qury).to eq("test request") + expect(subs).to be_false + end + + it "Decodes user: filter" do + query = "user:LinusTechTips broke something (again)" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new) + expect(chan).to eq("LinusTechTips") + expect(qury).to eq("broke something (again)") + expect(subs).to be_false + end + + it "Decodes type: filter" do + Invidious::Search::Filters::Type.each do |value| + query = "Eiffel 65 - Blue [1 Hour] type:#{value}" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(type: value)) + expect(chan).to eq("") + expect(qury).to eq("Eiffel 65 - Blue [1 Hour]") + expect(subs).to be_false + end + end + + it "Decodes content_type: filter" do + Invidious::Search::Filters::Type.each do |value| + query = "I like to watch content_type:#{value}" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(type: value)) + expect(chan).to eq("") + expect(qury).to eq("I like to watch") + expect(subs).to be_false + end + end + + it "Decodes date: filter" do + Invidious::Search::Filters::Date.each do |value| + query = "This date:#{value} is old!" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(date: value)) + expect(chan).to eq("") + expect(qury).to eq("This is old!") + expect(subs).to be_false + end + end + + it "Decodes duration: filter" do + Invidious::Search::Filters::Duration.each do |value| + query = "This duration:#{value} is old!" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(duration: value)) + expect(chan).to eq("") + expect(qury).to eq("This is old!") + expect(subs).to be_false + end + end + + it "Decodes feature: filter" do + Invidious::Search::Filters::Features.each do |value| + string = FEATURES_TEXT[value] + query = "I like my precious feature:#{string} ^^" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(features: value)) + expect(chan).to eq("") + expect(qury).to eq("I like my precious ^^") + expect(subs).to be_false + end + end + + it "Decodes features: filter" do + query = "This search has many features:vr180,cc,hdr :o" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + features = Invidious::Search::Filters::Features.flags(HDR, VR180, CCommons) + + expect(fltr).to eq(described_class.new(features: features)) + expect(chan).to eq("") + expect(qury).to eq("This search has many :o") + expect(subs).to be_false + end + + it "Decodes sort: filter" do + Invidious::Search::Filters::Sort.each do |value| + query = "Computer? sort:#{value} my files!" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(sort: value)) + expect(chan).to eq("") + expect(qury).to eq("Computer? my files!") + expect(subs).to be_false + end + end + + it "Decodes subscriptions: filter" do + query = "enable subscriptions:true" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new) + expect(chan).to eq("") + expect(qury).to eq("enable") + expect(subs).to be_true + end + + it "Ignores junk data" do + query = "duration:I sort:like type:cleaning features:stuff date:up!" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new) + expect(chan).to eq("") + expect(qury).to eq("") + expect(subs).to be_false + end + + it "Keeps unknown keys" do + query = "to:be or:not to:be" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new) + expect(chan).to eq("") + expect(qury).to eq("to:be or:not to:be") + expect(subs).to be_false + end + end + + # ------------------- + # Decode (URL) + # ------------------- + + describe "#from_iv_params" do + it "Decodes type= filter" do + Invidious::Search::Filters::Type.each do |value| + params = HTTP::Params.parse("type=#{value}") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(type: value)) + end + end + + it "Decodes date= filter" do + Invidious::Search::Filters::Date.each do |value| + params = HTTP::Params.parse("date=#{value}") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(date: value)) + end + end + + it "Decodes duration= filter" do + Invidious::Search::Filters::Duration.each do |value| + params = HTTP::Params.parse("duration=#{value}") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(duration: value)) + end + end + + it "Decodes features= filter (single)" do + Invidious::Search::Filters::Features.each do |value| + string = described_class.format_features(value) + params = HTTP::Params.parse("features=#{string}") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(features: value)) + end + end + + it "Decodes features= filter (multiple - comma separated)" do + features = Invidious::Search::Filters::Features.flags(HDR, VR180, CCommons) + params = HTTP::Params.parse("features=vr180%2Ccc%2Chdr") # %2C is a comma + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(features: features)) + end + + it "Decodes features= filter (multiple - URL parameters)" do + features = Invidious::Search::Filters::Features.flags(ThreeSixty, HD, FourK) + params = HTTP::Params.parse("features=4k&features=360&features=hd") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(features: features)) + end + + it "Decodes sort= filter" do + Invidious::Search::Filters::Sort.each do |value| + params = HTTP::Params.parse("sort=#{value}") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(sort: value)) + end + end + + it "Ignores junk data" do + params = HTTP::Params.parse("foo=bar&sort=views&answer=42&type=channel") + + expect(described_class.from_iv_params(params)).to eq( + described_class.new( + sort: Invidious::Search::Filters::Sort::Views, + type: Invidious::Search::Filters::Type::Channel + ) + ) + end + end + + # ------------------- + # Encode (URL) + # ------------------- + + describe "#to_iv_params" do + it "Encodes date filter" do + Invidious::Search::Filters::Date.each do |value| + filters = described_class.new(date: value) + params = filters.to_iv_params + + if value.none? + expect("#{params}").to eq("") + else + expect("#{params}").to eq("date=#{value.to_s.underscore}") + end + end + end + + it "Encodes type filter" do + Invidious::Search::Filters::Type.each do |value| + filters = described_class.new(type: value) + params = filters.to_iv_params + + if value.all? + expect("#{params}").to eq("") + else + expect("#{params}").to eq("type=#{value.to_s.underscore}") + end + end + end + + it "Encodes duration filter" do + Invidious::Search::Filters::Duration.each do |value| + filters = described_class.new(duration: value) + params = filters.to_iv_params + + if value.none? + expect("#{params}").to eq("") + else + expect("#{params}").to eq("duration=#{value.to_s.underscore}") + end + end + end + + it "Encodes features filter (single)" do + Invidious::Search::Filters::Features.each do |value| + filters = described_class.new(features: value) + + expect("#{filters.to_iv_params}") + .to eq("features=" + FEATURES_TEXT[value]) + end + end + + it "Encodes features filter (multiple)" do + features = Invidious::Search::Filters::Features.flags(Subtitles, Live, ThreeSixty) + filters = described_class.new(features: features) + + expect("#{filters.to_iv_params}") + .to eq("features=live%2Csubtitles%2C360") # %2C is a comma + end + + it "Encodes sort filter" do + Invidious::Search::Filters::Sort.each do |value| + filters = described_class.new(sort: value) + params = filters.to_iv_params + + if value.relevance? + expect("#{params}").to eq("") + else + expect("#{params}").to eq("sort=#{value.to_s.underscore}") + end + end + end + + it "Encodes multiple filters" do + filters = described_class.new( + date: Invidious::Search::Filters::Date::Today, + duration: Invidious::Search::Filters::Duration::Medium, + features: Invidious::Search::Filters::Features.flags(Location, Purchased), + sort: Invidious::Search::Filters::Sort::Relevance + ) + + params = filters.to_iv_params + + # Check the `date` param + expect(params).to have_key("date") + expect(params.fetch_all("date")).to contain_exactly("today") + + # Check the `type` param + expect(params).to_not have_key("type") + expect(params["type"]?).to be_nil + + # Check the `duration` param + expect(params).to have_key("duration") + expect(params.fetch_all("duration")).to contain_exactly("medium") + + # Check the `features` param + expect(params).to have_key("features") + expect(params.fetch_all("features")).to contain_exactly("location,purchased") + + # Check the `sort` param + expect(params).to_not have_key("sort") + expect(params["sort"]?).to be_nil + + # Check if there aren't other parameters + params.delete("date") + params.delete("duration") + params.delete("features") + + expect(params).to be_empty + end + end +end diff --git a/spec/invidious/search/query_spec.cr b/spec/invidious/search/query_spec.cr new file mode 100644 index 00000000..063b69f1 --- /dev/null +++ b/spec/invidious/search/query_spec.cr @@ -0,0 +1,242 @@ +require "../../../src/invidious/search/filters" +require "../../../src/invidious/search/query" + +require "http/params" +require "spectator" + +Spectator.configure do |config| + config.fail_blank + config.randomize +end + +Spectator.describe Invidious::Search::Query do + describe Type::Regular do + # ------------------- + # Query parsing + # ------------------- + + it "parses query with URL prameters (q)" do + query = described_class.new( + HTTP::Params.parse("q=What+is+Love+10+hour&type=video&duration=long"), + Invidious::Search::Query::Type::Regular, nil + ) + + expect(query.type).to eq(Invidious::Search::Query::Type::Regular) + expect(query.channel).to be_empty + expect(query.text).to eq("What is Love 10 hour") + + expect(query.filters).to eq( + Invidious::Search::Filters.new( + type: Invidious::Search::Filters::Type::Video, + duration: Invidious::Search::Filters::Duration::Long + ) + ) + end + + it "parses query with URL prameters (search_query)" do + query = described_class.new( + HTTP::Params.parse("search_query=What+is+Love+10+hour&type=video&duration=long"), + Invidious::Search::Query::Type::Regular, nil + ) + + expect(query.type).to eq(Invidious::Search::Query::Type::Regular) + expect(query.channel).to be_empty + expect(query.text).to eq("What is Love 10 hour") + + expect(query.filters).to eq( + Invidious::Search::Filters.new( + type: Invidious::Search::Filters::Type::Video, + duration: Invidious::Search::Filters::Duration::Long + ) + ) + end + + it "parses query with legacy filters (q)" do + query = described_class.new( + HTTP::Params.parse("q=Nyan+cat+duration:long"), + Invidious::Search::Query::Type::Regular, nil + ) + + expect(query.type).to eq(Invidious::Search::Query::Type::Regular) + expect(query.channel).to be_empty + expect(query.text).to eq("Nyan cat") + + expect(query.filters).to eq( + Invidious::Search::Filters.new( + duration: Invidious::Search::Filters::Duration::Long + ) + ) + end + + it "parses query with legacy filters (search_query)" do + query = described_class.new( + HTTP::Params.parse("search_query=Nyan+cat+duration:long"), + Invidious::Search::Query::Type::Regular, nil + ) + + expect(query.type).to eq(Invidious::Search::Query::Type::Regular) + expect(query.channel).to be_empty + expect(query.text).to eq("Nyan cat") + + expect(query.filters).to eq( + Invidious::Search::Filters.new( + duration: Invidious::Search::Filters::Duration::Long + ) + ) + end + + it "parses query with both URL params and legacy filters" do + query = described_class.new( + HTTP::Params.parse("q=Vamos+a+la+playa+duration:long&type=Video&date=year"), + Invidious::Search::Query::Type::Regular, nil + ) + + expect(query.type).to eq(Invidious::Search::Query::Type::Regular) + expect(query.channel).to be_empty + expect(query.text).to eq("Vamos a la playa duration:long") + + expect(query.filters).to eq( + Invidious::Search::Filters.new( + type: Invidious::Search::Filters::Type::Video, + date: Invidious::Search::Filters::Date::Year + ) + ) + end + + # ------------------- + # Type switching + # ------------------- + + it "switches to channel search (URL param)" do + query = described_class.new( + HTTP::Params.parse("q=thunderbolt+4&channel=UC0vBXGSyV14uvJ4hECDOl0Q"), + Invidious::Search::Query::Type::Regular, nil + ) + + expect(query.type).to eq(Invidious::Search::Query::Type::Channel) + expect(query.channel).to eq("UC0vBXGSyV14uvJ4hECDOl0Q") + expect(query.text).to eq("thunderbolt 4") + expect(query.filters.default?).to be_true + end + + it "switches to channel search (legacy)" do + query = described_class.new( + HTTP::Params.parse("q=channel%3AUCRPdsCVuH53rcbTcEkuY4uQ+rdna3"), + Invidious::Search::Query::Type::Regular, nil + ) + + expect(query.type).to eq(Invidious::Search::Query::Type::Channel) + expect(query.channel).to eq("UCRPdsCVuH53rcbTcEkuY4uQ") + expect(query.text).to eq("rdna3") + expect(query.filters.default?).to be_true + end + + it "switches to subscriptions search" do + query = described_class.new( + HTTP::Params.parse("q=subscriptions:true+tunak+tunak+tun"), + Invidious::Search::Query::Type::Regular, nil + ) + + expect(query.type).to eq(Invidious::Search::Query::Type::Subscriptions) + expect(query.channel).to be_empty + expect(query.text).to eq("tunak tunak tun") + expect(query.filters.default?).to be_true + end + end + + describe Type::Channel do + it "ignores extra parameters" do + query = described_class.new( + HTTP::Params.parse("q=Take+on+me+channel%3AUC12345679&type=video&date=year"), + Invidious::Search::Query::Type::Channel, nil + ) + + expect(query.type).to eq(Invidious::Search::Query::Type::Channel) + expect(query.channel).to be_empty + expect(query.text).to eq("Take on me") + expect(query.filters.default?).to be_true + end + end + + describe Type::Subscriptions do + it "works" do + query = described_class.new( + HTTP::Params.parse("q=Harlem+shake&type=video&date=year"), + Invidious::Search::Query::Type::Subscriptions, nil + ) + + expect(query.type).to eq(Invidious::Search::Query::Type::Subscriptions) + expect(query.channel).to be_empty + expect(query.text).to eq("Harlem shake") + + expect(query.filters).to eq( + Invidious::Search::Filters.new( + type: Invidious::Search::Filters::Type::Video, + date: Invidious::Search::Filters::Date::Year + ) + ) + end + end + + describe Type::Playlist do + it "ignores extra parameters" do + query = described_class.new( + HTTP::Params.parse("q=Harlem+shake+type:video+date:year&channel=UC12345679"), + Invidious::Search::Query::Type::Playlist, nil + ) + + expect(query.type).to eq(Invidious::Search::Query::Type::Playlist) + expect(query.channel).to be_empty + expect(query.text).to eq("Harlem shake") + + expect(query.filters).to eq( + Invidious::Search::Filters.new( + type: Invidious::Search::Filters::Type::Video, + date: Invidious::Search::Filters::Date::Year + ) + ) + end + end + + describe "#to_http_params" do + it "formats regular search" do + query = described_class.new( + HTTP::Params.parse("q=The+Simpsons+hiding+in+bush&duration=short"), + Invidious::Search::Query::Type::Regular, nil + ) + + params = query.to_http_params + + expect(params).to have_key("duration") + expect(params["duration"]?).to eq("short") + + expect(params).to have_key("q") + expect(params["q"]?).to eq("The Simpsons hiding in bush") + + # Check if there aren't other parameters + params.delete("duration") + params.delete("q") + expect(params).to be_empty + end + + it "formats channel search" do + query = described_class.new( + HTTP::Params.parse("q=channel:UC2DjFE7Xf11URZqWBigcVOQ%20multimeter"), + Invidious::Search::Query::Type::Regular, nil + ) + + params = query.to_http_params + + expect(params).to have_key("channel") + expect(params["channel"]?).to eq("UC2DjFE7Xf11URZqWBigcVOQ") + + expect(params).to have_key("q") + expect(params["q"]?).to eq("multimeter") + + # Check if there aren't other parameters + params.delete("channel") + params.delete("q") + expect(params).to be_empty + end + end +end diff --git a/spec/invidious/search/yt_filters_spec.cr b/spec/invidious/search/yt_filters_spec.cr new file mode 100644 index 00000000..8abed5ce --- /dev/null +++ b/spec/invidious/search/yt_filters_spec.cr @@ -0,0 +1,143 @@ +require "../../../src/invidious/search/filters" + +require "http/params" +require "spectator" + +Spectator.configure do |config| + config.fail_blank + config.randomize +end + +# Encoded filter values are extracted from the search +# page of Youtube with any browser devtools HTML inspector. + +DATE_FILTERS = { + Invidious::Search::Filters::Date::Hour => "EgIIAfABAQ%3D%3D", + Invidious::Search::Filters::Date::Today => "EgIIAvABAQ%3D%3D", + Invidious::Search::Filters::Date::Week => "EgIIA_ABAQ%3D%3D", + Invidious::Search::Filters::Date::Month => "EgIIBPABAQ%3D%3D", + Invidious::Search::Filters::Date::Year => "EgIIBfABAQ%3D%3D", +} + +TYPE_FILTERS = { + Invidious::Search::Filters::Type::Video => "EgIQAfABAQ%3D%3D", + Invidious::Search::Filters::Type::Channel => "EgIQAvABAQ%3D%3D", + Invidious::Search::Filters::Type::Playlist => "EgIQA_ABAQ%3D%3D", + Invidious::Search::Filters::Type::Movie => "EgIQBPABAQ%3D%3D", +} + +DURATION_FILTERS = { + Invidious::Search::Filters::Duration::Short => "EgIYAfABAQ%3D%3D", + Invidious::Search::Filters::Duration::Medium => "EgIYA_ABAQ%3D%3D", + Invidious::Search::Filters::Duration::Long => "EgIYAvABAQ%3D%3D", +} + +FEATURE_FILTERS = { + Invidious::Search::Filters::Features::Live => "EgJAAfABAQ%3D%3D", + Invidious::Search::Filters::Features::FourK => "EgJwAfABAQ%3D%3D", + Invidious::Search::Filters::Features::HD => "EgIgAfABAQ%3D%3D", + Invidious::Search::Filters::Features::Subtitles => "EgIoAfABAQ%3D%3D", + Invidious::Search::Filters::Features::CCommons => "EgIwAfABAQ%3D%3D", + Invidious::Search::Filters::Features::ThreeSixty => "EgJ4AfABAQ%3D%3D", + Invidious::Search::Filters::Features::VR180 => "EgPQAQHwAQE%3D", + Invidious::Search::Filters::Features::ThreeD => "EgI4AfABAQ%3D%3D", + Invidious::Search::Filters::Features::HDR => "EgPIAQHwAQE%3D", + Invidious::Search::Filters::Features::Location => "EgO4AQHwAQE%3D", + Invidious::Search::Filters::Features::Purchased => "EgJIAfABAQ%3D%3D", +} + +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 + # ------------------- + # Encode YT params + # ------------------- + + describe "#to_yt_params" do + sample DATE_FILTERS do |value, result| + it "Encodes upload date filter '#{value}'" do + expect(described_class.new(date: value).to_yt_params).to eq(result) + end + end + + sample TYPE_FILTERS do |value, result| + it "Encodes content type filter '#{value}'" do + expect(described_class.new(type: value).to_yt_params).to eq(result) + end + end + + sample DURATION_FILTERS do |value, result| + it "Encodes duration filter '#{value}'" do + expect(described_class.new(duration: value).to_yt_params).to eq(result) + end + end + + sample FEATURE_FILTERS do |value, result| + it "Encodes feature filter '#{value}'" do + expect(described_class.new(features: value).to_yt_params).to eq(result) + end + end + + sample SORT_FILTERS do |value, result| + it "Encodes sort filter '#{value}'" do + expect(described_class.new(sort: value).to_yt_params).to eq(result) + end + end + end + + # ------------------- + # Decode YT params + # ------------------- + + describe "#from_yt_params" do + sample DATE_FILTERS do |value, encoded| + it "Decodes upload date filter '#{value}'" do + params = HTTP::Params.parse("sp=#{encoded}") + + expect(described_class.from_yt_params(params)) + .to eq(described_class.new(date: value)) + end + end + + sample TYPE_FILTERS do |value, encoded| + it "Decodes content type filter '#{value}'" do + params = HTTP::Params.parse("sp=#{encoded}") + + expect(described_class.from_yt_params(params)) + .to eq(described_class.new(type: value)) + end + end + + sample DURATION_FILTERS do |value, encoded| + it "Decodes duration filter '#{value}'" do + params = HTTP::Params.parse("sp=#{encoded}") + + expect(described_class.from_yt_params(params)) + .to eq(described_class.new(duration: value)) + end + end + + sample FEATURE_FILTERS do |value, encoded| + it "Decodes feature filter '#{value}'" do + params = HTTP::Params.parse("sp=#{encoded}") + + expect(described_class.from_yt_params(params)) + .to eq(described_class.new(features: value)) + end + end + + sample SORT_FILTERS do |value, encoded| + it "Decodes sort filter '#{value}'" do + params = HTTP::Params.parse("sp=#{encoded}") + + expect(described_class.from_yt_params(params)) + .to eq(described_class.new(sort: value)) + end + end + end +end diff --git a/spec/invidious/user/imports_spec.cr b/spec/invidious/user/imports_spec.cr new file mode 100644 index 00000000..762ce0d8 --- /dev/null +++ b/spec/invidious/user/imports_spec.cr @@ -0,0 +1,51 @@ +require "spectator" +require "../../../src/invidious/user/imports" + +Spectator.configure do |config| + config.fail_blank + config.randomize +end + +def csv_sample + return <<-CSV + Kanal-ID,Kanal-URL,Kanaltitel + UC0hHW5Y08ggq-9kbrGgWj0A,http://www.youtube.com/channel/UC0hHW5Y08ggq-9kbrGgWj0A,Matias Marolla + UC0vBXGSyV14uvJ4hECDOl0Q,http://www.youtube.com/channel/UC0vBXGSyV14uvJ4hECDOl0Q,Techquickie + UC1sELGmy5jp5fQUugmuYlXQ,http://www.youtube.com/channel/UC1sELGmy5jp5fQUugmuYlXQ,Minecraft + UC9kFnwdCRrX7oTjqKd6-tiQ,http://www.youtube.com/channel/UC9kFnwdCRrX7oTjqKd6-tiQ,LUMOX - Topic + UCBa659QWEk1AI4Tg--mrJ2A,http://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A,Tom Scott + UCGu6_XQ64rXPR6nuitMQE_A,http://www.youtube.com/channel/UCGu6_XQ64rXPR6nuitMQE_A,Callcenter Fun + UCGwu0nbY2wSkW8N-cghnLpA,http://www.youtube.com/channel/UCGwu0nbY2wSkW8N-cghnLpA,Jaiden Animations + UCQ0OvZ54pCFZwsKxbltg_tg,http://www.youtube.com/channel/UCQ0OvZ54pCFZwsKxbltg_tg,Methos + UCRE6itj4Jte4manQEu3Y7OA,http://www.youtube.com/channel/UCRE6itj4Jte4manQEu3Y7OA,Chipflake + UCRLc6zsv_d0OEBO8OOkz-DA,http://www.youtube.com/channel/UCRLc6zsv_d0OEBO8OOkz-DA,Kegy + UCSl5Uxu2LyaoAoMMGp6oTJA,http://www.youtube.com/channel/UCSl5Uxu2LyaoAoMMGp6oTJA,Atomic Shrimp + UCXuqSBlHAE6Xw-yeJA0Tunw,http://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw,Linus Tech Tips + UCZ5XnGb-3t7jCkXdawN2tkA,http://www.youtube.com/channel/UCZ5XnGb-3t7jCkXdawN2tkA,Discord + CSV +end + +Spectator.describe Invidious::User::Import do + it "imports CSV" do + subscriptions = Invidious::User::Import.parse_subscription_export_csv(csv_sample) + + expect(subscriptions).to be_an(Array(String)) + expect(subscriptions.size).to eq(13) + + expect(subscriptions).to contain_exactly( + "UC0hHW5Y08ggq-9kbrGgWj0A", + "UC0vBXGSyV14uvJ4hECDOl0Q", + "UC1sELGmy5jp5fQUugmuYlXQ", + "UC9kFnwdCRrX7oTjqKd6-tiQ", + "UCBa659QWEk1AI4Tg--mrJ2A", + "UCGu6_XQ64rXPR6nuitMQE_A", + "UCGwu0nbY2wSkW8N-cghnLpA", + "UCQ0OvZ54pCFZwsKxbltg_tg", + "UCRE6itj4Jte4manQEu3Y7OA", + "UCRLc6zsv_d0OEBO8OOkz-DA", + "UCSl5Uxu2LyaoAoMMGp6oTJA", + "UCXuqSBlHAE6Xw-yeJA0Tunw", + "UCZ5XnGb-3t7jCkXdawN2tkA", + ).in_order + end +end diff --git a/spec/invidious/utils_spec.cr b/spec/invidious/utils_spec.cr new file mode 100644 index 00000000..7c2c2711 --- /dev/null +++ b/spec/invidious/utils_spec.cr @@ -0,0 +1,46 @@ +require "../spec_helper" + +Spectator.describe "Utils" do + describe "decode_date" do + it "parses short dates (en-US)" do + expect(decode_date("1s ago")).to be_close(Time.utc - 1.second, 500.milliseconds) + expect(decode_date("2min ago")).to be_close(Time.utc - 2.minutes, 500.milliseconds) + expect(decode_date("3h ago")).to be_close(Time.utc - 3.hours, 500.milliseconds) + expect(decode_date("4d ago")).to be_close(Time.utc - 4.days, 500.milliseconds) + expect(decode_date("5w ago")).to be_close(Time.utc - 5.weeks, 500.milliseconds) + expect(decode_date("6mo ago")).to be_close(Time.utc - 6.months, 500.milliseconds) + expect(decode_date("7y ago")).to be_close(Time.utc - 7.years, 500.milliseconds) + end + + it "parses short dates (en-GB)" do + expect(decode_date("55s ago")).to be_close(Time.utc - 55.seconds, 500.milliseconds) + expect(decode_date("44min ago")).to be_close(Time.utc - 44.minutes, 500.milliseconds) + expect(decode_date("22hr ago")).to be_close(Time.utc - 22.hours, 500.milliseconds) + expect(decode_date("1day ago")).to be_close(Time.utc - 1.day, 500.milliseconds) + expect(decode_date("2days ago")).to be_close(Time.utc - 2.days, 500.milliseconds) + expect(decode_date("3wk ago")).to be_close(Time.utc - 3.weeks, 500.milliseconds) + expect(decode_date("11mo ago")).to be_close(Time.utc - 11.months, 500.milliseconds) + expect(decode_date("11yr ago")).to be_close(Time.utc - 11.years, 500.milliseconds) + end + + it "parses long forms (singular)" do + expect(decode_date("1 second ago")).to be_close(Time.utc - 1.second, 500.milliseconds) + expect(decode_date("1 minute ago")).to be_close(Time.utc - 1.minute, 500.milliseconds) + expect(decode_date("1 hour ago")).to be_close(Time.utc - 1.hour, 500.milliseconds) + expect(decode_date("1 day ago")).to be_close(Time.utc - 1.day, 500.milliseconds) + expect(decode_date("1 week ago")).to be_close(Time.utc - 1.week, 500.milliseconds) + expect(decode_date("1 month ago")).to be_close(Time.utc - 1.month, 500.milliseconds) + expect(decode_date("1 year ago")).to be_close(Time.utc - 1.year, 500.milliseconds) + end + + it "parses long forms (plural)" do + expect(decode_date("5 seconds ago")).to be_close(Time.utc - 5.seconds, 500.milliseconds) + expect(decode_date("17 minutes ago")).to be_close(Time.utc - 17.minutes, 500.milliseconds) + expect(decode_date("23 hours ago")).to be_close(Time.utc - 23.hours, 500.milliseconds) + expect(decode_date("3 days ago")).to be_close(Time.utc - 3.days, 500.milliseconds) + expect(decode_date("2 weeks ago")).to be_close(Time.utc - 2.weeks, 500.milliseconds) + expect(decode_date("9 months ago")).to be_close(Time.utc - 9.months, 500.milliseconds) + expect(decode_date("8 years ago")).to be_close(Time.utc - 8.years, 500.milliseconds) + end + end +end diff --git a/spec/invidious/videos/regular_videos_extract_spec.cr b/spec/invidious/videos/regular_videos_extract_spec.cr new file mode 100644 index 00000000..f96703f6 --- /dev/null +++ b/spec/invidious/videos/regular_videos_extract_spec.cr @@ -0,0 +1,168 @@ +require "../../parsers_helper.cr" + +Spectator.describe "parse_video_info" do + it "parses a regular video" do + # Enable mock + _player = load_mock("video/regular_mrbeast.player") + _next = load_mock("video/regular_mrbeast.next") + + raw_data = _player.merge!(_next) + info = parse_video_info("2isYuQZMbdU", raw_data) + + # Some basic verifications + expect(typeof(info)).to eq(Hash(String, JSON::Any)) + + expect(info["videoType"].as_s).to eq("Video") + + # Basic video infos + + expect(info["title"].as_s).to eq("I Gave My 100,000,000th Subscriber An Island") + expect(info["views"].as_i).to eq(220_226_287) + expect(info["likes"].as_i).to eq(6_870_691) + + # For some reason the video length from VideoDetails and the + # one from microformat differs by 1s... + expect(info["lengthSeconds"].as_i).to be_between(930_i64, 931_i64) + + expect(info["published"].as_s).to eq("2022-08-04T00:00:00Z") + + # Extra video infos + + expect(info["allowedRegions"].as_a).to_not be_empty + expect(info["allowedRegions"].as_a.size).to eq(249) + + expect(info["allowedRegions"].as_a).to contain( + "AD", "BA", "BB", "BW", "BY", "EG", "GG", "HN", "NP", "NR", "TR", + "TT", "TV", "TW", "TZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", + "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" + ) + + expect(info["keywords"].as_a).to be_empty + + expect(info["allowRatings"].as_bool).to be_true + expect(info["isFamilyFriendly"].as_bool).to be_true + expect(info["isListed"].as_bool).to be_true + expect(info["isUpcoming"].as_bool).to be_false + + # Related videos + + expect(info["relatedVideos"].as_a.size).to eq(20) + + expect(info["relatedVideos"][0]["id"]).to eq("krsBRQbOPQ4") + expect(info["relatedVideos"][0]["title"]).to eq("$1 vs $250,000,000 Private Island!") + expect(info["relatedVideos"][0]["author"]).to eq("MrBeast") + expect(info["relatedVideos"][0]["ucid"]).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA") + expect(info["relatedVideos"][0]["view_count"]).to eq("230617484") + expect(info["relatedVideos"][0]["short_view_count"]).to eq("230M") + expect(info["relatedVideos"][0]["author_verified"]).to eq("true") + + # Description + + description = "🚀Launch a store on Shopify, I’ll buy from 100 random stores that do ▸ " + + expect(info["description"].as_s).to start_with(description) + expect(info["shortDescription"].as_s).to start_with(description) + expect(info["descriptionHtml"].as_s).to start_with(description) + + # Video metadata + + expect(info["genre"].as_s).to eq("Entertainment") + expect(info["genreUcid"].as_s?).to be_nil + expect(info["license"].as_s).to be_empty + + # Author infos + + expect(info["author"].as_s).to eq("MrBeast") + expect(info["ucid"].as_s).to eq("UCX6OQ3DkcsbYNE6H8uQQuVA") + + expect(info["authorThumbnail"].as_s).to eq( + "https://yt3.ggpht.com/fxGKYucJAVme-Yz4fsdCroCFCrANWqw0ql4GYuvx8Uq4l_euNJHgE-w9MTkLQA805vWCi-kE0g=s48-c-k-c0x00ffffff-no-rj" + ) + + expect(info["authorVerified"].as_bool).to be_true + expect(info["subCountText"].as_s).to eq("320M") + end + + it "parses a regular video with no descrition/comments" do + # Enable mock + _player = load_mock("video/regular_no-description.player") + _next = load_mock("video/regular_no-description.next") + + raw_data = _player.merge!(_next) + info = parse_video_info("iuevw6218F0", raw_data) + + # Some basic verifications + expect(typeof(info)).to eq(Hash(String, JSON::Any)) + + expect(info["videoType"].as_s).to eq("Video") + + # Basic video infos + + expect(info["title"].as_s).to eq("Chris Rea - Auberge") + expect(info["views"].as_i).to eq(14_324_584) + expect(info["likes"].as_i).to eq(35_870) + expect(info["lengthSeconds"].as_i).to eq(283_i64) + expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z") + + # Extra video infos + + expect(info["allowedRegions"].as_a).to_not be_empty + expect(info["allowedRegions"].as_a.size).to eq(249) + + expect(info["allowedRegions"].as_a).to contain( + "AD", "BA", "BB", "BW", "BY", "EG", "GG", "HN", "NP", "NR", "TR", + "TT", "TV", "TW", "TZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", + "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" + ) + + expect(info["keywords"].as_a).to_not be_empty + expect(info["keywords"].as_a.size).to eq(4) + + expect(info["keywords"].as_a).to contain_exactly( + "Chris", + "Rea", + "Auberge", + "1991" + ).in_any_order + + expect(info["allowRatings"].as_bool).to be_true + expect(info["isFamilyFriendly"].as_bool).to be_true + expect(info["isListed"].as_bool).to be_true + expect(info["isUpcoming"].as_bool).to be_false + + # Related videos + + expect(info["relatedVideos"].as_a.size).to eq(20) + + expect(info["relatedVideos"][0]["id"]).to eq("gUUdQfnshJ4") + expect(info["relatedVideos"][0]["title"]).to eq("Chris Rea - The Road To Hell 1989 Full Version") + expect(info["relatedVideos"][0]["author"]).to eq("NEA ZIXNH") + expect(info["relatedVideos"][0]["ucid"]).to eq("UCYMEOGcvav3gCgImK2J07CQ") + expect(info["relatedVideos"][0]["view_count"]).to eq("53298661") + expect(info["relatedVideos"][0]["short_view_count"]).to eq("53M") + expect(info["relatedVideos"][0]["author_verified"]).to eq("false") + + # Description + + expect(info["description"].as_s).to eq(" ") + expect(info["shortDescription"].as_s).to be_empty + expect(info["descriptionHtml"].as_s).to eq("") + + # Video metadata + + expect(info["genre"].as_s).to eq("Music") + expect(info["genreUcid"].as_s?).to be_nil + expect(info["license"].as_s).to be_empty + + # Author infos + + expect(info["author"].as_s).to eq("ChrisReaVideos") + expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA") + + expect(info["authorThumbnail"].as_s).to eq( + "https://yt3.ggpht.com/ytc/AIdro_n71nsegpKfjeRKwn1JJmK5IVMh_7j5m_h3_1KnUUg=s48-c-k-c0x00ffffff-no-rj" + ) + expect(info["authorVerified"].as_bool).to be_false + expect(info["subCountText"].as_s).to eq("3.11K") + end +end diff --git a/spec/invidious/videos/scheduled_live_extract_spec.cr b/spec/invidious/videos/scheduled_live_extract_spec.cr new file mode 100644 index 00000000..c3a9b228 --- /dev/null +++ b/spec/invidious/videos/scheduled_live_extract_spec.cr @@ -0,0 +1,111 @@ +require "../../parsers_helper.cr" + +Spectator.describe "parse_video_info" do + it "parses scheduled livestreams data" do + # Enable mock + _player = load_mock("video/scheduled_live_PBD-Podcast.player") + _next = load_mock("video/scheduled_live_PBD-Podcast.next") + + raw_data = _player.merge!(_next) + info = parse_video_info("N-yVic7BbY0", raw_data) + + # Some basic verifications + expect(typeof(info)).to eq(Hash(String, JSON::Any)) + + expect(info["videoType"].as_s).to eq("Scheduled") + + # Basic video infos + + expect(info["title"].as_s).to eq("Home Team | PBD Podcast | Ep. 241") + expect(info["views"].as_i).to eq(6) + expect(info["likes"].as_i).to eq(7) + expect(info["lengthSeconds"].as_i).to eq(0_i64) + expect(info["published"].as_s).to eq("2023-02-28T14:00:00Z") # Unix 1677592800 + + # Extra video infos + + expect(info["allowedRegions"].as_a).to_not be_empty + expect(info["allowedRegions"].as_a.size).to eq(249) + + expect(info["allowedRegions"].as_a).to contain( + "AD", "AR", "BA", "BT", "CZ", "FO", "GL", "IO", "KE", "KH", "LS", + "LT", "MP", "NO", "PR", "RO", "SE", "SK", "SS", "SX", "SZ", "ZW" + ) + + expect(info["keywords"].as_a).to_not be_empty + expect(info["keywords"].as_a.size).to eq(25) + + expect(info["keywords"].as_a).to contain_exactly( + "Patrick Bet-David", + "Valeutainment", + "The BetDavid Podcast", + "The BetDavid Show", + "Betdavid", + "PBD", + "BetDavid show", + "Betdavid podcast", + "podcast betdavid", + "podcast patrick", + "patrick bet david podcast", + "Valuetainment podcast", + "Entrepreneurs", + "Entrepreneurship", + "Entrepreneur Motivation", + "Entrepreneur Advice", + "Startup Entrepreneurs", + "valuetainment", + "patrick bet david", + "PBD podcast", + "Betdavid show", + "Betdavid Podcast", + "Podcast Betdavid", + "Show Betdavid", + "PBDPodcast" + ).in_any_order + + expect(info["allowRatings"].as_bool).to be_true + expect(info["isFamilyFriendly"].as_bool).to be_true + expect(info["isListed"].as_bool).to be_true + expect(info["isUpcoming"].as_bool).to be_true + + # Related videos + + expect(info["relatedVideos"].as_a.size).to eq(20) + + expect(info["relatedVideos"][0]["id"]).to eq("j7jPzzjbVuk") + expect(info["relatedVideos"][0]["author"]).to eq("Democracy Now!") + expect(info["relatedVideos"][0]["ucid"]).to eq("UCzuqE7-t13O4NIDYJfakrhw") + expect(info["relatedVideos"][0]["view_count"]).to eq("7576") + expect(info["relatedVideos"][0]["short_view_count"]).to eq("7.5K") + expect(info["relatedVideos"][0]["author_verified"]).to eq("true") + + # Description + + description_start_text = "PBD Podcast Episode 241. The home team is ready and at it again with the latest news, interesting topics and trending conversations on topics that matter. Try our sponsor Aura for 14 days free - https://aura.com/pbd" + + expect(info["description"].as_s).to start_with(description_start_text) + expect(info["shortDescription"].as_s).to start_with(description_start_text) + + # TODO: Update mocks right before the start of PDB podcast, either on friday or saturday (time unknown) + # expect(info["descriptionHtml"].as_s).to start_with( + # "PBD Podcast Episode 241. The home team is ready and at it again with the latest news, interesting topics and trending conversations on topics that matter. Try our sponsor Aura for 14 days free - <a href=\"https://aura.com/pbd\">aura.com/pbd</a>" + # ) + + # Video metadata + + expect(info["genre"].as_s).to eq("Entertainment") + expect(info["genreUcid"].as_s?).to be_nil + expect(info["license"].as_s).to be_empty + + # Author infos + + expect(info["author"].as_s).to eq("PBD Podcast") + expect(info["ucid"].as_s).to eq("UCGX7nGXpz-CmO_Arg-cgJ7A") + + expect(info["authorThumbnail"].as_s).to eq( + "https://yt3.ggpht.com/61ArDiQshJrvSXcGLhpFfIO3hlMabe2fksitcf6oGob0Mdr5gztdkXxRljICUodL4iuTSrtxW4A=s48-c-k-c0x00ffffff-no-rj" + ) + expect(info["authorVerified"].as_bool).to be_false + expect(info["subCountText"].as_s).to eq("594K") + end +end diff --git a/spec/locales_spec.cr b/spec/locales_spec.cr deleted file mode 100644 index 6a083ee7..00000000 --- a/spec/locales_spec.cr +++ /dev/null @@ -1,29 +0,0 @@ -require "spec" -require "json" -require "../src/invidious/helpers/i18n.cr" - -describe "Locales" do - describe "#consistency" do - locales_list = LOCALES.keys.select! { |key| key != "en-US" } - - locales_list.each do |locale| - puts "\nChecking locale #{locale}" - failed = false - - # Use "en-US" as the reference - LOCALES["en-US"].each_key do |ref_key| - # Catch exception in order to give a hint on what caused - # the failure, and test one locale completely before failing - begin - LOCALES[locale].has_key?(ref_key).should be_true - rescue - failed = true - puts " Missing key in locale #{locale}: '#{ref_key}'" - end - end - - # Throw failed assertion exception in here - failed.should be_false - end - end -end diff --git a/spec/parsers_helper.cr b/spec/parsers_helper.cr new file mode 100644 index 00000000..6589acad --- /dev/null +++ b/spec/parsers_helper.cr @@ -0,0 +1,35 @@ +require "db" +require "json" +require "kemal" + +require "protodec/utils" + +require "spectator" + +require "../src/invidious/exceptions" +require "../src/invidious/helpers/macros" +require "../src/invidious/helpers/logger" +require "../src/invidious/helpers/utils" + +require "../src/invidious/videos" +require "../src/invidious/videos/*" +require "../src/invidious/comments/content" + +require "../src/invidious/helpers/serialized_yt_data" +require "../src/invidious/yt_backend/extractors" +require "../src/invidious/yt_backend/extractors_utils" + +OUTPUT = File.open(File::NULL, "w") +LOGGER = Invidious::LogHandler.new(OUTPUT, LogLevel::Off) + +def load_mock(file) : Hash(String, JSON::Any) + file = File.join(__DIR__, "..", "mocks", file + ".json") + content = File.read(file) + + return JSON.parse(content).as_h +end + +Spectator.configure do |config| + config.fail_blank + config.randomize +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 00000000..b3060acf --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,18 @@ +require "kemal" +require "openssl/hmac" +require "pg" +require "protodec/utils" +require "yaml" +require "../src/invidious/helpers/*" +require "../src/invidious/channels/*" +require "../src/invidious/videos/caption" +require "../src/invidious/videos" +require "../src/invidious/playlists" +require "../src/invidious/search/ctoken" +require "../src/invidious/trending" +require "spectator" + +Spectator.configure do |config| + config.fail_blank + config.randomize +end diff --git a/src/ext/kemal_content_for.cr b/src/ext/kemal_content_for.cr new file mode 100644 index 00000000..a4f3fd96 --- /dev/null +++ b/src/ext/kemal_content_for.cr @@ -0,0 +1,16 @@ +# Overrides for Kemal's `content_for` macro in order to keep using +# kilt as it was before Kemal v1.1.1 (Kemal PR #618). + +require "kemal" +require "kilt" + +macro content_for(key, file = __FILE__) + %proc = ->() { + __kilt_io__ = IO::Memory.new + {{ yield }} + __kilt_io__.to_s + } + + CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, %proc + nil +end diff --git a/src/invidious/helpers/static_file_handler.cr b/src/ext/kemal_static_file_handler.cr index be9d36ab..eb068aeb 100644 --- a/src/invidious/helpers/static_file_handler.cr +++ b/src/ext/kemal_static_file_handler.cr @@ -111,7 +111,7 @@ module Kemal if @fallthrough call_next(context) else - context.response.status_code = 405 + context.response.status = HTTP::Status::METHOD_NOT_ALLOWED context.response.headers.add("Allow", "GET, HEAD") end return @@ -124,7 +124,7 @@ module Kemal # File path cannot contains '\0' (NUL) because all filesystem I know # don't accept '\0' character as file name. if request_path.includes? '\0' - context.response.status_code = 400 + context.response.status = HTTP::Status::BAD_REQUEST return end @@ -143,13 +143,15 @@ module Kemal add_cache_headers(context.response.headers, last_modified) if cache_request?(context, last_modified) - context.response.status_code = 304 + context.response.status = HTTP::Status::NOT_MODIFIED return end send_file(context, file_path, file[:data], file[:filestat]) else - is_dir = Dir.exists? file_path + file_info = File.info?(file_path) + is_dir = file_info.try &.directory? || false + is_file = file_info.try &.file? || false if request_path != expanded_path redirect_to context, expanded_path @@ -157,35 +159,34 @@ module Kemal redirect_to context, expanded_path + '/' end - if Dir.exists?(file_path) + return call_next(context) if file_info.nil? + + if is_dir if config.is_a?(Hash) && config["dir_listing"] == true context.response.content_type = "text/html" directory_listing(context.response, request_path, file_path) else call_next(context) end - elsif File.exists?(file_path) - last_modified = modification_time(file_path) + elsif is_file + last_modified = file_info.modification_time add_cache_headers(context.response.headers, last_modified) if cache_request?(context, last_modified) - context.response.status_code = 304 + context.response.status = HTTP::Status::NOT_MODIFIED return end - if @cached_files.sum { |element| element[1][:data].bytesize } + (size = File.size(file_path)) < CACHE_LIMIT + if @cached_files.sum(&.[1][:data].bytesize) + (size = File.size(file_path)) < CACHE_LIMIT data = Bytes.new(size) - File.open(file_path) do |file| - file.read(data) - end - filestat = File.info(file_path) + File.open(file_path, &.read(data)) - @cached_files[file_path] = {data: data, filestat: filestat} - send_file(context, file_path, data, filestat) + @cached_files[file_path] = {data: data, filestat: file_info} + send_file(context, file_path, data, file_info) else send_file(context, file_path) end - else + else # Not a normal file (FIFO/device/socket) call_next(context) end end diff --git a/src/invidious.cr b/src/invidious.cr index 65b1091b..0be73555 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -16,31 +16,57 @@ require "digest/md5" require "file_utils" + +# Require kemal, kilt, then our own overrides require "kemal" +require "kilt" +require "./ext/kemal_content_for.cr" +require "./ext/kemal_static_file_handler.cr" + +require "http_proxy" +require "athena-negotiation" require "openssl/hmac" require "option_parser" -require "pg" require "sqlite3" require "xml" require "yaml" require "compress/zip" require "protodec/utils" + +require "./invidious/database/*" +require "./invidious/database/migrations/*" +require "./invidious/http_server/*" require "./invidious/helpers/*" +require "./invidious/yt_backend/*" +require "./invidious/frontend/*" +require "./invidious/videos/*" + +require "./invidious/jsonify/**" + require "./invidious/*" +require "./invidious/comments/*" +require "./invidious/channels/*" +require "./invidious/user/*" +require "./invidious/search/*" require "./invidious/routes/**" require "./invidious/jobs/**" +# Declare the base namespace for invidious +module Invidious +end + +# Simple alias to make code easier to read +alias IV = Invidious + CONFIG = Config.load -HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) +HMAC_KEY = CONFIG.hmac_key -PG_DB = DB.open CONFIG.database_url -ARCHIVE_URL = URI.parse("https://archive.org") -LOGIN_URL = URI.parse("https://accounts.google.com") -PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") -REDDIT_URL = URI.parse("https://www.reddit.com") -TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com") -YT_URL = URI.parse("https://www.youtube.com") -HOST_URL = make_host_url(Kemal.config) +PG_DB = DB.open CONFIG.database_url +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"} @@ -65,7 +91,11 @@ SOFTWARE = { "branch" => "#{CURRENT_BRANCH}", } -YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 2.0, use_quic: CONFIG.use_quic) +YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size) + +# Image request pool + +GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size) # CLI Kemal.config.extra_options do |parser| @@ -92,10 +122,17 @@ Kemal.config.extra_options do |parser| parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{CONFIG.log_level})") do |log_level| CONFIG.log_level = LogLevel.parse(log_level) end + parser.on("-k", "--colorize", "Colorize logs") do + CONFIG.colorize_logs = true + end parser.on("-v", "--version", "Print version") do puts SOFTWARE.to_pretty_json exit end + parser.on("--migrate", "Run any migrations (beta, use at your own risk!!") do + Invidious::Database::Migrator.new(PG_DB).migrate + exit + end end Kemal::CLI.new ARGV @@ -104,25 +141,34 @@ if CONFIG.output.upcase != "STDOUT" FileUtils.mkdir_p(File.dirname(CONFIG.output)) end OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mode: "a") -LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) +LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level, CONFIG.colorize_logs) # Check table integrity -if CONFIG.check_tables - check_enum(PG_DB, "privacy", PlaylistPrivacy) +Invidious::Database.check_integrity(CONFIG) + +{% if !flag?(:skip_videojs_download) %} + # Resolve player dependencies. This is done at compile time. + # + # Running the script by itself would show some colorful feedback while this doesn't. + # Perhaps we should just move the script to runtime in order to get that feedback? - check_table(PG_DB, "channels", InvidiousChannel) - check_table(PG_DB, "channel_videos", ChannelVideo) - check_table(PG_DB, "playlists", InvidiousPlaylist) - check_table(PG_DB, "playlist_videos", PlaylistVideo) - check_table(PG_DB, "nonces", Nonce) - check_table(PG_DB, "session_ids", SessionId) - check_table(PG_DB, "users", User) - check_table(PG_DB, "videos", Video) + {% puts "\nChecking player dependencies, this may take more than 20 minutes... If it is stuck, check your internet connection.\n" %} + {% if flag?(:minified_player_dependencies) %} + {% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %} + {% else %} + {% puts run("../scripts/fetch-player-dependencies.cr").stringify %} + {% end %} + {% puts "\nDone checking player dependencies, now compiling Invidious...\n" %} +{% end %} - if CONFIG.cache_annotations - check_table(PG_DB, "annotations", Annotation) +# Misc + +DECRYPT_FUNCTION = + if sig_helper_address = CONFIG.signature_server.presence + IV::DecryptFunction.new(sig_helper_address) + else + nil end -end # Start jobs @@ -134,11 +180,6 @@ if CONFIG.feed_threads > 0 Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB) end -DECRYPT_FUNCTION = DecryptFunction.new(CONFIG.decrypt_polling) -if CONFIG.decrypt_polling - Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new -end - if CONFIG.statistics_enabled Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE) end @@ -151,12 +192,12 @@ if CONFIG.popular_enabled Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) end -if CONFIG.captcha_key - Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new -end +CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32) +Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url) + +Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new -connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32) -Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(connection_channel, CONFIG.database_url) +Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new Invidious::Jobs.start_all @@ -164,3747 +205,28 @@ def popular_videos Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get end -before_all do |env| - preferences = begin - Preferences.from_json(env.request.cookies["PREFS"]?.try &.value || "{}") - rescue - Preferences.from_json("{}") - end - - env.set "preferences", preferences - env.response.headers["X-XSS-Protection"] = "1; mode=block" - env.response.headers["X-Content-Type-Options"] = "nosniff" - extra_media_csp = "" - if CONFIG.disabled?("local") || !preferences.local - extra_media_csp += " https://*.googlevideo.com:443" - extra_media_csp += " https://*.youtube.com:443" - end - # TODO: Remove style-src's 'unsafe-inline', requires to remove all inline styles (<style> [..] </style>, style=" [..] ") - env.response.headers["Content-Security-Policy"] = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; manifest-src 'self'; media-src 'self' blob:#{extra_media_csp}; child-src blob:" - env.response.headers["Referrer-Policy"] = "same-origin" - - if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts - env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" - end - - next if { - "/sb/", - "/vi/", - "/s_p/", - "/yts/", - "/ggpht/", - "/api/manifest/", - "/videoplayback", - "/latest_version", - }.any? { |r| env.request.resource.starts_with? r } - - if env.request.cookies.has_key? "SID" - sid = env.request.cookies["SID"].value - - if sid.starts_with? "v1:" - raise "Cannot use token as SID" - end - - # Invidious users only have SID - if !env.request.cookies.has_key? "SSID" - if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) - user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, PG_DB, 1.week) - - preferences = user.preferences - env.set "preferences", preferences - - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - end - else - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - begin - user, sid = get_user(sid, headers, PG_DB, false) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, PG_DB, 1.week) - - preferences = user.preferences - env.set "preferences", preferences - - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - rescue ex - end - end - 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" - locale = env.params.query["hl"]? || preferences.locale - - preferences.dark_mode = dark_mode - preferences.thin_mode = thin_mode - preferences.locale = locale - env.set "preferences", preferences - - current_page = env.request.path - if env.request.query - query = HTTP::Params.parse(env.request.query.not_nil!) - - if query["referer"]? - query["referer"] = get_referer(env, "/") - end - - current_page += "?#{query}" - end - - env.set "current_page", URI.encode_www_form(current_page) -end - -Invidious::Routing.get "/", Invidious::Routes::Misc, :home -Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy -Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses - -Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle -Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect -Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect -Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect -Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect -Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect - -Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect -Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show - -Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Playlists, :index -Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new -Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create -Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe -Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page -Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete -Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit -Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update -Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page -Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax -Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show -Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix - -Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch -Invidious::Routing.get "/results", Invidious::Routes::Search, :results -Invidious::Routing.get "/search", Invidious::Routes::Search, :search - -Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page -Invidious::Routing.post "/login", Invidious::Routes::Login, :login -Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout - -Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show -Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update -Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme - -# Users - -post "/watch_ajax" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env, "/feed/subscriptions") - - redirect = env.params.query["redirect"]? - redirect ||= "true" - redirect = redirect == "true" - - if !user - if redirect - next env.redirect referer - else - next error_json(403, "No such user") - end - end - - user = user.as(User) - sid = sid.as(String) - token = env.params.body["csrf_token"]? - - id = env.params.query["id"]? - if !id - env.response.status_code = 400 - next - end - - begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) - rescue ex - if redirect - next error_template(400, ex) - else - next error_json(400, ex) - end - end - - if env.params.query["action_mark_watched"]? - action = "action_mark_watched" - elsif env.params.query["action_mark_unwatched"]? - action = "action_mark_unwatched" - else - next env.redirect referer - end - - case action - when "action_mark_watched" - if !user.watched.includes? id - PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.email) - end - when "action_mark_unwatched" - PG_DB.exec("UPDATE users SET watched = array_remove(watched, $1) WHERE email = $2", id, user.email) - else - next error_json(400, "Unsupported action #{action}") - end - - if redirect - env.redirect referer - else - env.response.content_type = "application/json" - "{}" - end -end - -# /modify_notifications -# will "ding" all subscriptions. -# /modify_notifications?receive_all_updates=false&receive_no_updates=false -# will "unding" all subscriptions. -get "/modify_notifications" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env, "/") - - redirect = env.params.query["redirect"]? - redirect ||= "false" - redirect = redirect == "true" - - if !user - if redirect - next env.redirect referer - else - next error_json(403, "No such user") - end - end - - user = user.as(User) - - if !user.password - channel_req = {} of String => String - - channel_req["receive_all_updates"] = env.params.query["receive_all_updates"]? || "true" - channel_req["receive_no_updates"] = env.params.query["receive_no_updates"]? || "" - channel_req["receive_post_updates"] = env.params.query["receive_post_updates"]? || "true" - - channel_req.reject! { |k, v| v != "true" && v != "false" } - - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) - - cookies = HTTP::Cookies.from_headers(headers) - html.cookies.each do |cookie| - if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name - if cookies[cookie.name]? - cookies[cookie.name] = cookie - else - cookies << cookie - end - end - end - headers = cookies.add_request_headers(headers) - - if match = html.body.match(/'XSRF_TOKEN': "(?<session_token>[^"]+)"/) - session_token = match["session_token"] - else - next env.redirect referer - end - - headers["content-type"] = "application/x-www-form-urlencoded" - channel_req["session_token"] = session_token - - subs = XML.parse_html(html.body) - subs.xpath_nodes(%q(//a[@class="subscription-title yt-uix-sessionlink"]/@href)).each do |channel| - channel_id = channel.content.lstrip("/channel/").not_nil! - channel_req["channel_id"] = channel_id - - YT_POOL.client &.post("/subscription_ajax?action_update_subscription_preferences=1", headers, form: channel_req) - end - end - - if redirect - env.redirect referer - else - env.response.content_type = "application/json" - "{}" - end -end - -post "/subscription_ajax" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env, "/") - - redirect = env.params.query["redirect"]? - redirect ||= "true" - redirect = redirect == "true" - - if !user - if redirect - next env.redirect referer - else - next error_json(403, "No such user") - end - end - - user = user.as(User) - sid = sid.as(String) - token = env.params.body["csrf_token"]? - - begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) - rescue ex - if redirect - next error_template(400, ex) - else - next error_json(400, ex) - end - end - - if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1 - action = "action_create_subscription_to_channel" - elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1 - action = "action_remove_subscriptions" - else - next env.redirect referer - end - - channel_id = env.params.query["c"]? - channel_id ||= "" - - if !user.password - # Sync subscriptions with YouTube - subscribe_ajax(channel_id, action, env.request.headers) - end - email = user.email - - case action - when "action_create_subscription_to_channel" - if !user.subscriptions.includes? channel_id - get_channel(channel_id, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions, $1) WHERE email = $2", channel_id, email) - end - when "action_remove_subscriptions" - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", channel_id, email) - else - next error_json(400, "Unsupported action #{action}") - end - - if redirect - env.redirect referer - else - env.response.content_type = "application/json" - "{}" - end -end - -get "/subscription_manager" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - - if !user.password - # Refresh account - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - user, sid = get_user(sid, headers, PG_DB) - end - - action_takeout = env.params.query["action_takeout"]?.try &.to_i? - action_takeout ||= 0 - action_takeout = action_takeout == 1 - - format = env.params.query["format"]? - format ||= "rss" - - if user.subscriptions.empty? - values = "'{}'" - else - values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}" - end - - subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel) - subscriptions.sort_by! { |channel| channel.author.downcase } - - if action_takeout - if format == "json" - env.response.content_type = "application/json" - env.response.headers["content-disposition"] = "attachment" - playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) - - next JSON.build do |json| - json.object do - json.field "subscriptions", user.subscriptions - json.field "watch_history", user.watched - json.field "preferences", user.preferences - json.field "playlists" do - json.array do - playlists.each do |playlist| - json.object do - json.field "title", playlist.title - json.field "description", html_to_content(playlist.description_html) - json.field "privacy", playlist.privacy.to_s - json.field "videos" do - json.array do - PG_DB.query_all("SELECT id FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 500", playlist.id, playlist.index, as: String).each do |video_id| - json.string video_id - end - end - end - end - end - end - end - end - end - else - env.response.content_type = "application/xml" - env.response.headers["content-disposition"] = "attachment" - export = XML.build do |xml| - xml.element("opml", version: "1.1") do - xml.element("body") do - if format == "newpipe" - title = "YouTube Subscriptions" - else - title = "Invidious Subscriptions" - end - - xml.element("outline", text: title, title: title) do - subscriptions.each do |channel| - if format == "newpipe" - xmlUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}" - else - xmlUrl = "#{HOST_URL}/feed/channel/#{channel.id}" - end - - xml.element("outline", text: channel.author, title: channel.author, - "type": "rss", xmlUrl: xmlUrl) - end - end - end - end - end - - next export.gsub(%(<?xml version="1.0"?>\n), "") - end - end - - templated "subscription_manager" -end - -get "/data_control" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - - templated "data_control" -end - -post "/data_control" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - referer = get_referer(env) - - if user - user = user.as(User) - - # TODO: Find a way to prevent browser timeout - - HTTP::FormData.parse(env.request) do |part| - body = part.body.gets_to_end - next if body.empty? - - # TODO: Unify into single import based on content-type - case part.name - when "import_invidious" - body = JSON.parse(body) - - if body["subscriptions"]? - user.subscriptions += body["subscriptions"].as_a.map { |a| a.as_s } - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) - - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) - end - - if body["watch_history"]? - user.watched += body["watch_history"].as_a.map { |a| a.as_s } - user.watched.uniq! - PG_DB.exec("UPDATE users SET watched = $1 WHERE email = $2", user.watched, user.email) - end - - if body["preferences"]? - user.preferences = Preferences.from_json(body["preferences"].to_json) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", user.preferences.to_json, user.email) - end - - if playlists = body["playlists"]?.try &.as_a? - playlists.each do |item| - title = item["title"]?.try &.as_s?.try &.delete("<>") - description = item["description"]?.try &.as_s?.try &.delete("\r") - privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy } - - next if !title - next if !description - next if !privacy - - playlist = create_playlist(PG_DB, title, privacy, user) - PG_DB.exec("UPDATE playlists SET description = $1 WHERE id = $2", description, playlist.id) - - videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| - raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500 - - video_id = video_id.try &.as_s? - next if !video_id - - begin - video = get_video(video_id, PG_DB) - rescue ex - next - end - - playlist_video = PlaylistVideo.new({ - title: video.title, - id: video.id, - author: video.author, - ucid: video.ucid, - length_seconds: video.length_seconds, - published: video.published, - plid: playlist.id, - live_now: video.live_now, - index: Random::Secure.rand(0_i64..Int64::MAX), - }) - - video_array = playlist_video.to_a - args = arg_array(video_array) - - PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) - PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist.id) - end - end - end - when "import_youtube" - if body[0..4] == "<opml" - subscriptions = XML.parse(body) - user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| - channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0] - end - else - subscriptions = JSON.parse(body) - user.subscriptions += subscriptions.as_a.compact_map do |entry| - entry["snippet"]["resourceId"]["channelId"].as_s - end - end - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) - - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) - when "import_freetube" - user.subscriptions += body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/).map do |md| - md["channel_id"] - end - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) - - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) - when "import_newpipe_subscriptions" - body = JSON.parse(body) - user.subscriptions += body["subscriptions"].as_a.compact_map do |channel| - if match = channel["url"].as_s.match(/\/channel\/(?<channel>UC[a-zA-Z0-9_-]{22})/) - next match["channel"] - elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/) - response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US") - html = XML.parse_html(response.body) - ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] - next ucid if ucid - end - - nil - end - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) - - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) - when "import_newpipe" - Compress::Zip::Reader.open(IO::Memory.new(body)) do |file| - file.each_entry do |entry| - if entry.filename == "newpipe.db" - tempfile = File.tempfile(".db") - File.write(tempfile.path, entry.io.gets_to_end) - db = DB.open("sqlite3://" + tempfile.path) - - user.watched += db.query_all("SELECT url FROM streams", as: String).map { |url| url.lchop("https://www.youtube.com/watch?v=") } - user.watched.uniq! - - PG_DB.exec("UPDATE users SET watched = $1 WHERE email = $2", user.watched, user.email) - - user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map { |url| url.lchop("https://www.youtube.com/channel/") } - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) - - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) - - db.close - tempfile.delete - end - end - end - else nil # Ignore - end - end - end - - env.redirect referer -end - -get "/change_password" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY, PG_DB) - - templated "change_password" -end - -post "/change_password" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - token = env.params.body["csrf_token"]? - - # We don't store passwords for Google accounts - if !user.password - next error_template(400, "Cannot change password for Google accounts") - end - - begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) - rescue ex - next error_template(400, ex) - end - - password = env.params.body["password"]? - if !password - next error_template(401, "Password is a required field") - end - - new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v } - - if new_passwords.size <= 1 || new_passwords.uniq.size != 1 - next error_template(400, "New passwords must match") - end - - new_password = new_passwords.uniq[0] - if new_password.empty? - next error_template(401, "Password cannot be empty") - end - - if new_password.bytesize > 55 - next error_template(400, "Password cannot be longer than 55 characters") - end - - if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) - next error_template(401, "Incorrect password") - end - - new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10) - PG_DB.exec("UPDATE users SET password = $1 WHERE email = $2", new_password.to_s, user.email) - - env.redirect referer -end - -get "/delete_account" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY, PG_DB) - - templated "delete_account" -end - -post "/delete_account" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - token = env.params.body["csrf_token"]? - - begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) - rescue ex - next error_template(400, ex) - end - - view_name = "subscriptions_#{sha256(user.email)}" - PG_DB.exec("DELETE FROM users * WHERE email = $1", user.email) - PG_DB.exec("DELETE FROM session_ids * WHERE email = $1", user.email) - PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}") - - env.request.cookies.each do |cookie| - cookie.expires = Time.utc(1990, 1, 1) - env.response.cookies << cookie - end - - env.redirect referer -end - -get "/clear_watch_history" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY, PG_DB) - - templated "clear_watch_history" -end - -post "/clear_watch_history" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - token = env.params.body["csrf_token"]? - - begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) - rescue ex - next error_template(400, ex) - end - - PG_DB.exec("UPDATE users SET watched = '{}' WHERE email = $1", user.email) - env.redirect referer -end - -get "/authorize_token" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB) - - scopes = env.params.query["scopes"]?.try &.split(",") - scopes ||= [] of String - - callback_url = env.params.query["callback_url"]? - if callback_url - callback_url = URI.parse(callback_url) - end - - expire = env.params.query["expire"]?.try &.to_i? - - templated "authorize_token" -end - -post "/authorize_token" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = env.get("user").as(User) - sid = sid.as(String) - token = env.params.body["csrf_token"]? - - begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) - rescue ex - next error_template(400, ex) - end - - scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } - callback_url = env.params.body["callbackUrl"]? - expire = env.params.body["expire"]?.try &.to_i? - - access_token = generate_token(user.email, scopes, expire, HMAC_KEY, PG_DB) - - if callback_url - access_token = URI.encode_www_form(access_token) - url = URI.parse(callback_url) - - if url.query - query = HTTP::Params.parse(url.query.not_nil!) - else - query = HTTP::Params.new - end - - query["token"] = access_token - url.query = query.to_s - - env.redirect url.to_s - else - csrf_token = "" - env.set "access_token", access_token - templated "authorize_token" - end -end - -get "/token_manager" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env, "/subscription_manager") - - if !user - next env.redirect referer - end - - user = user.as(User) - - tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1 ORDER BY issued DESC", user.email, as: {session: String, issued: Time}) - - templated "token_manager" -end - -post "/token_ajax" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - redirect = env.params.query["redirect"]? - redirect ||= "true" - redirect = redirect == "true" - - if !user - if redirect - next env.redirect referer - else - next error_json(403, "No such user") - end - end - - user = user.as(User) - sid = sid.as(String) - token = env.params.body["csrf_token"]? - - begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) - rescue ex - if redirect - next error_template(400, ex) - else - next error_json(400, ex) - end - end - - if env.params.query["action_revoke_token"]? - action = "action_revoke_token" - else - next env.redirect referer - end - - session = env.params.query["session"]? - session ||= "" - - case action - when .starts_with? "action_revoke_token" - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1 AND email = $2", session, user.email) - else - next error_json(400, "Unsupported action #{action}") - end - - if redirect - env.redirect referer - else - env.response.content_type = "application/json" - "{}" - end -end - -# Feeds - -get "/feed/playlists" do |env| - env.redirect "/view_all_playlists" -end - -get "/feed/top" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - message = translate(locale, "The Top feed has been removed from Invidious.") - templated "message" -end - -get "/feed/popular" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - if CONFIG.popular_enabled - templated "popular" - else - message = translate(locale, "The Popular feed has been disabled by the administrator.") - templated "message" - end -end - -get "/feed/trending" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - trending_type = env.params.query["type"]? - trending_type ||= "Default" - - region = env.params.query["region"]? - region ||= "US" - - begin - trending, plid = fetch_trending(trending_type, region, locale) - rescue ex - next error_template(500, ex) - end - - templated "trending" -end - -get "/feed/subscriptions" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - token = user.token - - if user.preferences.unseen_only - env.set "show_watched", true - end - - # Refresh account - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - if !user.password - user, sid = get_user(sid, headers, PG_DB) - end - - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) - - # "updated" here is used for delivering new notifications, so if - # we know a user has looked at their feed e.g. in the past 10 minutes, - # they've already seen a video posted 20 minutes ago, and don't need - # to be notified. - PG_DB.exec("UPDATE users SET notifications = $1, updated = $2 WHERE email = $3", [] of String, Time.utc, - user.email) - user.notifications = [] of String - env.set "user", user - - templated "subscriptions" -end - -get "/feed/history" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - referer = get_referer(env) - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - if !user - next env.redirect referer - end - - user = user.as(User) - - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - if user.watched[(page - 1) * max_results]? - watched = user.watched.reverse[(page - 1) * max_results, max_results] - end - watched ||= [] of String - - templated "history" -end - -get "/feed/channel/:ucid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/atom+xml" - - ucid = env.params.url["ucid"] - - params = HTTP::Params.parse(env.params.query["params"]? || "") - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - next env.redirect env.request.resource.gsub(ucid, ex.channel_id) - rescue ex - next error_atom(500, ex) - end - - response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") - rss = XML.parse_html(response.body) - - videos = rss.xpath_nodes("//feed/entry").map do |entry| - video_id = entry.xpath_node("videoid").not_nil!.content - title = entry.xpath_node("title").not_nil!.content - - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - - author = entry.xpath_node("author/name").not_nil!.content - ucid = entry.xpath_node("channelid").not_nil!.content - description_html = entry.xpath_node("group/description").not_nil!.to_s - views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64 - - SearchVideo.new({ - title: title, - id: video_id, - author: author, - ucid: ucid, - published: published, - views: views, - description_html: description_html, - length_seconds: 0, - live_now: false, - paid: false, - premium: false, - premiere_timestamp: nil, - }) - end - - XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", - "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", - "xml:lang": "en-US") do - xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") - xml.element("id") { xml.text "yt:channel:#{channel.ucid}" } - xml.element("yt:channelId") { xml.text channel.ucid } - xml.element("icon") { xml.text channel.author_thumbnail } - xml.element("title") { xml.text channel.author } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}") - - xml.element("author") do - xml.element("name") { xml.text channel.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } - end - - videos.each do |video| - video.to_xml(channel.auto_generated, params, xml) - end - end - end -end - -get "/feed/private" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/atom+xml" - - token = env.params.query["token"]? - - if !token - env.response.status_code = 403 - next - end - - user = PG_DB.query_one?("SELECT * FROM users WHERE token = $1", token.strip, as: User) - if !user - env.response.status_code = 403 - next - end - - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - params = HTTP::Params.parse(env.params.query["params"]? || "") - - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) - - XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", - "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", - "xml:lang": "en-US") do - 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) } - - (notifications + videos).each do |video| - video.to_xml(locale, params, xml) - end - end - end -end - -get "/feed/playlist/:plid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/atom+xml" - - plid = env.params.url["plid"] - - params = HTTP::Params.parse(env.params.query["params"]? || "") - path = env.request.path - - if plid.starts_with? "IV" - if playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - videos = get_playlist_videos(PG_DB, playlist, offset: 0, locale: locale) - - next XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", - "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", - "xml:lang": "en-US") do - xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") - xml.element("id") { xml.text "iv:playlist:#{plid}" } - xml.element("iv:playlistId") { xml.text plid } - xml.element("title") { xml.text playlist.title } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/playlist?list=#{plid}") - - xml.element("author") do - xml.element("name") { xml.text playlist.author } - end - - videos.each do |video| - video.to_xml(false, xml) - end - end - end - else - env.response.status_code = 404 - next - end - end - - response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}") - document = XML.parse(response.body) - - document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node| - node.attributes.each do |attribute| - case attribute.name - when "url", "href" - request_target = URI.parse(node[attribute.name]).request_target - query_string_opt = request_target.starts_with?("/watch?v=") ? "&#{params}" : "" - node[attribute.name] = "#{HOST_URL}#{request_target}#{query_string_opt}" - else nil # Skip - end - end - end - - document = document.to_xml(options: XML::SaveOptions::NO_DECL) - - document.scan(/<uri>(?<url>[^<]+)<\/uri>/).each do |match| - content = "#{HOST_URL}#{URI.parse(match["url"]).request_target}" - document = document.gsub(match[0], "<uri>#{content}</uri>") - end - - document -end - -get "/feeds/videos.xml" do |env| - if ucid = env.params.query["channel_id"]? - env.redirect "/feed/channel/#{ucid}" - elsif user = env.params.query["user"]? - env.redirect "/feed/channel/#{user}" - elsif plid = env.params.query["playlist_id"]? - env.redirect "/feed/playlist/#{plid}" - end -end - -# Support push notifications via PubSubHubbub - -get "/feed/webhook/:token" do |env| - verify_token = env.params.url["token"] - - mode = env.params.query["hub.mode"]? - topic = env.params.query["hub.topic"]? - challenge = env.params.query["hub.challenge"]? - - if !mode || !topic || !challenge - env.response.status_code = 400 - next - else - mode = mode.not_nil! - topic = topic.not_nil! - challenge = challenge.not_nil! - end - - case verify_token - when .starts_with? "v1" - _, time, nonce, signature = verify_token.split(":") - data = "#{time}:#{nonce}" - when .starts_with? "v2" - time, signature = verify_token.split(":") - data = "#{time}" - else - env.response.status_code = 400 - next - end - - # The hub will sometimes check if we're still subscribed after delivery errors, - # so we reply with a 200 as long as the request hasn't expired - if Time.utc.to_unix - time.to_i > 432000 - env.response.status_code = 400 - next - end - - if OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, data) != signature - env.response.status_code = 400 - next - end - - if ucid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["channel_id"]? - PG_DB.exec("UPDATE channels SET subscribed = $1 WHERE id = $2", Time.utc, ucid) - elsif plid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["playlist_id"]? - PG_DB.exec("UPDATE playlists SET subscribed = $1 WHERE id = $2", Time.utc, ucid) - else - env.response.status_code = 400 - next - end - - env.response.status_code = 200 - challenge -end - -post "/feed/webhook/:token" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - token = env.params.url["token"] - body = env.request.body.not_nil!.gets_to_end - signature = env.request.headers["X-Hub-Signature"].lchop("sha1=") - - if signature != OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, body) - LOGGER.error("/feed/webhook/#{token} : Invalid signature") - env.response.status_code = 200 - next - end - - spawn do - rss = XML.parse_html(body) - rss.xpath_nodes("//feed/entry").each do |entry| - id = entry.xpath_node("videoid").not_nil!.content - author = entry.xpath_node("author/name").not_nil!.content - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - - video = get_video(id, PG_DB, force_refresh: true) - - # Deliver notifications to `/api/v1/auth/notifications` - payload = { - "topic" => video.ucid, - "videoId" => video.id, - "published" => published.to_unix, - }.to_json - PG_DB.exec("NOTIFY notifications, E'#{payload}'") - - video = ChannelVideo.new({ - id: id, - title: video.title, - published: published, - updated: updated, - ucid: video.ucid, - author: author, - length_seconds: video.length_seconds, - live_now: video.live_now, - premiere_timestamp: video.premiere_timestamp, - views: video.views, - }) - - was_insert = PG_DB.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, - updated = $4, ucid = $5, author = $6, length_seconds = $7, - live_now = $8, premiere_timestamp = $9, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - - PG_DB.exec("UPDATE users SET notifications = array_append(notifications, $1), - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert - end - end - - env.response.status_code = 200 - next -end - -# Channels - -{"/channel/:ucid/live", "/user/:user/live", "/c/:user/live"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - # Appears to be a bug in routing, having several routes configured - # as `/a/:a`, `/b/:a`, `/c/:a` results in 404 - value = env.request.resource.split("/")[2] - body = "" - {"channel", "user", "c"}.each do |type| - response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1") - if response.status_code == 200 - body = response.body - end - end - - video_id = body.match(/'VIDEO_ID': "(?<id>[a-zA-Z0-9_-]{11})"/).try &.["id"]? - if video_id - params = [] of String - env.params.query.each do |k, v| - params << "#{k}=#{v}" - end - params = params.join("&") - - url = "/watch?v=#{video_id}" - if !params.empty? - url += "&#{params}" - end - - env.redirect url - else - env.redirect "/channel/#{value}" - end - end -end - -# YouTube appears to let users set a "brand" URL that -# is different from their username, so we convert that here -get "/c/:user" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.params.url["user"] - - response = YT_POOL.client &.get("/c/#{user}") - html = XML.parse_html(response.body) - - ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] - next env.redirect "/" if !ucid - - env.redirect "/channel/#{ucid}" -end - -# Legacy endpoint for /user/:username -get "/profile" do |env| - user = env.params.query["user"]? - if !user - env.redirect "/" - else - env.redirect "/user/#{user}" - end -end - -get "/attribution_link" do |env| - if query = env.params.query["u"]? - url = URI.parse(query).request_target - else - url = "/" - end - - env.redirect url -end - -# Page used by YouTube to provide captioning widget, since we -# don't support it we redirect to '/' -get "/timedtext_video" do |env| - env.redirect "/" -end - -get "/user/:user" do |env| - user = env.params.url["user"] - env.redirect "/channel/#{user}" -end - -get "/user/:user/videos" do |env| - user = env.params.url["user"] - env.redirect "/channel/#{user}/videos" -end - -get "/user/:user/about" do |env| - user = env.params.url["user"] - env.redirect "/channel/#{user}" -end - -get "/channel/:ucid/about" do |env| - ucid = env.params.url["ucid"] - env.redirect "/channel/#{ucid}" -end - -get "/channel/:ucid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - if user - user = user.as(User) - subscriptions = user.subscriptions - end - subscriptions ||= [] of String - - ucid = env.params.url["ucid"] - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - continuation = env.params.query["continuation"]? - - sort_by = env.params.query["sort_by"]?.try &.downcase - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - next env.redirect env.request.resource.gsub(ucid, ex.channel_id) - rescue ex - next error_template(500, ex) - end - - if channel.auto_generated - sort_options = {"last", "oldest", "newest"} - sort_by ||= "last" - - items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) - items.uniq! do |item| - if item.responds_to?(:title) - item.title - elsif item.responds_to?(:author) - item.author - end - end - items = items.select(&.is_a?(SearchPlaylist)).map(&.as(SearchPlaylist)) - items.each { |item| item.author = "" } - else - sort_options = {"newest", "oldest", "popular"} - sort_by ||= "newest" - - count, items = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - items.reject! &.paid - - env.set "search", "channel:#{channel.ucid} " - end - - templated "channel" -end - -get "/channel/:ucid/videos" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - ucid = env.params.url["ucid"] - params = env.request.query - - if !params || params.empty? - params = "" - else - params = "?#{params}" - end - - env.redirect "/channel/#{ucid}#{params}" -end - -get "/channel/:ucid/playlists" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - if user - user = user.as(User) - subscriptions = user.subscriptions - end - subscriptions ||= [] of String - - ucid = env.params.url["ucid"] - - continuation = env.params.query["continuation"]? - - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "last" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - next env.redirect env.request.resource.gsub(ucid, ex.channel_id) - rescue ex - next error_template(500, ex) - end - - if channel.auto_generated - next env.redirect "/channel/#{channel.ucid}" - end - - items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) - items = items.select { |item| item.is_a?(SearchPlaylist) }.map { |item| item.as(SearchPlaylist) } - items.each { |item| item.author = "" } - - env.set "search", "channel:#{channel.ucid} " - templated "playlists" -end - -get "/channel/:ucid/community" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - if user - user = user.as(User) - subscriptions = user.subscriptions - end - subscriptions ||= [] of String - - ucid = env.params.url["ucid"] - - thin_mode = env.params.query["thin_mode"]? || env.get("preferences").as(Preferences).thin_mode - thin_mode = thin_mode == "true" - - continuation = env.params.query["continuation"]? - # sort_by = env.params.query["sort_by"]?.try &.downcase - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - next env.redirect env.request.resource.gsub(ucid, ex.channel_id) - rescue ex - next error_template(500, ex) - end - - if !channel.tabs.includes? "community" - next env.redirect "/channel/#{channel.ucid}" - end - - begin - items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode)) - rescue ex : InfoException - env.response.status_code = 500 - error_message = ex.message - rescue ex - next error_template(500, ex) - end - - env.set "search", "channel:#{channel.ucid} " - templated "community" -end - -# API Endpoints - -get "/api/v1/stats" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.response.content_type = "application/json" - - if !CONFIG.statistics_enabled - next error_json(400, "Statistics are not enabled.") - end - - Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json -end - -# YouTube provides "storyboards", which are sprites containing x * y -# preview thumbnails for individual scenes in a video. -# See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails -get "/api/v1/storyboards/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - next error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - env.response.status_code = 500 - next - end - - storyboards = video.storyboards - width = env.params.query["width"]? - height = env.params.query["height"]? - - if !width && !height - response = JSON.build do |json| - json.object do - json.field "storyboards" do - generate_storyboards(json, id, storyboards) - end - end - end - - next response - end - - env.response.content_type = "text/vtt" - - storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" } - - if storyboard.empty? - env.response.status_code = 404 - next - else - storyboard = storyboard[0] - end - - String.build do |str| - str << <<-END_VTT - WEBVTT - - - END_VTT - - start_time = 0.milliseconds - end_time = storyboard[:interval].milliseconds - - storyboard[:storyboard_count].times do |i| - url = storyboard[:url] - authority = /(i\d?).ytimg.com/.match(url).not_nil![1]? - url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") - url = "#{HOST_URL}/sb/#{authority}/#{url}" - - storyboard[:storyboard_height].times do |j| - storyboard[:storyboard_width].times do |k| - str << <<-END_CUE - #{start_time}.000 --> #{end_time}.000 - #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} - - - END_CUE - - start_time += storyboard[:interval].milliseconds - end_time += storyboard[:interval].milliseconds - end - end - end - end -end - -get "/api/v1/captions/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 - # It is possible to use `/api/timedtext?type=list&v=#{id}` and - # `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly, - # but this does not provide links for auto-generated captions. - # - # In future this should be investigated as an alternative, since it does not require - # getting video info. - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - next error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - env.response.status_code = 500 - next - end - - captions = video.captions - - label = env.params.query["label"]? - lang = env.params.query["lang"]? - tlang = env.params.query["tlang"]? - - if !label && !lang - response = JSON.build do |json| - json.object do - json.field "captions" do - json.array do - captions.each do |caption| - json.object do - json.field "label", caption.name.simpleText - json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name.simpleText)}" - end - end - end - end - end - end - - next response - end - - env.response.content_type = "text/vtt; charset=UTF-8" - - if lang - caption = captions.select { |caption| caption.languageCode == lang } - else - caption = captions.select { |caption| caption.name.simpleText == label } - end - - if caption.empty? - env.response.status_code = 404 - next - else - caption = caption[0] - end - - url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target - - # Auto-generated captions often have cues that aren't aligned properly with the video, - # as well as some other markup that makes it cumbersome, so we try to fix that here - if caption.name.simpleText.includes? "auto-generated" - caption_xml = YT_POOL.client &.get(url).body - caption_xml = XML.parse(caption_xml) - - webvtt = String.build do |str| - str << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || caption.languageCode} - - - END_VTT - - caption_nodes = caption_xml.xpath_nodes("//transcript/text") - caption_nodes.each_with_index do |node, i| - start_time = node["start"].to_f.seconds - duration = node["dur"]?.try &.to_f.seconds - duration ||= start_time - - if caption_nodes.size > i + 1 - end_time = caption_nodes[i + 1]["start"].to_f.seconds - else - end_time = start_time + duration - end - - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - - text = HTML.unescape(node.content) - text = text.gsub(/<font color="#[a-fA-F0-9]{6}">/, "") - text = text.gsub(/<\/font>/, "") - if md = text.match(/(?<name>.*) : (?<text>.*)/) - text = "<v #{md["name"]}>#{md["text"]}</v>" - end - - str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE - end - end - else - webvtt = YT_POOL.client &.get("#{url}&format=vtt").body - end - - if title = env.params.query["title"]? - # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" - end - - webvtt -end - -get "/api/v1/comments/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - - source = env.params.query["source"]? - source ||= "youtube" - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - action = env.params.query["action"]? - action ||= "action_get_comments" - - continuation = env.params.query["continuation"]? - sort_by = env.params.query["sort_by"]?.try &.downcase - - if source == "youtube" - sort_by ||= "top" - - begin - comments = fetch_youtube_comments(id, PG_DB, continuation, format, locale, thin_mode, region, sort_by: sort_by, action: action) - rescue ex - next error_json(500, ex) - end - - next comments - elsif source == "reddit" - sort_by ||= "confidence" - - begin - comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) - content_html = template_reddit_comments(comments, locale) - - content_html = fill_links(content_html, "https", "www.reddit.com") - content_html = replace_links(content_html) - rescue ex - comments = nil - reddit_thread = nil - content_html = "" - end - - if !reddit_thread || !comments - env.response.status_code = 404 - next - end - - if format == "json" - reddit_thread = JSON.parse(reddit_thread.to_json).as_h - reddit_thread["comments"] = JSON.parse(comments.to_json) - - next reddit_thread.to_json - else - response = { - "title" => reddit_thread.title, - "permalink" => reddit_thread.permalink, - "contentHtml" => content_html, - } - - next response.to_json - end - end -end - -get "/api/v1/insights/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - next error_json(410, "YouTube has removed publicly available analytics.") -end - -get "/api/v1/annotations/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "text/xml" - - id = env.params.url["id"] - source = env.params.query["source"]? - source ||= "archive" - - if !id.match(/[a-zA-Z0-9_-]{11}/) - env.response.status_code = 400 - next - end - - annotations = "" - - case source - when "archive" - if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation)) - annotations = cached_annotation.annotations - else - index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') - - # IA doesn't handle leading hyphens, - # so we use https://archive.org/details/youtubeannotations_64 - if index == "62" - index = "64" - id = id.sub(/^-/, 'A') - end - - file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") - - location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) - - if !location.headers["Location"]? - env.response.status_code = location.status_code - end - - response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) - - if response.body.empty? - env.response.status_code = 404 - next - end - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - annotations = response.body - - cache_annotation(PG_DB, id, annotations) - end - else # "youtube" - response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - annotations = response.body - end - - etag = sha256(annotations)[0, 16] - if env.request.headers["If-None-Match"]?.try &.== etag - env.response.status_code = 304 - else - env.response.headers["ETag"] = etag - annotations - end -end - -get "/api/v1/videos/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - next error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - next error_json(500, ex) - end - - video.to_json(locale) -end - -get "/api/v1/trending" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - region = env.params.query["region"]? - trending_type = env.params.query["type"]? - - begin - trending, plid = fetch_trending(trending_type, region, locale) - rescue ex - next error_json(500, ex) - end - - videos = JSON.build do |json| - json.array do - trending.each do |video| - video.to_json(locale, json) - end - end - end - - videos -end - -get "/api/v1/popular" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - if !CONFIG.popular_enabled - error_message = {"error" => "Administrator has disabled this endpoint."}.to_json - env.response.status_code = 400 - next error_message - end - - JSON.build do |json| - json.array do - popular_videos.each do |video| - video.to_json(locale, json) - end - end - end -end - -get "/api/v1/top" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - env.response.status_code = 400 - {"error" => "The Top feed has been removed from Invidious."}.to_json -end - -get "/api/v1/channels/:ucid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - next error_json(500, ex) - end - - page = 1 - if channel.auto_generated - videos = [] of SearchVideo - count = 0 - else - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - next error_json(500, ex) - end - end - - JSON.build do |json| - # TODO: Refactor into `to_json` for InvidiousChannel - json.object do - json.field "author", channel.author - json.field "authorId", channel.ucid - json.field "authorUrl", channel.author_url - - json.field "authorBanners" do - json.array do - if channel.banner - qualities = { - {width: 2560, height: 424}, - {width: 2120, height: 351}, - {width: 1060, height: 175}, - } - qualities.each do |quality| - json.object do - json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-") - json.field "width", quality[:width] - json.field "height", quality[:height] - end - end - - json.object do - json.field "url", channel.banner.not_nil!.split("=w1060-")[0] - json.field "width", 512 - json.field "height", 288 - end - end - end - end - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "subCount", channel.sub_count - json.field "totalViews", channel.total_views - json.field "joined", channel.joined.to_unix - json.field "paid", channel.paid - - json.field "autoGenerated", channel.auto_generated - json.field "isFamilyFriendly", channel.is_family_friendly - json.field "description", html_to_content(channel.description_html) - json.field "descriptionHtml", channel.description_html - - json.field "allowedRegions", channel.allowed_regions - - json.field "latestVideos" do - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - - json.field "relatedChannels" do - json.array do - channel.related_channels.each do |related_channel| - json.object do - json.field "author", related_channel.author - json.field "authorId", related_channel.ucid - json.field "authorUrl", related_channel.author_url - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - end - end - end - end - end - end -end - -{"/api/v1/channels/:ucid/videos", "/api/v1/channels/videos/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - sort_by = env.params.query["sort"]?.try &.downcase - sort_by ||= env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - next error_json(500, ex) - end - - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - next error_json(500, ex) - end - - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end -end - -{"/api/v1/channels/:ucid/latest", "/api/v1/channels/latest/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - begin - videos = get_latest_videos(ucid) - rescue ex - next error_json(500, ex) - end - - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end -end - -{"/api/v1/channels/:ucid/playlists", "/api/v1/channels/playlists/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - continuation = env.params.query["continuation"]? - sort_by = env.params.query["sort"]?.try &.downcase || - env.params.query["sort_by"]?.try &.downcase || - "last" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - next error_json(500, ex) - end - - items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) - - JSON.build do |json| - json.object do - json.field "playlists" do - json.array do - items.each do |item| - item.to_json(locale, json) if item.is_a?(SearchPlaylist) - end - end - end - - json.field "continuation", continuation - end - end - end -end - -{"/api/v1/channels/:ucid/comments", "/api/v1/channels/comments/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - continuation = env.params.query["continuation"]? - # sort_by = env.params.query["sort_by"]?.try &.downcase - - begin - fetch_channel_community(ucid, continuation, locale, format, thin_mode) - rescue ex - next error_json(500, ex) - end - end -end - -get "/api/v1/channels/search/:ucid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - query = env.params.query["q"]? - query ||= "" - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - count, search_results = channel_search(query, page, ucid) - JSON.build do |json| - json.array do - search_results.each do |item| - item.to_json(locale, json) - end - end - end -end - -get "/api/v1/search" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - query = env.params.query["q"]? - query ||= "" - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "relevance" - - date = env.params.query["date"]?.try &.downcase - date ||= "" - - duration = env.params.query["duration"]?.try &.downcase - duration ||= "" - - features = env.params.query["features"]?.try &.split(",").map { |feature| feature.downcase } - features ||= [] of String - - content_type = env.params.query["type"]?.try &.downcase - content_type ||= "video" - - begin - search_params = produce_search_params(page, sort_by, date, content_type, duration, features) - rescue ex - next error_json(400, ex) - end - - count, search_results = search(query, search_params, region).as(Tuple) - JSON.build do |json| - json.array do - search_results.each do |item| - item.to_json(locale, json) - end - end - end -end - -get "/api/v1/search/suggestions" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - query = env.params.query["q"]? - query ||= "" - - begin - headers = HTTP::Headers{":authority" => "suggestqueries.google.com"} - response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body - - body = response[35..-2] - body = JSON.parse(body).as_a - suggestions = body[1].as_a[0..-2] - - JSON.build do |json| - json.object do - json.field "query", body[0].as_s - json.field "suggestions" do - json.array do - suggestions.each do |suggestion| - json.string suggestion[0].as_s - end - end - end - end - end - rescue ex - next error_json(500, ex) - end -end - -{"/api/v1/playlists/:plid", "/api/v1/auth/playlists/:plid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - plid = env.params.url["plid"] - - offset = env.params.query["index"]?.try &.to_i? - offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 } - offset ||= 0 - - continuation = env.params.query["continuation"]? - - format = env.params.query["format"]? - format ||= "json" - - if plid.starts_with? "RD" - next env.redirect "/api/v1/mixes/#{plid}" - end - - begin - playlist = get_playlist(PG_DB, plid, locale) - rescue ex : InfoException - next error_json(404, ex) - rescue ex - next error_json(404, "Playlist does not exist.") - end - - user = env.get?("user").try &.as(User) - if !playlist || playlist.privacy.private? && playlist.author != user.try &.email - next error_json(404, "Playlist does not exist.") - end - - response = playlist.to_json(offset, locale, continuation: continuation) - - if format == "html" - response = JSON.parse(response) - playlist_html = template_playlist(response) - index, next_video = response["videos"].as_a.skip(1).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil} - - response = { - "playlistHtml" => playlist_html, - "index" => index, - "nextVideo" => next_video, - }.to_json - end - - response - end -end - -get "/api/v1/mixes/:rdid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - rdid = env.params.url["rdid"] - - continuation = env.params.query["continuation"]? - continuation ||= rdid.lchop("RD")[0, 11] - - format = env.params.query["format"]? - format ||= "json" - - begin - mix = fetch_mix(rdid, continuation, locale: locale) - - if !rdid.ends_with? continuation - mix = fetch_mix(rdid, mix.videos[1].id) - index = mix.videos.index(mix.videos.select { |video| video.id == continuation }[0]?) - end - - mix.videos = mix.videos[index..-1] - rescue ex - next error_json(500, ex) - end - - response = JSON.build do |json| - json.object do - json.field "title", mix.title - json.field "mixId", mix.id - - json.field "videos" do - json.array do - mix.videos.each do |video| - json.object do - json.field "title", video.title - json.field "videoId", video.id - json.field "author", video.author - - json.field "authorId", video.ucid - json.field "authorUrl", "/channel/#{video.ucid}" - - json.field "videoThumbnails" do - json.array do - generate_thumbnails(json, video.id) - end - end - - json.field "index", video.index - json.field "lengthSeconds", video.length_seconds - end - end - end - end - end - end - - if format == "html" - response = JSON.parse(response) - playlist_html = template_mix(response) - next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"] - - response = { - "playlistHtml" => playlist_html, - "nextVideo" => next_video, - }.to_json - end - - response -end - -# Authenticated endpoints - -get "/api/v1/auth/notifications" do |env| - env.response.content_type = "text/event-stream" - - topics = env.params.query["topics"]?.try &.split(",").uniq.first(1000) - topics ||= [] of String - - create_notification_stream(env, topics, connection_channel) -end - -post "/api/v1/auth/notifications" do |env| - env.response.content_type = "text/event-stream" - - topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000) - topics ||= [] of String - - create_notification_stream(env, topics, connection_channel) -end - -get "/api/v1/auth/preferences" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - user.preferences.to_json -end - -post "/api/v1/auth/preferences" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - begin - preferences = Preferences.from_json(env.request.body || "{}") - rescue - preferences = user.preferences - end - - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) - - env.response.status_code = 204 -end - -get "/api/v1/auth/feed" do |env| - env.response.content_type = "application/json" - - user = env.get("user").as(User) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - max_results = env.params.query["max_results"]?.try &.to_i? - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) - - JSON.build do |json| - json.object do - json.field "notifications" do - json.array do - notifications.each do |video| - video.to_json(locale, json) - end - end - end - - json.field "videos" do - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end - end -end - -get "/api/v1/auth/subscriptions" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - if user.subscriptions.empty? - values = "'{}'" - else - values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}" - end - - subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel) - - JSON.build do |json| - json.array do - subscriptions.each do |subscription| - json.object do - json.field "author", subscription.author - json.field "authorId", subscription.id - end - end - end - end -end - -post "/api/v1/auth/subscriptions/:ucid" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - ucid = env.params.url["ucid"] - - if !user.subscriptions.includes? ucid - get_channel(ucid, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email) - end - - # For Google accounts, access tokens don't have enough information to - # make a request on the user's behalf, which is why we don't sync with - # YouTube. - - env.response.status_code = 204 -end - -delete "/api/v1/auth/subscriptions/:ucid" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - ucid = env.params.url["ucid"] - - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", ucid, user.email) - - env.response.status_code = 204 -end - -get "/api/v1/auth/playlists" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - user = env.get("user").as(User) - - playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1", user.email, as: InvidiousPlaylist) - - JSON.build do |json| - json.array do - playlists.each do |playlist| - playlist.to_json(0, locale, json) - end - end - end -end - -post "/api/v1/auth/playlists" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150) - if !title - next error_json(400, "Invalid title.") - end - - privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } - if !privacy - next error_json(400, "Invalid privacy setting.") - end - - if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 - next error_json(400, "User cannot have more than 100 playlists.") - end - - playlist = create_playlist(PG_DB, title, privacy, user) - env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}" - env.response.status_code = 201 - { - "title" => title, - "playlistId" => playlist.id, - }.to_json -end - -patch "/api/v1/auth/playlists/:plid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - user = env.get("user").as(User) - - plid = env.params.url["plid"] - - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email && playlist.privacy.private? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - end - - title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title - privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } || playlist.privacy - description = env.params.json["description"]?.try &.as(String).delete("\r") || playlist.description - - if title != playlist.title || - privacy != playlist.privacy || - description != playlist.description - updated = Time.utc - else - updated = playlist.updated - end - - PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid) - env.response.status_code = 204 -end - -delete "/api/v1/auth/playlists/:plid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - user = env.get("user").as(User) - - plid = env.params.url["plid"] - - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email && playlist.privacy.private? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - end - - PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) - PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) +# Routing - env.response.status_code = 204 -end - -post "/api/v1/auth/playlists/:plid/videos" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - user = env.get("user").as(User) - - plid = env.params.url["plid"] - - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email && playlist.privacy.private? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - end - - if playlist.index.size >= 500 - next error_json(400, "Playlist cannot have more than 500 videos") - end - - video_id = env.params.json["videoId"].try &.as(String) - if !video_id - next error_json(403, "Invalid videoId") - end - - begin - video = get_video(video_id, PG_DB) - rescue ex - next error_json(500, ex) - end - - playlist_video = PlaylistVideo.new({ - title: video.title, - id: video.id, - author: video.author, - ucid: video.ucid, - length_seconds: video.length_seconds, - published: video.published, - plid: plid, - live_now: video.live_now, - index: Random::Secure.rand(0_i64..Int64::MAX), - }) - - video_array = playlist_video.to_a - args = arg_array(video_array) - - PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) - PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid) - - env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{plid}/videos/#{playlist_video.index.to_u64.to_s(16).upcase}" - env.response.status_code = 201 - playlist_video.to_json(locale, index: playlist.index.size) -end - -delete "/api/v1/auth/playlists/:plid/videos/:index" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - user = env.get("user").as(User) - - plid = env.params.url["plid"] - index = env.params.url["index"].to_i64(16) - - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email && playlist.privacy.private? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - end - - if !playlist.index.includes? index - next error_json(404, "Playlist does not contain index") - end - - PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) - PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, plid) - - env.response.status_code = 204 -end - -# patch "/api/v1/auth/playlists/:plid/videos/:index" do |env| -# TODO: Playlist stub -# end - -get "/api/v1/auth/tokens" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - scopes = env.get("scopes").as(Array(String)) - - tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1", user.email, as: {session: String, issued: Time}) - - JSON.build do |json| - json.array do - tokens.each do |token| - json.object do - json.field "session", token[:session] - json.field "issued", token[:issued].to_unix - end - end - end - end -end - -post "/api/v1/auth/tokens/register" do |env| - user = env.get("user").as(User) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - case env.request.headers["Content-Type"]? - when "application/x-www-form-urlencoded" - scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } - callback_url = env.params.body["callbackUrl"]? - expire = env.params.body["expire"]?.try &.to_i? - when "application/json" - scopes = env.params.json["scopes"].as(Array).map { |v| v.as_s } - callback_url = env.params.json["callbackUrl"]?.try &.as(String) - expire = env.params.json["expire"]?.try &.as(Int64) - else - next error_json(400, "Invalid or missing header 'Content-Type'") - end - - if callback_url && callback_url.empty? - callback_url = nil - end - - if callback_url - callback_url = URI.parse(callback_url) - end - - if sid = env.get?("sid").try &.as(String) - env.response.content_type = "text/html" - - csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB, use_nonce: true) - next templated "authorize_token" - else - env.response.content_type = "application/json" - - superset_scopes = env.get("scopes").as(Array(String)) - - authorized_scopes = [] of String - scopes.each do |scope| - if scopes_include_scope(superset_scopes, scope) - authorized_scopes << scope - end - end - - access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB) - - if callback_url - access_token = URI.encode_www_form(access_token) - - if query = callback_url.query - query = HTTP::Params.parse(query.not_nil!) - else - query = HTTP::Params.new - end - - query["token"] = access_token - callback_url.query = query.to_s - - env.redirect callback_url.to_s - else - access_token - end - end -end - -post "/api/v1/auth/tokens/unregister" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.response.content_type = "application/json" - user = env.get("user").as(User) - scopes = env.get("scopes").as(Array(String)) - - session = env.params.json["session"]?.try &.as(String) - session ||= env.get("session").as(String) - - # Allow tokens to revoke other tokens with correct scope - if session == env.get("session").as(String) - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) - elsif scopes_include_scope(scopes, "GET:tokens") - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) - else - next error_json(400, "Cannot revoke session #{session}") - end - - env.response.status_code = 204 -end - -get "/api/manifest/dash/id/videoplayback" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.redirect "/videoplayback?#{env.params.query}" -end - -get "/api/manifest/dash/id/videoplayback/*" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.redirect env.request.path.lchop("/api/manifest/dash/id") -end - -get "/api/manifest/dash/id/:id" do |env| - env.response.headers.add("Access-Control-Allow-Origin", "*") - env.response.content_type = "application/dash+xml" - - local = env.params.query["local"]?.try &.== "true" - id = env.params.url["id"] - region = env.params.query["region"]? - - # Since some implementations create playlists based on resolution regardless of different codecs, - # we can opt to only add a source to a representation if it has a unique height within that representation - unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - next env.redirect env.request.resource.gsub(id, ex.video_id) - rescue ex - env.response.status_code = 403 - next - end - - if dashmpd = video.dash_manifest_url - manifest = YT_POOL.client &.get(URI.parse(dashmpd).request_target).body - - manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl| - url = baseurl.lchop("<BaseURL>") - url = url.rchop("</BaseURL>") - - if local - uri = URI.parse(url) - url = "#{uri.request_target}host/#{uri.host}/" - end - - "<BaseURL>#{url}</BaseURL>" - end - - next manifest - end - - adaptive_fmts = video.adaptive_fmts - - if local - adaptive_fmts.each do |fmt| - fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) - end - end - - audio_streams = video.audio_streams - video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse - - XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", - "profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static", - mediaPresentationDuration: "PT#{video.length_seconds}S") do - xml.element("Period") do - i = 0 - - {"audio/mp4", "audio/webm"}.each do |mime_type| - mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } - next if mime_streams.empty? - - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do - mime_streams.each do |fmt| - codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') - bandwidth = fmt["bitrate"].as_i - itag = fmt["itag"].as_i - url = fmt["url"].as_s - - xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do - xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", - value: "2") - xml.element("BaseURL") { xml.text url } - xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do - xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") - end - end - end - end - - i += 1 - end - - potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144} - - {"video/mp4", "video/webm"}.each do |mime_type| - mime_streams = video_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } - next if mime_streams.empty? - - heights = [] of Int32 - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do - mime_streams.each do |fmt| - codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') - bandwidth = fmt["bitrate"].as_i - itag = fmt["itag"].as_i - url = fmt["url"].as_s - width = fmt["width"].as_i - height = fmt["height"].as_i - - # Resolutions reported by YouTube player (may not accurately reflect source) - height = potential_heights.min_by { |i| (height - i).abs } - next if unique_res && heights.includes? height - heights << height - - xml.element("Representation", id: itag, codecs: codecs, width: width, height: height, - startWithSAP: "1", maxPlayoutRate: "1", - bandwidth: bandwidth, frameRate: fmt["fps"]) do - xml.element("BaseURL") { xml.text url } - xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do - xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") - end - end - end - end - - i += 1 - end - end - end - end -end - -get "/api/manifest/hls_variant/*" do |env| - response = YT_POOL.client &.get(env.request.path) - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - local = env.params.query["local"]?.try &.== "true" - - env.response.content_type = "application/x-mpegURL" - env.response.headers.add("Access-Control-Allow-Origin", "*") - - manifest = response.body - - if local - manifest = manifest.gsub("https://www.youtube.com", HOST_URL) - manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true") - end - - manifest -end - -get "/api/manifest/hls_playlist/*" do |env| - response = YT_POOL.client &.get(env.request.path) - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - local = env.params.query["local"]?.try &.== "true" - - env.response.content_type = "application/x-mpegURL" - env.response.headers.add("Access-Control-Allow-Origin", "*") - - manifest = response.body - - if local - manifest = manifest.gsub(/^https:\/\/r\d---.{11}\.c\.youtube\.com[^\n]*/m) do |match| - path = URI.parse(match).path - - path = path.lchop("/videoplayback/") - path = path.rchop("/") - - path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| - mimetype = mimetype.split("/") - mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] - end - - path = path.split("/") - - raw_params = {} of String => Array(String) - path.each_slice(2) do |pair| - key, value = pair - value = URI.decode_www_form(value) - - if raw_params[key]? - raw_params[key] << value - else - raw_params[key] = [value] - end - end - - raw_params = HTTP::Params.new(raw_params) - if fvip = raw_params["hls_chunk_host"].match(/r(?<fvip>\d+)---/) - raw_params["fvip"] = fvip["fvip"] - end - - raw_params["local"] = "true" - - "#{HOST_URL}/videoplayback?#{raw_params}" - end - end - - manifest -end - -# YouTube /videoplayback links expire after 6 hours, -# so we have a mechanism here to redirect to the latest version -get "/latest_version" do |env| - if env.params.query["download_widget"]? - download_widget = JSON.parse(env.params.query["download_widget"]) - - id = download_widget["id"].as_s - title = download_widget["title"].as_s - - if label = download_widget["label"]? - env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}" - next - else - itag = download_widget["itag"].as_s.to_i - local = "true" - end - end - - id ||= env.params.query["id"]? - itag ||= env.params.query["itag"]?.try &.to_i - - region = env.params.query["region"]? - - local ||= env.params.query["local"]? - local ||= "false" - local = local == "true" - - if !id || !itag - env.response.status_code = 400 - next - end - - video = get_video(id, PG_DB, region: region) - - fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } - url = fmt.try &.["url"]?.try &.as_s - - if !url - env.response.status_code = 404 - next - end - - url = URI.parse(url).request_target.not_nil! if local - url = "#{url}&title=#{title}" if title - - env.redirect url -end - -options "/videoplayback" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" -end - -options "/videoplayback/*" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" -end - -options "/api/manifest/dash/id/videoplayback" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" -end - -options "/api/manifest/dash/id/videoplayback/*" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" -end - -get "/videoplayback/*" do |env| - path = env.request.path - - path = path.lchop("/videoplayback/") - path = path.rchop("/") - - path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| - mimetype = mimetype.split("/") - mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] - end - - path = path.split("/") - - raw_params = {} of String => Array(String) - path.each_slice(2) do |pair| - key, value = pair - value = URI.decode_www_form(value) - - if raw_params[key]? - raw_params[key] << value - else - raw_params[key] = [value] - end - end - - query_params = HTTP::Params.new(raw_params) - - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.redirect "/videoplayback?#{query_params}" -end - -get "/videoplayback" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - query_params = env.params.query - - fvip = query_params["fvip"]? || "3" - mns = query_params["mn"]?.try &.split(",") - mns ||= [] of String - - if query_params["region"]? - region = query_params["region"] - query_params.delete("region") - end - - if query_params["host"]? && !query_params["host"].empty? - host = "https://#{query_params["host"]}" - query_params.delete("host") - else - host = "https://r#{fvip}---#{mns.pop}.googlevideo.com" - end - - url = "/videoplayback?#{query_params.to_s}" - - headers = HTTP::Headers.new - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - client = make_client(URI.parse(host), region) - response = HTTP::Client::Response.new(500) - error = "" - 5.times do - begin - response = client.head(url, headers) - - if response.headers["Location"]? - location = URI.parse(response.headers["Location"]) - env.response.headers["Access-Control-Allow-Origin"] = "*" - - new_host = "#{location.scheme}://#{location.host}" - if new_host != host - host = new_host - client.close - client = make_client(URI.parse(new_host), region) - end - - url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" - else - break - end - rescue Socket::Addrinfo::Error - if !mns.empty? - mn = mns.pop - end - fvip = "3" - - host = "https://r#{fvip}---#{mn}.googlevideo.com" - client = make_client(URI.parse(host), region) - rescue ex - error = ex.message - end - end - - if response.status_code >= 400 - env.response.status_code = response.status_code - env.response.content_type = "text/plain" - next error - end - - if url.includes? "&file=seg.ts" - if CONFIG.disabled?("livestreams") - next error_template(403, "Administrator has disabled this endpoint.") - end - - begin - client.get(url, headers) do |response| - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if location = response.headers["Location"]? - location = URI.parse(location) - location = "#{location.request_target}&host=#{location.host}" - - if region - location += "®ion=#{region}" - end - - next env.redirect location - end - - IO.copy(response.body_io, env.response) - end - rescue ex - end - else - if query_params["title"]? && CONFIG.disabled?("downloads") || - CONFIG.disabled?("dash") - next error_template(403, "Administrator has disabled this endpoint.") - end - - content_length = nil - first_chunk = true - range_start, range_end = parse_range(env.request.headers["Range"]?) - chunk_start = range_start - chunk_end = range_end - - if !chunk_end || chunk_end - chunk_start > HTTP_CHUNK_SIZE - chunk_end = chunk_start + HTTP_CHUNK_SIZE - 1 - end - - # TODO: Record bytes written so we can restart after a chunk fails - while true - if !range_end && content_length - range_end = content_length - end - - if range_end && chunk_start > range_end - break - end - - if range_end && chunk_end > range_end - chunk_end = range_end - end - - headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}" - - begin - client.get(url, headers) do |response| - if first_chunk - if !env.request.headers["Range"]? && response.status_code == 206 - env.response.status_code = 200 - else - env.response.status_code = response.status_code - end - - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) && key.downcase != "content-range" - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if location = response.headers["Location"]? - location = URI.parse(location) - location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" - - env.redirect location - break - end - - if title = query_params["title"]? - # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" - end - - if !response.headers.includes_word?("Transfer-Encoding", "chunked") - content_length = response.headers["Content-Range"].split("/")[-1].to_i64 - if env.request.headers["Range"]? - env.response.headers["Content-Range"] = "bytes #{range_start}-#{range_end || (content_length - 1)}/#{content_length}" - env.response.content_length = ((range_end.try &.+ 1) || content_length) - range_start - else - env.response.content_length = content_length - end - end - end - - proxy_file(response, env) - end - rescue ex - if ex.message != "Error reading socket: Connection reset by peer" - break - else - client.close - client = make_client(URI.parse(host), region) - end - end - - chunk_start = chunk_end + 1 - chunk_end += HTTP_CHUNK_SIZE - first_chunk = false - end - end - client.close -end - -get "/ggpht/*" do |env| - url = env.request.path.lchop("/ggpht") - - headers = HTTP::Headers{":authority" => "yt3.ggpht.com"} - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -options "/sb/:authority/:id/:storyboard/:index" do |env| - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" -end - -get "/sb/:authority/:id/:storyboard/:index" do |env| - authority = env.params.url["authority"] - id = env.params.url["id"] - storyboard = env.params.url["storyboard"] - index = env.params.url["index"] - - url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}" - - headers = HTTP::Headers.new - - headers[":authority"] = "#{authority}.ytimg.com" - - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Connection"] = "close" - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -get "/s_p/:id/:name" do |env| - id = env.params.url["id"] - name = env.params.url["name"] - - url = env.request.resource - - headers = HTTP::Headers{":authority" => "i9.ytimg.com"} - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 && response.status_code != 404 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -get "/yts/img/:name" do |env| - headers = HTTP::Headers.new - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(env.request.resource, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 && response.status_code != 404 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -get "/vi/:id/:name" do |env| - id = env.params.url["id"] - name = env.params.url["name"] - - headers = HTTP::Headers{":authority" => "i.ytimg.com"} - - if name == "maxres.jpg" - build_thumbnails(id).each do |thumb| - if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200 - name = thumb[:url] + ".jpg" - break - end - end - end - url = "/vi/#{id}/#{name}" - - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 && response.status_code != 404 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -get "/Captcha" do |env| - headers = HTTP::Headers{":authority" => "accounts.google.com"} - response = YT_POOL.client &.get(env.request.resource, headers) - env.response.headers["Content-Type"] = response.headers["Content-Type"] - response.body +before_all do |env| + Invidious::Routes::BeforeAll.handle(env) end -# Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos -get "/watch_videos" do |env| - response = YT_POOL.client &.get(env.request.resource) - if url = response.headers["Location"]? - url = URI.parse(url).request_target - next env.redirect url - end - - env.response.status_code = response.status_code -end +Invidious::Routing.register_all error 404 do |env| - if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/) - item = md["id"] - - # Check if item is branding URL e.g. https://youtube.com/gaming - response = YT_POOL.client &.get("/#{item}") - - if response.status_code == 301 - response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target) - end - - if response.body.empty? - env.response.headers["Location"] = "/" - halt env, status_code: 302 - end - - html = XML.parse_html(response.body) - ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] - - if ucid - env.response.headers["Location"] = "/channel/#{ucid}" - halt env, status_code: 302 - end - - params = [] of String - env.params.query.each do |k, v| - params << "#{k}=#{v}" - end - params = params.join("&") - - url = "/watch?v=#{item}" - if !params.empty? - url += "&#{params}" - end - - # Check if item is video ID - if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404 - env.response.headers["Location"] = url - halt env, status_code: 302 - end - end - - env.response.headers["Location"] = "/" - halt env, status_code: 302 + Invidious::Routes::ErrorRoutes.error_404(env) end error 500 do |env, ex| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? error_template(500, ex) end -static_headers do |response, filepath, filestat| +static_headers do |response| response.headers.add("Cache-Control", "max-age=2629800") end +# Init Kemal + public_folder "assets" Kemal.config.powered_by_header = false @@ -3914,9 +236,16 @@ add_handler AuthHandler.new add_handler DenyFrame.new add_context_storage_type(Array(String)) add_context_storage_type(Preferences) -add_context_storage_type(User) +add_context_storage_type(Invidious::User) Kemal.config.logger = LOGGER +Kemal.config.app_name = "Invidious" + +# Use in kemal's production mode. +# Users can also set the KEMAL_ENV environmental variable for this to be set automatically. +{% if flag?(:release) || flag?(:production) %} + Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV") +{% end %} Kemal.run do |config| if CONFIG.bind_unix @@ -3925,7 +254,7 @@ Kemal.run do |config| end config.server.not_nil!.bind_unix CONFIG.bind_unix.not_nil! else - config.host_binding = config.host_binding != "0.0.0.0" ? config.host_binding : CONFIG.host_binding - config.port = config.port != 3000 ? config.port : CONFIG.port + Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding + Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port end end diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr deleted file mode 100644 index 3109b508..00000000 --- a/src/invidious/channels.cr +++ /dev/null @@ -1,984 +0,0 @@ -struct InvidiousChannel - include DB::Serializable - - property id : String - property author : String - property updated : Time - property deleted : Bool - property subscribed : Time? -end - -struct ChannelVideo - include DB::Serializable - - property id : String - property title : String - property published : Time - property updated : Time - property ucid : String - property author : String - property length_seconds : Int32 = 0 - property live_now : Bool = false - property premiere_timestamp : Time? = nil - property views : Int64? = nil - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "shortVideo" - - json.field "title", self.title - json.field "videoId", self.id - json.field "videoThumbnails" do - generate_thumbnails(json, self.id) - end - - json.field "lengthSeconds", self.length_seconds - - json.field "author", self.author - 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 "viewCount", self.views - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end - - def to_xml(locale, query_params, xml : XML::Builder) - query_params["v"] = self.id - - xml.element("entry") do - xml.element("id") { xml.text "yt:video:#{self.id}" } - xml.element("yt:videoId") { xml.text self.id } - xml.element("yt:channelId") { xml.text self.ucid } - xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") - - xml.element("author") do - xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } - end - - xml.element("content", type: "xhtml") do - xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do - xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") - end - end - end - - xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } - xml.element("updated") { xml.text self.updated.to_s("%Y-%m-%dT%H:%M:%S%:z") } - - xml.element("media:group") do - xml.element("media:title") { xml.text self.title } - xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", - width: "320", height: "180") - end - end - end - - def to_xml(locale, xml : XML::Builder | Nil = nil) - if xml - to_xml(locale, xml) - else - XML.build do |xml| - to_xml(locale, xml) - end - end - end - - def to_tuple - {% begin %} - { - {{*@type.instance_vars.map { |var| var.name }}} - } - {% end %} - end -end - -struct AboutRelatedChannel - include DB::Serializable - - property ucid : String - property author : String - property author_url : String - property author_thumbnail : String -end - -# TODO: Refactor into either SearchChannel or InvidiousChannel -struct AboutChannel - include DB::Serializable - - property ucid : String - property author : String - property auto_generated : Bool - property author_url : String - property author_thumbnail : String - property banner : String? - property description_html : String - property paid : Bool - property total_views : Int64 - property sub_count : Int32 - property joined : Time - property is_family_friendly : Bool - property allowed_regions : Array(String) - property related_channels : Array(AboutRelatedChannel) - property tabs : Array(String) -end - -class ChannelRedirect < Exception - property channel_id : String - - def initialize(@channel_id) - end -end - -def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10) - finished_channel = Channel(String | Nil).new - - spawn do - active_threads = 0 - active_channel = Channel(Nil).new - - channels.each do |ucid| - if active_threads >= max_threads - active_channel.receive - active_threads -= 1 - end - - active_threads += 1 - spawn do - begin - get_channel(ucid, db, refresh, pull_all_videos) - finished_channel.send(ucid) - rescue ex - finished_channel.send(nil) - ensure - active_channel.send(nil) - end - end - end - end - - final = [] of String - channels.size.times do - if ucid = finished_channel.receive - final << ucid - end - end - - return final -end - -def get_channel(id, db, refresh = true, pull_all_videos = true) - if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel) - if refresh && Time.utc - channel.updated > 10.minutes - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) - channel_array = channel.to_a - args = arg_array(channel_array) - - db.exec("INSERT INTO channels VALUES (#{args}) \ - ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array) - end - else - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) - channel_array = channel.to_a - args = arg_array(channel_array) - - db.exec("INSERT INTO channels VALUES (#{args})", args: channel_array) - end - - return channel -end - -def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) - LOGGER.debug("fetch_channel: #{ucid}") - LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") - - LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed") - rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body - LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed") - rss = XML.parse_html(rss) - - author = rss.xpath_node(%q(//feed/title)) - if !author - raise InfoException.new("Deleted or invalid channel") - end - author = author.content - - # Auto-generated channels - # https://support.google.com/youtube/answer/2579942 - if author.ends_with?(" - Topic") || - {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author - auto_generated = true - end - - LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") - - page = 1 - - LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page") - response_body = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - - videos = [] of SearchVideo - begin - initial_data = JSON.parse(response_body) - raise InfoException.new("Could not extract channel JSON") if !initial_data - - LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel videos page initial_data") - videos = extract_videos(initial_data.as_h, author, ucid) - rescue ex - if response_body.includes?("To continue with your YouTube experience, please fill out the form below.") || - response_body.includes?("https://www.google.com/sorry/index") - raise InfoException.new("Could not extract channel info. Instance is likely blocked.") - end - raise ex - end - - LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") - rss.xpath_nodes("//feed/entry").each do |entry| - video_id = entry.xpath_node("videoid").not_nil!.content - title = entry.xpath_node("title").not_nil!.content - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - author = entry.xpath_node("author/name").not_nil!.content - ucid = entry.xpath_node("channelid").not_nil!.content - views = entry.xpath_node("group/community/statistics").try &.["views"]?.try &.to_i64? - views ||= 0_i64 - - channel_video = videos.select { |video| video.id == video_id }[0]? - - length_seconds = channel_video.try &.length_seconds - length_seconds ||= 0 - - live_now = channel_video.try &.live_now - live_now ||= false - - premiere_timestamp = channel_video.try &.premiere_timestamp - - video = ChannelVideo.new({ - id: video_id, - title: title, - published: published, - updated: Time.utc, - ucid: ucid, - author: author, - length_seconds: length_seconds, - live_now: live_now, - premiere_timestamp: premiere_timestamp, - views: views, - }) - - LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video") - - # We don't include the 'premiere_timestamp' here because channel pages don't include them, - # meaning the above timestamp is always null - was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ - updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - - if was_insert - LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") - db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) - else - LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") - end - end - - if pull_all_videos - page += 1 - - ids = [] of String - - loop do - response_body = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - initial_data = JSON.parse(response_body) - raise InfoException.new("Could not extract channel JSON") if !initial_data - videos = extract_videos(initial_data.as_h, author, ucid) - - count = videos.size - videos = videos.map { |video| ChannelVideo.new({ - id: video.id, - title: video.title, - published: video.published, - updated: Time.utc, - ucid: video.ucid, - author: video.author, - length_seconds: video.length_seconds, - live_now: video.live_now, - premiere_timestamp: video.premiere_timestamp, - views: video.views, - }) } - - videos.each do |video| - ids << video.id - - # We are notified of Red videos elsewhere (PubSub), which includes a correct published date, - # so since they don't provide a published date here we can safely ignore them. - if Time.utc - video.published > 1.minute - was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ - updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - - db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert - end - end - - break if count < 25 - page += 1 - end - end - - channel = InvidiousChannel.new({ - id: ucid, - author: author, - updated: Time.utc, - deleted: false, - subscribed: nil, - }) - - return channel -end - -def fetch_channel_playlists(ucid, author, continuation, sort_by) - if continuation - response_json = request_youtube_api_browse(continuation) - result = JSON.parse(response_json) - continuationItems = result["onResponseReceivedActions"]? - .try &.[0]["appendContinuationItemsAction"]["continuationItems"] - - return [] of SearchItem, nil if !continuationItems - - items = [] of SearchItem - continuationItems.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item| - extract_item(item, author, ucid).try { |t| items << t } - } - - continuation = continuationItems.as_a.last["continuationItemRenderer"]? - .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s - else - url = "/channel/#{ucid}/playlists?flow=list&view=1" - - case sort_by - when "last", "last_added" - # - when "oldest", "oldest_created" - url += "&sort=da" - when "newest", "newest_created" - url += "&sort=dd" - else nil # Ignore - end - - response = YT_POOL.client &.get(url) - initial_data = extract_initial_data(response.body) - return [] of SearchItem, nil if !initial_data - - items = extract_items(initial_data, author, ucid) - continuation = response.body.match(/"token":"(?<continuation>[^"]+)"/).try &.["continuation"]? - end - - return items, continuation -end - -def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:base64" => { - "2:string" => "videos", - "6:varint" => 2_i64, - "7:varint" => 1_i64, - "12:varint" => 1_i64, - "13:string" => "", - "23:varint" => 0_i64, - }, - }, - } - - if !v2 - if auto_generated - seed = Time.unix(1525757349) - until seed >= Time.utc - seed += 1.month - end - timestamp = seed - (page - 1).months - - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x36_i64 - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{timestamp.to_unix}" - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{page}" - end - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 - - object["80226972:embedded"]["3:base64"].as(Hash)["61:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ - "1:string" => Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ - "1:varint" => 30_i64 * (page - 1), - }))), - }))) - end - - case sort_by - when "newest" - when "popular" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x01_i64 - when "oldest" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x02_i64 - else nil # Ignore - end - - object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) - object["80226972:embedded"].delete("3:base64") - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end - -# Used in bypass_captcha_job.cr -def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) - continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2) - return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" -end - -# ## NOTE: DEPRECATED -# Reason -> Unstable -# The Protobuf object must be provided with an id of the last playlist from the current "page" -# in order to fetch the next one accurately -# (if the id isn't included, entries shift around erratically between pages, -# leading to repetitions and skip overs) -# -# Since it's impossible to produce the appropriate Protobuf without an id being provided by the user, -# it's better to stick to continuation tokens provided by the first request and onward -def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false) - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:base64" => { - "2:string" => "playlists", - "6:varint" => 2_i64, - "7:varint" => 1_i64, - "12:varint" => 1_i64, - "13:string" => "", - "23:varint" => 0_i64, - }, - }, - } - - if cursor - cursor = Base64.urlsafe_encode(cursor, false) if !auto_generated - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor - end - - if auto_generated - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x32_i64 - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 1_i64 - case sort - when "oldest", "oldest_created" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 2_i64 - when "newest", "newest_created" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64 - when "last", "last_added" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64 - else nil # Ignore - end - end - - object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) - object["80226972:embedded"].delete("3:base64") - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" -end - -# TODO: Add "sort_by" -def fetch_channel_community(ucid, continuation, locale, format, thin_mode) - response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") - if response.status_code != 200 - response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en") - end - - if response.status_code != 200 - raise InfoException.new("This channel does not exist.") - end - - ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"] - - if !continuation || continuation.empty? - initial_data = extract_initial_data(response.body) - body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]? - - if !body - raise InfoException.new("Could not extract community tab.") - end - - body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"] - else - continuation = produce_channel_community_continuation(ucid, continuation) - - headers = HTTP::Headers.new - headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"] - - session_token = response.body.match(/"XSRF_TOKEN":"(?<session_token>[^"]+)"/).try &.["session_token"]? || "" - post_req = { - session_token: session_token, - } - - response = YT_POOL.client &.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req) - body = JSON.parse(response.body) - - body = body["response"]["continuationContents"]["itemSectionContinuation"]? || - body["response"]["continuationContents"]["backstageCommentsContinuation"]? - - if !body - raise InfoException.new("Could not extract continuation.") - end - end - - continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s - posts = body["contents"].as_a - - if message = posts[0]["messageRenderer"]? - error_message = (message["text"]["simpleText"]? || - message["text"]["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s || "" - raise InfoException.new(error_message) - end - - response = JSON.build do |json| - json.object do - json.field "authorId", ucid - json.field "comments" do - json.array do - posts.each do |post| - comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? || - post["backstageCommentsContinuation"]? - - post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? || - post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]? - - next if !post - - content_html = post["contentText"]?.try { |t| parse_content(t) } || "" - author = post["authorText"]?.try &.["simpleText"]? || "" - - json.object do - json.field "author", author - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s - - qualities.each do |quality| - json.object do - json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-") - json.field "width", quality - json.field "height", quality - end - end - end - end - - if post["authorEndpoint"]? - json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"] - json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s - else - json.field "authorId", "" - json.field "authorUrl", "" - end - - published_text = post["publishedTimeText"]["runs"][0]["text"].as_s - published = decode_date(published_text.rchop(" (edited)")) - - if published_text.includes?(" (edited)") - json.field "isEdited", true - else - json.field "isEdited", false - end - - like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"] - .try &.as_s.gsub(/\D/, "").to_i? || 0 - - json.field "content", 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 "likeCount", like_count - json.field "commentId", post["postId"]? || post["commentId"]? || "" - json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid - - if attachment = post["backstageAttachment"]? - json.field "attachment" do - json.object do - case attachment.as_h - when .has_key?("videoRenderer") - attachment = attachment["videoRenderer"] - json.field "type", "video" - - if !attachment["videoId"]? - error_message = (attachment["title"]["simpleText"]? || - attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?) - - json.field "error", error_message - else - video_id = attachment["videoId"].as_s - - video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]? - json.field "title", video_title - json.field "videoId", video_id - json.field "videoThumbnails" do - generate_thumbnails(json, video_id) - end - - json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s) - - author_info = attachment["ownerText"]["runs"][0].as_h - - json.field "author", author_info["text"].as_s - json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"] - json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"] - - # TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers" - # TODO: json.field "authorVerified", "ownerBadges" - - published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s) - - json.field "published", published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) - - view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64 - - json.field "viewCount", view_count - json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count)) - end - when .has_key?("backstageImageRenderer") - attachment = attachment["backstageImageRenderer"] - json.field "type", "image" - - json.field "imageThumbnails" do - json.array do - thumbnail = attachment["image"]["thumbnails"][0].as_h - width = thumbnail["width"].as_i - height = thumbnail["height"].as_i - aspect_ratio = (width.to_f / height.to_f) - url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") - - qualities = {320, 560, 640, 1280, 2000} - - qualities.each do |quality| - json.object do - json.field "url", url.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", (quality / aspect_ratio).ceil.to_i - end - end - end - end - # TODO - # when .has_key?("pollRenderer") - # attachment = attachment["pollRenderer"] - # json.field "type", "poll" - else - json.field "type", "unknown" - json.field "error", "Unrecognized attachment type." - end - end - end - end - - if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? || - comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s.gsub(/\D/, "").to_i?) - continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s - continuation ||= "" - - json.field "replies" do - json.object do - json.field "replyCount", reply_count - json.field "continuation", extract_channel_community_cursor(continuation) - end - end - end - end - end - end - end - - if body["continuations"]? - continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s - json.field "continuation", extract_channel_community_cursor(continuation) - end - end - end - - if format == "html" - response = JSON.parse(response) - content_html = template_youtube_comments(response, locale, thin_mode) - - response = JSON.build do |json| - json.object do - json.field "contentHtml", content_html - end - end - end - - return response -end - -def produce_channel_community_continuation(ucid, cursor) - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:string" => cursor || "", - }, - } - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end - -def extract_channel_community_cursor(continuation) - object = URI.decode_www_form(continuation) - .try { |i| Base64.decode(i) } - .try { |i| IO::Memory.new(i) } - .try { |i| Protodec::Any.parse(i) } - .try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h } - - if object["53:2:embedded"]?.try &.["3:0:embedded"]? - object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"] - .try { |i| i["2:0:base64"].as_h } - .try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i, padding: false) } - - object["53:2:embedded"]["3:0:embedded"].as_h.delete("2:0:base64") - end - - cursor = Protodec::Any.cast_json(object) - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - - cursor -end - -def get_about_info(ucid, locale) - result = YT_POOL.client &.get("/channel/#{ucid}/about?gl=US&hl=en") - if result.status_code != 200 - result = YT_POOL.client &.get("/user/#{ucid}/about?gl=US&hl=en") - end - - if md = result.headers["location"]?.try &.match(/\/channel\/(?<ucid>UC[a-zA-Z0-9_-]{22})/) - raise ChannelRedirect.new(channel_id: md["ucid"]) - end - - if result.status_code != 200 - raise InfoException.new("This channel does not exist.") - end - - about = XML.parse_html(result.body) - if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")])) - raise InfoException.new("This channel does not exist.") - end - - initdata = extract_initial_data(result.body) - if initdata.empty? - error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip - error_message ||= translate(locale, "Could not get channel info.") - raise InfoException.new(error_message) - end - - if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]? - raise ChannelRedirect.new(channel_id: browse_endpoint["browseId"].to_s) - end - - auto_generated = false - # Check for special auto generated gaming channels - if !initdata.has_key?("metadata") - auto_generated = true - end - - if auto_generated - author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s - author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s - author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s - - # Raises a KeyError on failure. - banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? - banner = banners.try &.[-1]?.try &.["url"].as_s? - - description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s - description_html = HTML.escape(description).gsub("\n", "<br>") - - paid = false - is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool - allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s } - - related_channels = [] of AboutRelatedChannel - else - 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 - - ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s - - # Raises a KeyError on failure. - banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? - banner = banners.try &.[-1]?.try &.["url"].as_s? - - # if banner.includes? "channels/c4/default_banner" - # banner = nil - # end - - description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" - description_html = HTML.escape(description).gsub("\n", "<br>") - - paid = about.xpath_node(%q(//meta[@itemprop="paid"])).not_nil!["content"] == "True" - is_family_friendly = about.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True" - allowed_regions = about.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",") - - related_channels = initdata["contents"]["twoColumnBrowseResultsRenderer"] - .["secondaryContents"]?.try &.["browseSecondaryContentsRenderer"]["contents"][0]? - .try &.["verticalChannelSectionRenderer"]?.try &.["items"]?.try &.as_a.map do |node| - renderer = node["miniChannelRenderer"]? - related_id = renderer.try &.["channelId"]?.try &.as_s? - related_id ||= "" - - related_title = renderer.try &.["title"]?.try &.["simpleText"]?.try &.as_s? - related_title ||= "" - - related_author_url = renderer.try &.["navigationEndpoint"]?.try &.["commandMetadata"]?.try &.["webCommandMetadata"]? - .try &.["url"]?.try &.as_s? - related_author_url ||= "" - - related_author_thumbnails = renderer.try &.["thumbnail"]?.try &.["thumbnails"]?.try &.as_a? - related_author_thumbnails ||= [] of JSON::Any - - related_author_thumbnail = "" - if related_author_thumbnails.size > 0 - related_author_thumbnail = related_author_thumbnails[-1]["url"]?.try &.as_s? - related_author_thumbnail ||= "" - end - - AboutRelatedChannel.new({ - ucid: related_id, - author: related_title, - author_url: related_author_url, - author_thumbnail: related_author_thumbnail, - }) - end - related_channels ||= [] of AboutRelatedChannel - end - - total_views = 0_i64 - joined = Time.unix(0) - tabs = [] of String - - tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?.try &.as_a? - if !tabs_json.nil? - # Retrieve information from the tabs array. The index we are looking for varies between channels. - tabs_json.each do |node| - # Try to find the about section which is located in only one of the tabs. - channel_about_meta = node["tabRenderer"]?.try &.["content"]?.try &.["sectionListRenderer"]? - .try &.["contents"]?.try &.[0]?.try &.["itemSectionRenderer"]?.try &.["contents"]? - .try &.[0]?.try &.["channelAboutFullMetadataRenderer"]? - - if !channel_about_meta.nil? - total_views = channel_about_meta["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 - - # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. - joined = channel_about_meta["joinedDateText"]?.try &.["runs"]?.try &.as_a.reduce("") { |acc, node| acc + node["text"].as_s } - .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) - - # Normal Auto-generated channels - # https://support.google.com/youtube/answer/2579942 - # For auto-generated channels, channel_about_meta only has ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] - if (channel_about_meta["primaryLinks"]?.try &.size || 0) == 1 && (channel_about_meta["primaryLinks"][0]?) && - (channel_about_meta["primaryLinks"][0]["title"]?.try &.["simpleText"]?.try &.as_s? || "") == "Auto-generated by YouTube" - auto_generated = true - end - end - end - tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map { |node| node["tabRenderer"]["title"].as_s.downcase } - end - - sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s? - .try { |text| short_text_to_number(text.split(" ")[0]) } || 0 - - AboutChannel.new({ - ucid: ucid, - author: author, - auto_generated: auto_generated, - author_url: author_url, - author_thumbnail: author_thumbnail, - banner: banner, - description_html: description_html, - paid: paid, - total_views: total_views, - sub_count: sub_count, - joined: joined, - is_family_friendly: is_family_friendly, - allowed_regions: allowed_regions, - related_channels: related_channels, - tabs: tabs, - }) -end - -def get_channel_videos_response(ucid, page = 1, auto_generated = nil, sort_by = "newest") - continuation = produce_channel_videos_continuation(ucid, page, - auto_generated: auto_generated, sort_by: sort_by, v2: true) - - return request_youtube_api_browse(continuation) -end - -def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") - videos = [] of SearchVideo - - 2.times do |i| - response_json = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) - initial_data = JSON.parse(response_json) - break if !initial_data - videos.concat extract_videos(initial_data.as_h, author, ucid) - end - - return videos.size, videos -end - -def get_latest_videos(ucid) - response_json = get_channel_videos_response(ucid) - initial_data = JSON.parse(response_json) - return [] of SearchVideo if !initial_data - author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s - items = extract_videos(initial_data.as_h, author, ucid) - - return items -end diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr new file mode 100644 index 00000000..13909527 --- /dev/null +++ b/src/invidious/channels/about.cr @@ -0,0 +1,206 @@ +# TODO: Refactor into either SearchChannel or InvidiousChannel +record AboutChannel, + ucid : String, + author : String, + auto_generated : Bool, + author_url : String, + author_thumbnail : String, + banner : String?, + description : String, + description_html : String, + total_views : Int64, + sub_count : Int32, + joined : Time, + is_family_friendly : Bool, + allowed_regions : Array(String), + tabs : Array(String), + tags : Array(String), + verified : Bool, + is_age_gated : Bool + +def get_about_info(ucid, locale) : AboutChannel + begin + # Fetch channel information from channel home page + initdata = YoutubeAPI.browse(browse_id: ucid, params: "") + rescue + raise InfoException.new("Could not get channel info.") + end + + if initdata.dig?("alerts", 0, "alertRenderer", "type") == "ERROR" + error_message = initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s + if error_message == "This channel does not exist." + raise NotFoundException.new(error_message) + else + raise InfoException.new(error_message) + end + end + + if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]? + raise ChannelRedirect.new(channel_id: browse_endpoint["browseId"].to_s) + end + + auto_generated = false + # Check for special auto generated gaming channels + if !initdata.has_key?("metadata") + auto_generated = true + end + + tags = [] of String + tab_names = [] of String + total_views = 0_i64 + joined = Time.unix(0) + + if age_gate_renderer = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer") + description_node = nil + author = age_gate_renderer["channelTitle"].as_s + ucid = initdata.dig("responseContext", "serviceTrackingParams", 0, "params", 0, "value").as_s + author_url = "https://www.youtube.com/channel/#{ucid}" + author_thumbnail = age_gate_renderer.dig("avatar", "thumbnails", 0, "url").as_s + banner = nil + is_family_friendly = false + is_age_gated = true + tab_names = ["videos", "shorts", "streams"] + auto_generated = false + else + if auto_generated + author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s + author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s + author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s + + # Raises a KeyError on failure. + banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? + banner = banners.try &.[-1]?.try &.["url"].as_s? + + description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"] + # some channels have the description in a simpleText + # ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/ + description_node = description_base_node.dig?("simpleText") || description_base_node + + tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges") + .try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String + else + 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")) + + ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s + + # Raises a KeyError on failure. + 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? + + # if banner.includes? "channels/c4/default_banner" + # banner = nil + # end + + description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]? + tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String + end + + is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool + if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]? + # Get the name of the tabs available on this channel + tab_names = tabs_json.as_a.compact_map do |entry| + name = entry.dig?("tabRenderer", "title").try &.as_s.downcase + + # This is a small fix to not add extra code on the HTML side + # I.e, the URL for the "live" tab is .../streams, so use "streams" + # everywhere for the sake of simplicity + (name == "live") ? "streams" : name + end + + # Get the currently active tab ("About") + about_tab = extract_selected_tab(tabs_json) + + # Try to find the about metadata section + channel_about_meta = about_tab.dig?( + "content", + "sectionListRenderer", "contents", 0, + "itemSectionRenderer", "contents", 0, + "channelAboutFullMetadataRenderer" + ) + + if !channel_about_meta.nil? + total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 + + # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. + joined = extract_text(channel_about_meta["joinedDateText"]?) + .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) + + # Normal Auto-generated channels + # https://support.google.com/youtube/answer/2579942 + # For auto-generated channels, channel_about_meta only has + # ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] + auto_generated = ( + (channel_about_meta["primaryLinks"]?.try &.size) == 1 && \ + extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" || + channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "Auto-generated by YouTube" + ) + end + end + end + + allowed_regions = initdata + .dig?("microformat", "microformatDataRenderer", "availableCountries") + .try &.as_a.map(&.as_s) || [] of String + + description = !description_node.nil? ? description_node.as_s : "" + description_html = HTML.escape(description) + + if !description_node.nil? + if description_node.as_h?.nil? + description_node = text_to_parsed_content(description_node.as_s) + end + description_html = parse_content(description_node) + if description_html == "" && description != "" + description_html = HTML.escape(description) + end + end + + sub_count = 0 + + 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 + end + break if sub_count != 0 + end + end + + AboutChannel.new( + ucid: ucid, + author: author, + auto_generated: auto_generated, + author_url: author_url, + author_thumbnail: author_thumbnail, + banner: banner, + description: description, + description_html: description_html, + total_views: total_views, + sub_count: sub_count, + joined: joined, + is_family_friendly: is_family_friendly, + allowed_regions: allowed_regions, + tabs: tab_names, + tags: tags, + verified: author_verified || false, + is_age_gated: is_age_gated || false, + ) +end + +def fetch_related_channels(about_channel : AboutChannel, continuation : String? = nil) : {Array(SearchChannel), String?} + if continuation.nil? + # params is {"2:string":"channels"} encoded + initial_data = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D") + else + initial_data = YoutubeAPI.browse(continuation) + end + + items, continuation = extract_items(initial_data) + + return items.select(SearchChannel), continuation +end diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr new file mode 100644 index 00000000..1478c8fc --- /dev/null +++ b/src/invidious/channels/channels.cr @@ -0,0 +1,304 @@ +struct InvidiousChannel + include DB::Serializable + + property id : String + property author : String + property updated : Time + property deleted : Bool + property subscribed : Time? +end + +struct ChannelVideo + include DB::Serializable + + property id : String + property title : String + property published : Time + property updated : Time + property ucid : String + property author : String + property length_seconds : Int32 = 0 + property live_now : Bool = false + property premiere_timestamp : Time? = nil + property views : Int64? = nil + + def to_json(locale, json : JSON::Builder) + json.object do + json.field "type", "shortVideo" + + json.field "title", self.title + json.field "videoId", self.id + json.field "videoThumbnails" do + Invidious::JSONify::APIv1.thumbnails(json, self.id) + end + + json.field "lengthSeconds", self.length_seconds + + json.field "author", self.author + 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 "viewCount", self.views + end + end + + def to_json(locale, _json : Nil = nil) + JSON.build do |json| + to_json(locale, json) + end + end + + def to_xml(locale, query_params, xml : XML::Builder) + query_params["v"] = self.id + + xml.element("entry") do + xml.element("id") { xml.text "yt:video:#{self.id}" } + xml.element("yt:videoId") { xml.text self.id } + xml.element("yt:channelId") { xml.text self.ucid } + xml.element("title") { xml.text self.title } + xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") + + xml.element("author") do + xml.element("name") { xml.text self.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } + end + + xml.element("content", type: "xhtml") do + xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do + xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do + xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") + end + end + end + + xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } + xml.element("updated") { xml.text self.updated.to_s("%Y-%m-%dT%H:%M:%S%:z") } + + xml.element("media:group") do + xml.element("media:title") { xml.text self.title } + xml.element("media:thumbnail", url: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg", + width: "320", height: "180") + end + end + end + + def to_xml(locale, _xml : Nil = nil) + XML.build do |xml| + to_xml(locale, xml) + end + end + + def to_tuple + {% begin %} + { + {{@type.instance_vars.map(&.name).splat}} + } + {% end %} + end +end + +class ChannelRedirect < Exception + property channel_id : String + + def initialize(@channel_id) + end +end + +def get_batch_channels(channels) + finished_channel = Channel(String | Nil).new + max_threads = 10 + + spawn do + active_threads = 0 + active_channel = Channel(Nil).new + + channels.each do |ucid| + if active_threads >= max_threads + active_channel.receive + active_threads -= 1 + end + + active_threads += 1 + spawn do + begin + get_channel(ucid) + finished_channel.send(ucid) + rescue ex + finished_channel.send(nil) + ensure + active_channel.send(nil) + end + end + end + end + + final = [] of String + channels.size.times do + if ucid = finished_channel.receive + final << ucid + end + end + + return final +end + +def get_channel(id) : InvidiousChannel + channel = Invidious::Database::Channels.select(id) + + if channel.nil? || (Time.utc - channel.updated) > 2.days + channel = fetch_channel(id, pull_all_videos: false) + Invidious::Database::Channels.insert(channel, update_on_conflict: true) + end + + return channel +end + +def fetch_channel(ucid, pull_all_videos : Bool) + LOGGER.debug("fetch_channel: #{ucid}") + LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}") + + namespaces = { + "yt" => "http://www.youtube.com/xml/schemas/2015", + "media" => "http://search.yahoo.com/mrss/", + "default" => "http://www.w3.org/2005/Atom", + } + + LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed") + rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body + LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed") + rss = XML.parse(rss) + + author = rss.xpath_node("//default:feed/default:title", namespaces) + if !author + raise InfoException.new("Deleted or invalid channel") + end + + author = author.content + + # Auto-generated channels + # https://support.google.com/youtube/answer/2579942 + if author.ends_with?(" - Topic") || + {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author + auto_generated = true + end + + LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") + + channel = InvidiousChannel.new({ + id: ucid, + author: author, + updated: Time.utc, + deleted: false, + subscribed: nil, + }) + + LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page") + videos, continuation = IV::Channel::Tabs.get_videos(channel) + + LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") + rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry| + video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content + title = entry.xpath_node("default:title", namespaces).not_nil!.content + + published = Time.parse_rfc3339( + entry.xpath_node("default:published", namespaces).not_nil!.content + ) + updated = Time.parse_rfc3339( + entry.xpath_node("default:updated", namespaces).not_nil!.content + ) + + author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content + ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content + + views = entry + .xpath_node("media:group/media:community/media:statistics", namespaces) + .try &.["views"]?.try &.to_i64? || 0_i64 + + channel_video = videos + .select(SearchVideo) + .select(&.id.== video_id)[0]? + + length_seconds = channel_video.try &.length_seconds + length_seconds ||= 0 + + live_now = channel_video.try &.badges.live_now? + live_now ||= false + + premiere_timestamp = channel_video.try &.premiere_timestamp + + video = ChannelVideo.new({ + id: video_id, + title: title, + published: published, + updated: updated, + ucid: ucid, + author: author, + length_seconds: length_seconds, + live_now: live_now, + premiere_timestamp: premiere_timestamp, + views: views, + }) + + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video") + + # We don't include the 'premiere_timestamp' here because channel pages don't include them, + # meaning the above timestamp is always null + was_insert = Invidious::Database::ChannelVideos.insert(video) + + if was_insert + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") + if CONFIG.enable_user_notifications + Invidious::Database::Users.add_notification(video) + else + Invidious::Database::Users.feed_needs_update(video) + end + else + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") + end + end + + if pull_all_videos + loop do + # Keep fetching videos using the continuation token retrieved earlier + videos, continuation = IV::Channel::Tabs.get_videos(channel, continuation: continuation) + + count = 0 + videos.select(SearchVideo).each do |video| + count += 1 + video = ChannelVideo.new({ + id: video.id, + title: video.title, + published: video.published, + updated: Time.utc, + ucid: video.ucid, + author: video.author, + length_seconds: video.length_seconds, + live_now: video.badges.live_now?, + premiere_timestamp: video.premiere_timestamp, + views: video.views, + }) + + # We are notified of Red videos elsewhere (PubSub), which includes a correct published date, + # so since they don't provide a published date here we can safely ignore them. + if Time.utc - video.published > 1.minute + was_insert = Invidious::Database::ChannelVideos.insert(video) + if was_insert + if CONFIG.enable_user_notifications + Invidious::Database::Users.add_notification(video) + else + Invidious::Database::Users.feed_needs_update(video) + end + end + end + end + + break if count < 25 + sleep 500.milliseconds + end + end + + channel.updated = Time.utc + return channel +end diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr new file mode 100644 index 00000000..49ffd990 --- /dev/null +++ b/src/invidious/channels/community.cr @@ -0,0 +1,332 @@ +private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000} + +# TODO: Add "sort_by" +def fetch_channel_community(ucid, cursor, locale, format, thin_mode) + if cursor.nil? + # Egljb21tdW5pdHk%3D is the protobuf object to load "community" + initial_data = YoutubeAPI.browse(ucid, params: "Egljb21tdW5pdHk%3D") + + items = [] of JSON::Any + extract_items(initial_data) do |item| + items << item + end + else + continuation = produce_channel_community_continuation(ucid, cursor) + initial_data = YoutubeAPI.browse(continuation: continuation) + + container = initial_data.dig?("continuationContents", "itemSectionContinuation", "contents") + + raise InfoException.new("Can't extract community data") if container.nil? + + items = container.as_a + end + + return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode) +end + +def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode) + object = { + "2:string" => "community", + "25:embedded" => { + "22:string" => post_id.to_s, + }, + "45:embedded" => { + "2:varint" => 1_i64, + "3:varint" => 1_i64, + }, + } + params = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + initial_data = YoutubeAPI.browse(ucid, params: params) + + items = [] of JSON::Any + extract_items(initial_data) do |item| + items << item + end + + return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode, is_single_post: true) +end + +def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_single_post : Bool = false) + if message = items[0]["messageRenderer"]? + error_message = (message["text"]["simpleText"]? || + message["text"]["runs"]?.try &.[0]?.try &.["text"]?) + .try &.as_s || "" + if error_message == "This channel does not exist." + raise NotFoundException.new(error_message) + else + raise InfoException.new(error_message) + end + end + + response = JSON.build do |json| + json.object do + json.field "authorId", ucid + if is_single_post + json.field "singlePost", true + end + json.field "comments" do + json.array do + items.each do |post| + comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? || + post["backstageCommentsContinuation"]? + + post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? || + post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]? + + next if !post + + content_html = post["contentText"]?.try { |t| parse_content(t) } || "" + author = post["authorText"]["runs"]?.try &.[0]?.try &.["text"]? || "" + + json.object do + json.field "author", author + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s + + qualities.each do |quality| + json.object do + json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-") + json.field "width", quality + json.field "height", quality + end + end + end + end + + if post["authorEndpoint"]? + json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"] + json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s + else + json.field "authorId", "" + json.field "authorUrl", "" + end + + published_text = post["publishedTimeText"]["runs"][0]["text"].as_s + published = decode_date(published_text.rchop(" (edited)")) + + if published_text.includes?(" (edited)") + json.field "isEdited", true + else + json.field "isEdited", false + end + + like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"] + .try &.as_s.gsub(/\D/, "").to_i? || 0 + + 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 "contentHtml", content_html + + json.field "published", published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) + + json.field "likeCount", like_count + json.field "replyCount", reply_count + json.field "commentId", post["postId"]? || post["commentId"]? || "" + json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid + + if attachment = post["backstageAttachment"]? + json.field "attachment" do + case attachment.as_h + when .has_key?("videoRenderer") + parse_item(attachment) + .as(SearchVideo) + .to_json(locale, json) + when .has_key?("backstageImageRenderer") + json.object do + attachment = attachment["backstageImageRenderer"] + json.field "type", "image" + + json.field "imageThumbnails" do + json.array do + thumbnail = attachment["image"]["thumbnails"][0].as_h + width = thumbnail["width"].as_i + height = thumbnail["height"].as_i + aspect_ratio = (width.to_f / height.to_f) + url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") + + IMAGE_QUALITIES.each do |quality| + json.object do + json.field "url", url.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", (quality / aspect_ratio).ceil.to_i + end + end + end + end + end + when .has_key?("pollRenderer") + json.object do + attachment = attachment["pollRenderer"] + json.field "type", "poll" + json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0]) + json.field "choices" do + json.array do + attachment["choices"].as_a.each do |choice| + json.object do + json.field "text", choice.dig("text", "runs", 0, "text").as_s + # A choice can have an image associated with it. + # Ex post: https://www.youtube.com/post/UgkxD4XavXUD4NQiddJXXdohbwOwcVqrH9Re + if choice["image"]? + thumbnail = choice["image"]["thumbnails"][0].as_h + width = thumbnail["width"].as_i + height = thumbnail["height"].as_i + aspect_ratio = (width.to_f / height.to_f) + url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") + json.field "image" do + json.array do + IMAGE_QUALITIES.each do |quality| + json.object do + json.field "url", url.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", (quality / aspect_ratio).ceil.to_i + end + end + end + end + end + end + end + end + end + end + when .has_key?("postMultiImageRenderer") + json.object do + attachment = attachment["postMultiImageRenderer"] + json.field "type", "multiImage" + json.field "images" do + json.array do + attachment["images"].as_a.each do |image| + json.array do + thumbnail = image["backstageImageRenderer"]["image"]["thumbnails"][0].as_h + width = thumbnail["width"].as_i + height = thumbnail["height"].as_i + aspect_ratio = (width.to_f / height.to_f) + url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") + + IMAGE_QUALITIES.each do |quality| + json.object do + json.field "url", url.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", (quality / aspect_ratio).ceil.to_i + end + end + end + end + end + end + end + when .has_key?("playlistRenderer") + parse_item(attachment) + .as(SearchPlaylist) + .to_json(locale, json) + when .has_key?("quizRenderer") + json.object do + attachment = attachment["quizRenderer"] + json.field "type", "quiz" + json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0]) + json.field "choices" do + json.array do + attachment["choices"].as_a.each do |choice| + json.object do + json.field "text", choice.dig("text", "runs", 0, "text").as_s + json.field "isCorrect", choice["isCorrect"].as_bool + end + end + end + end + end + else + json.object do + json.field "type", "unknown" + json.field "error", "Unrecognized attachment type." + end + end + end + end + + if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? || + comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?) + .try &.as_s.gsub(/\D/, "").to_i?) + continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s + continuation ||= "" + + json.field "replies" do + json.object do + json.field "replyCount", reply_count + json.field "continuation", extract_channel_community_cursor(continuation) + end + end + end + end + end + end + end + if !is_single_post + if cont = items.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token") + json.field "continuation", extract_channel_community_cursor(cont.as_s) + end + end + end + end + + if format == "html" + response = JSON.parse(response) + content_html = IV::Frontend::Comments.template_youtube(response, locale, thin_mode) + + response = JSON.build do |json| + json.object do + json.field "contentHtml", content_html + end + end + end + + return response +end + +def produce_channel_community_continuation(ucid, cursor) + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:string" => cursor || "", + }, + } + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation +end + +def extract_channel_community_cursor(continuation) + object = URI.decode_www_form(continuation) + .try { |i| Base64.decode(i) } + .try { |i| IO::Memory.new(i) } + .try { |i| Protodec::Any.parse(i) } + .try(&.["80226972:0:embedded"]["3:1:base64"].as_h) + + if object["53:2:embedded"]?.try &.["3:0:embedded"]? + object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"] + .try(&.["2:0:base64"].as_h) + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i, padding: false) } + + object["53:2:embedded"]["3:0:embedded"].as_h.delete("2:0:base64") + end + + cursor = Protodec::Any.cast_json(object) + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + + cursor +end diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr new file mode 100644 index 00000000..91029fe3 --- /dev/null +++ b/src/invidious/channels/playlists.cr @@ -0,0 +1,46 @@ +def fetch_channel_playlists(ucid, author, continuation, sort_by) + if continuation + initial_data = YoutubeAPI.browse(continuation) + else + params = + case sort_by + when "last", "last_added" + # Equivalent to "&sort=lad" + # {"2:string": "playlists", "3:varint": 4, "4:varint": 1, "6:varint": 1} + "EglwbGF5bGlzdHMYBCABMAE%3D" + when "oldest", "oldest_created" + # formerly "&sort=da" + # Not available anymore :c or maybe ?? + # {"2:string": "playlists", "3:varint": 2, "4:varint": 1, "6:varint": 1} + "EglwbGF5bGlzdHMYAiABMAE%3D" + # {"2:string": "playlists", "3:varint": 1, "4:varint": 1, "6:varint": 1} + # "EglwbGF5bGlzdHMYASABMAE%3D" + when "newest", "newest_created" + # Formerly "&sort=dd" + # {"2:string": "playlists", "3:varint": 3, "4:varint": 1, "6:varint": 1} + "EglwbGF5bGlzdHMYAyABMAE%3D" + end + + initial_data = YoutubeAPI.browse(ucid, params: params || "") + end + + return extract_items(initial_data, author, ucid) +end + +def fetch_channel_podcasts(ucid, author, continuation) + if continuation + initial_data = YoutubeAPI.browse(continuation) + else + initial_data = YoutubeAPI.browse(ucid, params: "Eghwb2RjYXN0c_IGBQoDugEA") + end + return extract_items(initial_data, author, ucid) +end + +def fetch_channel_releases(ucid, author, continuation) + if continuation + initial_data = YoutubeAPI.browse(continuation) + else + initial_data = YoutubeAPI.browse(ucid, params: "EghyZWxlYXNlc_IGBQoDsgEA") + end + return extract_items(initial_data, author, ucid) +end diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr new file mode 100644 index 00000000..96400f47 --- /dev/null +++ b/src/invidious/channels/videos.cr @@ -0,0 +1,192 @@ +module Invidious::Channel::Tabs + extend self + + # ------------------- + # Regular videos + # ------------------- + + # Wrapper for AboutChannel, as we still need to call get_videos with + # an author name and ucid directly (e.g in RSS feeds). + # TODO: figure out how to get rid of that + def get_videos(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") + return get_videos( + channel.author, channel.ucid, + continuation: continuation, sort_by: sort_by + ) + end + + # Wrapper for InvidiousChannel, as we still need to call get_videos with + # an author name and ucid directly (e.g in RSS feeds). + # TODO: figure out how to get rid of that + def get_videos(channel : InvidiousChannel, *, continuation : String? = nil, sort_by = "newest") + return get_videos( + channel.author, channel.id, + continuation: continuation, sort_by: sort_by + ) + end + + def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest") + continuation ||= make_initial_videos_ctoken(ucid, sort_by) + initial_data = YoutubeAPI.browse(continuation: continuation) + + return extract_items(initial_data, author, ucid) + end + + def get_60_videos(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") + if continuation.nil? + # Fetch the first "page" of video + items, next_continuation = get_videos(channel, sort_by: sort_by) + else + # Fetch a "page" of videos using the given continuation token + items, next_continuation = get_videos(channel, continuation: continuation) + end + + # If there is more to load, then load a second "page" + # and replace the previous continuation token + if !next_continuation.nil? + items_2, next_continuation = get_videos(channel, continuation: next_continuation) + items.concat items_2 + end + + return items, next_continuation + end + + # ------------------- + # Shorts + # ------------------- + + def get_shorts(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") + continuation ||= make_initial_shorts_ctoken(channel.ucid, sort_by) + initial_data = YoutubeAPI.browse(continuation: continuation) + + return extract_items(initial_data, channel.author, channel.ucid) + end + + # ------------------- + # Livestreams + # ------------------- + + def get_livestreams(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") + continuation ||= make_initial_livestreams_ctoken(channel.ucid, sort_by) + initial_data = YoutubeAPI.browse(continuation: continuation) + + return extract_items(initial_data, channel.author, channel.ucid) + end + + def get_60_livestreams(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") + if continuation.nil? + # Fetch the first "page" of stream + items, next_continuation = get_livestreams(channel, sort_by: sort_by) + else + # Fetch a "page" of streams using the given continuation token + items, next_continuation = get_livestreams(channel, continuation: continuation) + end + + # If there is more to load, then load a second "page" + # and replace the previous continuation token + if !next_continuation.nil? + items_2, next_continuation = get_livestreams(channel, continuation: next_continuation) + items.concat items_2 + end + + return items, next_continuation + end + + # ------------------- + # C-tokens + # ------------------- + + private def sort_options_videos_short(sort_by : String) + case sort_by + when "newest" then return 4_i64 + when "popular" then return 2_i64 + when "oldest" then return 5_i64 + else return 4_i64 # Fallback to "newest" + end + end + + # Generate the initial "continuation token" to get the first page of the + # "videos" tab. The following page requires the ctoken provided in that + # first page, and so on. + private def make_initial_videos_ctoken(ucid : String, sort_by = "newest") + object = { + "15:embedded" => { + "2:embedded" => { + "1:string" => "00000000-0000-0000-0000-000000000000", + }, + "4:varint" => sort_options_videos_short(sort_by), + }, + } + + return channel_ctoken_wrap(ucid, object) + end + + # Generate the initial "continuation token" to get the first page of the + # "shorts" tab. The following page requires the ctoken provided in that + # first page, and so on. + private def make_initial_shorts_ctoken(ucid : String, sort_by = "newest") + object = { + "10:embedded" => { + "2:embedded" => { + "1:string" => "00000000-0000-0000-0000-000000000000", + }, + "4:varint" => sort_options_videos_short(sort_by), + }, + } + + return channel_ctoken_wrap(ucid, object) + end + + # Generate the initial "continuation token" to get the first page of the + # "livestreams" tab. The following page requires the ctoken provided in that + # first page, and so on. + private def make_initial_livestreams_ctoken(ucid : String, sort_by = "newest") + sort_by_numerical = + case sort_by + when "newest" then 12_i64 + when "popular" then 14_i64 + when "oldest" then 13_i64 + else 12_i64 # Fallback to "newest" + end + + object = { + "14:embedded" => { + "2:embedded" => { + "1:string" => "00000000-0000-0000-0000-000000000000", + }, + "5:varint" => sort_by_numerical, + }, + } + + return channel_ctoken_wrap(ucid, object) + end + + # The protobuf structure common between videos/shorts/livestreams + private def channel_ctoken_wrap(ucid : String, object) + object_inner = { + "110:embedded" => { + "3:embedded" => object, + }, + } + + object_inner_encoded = object_inner + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:string" => object_inner_encoded, + }, + } + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation + end +end diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr deleted file mode 100644 index 81d6ac2b..00000000 --- a/src/invidious/comments.cr +++ /dev/null @@ -1,662 +0,0 @@ -class RedditThing - include JSON::Serializable - - property kind : String - property data : RedditComment | RedditLink | RedditMore | RedditListing -end - -class RedditComment - include JSON::Serializable - - property author : String - property body_html : String - property replies : RedditThing | String - property score : Int32 - property depth : Int32 - property permalink : String - - @[JSON::Field(converter: RedditComment::TimeConverter)] - property created_utc : Time - - module TimeConverter - def self.from_json(value : JSON::PullParser) : Time - Time.unix(value.read_float.to_i) - end - - def self.to_json(value : Time, json : JSON::Builder) - json.number(value.to_unix) - end - end -end - -struct RedditLink - include JSON::Serializable - - property author : String - property score : Int32 - property subreddit : String - property num_comments : Int32 - property id : String - property permalink : String - property title : String -end - -struct RedditMore - include JSON::Serializable - - property children : Array(String) - property count : Int32 - property depth : Int32 -end - -class RedditListing - include JSON::Serializable - - property children : Array(RedditThing) - property modhash : String -end - -def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, sort_by = "top", action = "action_get_comments") - video = get_video(id, db, region: region) - session_token = video.session_token - - case cursor - when nil, "" - ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by) - # when .starts_with? "Ug" - # ctoken = produce_comment_reply_continuation(id, video.ucid, cursor) - when .starts_with? "ADSJ" - ctoken = produce_comment_continuation(id, cursor: cursor, sort_by: sort_by) - else - ctoken = cursor - end - - if !session_token - if format == "json" - return {"comments" => [] of String}.to_json - else - return {"contentHtml" => "", "commentCount" => 0}.to_json - end - end - - post_req = { - page_token: ctoken, - session_token: session_token, - } - - headers = HTTP::Headers{ - "cookie" => video.cookie, - } - - response = YT_POOL.client(region, &.post("/comment_service_ajax?#{action}=1&hl=en&gl=US&pbj=1", headers, form: post_req)) - response = JSON.parse(response.body) - - # For some reason youtube puts it in an array for comment_replies but otherwise it's the same - if action == "action_get_comment_replies" - response = response[1] - end - - if !response["response"]["continuationContents"]? - raise InfoException.new("Could not fetch comments") - end - - response = response["response"]["continuationContents"] - if response["commentRepliesContinuation"]? - body = response["commentRepliesContinuation"] - else - body = response["itemSectionContinuation"] - end - - contents = body["contents"]? - if !contents - if format == "json" - return {"comments" => [] of String}.to_json - else - return {"contentHtml" => "", "commentCount" => 0}.to_json - end - end - - response = JSON.build do |json| - json.object do - if body["header"]? - count_text = body["header"]["commentsHeaderRenderer"]["countText"] - comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s.gsub(/\D/, "").to_i? || 0 - - json.field "commentCount", comment_count - end - - json.field "videoId", id - - json.field "comments" do - json.array do - contents.as_a.each do |node| - json.object do - if !response["commentRepliesContinuation"]? - node = node["commentThreadRenderer"] - end - - if node["replies"]? - node_replies = node["replies"]["commentRepliesRenderer"] - end - - if !response["commentRepliesContinuation"]? - node_comment = node["comment"]["commentRenderer"] - else - node_comment = node["commentRenderer"] - end - - content_html = node_comment["contentText"]?.try { |t| parse_content(t) } || "" - author = node_comment["authorText"]?.try &.["simpleText"]? || "" - - json.field "author", author - json.field "authorThumbnails" do - json.array do - node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail| - json.object do - json.field "url", thumbnail["url"] - json.field "width", thumbnail["width"] - json.field "height", thumbnail["height"] - end - end - end - end - - if node_comment["authorEndpoint"]? - json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"] - json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] - else - json.field "authorId", "" - json.field "authorUrl", "" - end - - published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s - published = decode_date(published_text.rchop(" (edited)")) - - if published_text.includes?(" (edited)") - json.field "isEdited", true - else - json.field "isEdited", false - end - - json.field "content", 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)) - - comment_action_buttons_renderer = node_comment["actionButtons"]["commentActionButtonsRenderer"] - - json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i - json.field "commentId", node_comment["commentId"] - json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"] - - if comment_action_buttons_renderer["creatorHeart"]? - hearth_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"] - json.field "creatorHeart" do - json.object do - json.field "creatorThumbnail", hearth_data["thumbnails"][-1]["url"] - json.field "creatorName", hearth_data["accessibility"]["accessibilityData"]["label"] - end - end - end - - if node_replies && !response["commentRepliesContinuation"]? - if node_replies["moreText"]? - reply_count = (node_replies["moreText"]["simpleText"]? || node_replies["moreText"]["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s.gsub(/\D/, "").to_i? || 1 - elsif node_replies["viewReplies"]? - reply_count = node_replies["viewReplies"]["buttonRenderer"]["text"]?.try &.["runs"][1]?.try &.["text"]?.try &.as_s.to_i? || 1 - else - reply_count = 1 - end - - continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s - continuation ||= "" - - json.field "replies" do - json.object do - json.field "replyCount", reply_count - json.field "continuation", continuation - end - end - end - end - end - end - end - - if body["continuations"]? - continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s - json.field "continuation", continuation - end - end - end - - if format == "html" - response = JSON.parse(response) - content_html = template_youtube_comments(response, locale, thin_mode, action == "action_get_comment_replies") - - response = JSON.build do |json| - json.object do - json.field "contentHtml", content_html - - if response["commentCount"]? - json.field "commentCount", response["commentCount"] - else - json.field "commentCount", 0 - end - end - end - end - - return response -end - -def fetch_reddit_comments(id, sort_by = "confidence") - client = make_client(REDDIT_URL) - headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} - - # TODO: Use something like #479 for a static list of instances to use here - query = "(url:3D#{id}%20OR%20url:#{id})%20(site:invidio.us%20OR%20site:youtube.com%20OR%20site:youtu.be)" - search_results = client.get("/search.json?q=#{query}", headers) - - if search_results.status_code == 200 - search_results = RedditThing.from_json(search_results.body) - - # For videos that have more than one thread, choose the one with the highest score - thread = search_results.data.as(RedditListing).children.sort_by { |child| child.data.as(RedditLink).score }[-1] - thread = thread.data.as(RedditLink) - - result = client.get("/r/#{thread.subreddit}/comments/#{thread.id}.json?limit=100&sort=#{sort_by}", headers).body - result = Array(RedditThing).from_json(result) - elsif search_results.status_code == 302 - # Previously, if there was only one result then the API would redirect to that result. - # Now, it appears it will still return a listing so this section is likely unnecessary. - - result = client.get(search_results.headers["Location"], headers).body - result = Array(RedditThing).from_json(result) - - thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink) - else - raise InfoException.new("Could not fetch comments") - end - - client.close - - comments = result[1].data.as(RedditListing).children - return comments, thread -end - -def template_youtube_comments(comments, locale, thin_mode, is_replies = false) - String.build do |html| - root = comments["comments"].as_a - root.each do |child| - if child["replies"]? - replies_html = <<-END_HTML - <div id="replies" class="pure-g"> - <div class="pure-u-1-24"></div> - <div class="pure-u-23-24"> - <p> - <a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}" - data-onclick="get_youtube_replies" data-load-replies>#{translate(locale, "View `x` replies", number_with_separator(child["replies"]["replyCount"]))}</a> - </p> - </div> - </div> - END_HTML - end - - if !thin_mode - author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}" - else - author_thumbnail = "" - end - - html << <<-END_HTML - <div class="pure-g" style="width:100%"> - <div class="channel-profile pure-u-4-24 pure-u-md-2-24"> - <img style="padding-right:1em;padding-top:1em;width:90%" src="#{author_thumbnail}"> - </div> - <div class="pure-u-20-24 pure-u-md-22-24"> - <p> - <b> - <a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{child["author"]}</a> - </b> - <p style="white-space:pre-wrap">#{child["contentHtml"]}</p> - END_HTML - - if child["attachment"]? - attachment = child["attachment"] - - case attachment["type"] - when "image" - attachment = attachment["imageThumbnails"][1] - - html << <<-END_HTML - <div class="pure-g"> - <div class="pure-u-1 pure-u-md-1-2"> - <img style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}"> - </div> - </div> - END_HTML - when "video" - html << <<-END_HTML - <div class="pure-g"> - <div class="pure-u-1 pure-u-md-1-2"> - <div style="position:relative;width:100%;height:0;padding-bottom:56.25%;margin-bottom:5px"> - END_HTML - - if attachment["error"]? - html << <<-END_HTML - <p>#{attachment["error"]}</p> - END_HTML - else - html << <<-END_HTML - <iframe id='ivplayer' style='position:absolute;width:100%;height:100%;left:0;top:0' src='/embed/#{attachment["videoId"]?}?autoplay=0' style='border:none;'></iframe> - END_HTML - end - - html << <<-END_HTML - </div> - </div> - </div> - END_HTML - else nil # Ignore - end - end - - html << <<-END_HTML - <span title="#{Time.unix(child["published"].as_i64).to_s(translate(locale, "%A %B %-d, %Y"))}">#{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""}</span> - | - END_HTML - - if comments["videoId"]? - html << <<-END_HTML - <a href="https://www.youtube.com/watch?v=#{comments["videoId"]}&lc=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a> - | - END_HTML - elsif comments["authorId"]? - html << <<-END_HTML - <a href="https://www.youtube.com/channel/#{comments["authorId"]}/community?lb=#{child["commentId"]}" title="#{translate(locale, "YouTube comment permalink")}">[YT]</a> - | - END_HTML - end - - html << <<-END_HTML - <i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])} - END_HTML - - if child["creatorHeart"]? - if !thin_mode - creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}" - else - creator_thumbnail = "" - end - - html << <<-END_HTML - <span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}"> - <div class="creator-heart"> - <img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img> - <div class="creator-heart-small-hearted"> - <div class="icon ion-ios-heart creator-heart-small-container"></div> - </div> - </div> - </span> - END_HTML - end - - html << <<-END_HTML - </p> - #{replies_html} - </div> - </div> - END_HTML - end - - if comments["continuation"]? - html << <<-END_HTML - <div class="pure-g"> - <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> - </p> - </div> - </div> - END_HTML - end - end -end - -def template_reddit_comments(root, locale) - String.build do |html| - root.each do |child| - if child.data.is_a?(RedditComment) - child = child.data.as(RedditComment) - body_html = HTML.unescape(child.body_html) - - replies_html = "" - if child.replies.is_a?(RedditThing) - replies = child.replies.as(RedditThing) - replies_html = template_reddit_comments(replies.data.as(RedditListing).children, locale) - end - - if child.depth > 0 - html << <<-END_HTML - <div class="pure-g"> - <div class="pure-u-1-24"> - </div> - <div class="pure-u-23-24"> - END_HTML - else - html << <<-END_HTML - <div class="pure-g"> - <div class="pure-u-1"> - END_HTML - end - - html << <<-END_HTML - <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(locale, "`x` points", number_with_separator(child.score))} - <span title="#{child.created_utc.to_s(translate(locale, "%a %B %-d %T %Y UTC"))}">#{translate(locale, "`x` ago", recode_date(child.created_utc, locale))}</span> - <a href="https://www.reddit.com#{child.permalink}" title="#{translate(locale, "permalink")}">#{translate(locale, "permalink")}</a> - </p> - <div> - #{body_html} - #{replies_html} - </div> - </div> - </div> - END_HTML - end - end - end -end - -def replace_links(html) - html = XML.parse_html(html) - - html.xpath_nodes(%q(//a)).each do |anchor| - url = URI.parse(anchor["href"]) - - if {"www.youtube.com", "m.youtube.com", "youtu.be"}.includes?(url.host) - if url.path == "/redirect" - params = HTTP::Params.parse(url.query.not_nil!) - anchor["href"] = params["q"]? - else - anchor["href"] = url.request_target - end - elsif url.to_s == "#" - begin - length_seconds = decode_length_seconds(anchor.content) - rescue ex - length_seconds = decode_time(anchor.content) - end - - if length_seconds > 0 - anchor["href"] = "javascript:void(0)" - anchor["onclick"] = "player.currentTime(#{length_seconds})" - else - anchor["href"] = url.request_target - end - end - end - - html = html.xpath_node(%q(//body)).not_nil! - if node = html.xpath_node(%q(./p)) - html = node - end - - return html.to_xml(options: XML::SaveOptions::NO_DECL) -end - -def fill_links(html, scheme, host) - html = XML.parse_html(html) - - html.xpath_nodes("//a").each do |match| - url = URI.parse(match["href"]) - # Reddit links don't have host - if !url.host && !match["href"].starts_with?("javascript") && !url.to_s.ends_with? "#" - url.scheme = scheme - url.host = host - match["href"] = url - end - end - - if host == "www.youtube.com" - html = html.xpath_node(%q(//body/p)).not_nil! - end - - return html.to_xml(options: XML::SaveOptions::NO_DECL) -end - -def parse_content(content : JSON::Any) : String - content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || - content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s } || "" -end - -def content_to_comment_html(content) - comment_html = content.map do |run| - text = HTML.escape(run["text"].as_s).gsub("\n", "<br>") - - if run["bold"]? - text = "<b>#{text}</b>" - end - - if run["italics"]? - text = "<i>#{text}</i>" - end - - if run["navigationEndpoint"]? - if url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s - url = URI.parse(url) - - if !url.host || {"m.youtube.com", "www.youtube.com", "youtu.be"}.includes? url.host - if url.path == "/redirect" - url = HTTP::Params.parse(url.query.not_nil!)["q"] - else - url = url.request_target - end - end - - text = %(<a href="#{url}">#{text}</a>) - elsif watch_endpoint = run["navigationEndpoint"]["watchEndpoint"]? - length_seconds = watch_endpoint["startTimeSeconds"]? - video_id = watch_endpoint["videoId"].as_s - - if length_seconds && length_seconds.as_i > 0 - text = %(<a href="javascript:void(0)" data-onclick="jump_to_time" data-jump-time="#{length_seconds}">#{text}</a>) - else - text = %(<a href="/watch?v=#{video_id}">#{text}</a>) - end - elsif url = run["navigationEndpoint"]["commandMetadata"]?.try &.["webCommandMetadata"]["url"].as_s - text = %(<a href="#{url}">#{text}</a>) - end - end - - text - end.join("").delete('\ufeff') - - return comment_html -end - -def produce_comment_continuation(video_id, cursor = "", sort_by = "top") - object = { - "2:embedded" => { - "2:string" => video_id, - "25:varint" => 0_i64, - "28:varint" => 1_i64, - "36:embedded" => { - "5:varint" => -1_i64, - "8:varint" => 0_i64, - }, - "40:embedded" => { - "1:varint" => 4_i64, - "3:string" => "https://www.youtube.com", - "4:string" => "", - }, - }, - "3:varint" => 6_i64, - "6:embedded" => { - "1:string" => cursor, - "4:embedded" => { - "4:string" => video_id, - "6:varint" => 0_i64, - }, - "5:varint" => 20_i64, - }, - } - - case sort_by - when "top" - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 - when "new", "newest" - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64 - else # top - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 - end - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end - -def produce_comment_reply_continuation(video_id, ucid, comment_id) - object = { - "2:embedded" => { - "2:string" => video_id, - "24:varint" => 1_i64, - "25:varint" => 1_i64, - "28:varint" => 1_i64, - "36:embedded" => { - "5:varint" => -1_i64, - "8:varint" => 0_i64, - }, - }, - "3:varint" => 6_i64, - "6:embedded" => { - "3:embedded" => { - "2:string" => comment_id, - "4:embedded" => { - "1:varint" => 0_i64, - }, - "5:string" => ucid, - "6:string" => video_id, - "8:varint" => 1_i64, - "9:varint" => 10_i64, - }, - }, - } - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end diff --git a/src/invidious/comments/content.cr b/src/invidious/comments/content.cr new file mode 100644 index 00000000..1f55bfe6 --- /dev/null +++ b/src/invidious/comments/content.cr @@ -0,0 +1,89 @@ +def text_to_parsed_content(text : String) : JSON::Any + nodes = [] of JSON::Any + # For each line convert line to array of nodes + text.split('\n').each do |line| + # In first case line is just a simple node before + # check patterns inside line + # { 'text': line } + current_nodes = [] of JSON::Any + initial_node = {"text" => line} + current_nodes << (JSON.parse(initial_node.to_json)) + + # For each match with url pattern, get last node and preserve + # last node before create new node with url information + # { 'text': match, 'navigationEndpoint': { 'urlEndpoint' : 'url': match } } + line.scan(/https?:\/\/[^ ]*/).each do |url_match| + # Retrieve last node and update node without match + last_node = current_nodes[-1].as_h + splitted_last_node = last_node["text"].as_s.split(url_match[0]) + last_node["text"] = JSON.parse(splitted_last_node[0].to_json) + current_nodes[-1] = JSON.parse(last_node.to_json) + # Create new node with match and navigation infos + current_node = {"text" => url_match[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => url_match[0]}}} + current_nodes << (JSON.parse(current_node.to_json)) + # If text remain after match create new simple node with text after match + after_node = {"text" => splitted_last_node.size > 1 ? splitted_last_node[1] : ""} + current_nodes << (JSON.parse(after_node.to_json)) + end + + # After processing of matches inside line + # Add \n at end of last node for preserve carriage return + last_node = current_nodes[-1].as_h + last_node["text"] = JSON.parse("#{last_node["text"]}\n".to_json) + current_nodes[-1] = JSON.parse(last_node.to_json) + + # Finally add final nodes to nodes returned + current_nodes.each do |node| + nodes << (node) + end + end + return JSON.parse({"runs" => nodes}.to_json) +end + +def parse_content(content : JSON::Any, video_id : String? = "") : String + content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || + content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r, video_id).try &.to_s.gsub("\n", "<br>") } || "" +end + +def content_to_comment_html(content, video_id : String? = "") + html_array = content.map do |run| + # Sometimes, there is an empty element. + # See: https://github.com/iv-org/invidious/issues/3096 + next if run.as_h.empty? + + text = HTML.escape(run["text"].as_s) + + if navigation_endpoint = run.dig?("navigationEndpoint") + text = parse_link_endpoint(navigation_endpoint, text, video_id) + end + + text = "<b>#{text}</b>" if run["bold"]? + text = "<s>#{text}</s>" if run["strikethrough"]? + text = "<i>#{text}</i>" if run["italics"]? + + # check for custom emojis + if run["emoji"]? + if run["emoji"]["isCustomEmoji"]?.try &.as_bool + if emoji_image = run.dig?("emoji", "image") + emoji_alt = emoji_image.dig?("accessibility", "accessibilityData", "label").try &.as_s || text + emoji_thumb = emoji_image["thumbnails"][0] + text = String.build do |str| + str << %(<img alt=") << emoji_alt << "\" " + str << %(src="/ggpht) << URI.parse(emoji_thumb["url"].as_s).request_target << "\" " + str << %(title=") << emoji_alt << "\" " + str << %(width=") << emoji_thumb["width"] << "\" " + str << %(height=") << emoji_thumb["height"] << "\" " + str << %(class="channel-emoji" />) + end + else + # Hide deleted channel emoji + text = "" + end + end + end + + text + end + + return html_array.join("").delete('\ufeff') +end diff --git a/src/invidious/comments/links_util.cr b/src/invidious/comments/links_util.cr new file mode 100644 index 00000000..f89b86d3 --- /dev/null +++ b/src/invidious/comments/links_util.cr @@ -0,0 +1,76 @@ +module Invidious::Comments + extend self + + def replace_links(html) + # Check if the document is empty + # Prevents edge-case bug with Reddit comments, see issue #3115 + if html.nil? || html.empty? + return html + end + + html = XML.parse_html(html) + + html.xpath_nodes(%q(//a)).each do |anchor| + url = URI.parse(anchor["href"]) + + if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be") + if url.host.try &.ends_with? "youtu.be" + url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}" + else + if url.path == "/redirect" + params = HTTP::Params.parse(url.query.not_nil!) + anchor["href"] = params["q"]? + else + anchor["href"] = url.request_target + end + end + elsif url.to_s == "#" + begin + length_seconds = decode_length_seconds(anchor.content) + rescue ex + length_seconds = decode_time(anchor.content) + end + + if length_seconds > 0 + anchor["href"] = "javascript:void(0)" + anchor["onclick"] = "player.currentTime(#{length_seconds})" + else + anchor["href"] = url.request_target + end + end + end + + html = html.xpath_node(%q(//body)).not_nil! + if node = html.xpath_node(%q(./p)) + html = node + end + + return html.to_xml(options: XML::SaveOptions::NO_DECL) + end + + def fill_links(html, scheme, host) + # Check if the document is empty + # Prevents edge-case bug with Reddit comments, see issue #3115 + if html.nil? || html.empty? + return html + end + + html = XML.parse_html(html) + + html.xpath_nodes("//a").each do |match| + url = URI.parse(match["href"]) + # Reddit links don't have host + if !url.host && !match["href"].starts_with?("javascript") && !url.to_s.ends_with? "#" + url.scheme = scheme + url.host = host + match["href"] = url + end + end + + if host == "www.youtube.com" + html = html.xpath_node(%q(//body/p)).not_nil! + end + + return html.to_xml(options: XML::SaveOptions::NO_DECL) + end +end diff --git a/src/invidious/comments/reddit.cr b/src/invidious/comments/reddit.cr new file mode 100644 index 00000000..ba9c19f1 --- /dev/null +++ b/src/invidious/comments/reddit.cr @@ -0,0 +1,41 @@ +module Invidious::Comments + extend self + + def fetch_reddit(id, sort_by = "confidence") + client = make_client(REDDIT_URL) + headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} + + # TODO: Use something like #479 for a static list of instances to use here + query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"}) + search_results = client.get("/search.json?#{query}", headers) + + if search_results.status_code == 200 + search_results = RedditThing.from_json(search_results.body) + + # For videos that have more than one thread, choose the one with the highest score + threads = search_results.data.as(RedditListing).children + thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink)) + result = thread.try do |t| + body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body + Array(RedditThing).from_json(body) + end + result ||= [] of RedditThing + elsif search_results.status_code == 302 + # Previously, if there was only one result then the API would redirect to that result. + # Now, it appears it will still return a listing so this section is likely unnecessary. + + result = client.get(search_results.headers["Location"], headers).body + result = Array(RedditThing).from_json(result) + + thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink) + else + raise NotFoundException.new("Comments not found.") + end + + client.close + + comments = result[1]?.try(&.data.as(RedditListing).children) + comments ||= [] of RedditThing + return comments, thread + end +end diff --git a/src/invidious/comments/reddit_types.cr b/src/invidious/comments/reddit_types.cr new file mode 100644 index 00000000..796a1183 --- /dev/null +++ b/src/invidious/comments/reddit_types.cr @@ -0,0 +1,57 @@ +class RedditThing + include JSON::Serializable + + property kind : String + property data : RedditComment | RedditLink | RedditMore | RedditListing +end + +class RedditComment + include JSON::Serializable + + property author : String + property body_html : String + property replies : RedditThing | String + property score : Int32 + property depth : Int32 + property permalink : String + + @[JSON::Field(converter: RedditComment::TimeConverter)] + property created_utc : Time + + module TimeConverter + def self.from_json(value : JSON::PullParser) : Time + Time.unix(value.read_float.to_i) + end + + def self.to_json(value : Time, json : JSON::Builder) + json.number(value.to_unix) + end + end +end + +struct RedditLink + include JSON::Serializable + + property author : String + property score : Int32 + property subreddit : String + property num_comments : Int32 + property id : String + property permalink : String + property title : String +end + +struct RedditMore + include JSON::Serializable + + property children : Array(String) + property count : Int32 + property depth : Int32 +end + +class RedditListing + include JSON::Serializable + + property children : Array(RedditThing) + property modhash : String +end diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr new file mode 100644 index 00000000..0716fcde --- /dev/null +++ b/src/invidious/comments/youtube.cr @@ -0,0 +1,365 @@ +module Invidious::Comments + extend self + + def fetch_youtube(id, cursor, format, locale, thin_mode, region, sort_by = "top") + case cursor + when nil, "" + ctoken = Comments.produce_continuation(id, cursor: "", sort_by: sort_by) + when .starts_with? "ADSJ" + ctoken = Comments.produce_continuation(id, cursor: cursor, sort_by: sort_by) + else + ctoken = cursor + end + + client_config = YoutubeAPI::ClientConfig.new(region: region) + response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) + return parse_youtube(id, response, format, locale, thin_mode, sort_by) + end + + def fetch_community_post_comments(ucid, post_id) + object = { + "2:string" => "community", + "25:embedded" => { + "22:string" => post_id, + }, + "45:embedded" => { + "2:varint" => 1_i64, + "3:varint" => 1_i64, + }, + "53:embedded" => { + "4:embedded" => { + "6:varint" => 0_i64, + "27:varint" => 1_i64, + "29:string" => post_id, + "30:string" => ucid, + }, + "8:string" => "comments-section", + }, + } + + object_parsed = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + + object2 = { + "80226972:embedded" => { + "2:string" => ucid, + "3:string" => object_parsed, + }, + } + + continuation = object2.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + initial_data = YoutubeAPI.browse(continuation: continuation) + return initial_data + end + + def parse_youtube(id, response, format, locale, thin_mode, sort_by = "top", is_post = false) + contents = nil + + if on_response_received_endpoints = response["onResponseReceivedEndpoints"]? + header = nil + on_response_received_endpoints.as_a.each do |item| + if item["reloadContinuationItemsCommand"]? + case item["reloadContinuationItemsCommand"]["slot"] + when "RELOAD_CONTINUATION_SLOT_HEADER" + header = item["reloadContinuationItemsCommand"]["continuationItems"][0] + when "RELOAD_CONTINUATION_SLOT_BODY" + # continuationItems is nil when video has no comments + contents = item["reloadContinuationItemsCommand"]["continuationItems"]? + end + elsif item["appendContinuationItemsAction"]? + contents = item["appendContinuationItemsAction"]["continuationItems"] + end + end + elsif response["continuationContents"]? + response = response["continuationContents"] + if response["commentRepliesContinuation"]? + body = response["commentRepliesContinuation"] + else + body = response["itemSectionContinuation"] + end + contents = body["contents"]? + header = body["header"]? + else + raise NotFoundException.new("Comments not found.") + end + + if !contents + if format == "json" + return {"comments" => [] of String}.to_json + else + return {"contentHtml" => "", "commentCount" => 0}.to_json + end + end + + continuation_item_renderer = nil + contents.as_a.reject! do |item| + if item["continuationItemRenderer"]? + continuation_item_renderer = item["continuationItemRenderer"] + true + end + end + + mutations = response.dig?("frameworkUpdates", "entityBatchUpdate", "mutations").try &.as_a || [] of JSON::Any + + response = JSON.build do |json| + json.object do + if header + count_text = header["commentsHeaderRenderer"]["countText"] + comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?) + .try &.as_s.gsub(/\D/, "").to_i? || 0 + json.field "commentCount", comment_count + end + + if is_post + json.field "postId", id + else + json.field "videoId", id + end + + json.field "comments" do + json.array do + contents.as_a.each do |node| + json.object do + if node["commentThreadRenderer"]? + node = node["commentThreadRenderer"] + end + + if node["replies"]? + node_replies = node["replies"]["commentRepliesRenderer"] + end + + if cvm = node["commentViewModel"]? + # two commentViewModels for inital request + # one commentViewModel when getting a replies to a comment + cvm = cvm["commentViewModel"] if cvm["commentViewModel"]? + + comment_key = cvm["commentKey"] + toolbar_key = cvm["toolbarStateKey"] + comment_mutation = mutations.find { |i| i.dig?("payload", "commentEntityPayload", "key") == comment_key } + toolbar_mutation = mutations.find { |i| i.dig?("entityKey") == toolbar_key } + + if !comment_mutation.nil? && !toolbar_mutation.nil? + # todo parse styleRuns, commandRuns and attachmentRuns for comments + html_content = parse_description(comment_mutation.dig("payload", "commentEntityPayload", "properties", "content"), id) + comment_author = comment_mutation.dig("payload", "commentEntityPayload", "author") + json.field "authorId", comment_author["channelId"].as_s + json.field "authorUrl", "/channel/#{comment_author["channelId"].as_s}" + json.field "author", comment_author["displayName"].as_s + json.field "verified", comment_author["isVerified"].as_bool + json.field "authorThumbnails" do + json.array do + comment_mutation.dig?("payload", "commentEntityPayload", "avatar", "image", "sources").try &.as_a.each do |thumbnail| + json.object do + json.field "url", thumbnail["url"] + json.field "width", thumbnail["width"] + json.field "height", thumbnail["height"] + end + end + end + end + + json.field "authorIsChannelOwner", comment_author["isCreator"].as_bool + json.field "isSponsor", (comment_author["sponsorBadgeUrl"]? != nil) + + if sponsor_badge_url = comment_author["sponsorBadgeUrl"]? + # Sponsor icon thumbnails always have one object and there's only ever the url property in it + json.field "sponsorIconUrl", sponsor_badge_url + end + + comment_toolbar = comment_mutation.dig("payload", "commentEntityPayload", "toolbar") + json.field "likeCount", short_text_to_number(comment_toolbar["likeCountNotliked"].as_s) + reply_count = short_text_to_number(comment_toolbar["replyCount"]?.try &.as_s || "0") + + if heart_state = toolbar_mutation.dig?("payload", "engagementToolbarStateEntityPayload", "heartState") + if heart_state.as_s == "TOOLBAR_HEART_STATE_HEARTED" + json.field "creatorHeart" do + json.object do + json.field "creatorThumbnail", comment_toolbar["creatorThumbnailUrl"].as_s + json.field "creatorName", comment_toolbar["heartActiveTooltip"].as_s.sub("❤ by ", "") + end + end + end + end + + published_text = comment_mutation.dig?("payload", "commentEntityPayload", "properties", "publishedTime").try &.as_s + end + + json.field "isPinned", (cvm.dig?("pinnedText") != nil) + json.field "commentId", cvm["commentId"] + else + if node["comment"]? + node_comment = node["comment"]["commentRenderer"] + else + node_comment = node["commentRenderer"] + end + json.field "commentId", node_comment["commentId"] + html_content = node_comment["contentText"]?.try { |t| parse_content(t, id) } + + json.field "verified", (node_comment["authorCommentBadge"]? != nil) + + json.field "author", node_comment["authorText"]?.try &.["simpleText"]? || "" + json.field "authorThumbnails" do + json.array do + node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail| + json.object do + json.field "url", thumbnail["url"] + json.field "width", thumbnail["width"] + json.field "height", thumbnail["height"] + end + end + end + end + + if comment_action_buttons_renderer = node_comment.dig?("actionButtons", "commentActionButtonsRenderer") + json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i + if comment_action_buttons_renderer["creatorHeart"]? + heart_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"] + json.field "creatorHeart" do + json.object do + json.field "creatorThumbnail", heart_data["thumbnails"][-1]["url"] + json.field "creatorName", heart_data["accessibility"]["accessibilityData"]["label"] + end + end + end + end + + if node_comment["authorEndpoint"]? + json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"] + json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] + else + json.field "authorId", "" + json.field "authorUrl", "" + end + + json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"] + json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) + published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s + + json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil) + if node_comment["sponsorCommentBadge"]? + # Sponsor icon thumbnails always have one object and there's only ever the url property in it + json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s + end + + reply_count = node_comment["replyCount"]? + end + + content_html = html_content || "" + json.field "content", html_to_content(content_html) + json.field "contentHtml", content_html + + if published_text != nil + published_text = published_text.to_s + if published_text.includes?(" (edited)") + json.field "isEdited", true + published = decode_date(published_text.rchop(" (edited)")) + else + json.field "isEdited", false + published = decode_date(published_text) + end + + json.field "published", published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) + end + + if node_replies && !response["commentRepliesContinuation"]? + if node_replies["continuations"]? + continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s + elsif node_replies["contents"]? + continuation = node_replies["contents"]?.try &.as_a[0]["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].as_s + end + continuation ||= "" + + json.field "replies" do + json.object do + json.field "replyCount", reply_count || 1 + json.field "continuation", continuation + end + end + end + end + end + end + end + + if continuation_item_renderer + if continuation_item_renderer["continuationEndpoint"]? + continuation_endpoint = continuation_item_renderer["continuationEndpoint"] + elsif continuation_item_renderer["button"]? + continuation_endpoint = continuation_item_renderer["button"]["buttonRenderer"]["command"] + end + if continuation_endpoint + json.field "continuation", continuation_endpoint["continuationCommand"]["token"].as_s + end + end + end + end + + if format == "html" + response = JSON.parse(response) + content_html = Frontend::Comments.template_youtube(response, locale, thin_mode) + response = JSON.build do |json| + json.object do + json.field "contentHtml", content_html + + if response["commentCount"]? + json.field "commentCount", response["commentCount"] + else + json.field "commentCount", 0 + end + end + end + end + + return response + end + + def produce_continuation(video_id, cursor = "", sort_by = "top") + object = { + "2:embedded" => { + "2:string" => video_id, + "25:varint" => 0_i64, + "28:varint" => 1_i64, + "36:embedded" => { + "5:varint" => -1_i64, + "8:varint" => 0_i64, + }, + "40:embedded" => { + "1:varint" => 4_i64, + "3:string" => "https://www.youtube.com", + "4:string" => "", + }, + }, + "3:varint" => 6_i64, + "6:embedded" => { + "1:string" => cursor, + "4:embedded" => { + "4:string" => video_id, + "6:varint" => 0_i64, + }, + "5:varint" => 20_i64, + }, + } + + case sort_by + when "top" + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 + when "new", "newest" + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64 + else # top + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 + end + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation + end +end diff --git a/src/invidious/config.cr b/src/invidious/config.cr new file mode 100644 index 00000000..ff768197 --- /dev/null +++ b/src/invidious/config.cr @@ -0,0 +1,256 @@ +struct DBConfig + include YAML::Serializable + + property user : String + property password : String + property host : String + property port : Int32 + property dbname : String +end + +struct ConfigPreferences + include YAML::Serializable + + property annotations : Bool = false + property annotations_subscribed : Bool = false + property preload : Bool = true + property autoplay : Bool = false + property captions : Array(String) = ["", "", ""] + property comments : Array(String) = ["youtube", ""] + property continue : Bool = false + property continue_autoplay : Bool = true + property dark_mode : String = "" + property latest_only : Bool = false + property listen : Bool = false + property local : Bool = false + property locale : String = "en-US" + property watch_history : Bool = true + property max_results : Int32 = 40 + property notifications_only : Bool = false + property player_style : String = "invidious" + property quality : String = "hd720" + property quality_dash : String = "auto" + property default_home : String? = "Popular" + property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"] + property automatic_instance_redirect : Bool = false + property region : String = "US" + property related_videos : Bool = true + property sort : String = "published" + property speed : Float32 = 1.0_f32 + property thin_mode : Bool = false + property unseen_only : Bool = false + property video_loop : Bool = false + property extend_desc : Bool = false + property volume : Int32 = 100 + property vr_mode : Bool = true + property show_nick : Bool = true + property save_player_pos : Bool = false + + def to_tuple + {% begin %} + { + {{(@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }).splat}} + } + {% end %} + end +end + +struct HTTPProxyConfig + include YAML::Serializable + + property user : String + property password : String + property host : String + property port : Int32 +end + +class Config + include YAML::Serializable + + # Number of threads to use for crawling videos from channels (for updating subscriptions) + property channel_threads : Int32 = 1 + # Time interval between two executions of the job that crawls channel videos (subscriptions update). + @[YAML::Field(converter: Preferences::TimeSpanConverter)] + property channel_refresh_interval : Time::Span = 30.minutes + # Number of threads to use for updating feeds + property feed_threads : Int32 = 1 + # Log file path or STDOUT + property output : String = "STDOUT" + # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr + property log_level : LogLevel = LogLevel::Info + # Enables colors in logs. Useful for debugging purposes + property colorize_logs : Bool = false + # Database configuration with separate parameters (username, hostname, etc) + property db : DBConfig? = nil + + # Database configuration using 12-Factor "Database URL" syntax + @[YAML::Field(converter: Preferences::URIConverter)] + property database_url : URI = URI.parse("") + # Used for crawling channels: threads should check all videos uploaded by a channel + property full_refresh : Bool = false + + # Jobs config structure. See jobs.cr and jobs/base_job.cr + property jobs = Invidious::Jobs::JobsConfig.new + + # Used to tell Invidious it is behind a proxy, so links to resources should be https:// + property https_only : Bool? + # HMAC signing key for CSRF tokens and verifying pubsub subscriptions + property hmac_key : String = "" + # Domain to be used for links to resources on the site where an absolute URL is required + property domain : String? + # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) + property use_pubsub_feeds : Bool | Int32 = false + property popular_enabled : Bool = true + property captcha_enabled : Bool = true + property login_enabled : Bool = true + property registration_enabled : Bool = true + property statistics_enabled : Bool = false + property admins : Array(String) = [] of String + property external_port : Int32? = nil + property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("") + # For compliance with DMCA, disables download widget using list of video IDs + property dmca_content : Array(String) = [] of String + # Check table integrity, automatically try to add any missing columns, create tables, etc. + property check_tables : Bool = false + # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards + property cache_annotations : Bool = false + # Optional banner to be displayed along top of page for announcements, etc. + property banner : String? = nil + # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely + property hsts : Bool? = true + # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' + property disable_proxy : Bool? | Array(String)? = false + # Enable the user notifications for all users + property enable_user_notifications : Bool = true + + # URL to the modified source code to be easily AGPL compliant + # Will display in the footer, next to the main source code link + property modified_source_code_url : String? = nil + + # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) + @[YAML::Field(converter: Preferences::FamilyConverter)] + property force_resolve : Socket::Family = Socket::Family::UNSPEC + + # External signature solver server socket (either a path to a UNIX domain socket or "<IP>:<Port>") + property signature_server : String? = nil + + # Port to listen for connections (overridden by command line argument) + property port : Int32 = 3000 + # Host to bind (overridden by command line argument) + property host_binding : String = "0.0.0.0" + # Make Invidious listening on UNIX sockets - Example: /tmp/invidious.sock + property bind_unix : String? = nil + # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) + property pool_size : Int32 = 100 + # HTTP Proxy configuration + property http_proxy : HTTPProxyConfig? = nil + + # Use Innertube's transcripts API instead of timedtext for closed captions + property use_innertube_for_captions : Bool = false + + # visitor data ID for Google session + property visitor_data : String? = nil + # poToken for passing bot attestation + property po_token : String? = nil + + # Saved cookies in "name1=value1; name2=value2..." format + @[YAML::Field(converter: Preferences::StringToCookies)] + property cookies : HTTP::Cookies = HTTP::Cookies.new + + # Playlist length limit + property playlist_length_limit : Int32 = 500 + + def disabled?(option) + case disabled = CONFIG.disable_proxy + when Bool + return disabled + when Array + if disabled.includes? option + return true + else + return false + end + else + return false + end + end + + def self.load + # Load config from file or YAML string env var + env_config_file = "INVIDIOUS_CONFIG_FILE" + env_config_yaml = "INVIDIOUS_CONFIG" + + config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml" + config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file) + + config = Config.from_yaml(config_yaml) + + # Update config from env vars (upcased and prefixed with "INVIDIOUS_") + {% for ivar in Config.instance_vars %} + {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %} + + if ENV.has_key?({{env_id}}) + env_value = ENV.fetch({{env_id}}) + success = false + + # Use YAML converter if specified + {% ann = ivar.annotation(::YAML::Field) %} + {% if ann && ann[:converter] %} + config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0]) + success = true + + # Use regular YAML parser otherwise + {% else %} + {% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %} + # Sort types to avoid parsing nulls and numbers as strings + {% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %} + {{ivar_types}}.each do |ivar_type| + if !success + begin + config.{{ivar.id}} = ivar_type.from_yaml(env_value) + success = true + rescue + # nop + end + end + end + {% end %} + + # Exit on fail + if !success + puts %(Config.{{ivar.id}} failed to parse #{env_value} as {{ivar.type}}) + exit(1) + end + end + {% end %} + + # HMAC_key is mandatory + # See: https://github.com/iv-org/invidious/issues/3854 + if config.hmac_key.empty? + puts "Config: 'hmac_key' is required/can't be empty" + exit(1) + elsif config.hmac_key == "CHANGE_ME!!" + puts "Config: The value of 'hmac_key' needs to be changed!!" + exit(1) + end + + # Build database_url from db.* if it's not set directly + if config.database_url.to_s.empty? + if db = config.db + config.database_url = URI.new( + scheme: "postgres", + user: db.user, + password: db.password, + host: db.host, + port: db.port, + path: db.dbname, + ) + else + puts "Config: Either database_url or db.* is required" + exit(1) + end + end + + return config + end +end diff --git a/src/invidious/database/annotations.cr b/src/invidious/database/annotations.cr new file mode 100644 index 00000000..03749473 --- /dev/null +++ b/src/invidious/database/annotations.cr @@ -0,0 +1,24 @@ +require "./base.cr" + +module Invidious::Database::Annotations + extend self + + def insert(id : String, annotations : String) + request = <<-SQL + INSERT INTO annotations + VALUES ($1, $2) + ON CONFLICT DO NOTHING + SQL + + PG_DB.exec(request, id, annotations) + end + + def select(id : String) : Annotation? + request = <<-SQL + SELECT * FROM annotations + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: Annotation) + end +end diff --git a/src/invidious/database/base.cr b/src/invidious/database/base.cr new file mode 100644 index 00000000..0fb1b6af --- /dev/null +++ b/src/invidious/database/base.cr @@ -0,0 +1,136 @@ +require "pg" + +module Invidious::Database + extend self + + # Checks table integrity + # + # Note: config is passed as a parameter to avoid complex + # dependencies between different parts of the software. + def check_integrity(cfg) + return if !cfg.check_tables + Invidious::Database.check_enum("privacy", PlaylistPrivacy) + + Invidious::Database.check_table("channels", InvidiousChannel) + Invidious::Database.check_table("channel_videos", ChannelVideo) + Invidious::Database.check_table("playlists", InvidiousPlaylist) + Invidious::Database.check_table("playlist_videos", PlaylistVideo) + Invidious::Database.check_table("nonces", Nonce) + Invidious::Database.check_table("session_ids", SessionId) + Invidious::Database.check_table("users", User) + Invidious::Database.check_table("videos", Video) + + if cfg.cache_annotations + Invidious::Database.check_table("annotations", Annotation) + end + end + + # + # Table/enum integrity checks + # + + def check_enum(enum_name, struct_type = nil) + return # TODO + + if !PG_DB.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) + LOGGER.info("check_enum: CREATE TYPE #{enum_name}") + + PG_DB.using_connection do |conn| + conn.as(PG::Connection).exec_all(File.read("config/sql/#{enum_name}.sql")) + end + end + end + + def check_table(table_name, struct_type = nil) + # Create table if it doesn't exist + begin + PG_DB.exec("SELECT * FROM #{table_name} LIMIT 0") + rescue ex + LOGGER.info("check_table: check_table: CREATE TABLE #{table_name}") + + PG_DB.using_connection do |conn| + conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql")) + end + end + + return if !struct_type + + struct_array = struct_type.type_array + column_array = get_column_array(PG_DB, table_name) + column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?<types>[\d\D]*?)\);/) + .try &.["types"].split(",").map(&.strip).reject &.starts_with?("CONSTRAINT") + + return if !column_types + + struct_array.each_with_index do |name, i| + if name != column_array[i]? + if !column_array[i]? + new_column = column_types.select(&.starts_with?(name))[0] + LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + PG_DB.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + next + end + + # Column doesn't exist + if !column_array.includes? name + new_column = column_types.select(&.starts_with?(name))[0] + PG_DB.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + end + + # Column exists but in the wrong position, rotate + if struct_array.includes? column_array[i] + until name == column_array[i] + new_column = column_types.select(&.starts_with?(column_array[i]))[0]?.try &.gsub("#{column_array[i]}", "#{column_array[i]}_new") + + # There's a column we didn't expect + if !new_column + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}") + PG_DB.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + + column_array = get_column_array(PG_DB, table_name) + next + end + + LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + PG_DB.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + + LOGGER.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") + PG_DB.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") + + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + PG_DB.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + + LOGGER.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") + PG_DB.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") + + column_array = get_column_array(PG_DB, table_name) + end + else + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + PG_DB.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + end + end + end + + return if column_array.size <= struct_array.size + + column_array.each do |column| + if !struct_array.includes? column + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + PG_DB.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + end + end + end + + def get_column_array(db, table_name) + column_array = [] of String + PG_DB.query("SELECT * FROM #{table_name} LIMIT 0") do |rs| + rs.column_count.times do |i| + column = rs.as(PG::ResultSet).field(i) + column_array << column.name + end + end + + return column_array + end +end diff --git a/src/invidious/database/channels.cr b/src/invidious/database/channels.cr new file mode 100644 index 00000000..df44e485 --- /dev/null +++ b/src/invidious/database/channels.cr @@ -0,0 +1,158 @@ +require "./base.cr" + +# +# This module contains functions related to the "channels" table. +# +module Invidious::Database::Channels + extend self + + # ------------------- + # Insert / delete + # ------------------- + + def insert(channel : InvidiousChannel, update_on_conflict : Bool = false) + channel_array = channel.to_a + + request = <<-SQL + INSERT INTO channels + VALUES (#{arg_array(channel_array)}) + SQL + + if update_on_conflict + request += <<-SQL + ON CONFLICT (id) DO UPDATE + SET author = $2, updated = $3 + SQL + end + + PG_DB.exec(request, args: channel_array) + end + + # ------------------- + # Update + # ------------------- + + def update_author(id : String, author : String) + request = <<-SQL + UPDATE channels + SET updated = now(), author = $1, deleted = false + WHERE id = $2 + SQL + + PG_DB.exec(request, author, id) + end + + def update_subscription_time(id : String) + request = <<-SQL + UPDATE channels + SET subscribed = now() + WHERE id = $1 + SQL + + PG_DB.exec(request, id) + end + + def update_mark_deleted(id : String) + request = <<-SQL + UPDATE channels + SET updated = now(), deleted = true + WHERE id = $1 + SQL + + PG_DB.exec(request, id) + end + + # ------------------- + # Select + # ------------------- + + def select(id : String) : InvidiousChannel? + request = <<-SQL + SELECT * FROM channels + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: InvidiousChannel) + end + + def select(ids : Array(String)) : Array(InvidiousChannel)? + return [] of InvidiousChannel if ids.empty? + + request = <<-SQL + SELECT * FROM channels + WHERE id = ANY($1) + SQL + + return PG_DB.query_all(request, ids, as: InvidiousChannel) + end +end + +# +# This module contains functions related to the "channel_videos" table. +# +module Invidious::Database::ChannelVideos + extend self + + # ------------------- + # Insert + # ------------------- + + # This function returns the status of the query (i.e: success?) + def insert(video : ChannelVideo, with_premiere_timestamp : Bool = false) : Bool + if with_premiere_timestamp + last_items = "premiere_timestamp = $9, views = $10" + else + last_items = "views = $10" + end + + request = <<-SQL + INSERT INTO channel_videos + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE + SET title = $2, published = $3, updated = $4, ucid = $5, + author = $6, length_seconds = $7, live_now = $8, #{last_items} + RETURNING (xmax=0) AS was_insert + SQL + + return PG_DB.query_one(request, *video.to_tuple, as: Bool) + end + + # ------------------- + # Select + # ------------------- + + def select(ids : Array(String)) : Array(ChannelVideo) + return [] of ChannelVideo if ids.empty? + + request = <<-SQL + SELECT * FROM channel_videos + WHERE id = ANY($1) + ORDER BY published DESC + SQL + + return PG_DB.query_all(request, ids, as: ChannelVideo) + end + + def select_notfications(ucid : String, since : Time) : Array(ChannelVideo) + request = <<-SQL + SELECT * FROM channel_videos + WHERE ucid = $1 AND published > $2 + ORDER BY published DESC + LIMIT 15 + SQL + + return PG_DB.query_all(request, ucid, since, as: ChannelVideo) + end + + def select_popular_videos : Array(ChannelVideo) + request = <<-SQL + SELECT DISTINCT ON (ucid) * + FROM channel_videos + WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d + GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40) + ORDER BY ucid, published DESC + SQL + + PG_DB.query_all(request, as: ChannelVideo) + end +end diff --git a/src/invidious/database/migration.cr b/src/invidious/database/migration.cr new file mode 100644 index 00000000..921d8f38 --- /dev/null +++ b/src/invidious/database/migration.cr @@ -0,0 +1,38 @@ +abstract class Invidious::Database::Migration + macro inherited + Migrator.migrations << self + end + + @@version : Int64? + + def self.version(version : Int32 | Int64) + @@version = version.to_i64 + end + + getter? completed = false + + def initialize(@db : DB::Database) + end + + abstract def up(conn : DB::Connection) + + def migrate + # migrator already ignores completed migrations + # but this is an extra check to make sure a migration doesn't run twice + return if completed? + + @db.transaction do |txn| + up(txn.connection) + track(txn.connection) + @completed = true + end + end + + def version : Int64 + @@version.not_nil! + end + + private def track(conn : DB::Connection) + conn.exec("INSERT INTO #{Migrator::MIGRATIONS_TABLE} (version) VALUES ($1)", version) + end +end diff --git a/src/invidious/database/migrations/0001_create_channels_table.cr b/src/invidious/database/migrations/0001_create_channels_table.cr new file mode 100644 index 00000000..a1362bcf --- /dev/null +++ b/src/invidious/database/migrations/0001_create_channels_table.cr @@ -0,0 +1,30 @@ +module Invidious::Database::Migrations + class CreateChannelsTable < Migration + version 1 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.channels + ( + id text NOT NULL, + author text, + updated timestamp with time zone, + deleted boolean, + subscribed timestamp with time zone, + CONSTRAINT channels_id_key UNIQUE (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.channels TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS channels_id_idx + ON public.channels + USING btree + (id COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0002_create_videos_table.cr b/src/invidious/database/migrations/0002_create_videos_table.cr new file mode 100644 index 00000000..c2ac84f8 --- /dev/null +++ b/src/invidious/database/migrations/0002_create_videos_table.cr @@ -0,0 +1,28 @@ +module Invidious::Database::Migrations + class CreateVideosTable < Migration + version 2 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE UNLOGGED TABLE IF NOT EXISTS public.videos + ( + id text NOT NULL, + info text, + updated timestamp with time zone, + CONSTRAINT videos_pkey PRIMARY KEY (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.videos TO current_user; + SQL + + conn.exec <<-SQL + CREATE UNIQUE INDEX IF NOT EXISTS id_idx + ON public.videos + USING btree + (id COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0003_create_channel_videos_table.cr b/src/invidious/database/migrations/0003_create_channel_videos_table.cr new file mode 100644 index 00000000..c9b62e4c --- /dev/null +++ b/src/invidious/database/migrations/0003_create_channel_videos_table.cr @@ -0,0 +1,35 @@ +module Invidious::Database::Migrations + class CreateChannelVideosTable < Migration + version 3 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.channel_videos + ( + id text NOT NULL, + title text, + published timestamp with time zone, + updated timestamp with time zone, + ucid text, + author text, + length_seconds integer, + live_now boolean, + premiere_timestamp timestamp with time zone, + views bigint, + CONSTRAINT channel_videos_id_key UNIQUE (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.channel_videos TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx + ON public.channel_videos + USING btree + (ucid COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0004_create_users_table.cr b/src/invidious/database/migrations/0004_create_users_table.cr new file mode 100644 index 00000000..a13ba15f --- /dev/null +++ b/src/invidious/database/migrations/0004_create_users_table.cr @@ -0,0 +1,34 @@ +module Invidious::Database::Migrations + class CreateUsersTable < Migration + version 4 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.users + ( + updated timestamp with time zone, + notifications text[], + subscriptions text[], + email text NOT NULL, + preferences text, + password text, + token text, + watched text[], + feed_needs_update boolean, + CONSTRAINT users_email_key UNIQUE (email) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.users TO current_user; + SQL + + conn.exec <<-SQL + CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx + ON public.users + USING btree + (lower(email) COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0005_create_session_ids_table.cr b/src/invidious/database/migrations/0005_create_session_ids_table.cr new file mode 100644 index 00000000..13c2228d --- /dev/null +++ b/src/invidious/database/migrations/0005_create_session_ids_table.cr @@ -0,0 +1,28 @@ +module Invidious::Database::Migrations + class CreateSessionIdsTable < Migration + version 5 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.session_ids + ( + id text NOT NULL, + email text, + issued timestamp with time zone, + CONSTRAINT session_ids_pkey PRIMARY KEY (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.session_ids TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS session_ids_id_idx + ON public.session_ids + USING btree + (id COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0006_create_nonces_table.cr b/src/invidious/database/migrations/0006_create_nonces_table.cr new file mode 100644 index 00000000..cf1229e1 --- /dev/null +++ b/src/invidious/database/migrations/0006_create_nonces_table.cr @@ -0,0 +1,27 @@ +module Invidious::Database::Migrations + class CreateNoncesTable < Migration + version 6 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.nonces + ( + nonce text, + expire timestamp with time zone, + CONSTRAINT nonces_id_key UNIQUE (nonce) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.nonces TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS nonces_nonce_idx + ON public.nonces + USING btree + (nonce COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0007_create_annotations_table.cr b/src/invidious/database/migrations/0007_create_annotations_table.cr new file mode 100644 index 00000000..dcecbc3b --- /dev/null +++ b/src/invidious/database/migrations/0007_create_annotations_table.cr @@ -0,0 +1,20 @@ +module Invidious::Database::Migrations + class CreateAnnotationsTable < Migration + version 7 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.annotations + ( + id text NOT NULL, + annotations xml, + CONSTRAINT annotations_id_key UNIQUE (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.annotations TO current_user; + SQL + end + end +end diff --git a/src/invidious/database/migrations/0008_create_playlists_table.cr b/src/invidious/database/migrations/0008_create_playlists_table.cr new file mode 100644 index 00000000..6aa16e1a --- /dev/null +++ b/src/invidious/database/migrations/0008_create_playlists_table.cr @@ -0,0 +1,50 @@ +module Invidious::Database::Migrations + class CreatePlaylistsTable < Migration + version 8 + + def up(conn : DB::Connection) + if !privacy_type_exists?(conn) + conn.exec <<-SQL + CREATE TYPE public.privacy AS ENUM + ( + 'Public', + 'Unlisted', + 'Private' + ); + SQL + end + + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.playlists + ( + title text, + id text primary key, + author text, + description text, + video_count integer, + created timestamptz, + updated timestamptz, + privacy privacy, + index int8[] + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON public.playlists TO current_user; + SQL + end + + private def privacy_type_exists?(conn : DB::Connection) : Bool + request = <<-SQL + SELECT 1 AS one + FROM pg_type + INNER JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace + WHERE pg_namespace.nspname = 'public' + AND pg_type.typname = 'privacy' + LIMIT 1; + SQL + + !conn.query_one?(request, as: Int32).nil? + end + end +end diff --git a/src/invidious/database/migrations/0009_create_playlist_videos_table.cr b/src/invidious/database/migrations/0009_create_playlist_videos_table.cr new file mode 100644 index 00000000..84938b9b --- /dev/null +++ b/src/invidious/database/migrations/0009_create_playlist_videos_table.cr @@ -0,0 +1,27 @@ +module Invidious::Database::Migrations + class CreatePlaylistVideosTable < Migration + version 9 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.playlist_videos + ( + title text, + id text, + author text, + ucid text, + length_seconds integer, + published timestamptz, + plid text references playlists(id), + index int8, + live_now boolean, + PRIMARY KEY (index,plid) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.playlist_videos TO current_user; + SQL + end + end +end diff --git a/src/invidious/database/migrations/0010_make_videos_unlogged.cr b/src/invidious/database/migrations/0010_make_videos_unlogged.cr new file mode 100644 index 00000000..f5d19683 --- /dev/null +++ b/src/invidious/database/migrations/0010_make_videos_unlogged.cr @@ -0,0 +1,11 @@ +module Invidious::Database::Migrations + class MakeVideosUnlogged < Migration + version 10 + + def up(conn : DB::Connection) + conn.exec <<-SQL + ALTER TABLE public.videos SET UNLOGGED; + SQL + end + end +end diff --git a/src/invidious/database/migrator.cr b/src/invidious/database/migrator.cr new file mode 100644 index 00000000..660c3203 --- /dev/null +++ b/src/invidious/database/migrator.cr @@ -0,0 +1,49 @@ +class Invidious::Database::Migrator + MIGRATIONS_TABLE = "public.invidious_migrations" + + class_getter migrations = [] of Invidious::Database::Migration.class + + def initialize(@db : DB::Database) + end + + def migrate + versions = load_versions + + ran_migration = false + load_migrations.sort_by(&.version) + .each do |migration| + next if versions.includes?(migration.version) + + puts "Running migration: #{migration.class.name}" + migration.migrate + ran_migration = true + end + + puts "No migrations to run." unless ran_migration + end + + def pending_migrations? : Bool + versions = load_versions + + load_migrations.sort_by(&.version) + .any? { |migration| !versions.includes?(migration.version) } + end + + private def load_migrations : Array(Invidious::Database::Migration) + self.class.migrations.map(&.new(@db)) + end + + private def load_versions : Array(Int64) + create_migrations_table + @db.query_all("SELECT version FROM #{MIGRATIONS_TABLE}", as: Int64) + end + + private def create_migrations_table + @db.exec <<-SQL + CREATE TABLE IF NOT EXISTS #{MIGRATIONS_TABLE} ( + id bigserial PRIMARY KEY, + version bigint NOT NULL + ) + SQL + end +end diff --git a/src/invidious/database/nonces.cr b/src/invidious/database/nonces.cr new file mode 100644 index 00000000..b87c81ec --- /dev/null +++ b/src/invidious/database/nonces.cr @@ -0,0 +1,55 @@ +require "./base.cr" + +module Invidious::Database::Nonces + extend self + + # ------------------- + # Insert / Delete + # ------------------- + + def insert(nonce : String, expire : Time) + request = <<-SQL + INSERT INTO nonces + VALUES ($1, $2) + ON CONFLICT DO NOTHING + SQL + + PG_DB.exec(request, nonce, expire) + end + + def delete_expired + request = <<-SQL + DELETE FROM nonces * + WHERE expire < now() + SQL + + PG_DB.exec(request) + end + + # ------------------- + # Update + # ------------------- + + def update_set_expired(nonce : String) + request = <<-SQL + UPDATE nonces + SET expire = $1 + WHERE nonce = $2 + SQL + + PG_DB.exec(request, Time.utc(1990, 1, 1), nonce) + end + + # ------------------- + # Select + # ------------------- + + def select(nonce : String) : Tuple(String, Time)? + request = <<-SQL + SELECT * FROM nonces + WHERE nonce = $1 + SQL + + return PG_DB.query_one?(request, nonce, as: {String, Time}) + end +end diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr new file mode 100644 index 00000000..08aa719a --- /dev/null +++ b/src/invidious/database/playlists.cr @@ -0,0 +1,262 @@ +require "./base.cr" + +# +# This module contains functions related to the "playlists" table. +# +module Invidious::Database::Playlists + extend self + + # ------------------- + # Insert / delete + # ------------------- + + def insert(playlist : InvidiousPlaylist) + playlist_array = playlist.to_a + + request = <<-SQL + INSERT INTO playlists + VALUES (#{arg_array(playlist_array)}) + SQL + + PG_DB.exec(request, args: playlist_array) + end + + # deletes the given playlist and connected playlist videos + def delete(id : String) + PlaylistVideos.delete_by_playlist(id) + request = <<-SQL + DELETE FROM playlists * + WHERE id = $1 + SQL + + PG_DB.exec(request, id) + end + + # ------------------- + # Update + # ------------------- + + def update(id : String, title : String, privacy, description, updated) + request = <<-SQL + UPDATE playlists + SET title = $1, privacy = $2, description = $3, updated = $4 + WHERE id = $5 + SQL + + PG_DB.exec(request, title, privacy, description, updated, id) + end + + def update_description(id : String, description) + request = <<-SQL + UPDATE playlists + SET description = $1 + WHERE id = $2 + SQL + + PG_DB.exec(request, description, id) + end + + def update_subscription_time(id : String) + request = <<-SQL + UPDATE playlists + SET subscribed = now() + WHERE id = $1 + SQL + + PG_DB.exec(request, id) + end + + def update_video_added(id : String, index : String | Int64) + request = <<-SQL + UPDATE playlists + SET index = array_append(index, $1), + video_count = cardinality(index) + 1, + updated = now() + WHERE id = $2 + SQL + + PG_DB.exec(request, index, id) + end + + def update_video_removed(id : String, index : String | Int64) + request = <<-SQL + UPDATE playlists + SET index = array_remove(index, $1), + video_count = cardinality(index) - 1, + updated = now() + WHERE id = $2 + SQL + + PG_DB.exec(request, index, id) + end + + # ------------------- + # Salect + # ------------------- + + def select(*, id : String) : InvidiousPlaylist? + request = <<-SQL + SELECT * FROM playlists + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: InvidiousPlaylist) + end + + def select_all(*, author : String) : Array(InvidiousPlaylist) + request = <<-SQL + SELECT * FROM playlists + WHERE author = $1 + SQL + + return PG_DB.query_all(request, author, as: InvidiousPlaylist) + end + + # ------------------- + # Salect (filtered) + # ------------------- + + def select_like_iv(email : String) : Array(InvidiousPlaylist) + request = <<-SQL + SELECT * FROM playlists + WHERE author = $1 AND id LIKE 'IV%' + ORDER BY created + SQL + + PG_DB.query_all(request, email, as: InvidiousPlaylist) + end + + def select_not_like_iv(email : String) : Array(InvidiousPlaylist) + request = <<-SQL + SELECT * FROM playlists + WHERE author = $1 AND id NOT LIKE 'IV%' + ORDER BY created + SQL + + PG_DB.query_all(request, email, as: InvidiousPlaylist) + end + + def select_user_created_playlists(email : String) : Array({String, String}) + request = <<-SQL + SELECT id,title FROM playlists + WHERE author = $1 AND id LIKE 'IV%' + ORDER BY title + SQL + + PG_DB.query_all(request, email, as: {String, String}) + end + + # ------------------- + # Misc checks + # ------------------- + + # Check if given playlist ID exists + def exists?(id : String) : Bool + request = <<-SQL + SELECT id FROM playlists + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: String).nil? + end + + # Count how many playlist a user has created. + def count_owned_by(author : String) : Int64 + request = <<-SQL + SELECT count(*) FROM playlists + WHERE author = $1 + SQL + + return PG_DB.query_one?(request, author, as: Int64) || 0_i64 + end +end + +# +# This module contains functions related to the "playlist_videos" table. +# +module Invidious::Database::PlaylistVideos + extend self + + private alias VideoIndex = Int64 | Array(Int64) + + # ------------------- + # Insert / Delete + # ------------------- + + def insert(video : PlaylistVideo) + video_array = video.to_a + + request = <<-SQL + INSERT INTO playlist_videos + VALUES (#{arg_array(video_array)}) + SQL + + PG_DB.exec(request, args: video_array) + end + + def delete(index) + request = <<-SQL + DELETE FROM playlist_videos * + WHERE index = $1 + SQL + + PG_DB.exec(request, index) + end + + def delete_by_playlist(plid : String) + request = <<-SQL + DELETE FROM playlist_videos * + WHERE plid = $1 + SQL + + PG_DB.exec(request, plid) + end + + # ------------------- + # Salect + # ------------------- + + def select(plid : String, index : VideoIndex, offset, limit = 100) : Array(PlaylistVideo) + request = <<-SQL + SELECT * FROM playlist_videos + WHERE plid = $1 + ORDER BY array_position($2, index) + LIMIT $3 + OFFSET $4 + SQL + + return PG_DB.query_all(request, plid, index, limit, offset, as: PlaylistVideo) + end + + def select_index(plid : String, vid : String) : Int64? + request = <<-SQL + SELECT index FROM playlist_videos + WHERE plid = $1 AND id = $2 + LIMIT 1 + SQL + + return PG_DB.query_one?(request, plid, vid, as: Int64) + end + + def select_one_id(plid : String, index : VideoIndex) : String? + request = <<-SQL + SELECT id FROM playlist_videos + WHERE plid = $1 + ORDER BY array_position($2, index) + LIMIT 1 + SQL + + return PG_DB.query_one?(request, plid, index, as: String) + end + + def select_ids(plid : String, index : VideoIndex, limit = 500) : Array(String) + request = <<-SQL + SELECT id FROM playlist_videos + WHERE plid = $1 + ORDER BY array_position($2, index) + LIMIT $3 + SQL + + return PG_DB.query_all(request, plid, index, limit, as: String) + end +end diff --git a/src/invidious/database/sessions.cr b/src/invidious/database/sessions.cr new file mode 100644 index 00000000..96587082 --- /dev/null +++ b/src/invidious/database/sessions.cr @@ -0,0 +1,74 @@ +require "./base.cr" + +module Invidious::Database::SessionIDs + extend self + + # ------------------- + # Insert + # ------------------- + + def insert(sid : String, email : String, handle_conflicts : Bool = false) + request = <<-SQL + INSERT INTO session_ids + VALUES ($1, $2, now()) + SQL + + request += " ON CONFLICT (id) DO NOTHING" if handle_conflicts + + PG_DB.exec(request, sid, email) + end + + # ------------------- + # Delete + # ------------------- + + def delete(*, sid : String) + request = <<-SQL + DELETE FROM session_ids * + WHERE id = $1 + SQL + + PG_DB.exec(request, sid) + end + + def delete(*, email : String) + request = <<-SQL + DELETE FROM session_ids * + WHERE email = $1 + SQL + + PG_DB.exec(request, email) + end + + def delete(*, sid : String, email : String) + request = <<-SQL + DELETE FROM session_ids * + WHERE id = $1 AND email = $2 + SQL + + PG_DB.exec(request, sid, email) + end + + # ------------------- + # Select + # ------------------- + + def select_email(sid : String) : String? + request = <<-SQL + SELECT email FROM session_ids + WHERE id = $1 + SQL + + PG_DB.query_one?(request, sid, as: String) + end + + def select_all(email : String) : Array({session: String, issued: Time}) + request = <<-SQL + SELECT id, issued FROM session_ids + WHERE email = $1 + ORDER BY issued DESC + SQL + + PG_DB.query_all(request, email, as: {session: String, issued: Time}) + end +end diff --git a/src/invidious/database/statistics.cr b/src/invidious/database/statistics.cr new file mode 100644 index 00000000..9e4963fd --- /dev/null +++ b/src/invidious/database/statistics.cr @@ -0,0 +1,49 @@ +require "./base.cr" + +module Invidious::Database::Statistics + extend self + + # ------------------- + # User stats + # ------------------- + + def count_users_total : Int64 + request = <<-SQL + SELECT count(*) FROM users + SQL + + PG_DB.query_one(request, as: Int64) + end + + def count_users_active_6m : Int64 + request = <<-SQL + SELECT count(*) FROM users + WHERE CURRENT_TIMESTAMP - updated < '6 months' + SQL + + PG_DB.query_one(request, as: Int64) + end + + def count_users_active_1m : Int64 + request = <<-SQL + SELECT count(*) FROM users + WHERE CURRENT_TIMESTAMP - updated < '1 month' + SQL + + PG_DB.query_one(request, as: Int64) + end + + # ------------------- + # Channel stats + # ------------------- + + def channel_last_update : Time? + request = <<-SQL + SELECT updated FROM channels + ORDER BY updated DESC + LIMIT 1 + SQL + + PG_DB.query_one?(request, as: Time) + end +end diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr new file mode 100644 index 00000000..d54e6a76 --- /dev/null +++ b/src/invidious/database/users.cr @@ -0,0 +1,228 @@ +require "./base.cr" + +module Invidious::Database::Users + extend self + + # ------------------- + # Insert / delete + # ------------------- + + def insert(user : User, update_on_conflict : Bool = false) + user_array = user.to_a + user_array[4] = user_array[4].to_json # User preferences + + request = <<-SQL + INSERT INTO users + VALUES (#{arg_array(user_array)}) + SQL + + if update_on_conflict + request += <<-SQL + ON CONFLICT (email) DO UPDATE + SET updated = $1, subscriptions = $3 + SQL + end + + PG_DB.exec(request, args: user_array) + end + + def delete(user : User) + request = <<-SQL + DELETE FROM users * + WHERE email = $1 + SQL + + PG_DB.exec(request, user.email) + end + + # ------------------- + # Update (history) + # ------------------- + + def update_watch_history(user : User) + request = <<-SQL + UPDATE users + SET watched = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, user.watched, user.email) + end + + def mark_watched(user : User, vid : String) + request = <<-SQL + UPDATE users + SET watched = array_append(array_remove(watched, $1), $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, vid, user.email) + end + + def mark_unwatched(user : User, vid : String) + request = <<-SQL + UPDATE users + SET watched = array_remove(watched, $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, vid, user.email) + end + + def clear_watch_history(user : User) + request = <<-SQL + UPDATE users + SET watched = '{}' + WHERE email = $1 + SQL + + PG_DB.exec(request, user.email) + end + + # ------------------- + # Update (channels) + # ------------------- + + def update_subscriptions(user : User) + request = <<-SQL + UPDATE users + SET feed_needs_update = true, subscriptions = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, user.subscriptions, user.email) + end + + def subscribe_channel(user : User, ucid : String) + request = <<-SQL + UPDATE users + SET feed_needs_update = true, + subscriptions = array_append(subscriptions,$1) + WHERE email = $2 + SQL + + PG_DB.exec(request, ucid, user.email) + end + + def unsubscribe_channel(user : User, ucid : String) + request = <<-SQL + UPDATE users + SET feed_needs_update = true, + subscriptions = array_remove(subscriptions, $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, ucid, user.email) + end + + # ------------------- + # Update (notifs) + # ------------------- + + def add_notification(video : ChannelVideo) + request = <<-SQL + UPDATE users + SET notifications = array_append(notifications, $1), + feed_needs_update = true + WHERE $2 = ANY(subscriptions) + SQL + + PG_DB.exec(request, video.id, video.ucid) + end + + def remove_notification(user : User, vid : String) + request = <<-SQL + UPDATE users + SET notifications = array_remove(notifications, $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, vid, user.email) + end + + def clear_notifications(user : User) + request = <<-SQL + UPDATE users + SET notifications = '{}', updated = now() + WHERE email = $1 + SQL + + PG_DB.exec(request, user.email) + end + + # ------------------- + # Update (misc) + # ------------------- + + def feed_needs_update(video : ChannelVideo) + request = <<-SQL + UPDATE users + SET feed_needs_update = true + WHERE $1 = ANY(subscriptions) + SQL + + PG_DB.exec(request, video.ucid) + end + + def update_preferences(user : User) + request = <<-SQL + UPDATE users + SET preferences = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, user.preferences.to_json, user.email) + end + + def update_password(user : User, pass : String) + request = <<-SQL + UPDATE users + SET password = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, pass, user.email) + end + + # ------------------- + # Select + # ------------------- + + def select(*, email : String) : User? + request = <<-SQL + SELECT * FROM users + WHERE email = $1 + SQL + + return PG_DB.query_one?(request, email, as: User) + end + + # Same as select, but can raise an exception + def select!(*, email : String) : User + request = <<-SQL + SELECT * FROM users + WHERE email = $1 + SQL + + return PG_DB.query_one(request, email, as: User) + end + + def select(*, token : String) : User? + request = <<-SQL + SELECT * FROM users + WHERE token = $1 + SQL + + return PG_DB.query_one?(request, token, as: User) + end + + def select_notifications(user : User) : Array(String) + request = <<-SQL + SELECT notifications + FROM users + WHERE email = $1 + SQL + + return PG_DB.query_one(request, user.email, as: Array(String)) + end +end diff --git a/src/invidious/database/videos.cr b/src/invidious/database/videos.cr new file mode 100644 index 00000000..695f5b33 --- /dev/null +++ b/src/invidious/database/videos.cr @@ -0,0 +1,52 @@ +require "./base.cr" + +module Invidious::Database::Videos + extend self + + def insert(video : Video) + request = <<-SQL + INSERT INTO videos + VALUES ($1, $2, $3) + ON CONFLICT (id) DO NOTHING + SQL + + PG_DB.exec(request, video.id, video.info.to_json, video.updated) + end + + def delete(id) + request = <<-SQL + DELETE FROM videos * + WHERE id = $1 + SQL + + PG_DB.exec(request, id) + end + + def delete_expired + request = <<-SQL + DELETE FROM videos * + WHERE updated < (now() - interval '6 hours') + SQL + + PG_DB.exec(request) + end + + def update(video : Video) + request = <<-SQL + UPDATE videos + SET (id, info, updated) = ($1, $2, $3) + WHERE id = $1 + SQL + + PG_DB.exec(request, video.id, video.info.to_json, video.updated) + end + + def select(id : String) : Video? + request = <<-SQL + SELECT * FROM videos + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: Video) + end +end diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr new file mode 100644 index 00000000..690db907 --- /dev/null +++ b/src/invidious/exceptions.cr @@ -0,0 +1,40 @@ +# InfoExceptions are for displaying information to the user. +# +# An InfoException might or might not indicate that something went wrong. +# Historically Invidious didn't differentiate between these two options, so to +# maintain previous functionality InfoExceptions do not print backtraces. +class InfoException < Exception +end + +# Exception used to hold the bogus UCID during a channel search. +class ChannelSearchException < InfoException + getter channel : String + + def initialize(@channel) + end +end + +# Exception used to hold the name of the missing item +# Should be used in all parsing functions +class BrokenTubeException < Exception + getter element : String + + def initialize(@element) + end + + def message + return "Missing JSON element \"#{@element}\"" + end +end + +# Exception threw when an element is not found. +class NotFoundException < InfoException +end + +class VideoNotAvailableException < Exception +end + +# Exception used to indicate that the JSON response from YT is missing +# some important informations, and that the query should be sent again. +class RetryOnceException < Exception +end diff --git a/src/invidious/frontend/channel_page.cr b/src/invidious/frontend/channel_page.cr new file mode 100644 index 00000000..fe7d6d6e --- /dev/null +++ b/src/invidious/frontend/channel_page.cr @@ -0,0 +1,46 @@ +module Invidious::Frontend::ChannelPage + extend self + + enum TabsAvailable + Videos + Shorts + Streams + Podcasts + Releases + Playlists + Community + Channels + end + + def generate_tabs_links(locale : String, channel : AboutChannel, selected_tab : TabsAvailable) + return String.build(1500) do |str| + base_url = "/channel/#{channel.ucid}" + + TabsAvailable.each do |tab| + # Ignore playlists, as it is not supported for auto-generated channels yet + next if (tab.playlists? && channel.auto_generated) + + tab_name = tab.to_s.downcase + + if channel.tabs.includes? tab_name + str << %(<div class="pure-u-1 pure-md-1-3">\n) + + if tab == selected_tab + str << "\t<b>" + str << translate(locale, "channel_tab_#{tab_name}_label") + str << "</b>\n" + else + # Video tab doesn't have the last path component + url = tab.videos? ? base_url : "#{base_url}/#{tab_name}" + + str << %(\t<a href=") << url << %(">) + str << translate(locale, "channel_tab_#{tab_name}_label") + str << "</a>\n" + end + + str << "</div>" + end + end + end + end +end diff --git a/src/invidious/frontend/comments_reddit.cr b/src/invidious/frontend/comments_reddit.cr new file mode 100644 index 00000000..4dda683e --- /dev/null +++ b/src/invidious/frontend/comments_reddit.cr @@ -0,0 +1,50 @@ +module Invidious::Frontend::Comments + extend self + + def template_reddit(root, locale) + String.build do |html| + root.each do |child| + if child.data.is_a?(RedditComment) + child = child.data.as(RedditComment) + body_html = HTML.unescape(child.body_html) + + replies_html = "" + if child.replies.is_a?(RedditThing) + replies = child.replies.as(RedditThing) + replies_html = self.template_reddit(replies.data.as(RedditListing).children, locale) + end + + if child.depth > 0 + html << <<-END_HTML + <div class="pure-g"> + <div class="pure-u-1-24"> + </div> + <div class="pure-u-23-24"> + END_HTML + else + html << <<-END_HTML + <div class="pure-g"> + <div class="pure-u-1"> + END_HTML + end + + html << <<-END_HTML + <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> + </p> + <div> + #{body_html} + #{replies_html} + </div> + </div> + </div> + END_HTML + end + end + end + end +end diff --git a/src/invidious/frontend/comments_youtube.cr b/src/invidious/frontend/comments_youtube.cr new file mode 100644 index 00000000..a0e1d783 --- /dev/null +++ b/src/invidious/frontend/comments_youtube.cr @@ -0,0 +1,208 @@ +module Invidious::Frontend::Comments + extend self + + def template_youtube(comments, locale, thin_mode, is_replies = false) + String.build do |html| + root = comments["comments"].as_a + root.each do |child| + if child["replies"]? + replies_count_text = translate_count(locale, + "comments_view_x_replies", + child["replies"]["replyCount"].as_i64 || 0, + NumberFormatting::Separator + ) + + replies_html = <<-END_HTML + <div id="replies" class="pure-g"> + <div class="pure-u-1-24"></div> + <div class="pure-u-23-24"> + <p> + <a href="javascript:void(0)" data-continuation="#{child["replies"]["continuation"]}" + data-onclick="get_youtube_replies" data-load-replies>#{replies_count_text}</a> + </p> + </div> + </div> + END_HTML + elsif comments["authorId"]? && !comments["singlePost"]? + # for posts we should display a link to the post + replies_count_text = translate_count(locale, + "comments_view_x_replies", + child["replyCount"].as_i64 || 0, + NumberFormatting::Separator + ) + + replies_html = <<-END_HTML + <div class="pure-g"> + <div class="pure-u-1-24"></div> + <div class="pure-u-23-24"> + <p> + <a href="/post/#{child["commentId"]}?ucid=#{comments["authorId"]}">#{replies_count_text}</a> + </p> + </div> + </div> + END_HTML + end + + if !thin_mode + author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}" + else + author_thumbnail = "" + end + + author_name = HTML.escape(child["author"].as_s) + sponsor_icon = "" + if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool + author_name += " <i class=\"icon ion ion-md-checkmark-circle\"></i>" + elsif child["verified"]?.try &.as_bool + author_name += " <i class=\"icon ion ion-md-checkmark\"></i>" + end + + if child["isSponsor"]?.try &.as_bool + 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 << %(width="16" height="16" />) + end + end + html << <<-END_HTML + <div class="pure-g" style="width:100%"> + <div class="channel-profile pure-u-4-24 pure-u-md-2-24"> + <img loading="lazy" style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}" alt="" /> + </div> + <div class="pure-u-20-24 pure-u-md-22-24"> + <p> + <b> + <a class="#{child["authorIsChannelOwner"] == true ? "channel-owner" : ""}" href="#{child["authorUrl"]}">#{author_name}</a> + </b> + #{sponsor_icon} + <p style="white-space:pre-wrap">#{child["contentHtml"]}</p> + END_HTML + + if child["attachment"]? + attachment = child["attachment"] + + case attachment["type"] + when "image" + attachment = attachment["imageThumbnails"][1] + + html << <<-END_HTML + <div class="pure-g"> + <div class="pure-u-1 pure-u-md-1-2"> + <img loading="lazy" style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}" alt="" /> + </div> + </div> + END_HTML + when "video" + if attachment["error"]? + html << <<-END_HTML + <div class="pure-g video-iframe-wrapper"> + <p>#{attachment["error"]}</p> + </div> + END_HTML + else + html << <<-END_HTML + <div class="pure-g video-iframe-wrapper"> + <iframe class="video-iframe" src='/embed/#{attachment["videoId"]?}?autoplay=0'></iframe> + </div> + END_HTML + end + when "multiImage" + html << <<-END_HTML + <section class="carousel"> + <a class="skip-link" href="#skip-#{child["commentId"]}">#{translate(locale, "carousel_skip")}</a> + <div class="slides"> + END_HTML + image_array = attachment["images"].as_a + + image_array.each_index do |i| + html << <<-END_HTML + <div class="slides-item slide-#{i + 1}" id="#{child["commentId"]}-slide-#{i + 1}" aria-label="#{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 + end + + html << <<-END_HTML + </div> + <div class="carousel__nav"> + 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> + END_HTML + end + html << <<-END_HTML + </div> + <div id="skip-#{child["commentId"]}"></div> + </section> + END_HTML + else nil # Ignore + end + end + + 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> + | + 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> + | + 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> + | + END_HTML + end + + html << <<-END_HTML + <i class="icon ion-ios-thumbs-up"></i> #{number_with_separator(child["likeCount"])} + END_HTML + + if child["creatorHeart"]? + if !thin_mode + creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}" + else + creator_thumbnail = "" + end + + html << <<-END_HTML + + <span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}"> + <span class="creator-heart"> + <img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}" alt="" /> + <span class="creator-heart-small-hearted"> + <span class="icon ion-ios-heart creator-heart-small-container"></span> + </span> + </span> + </span> + END_HTML + end + + html << <<-END_HTML + </p> + #{replies_html} + </div> + </div> + END_HTML + end + + if comments["continuation"]? + html << <<-END_HTML + <div class="pure-g"> + <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> + </p> + </div> + </div> + END_HTML + end + end + end +end diff --git a/src/invidious/frontend/misc.cr b/src/invidious/frontend/misc.cr new file mode 100644 index 00000000..7a6cf79d --- /dev/null +++ b/src/invidious/frontend/misc.cr @@ -0,0 +1,14 @@ +module Invidious::Frontend::Misc + extend self + + def redirect_url(env : HTTP::Server::Context) + prefs = env.get("preferences").as(Preferences) + + if prefs.automatic_instance_redirect + current_page = env.get?("current_page").as(String) + return "/redirect?referer=#{current_page}" + else + return "https://redirect.invidious.io#{env.request.resource}" + end + end +end diff --git a/src/invidious/frontend/pagination.cr b/src/invidious/frontend/pagination.cr new file mode 100644 index 00000000..3f931f4e --- /dev/null +++ b/src/invidious/frontend/pagination.cr @@ -0,0 +1,97 @@ +require "uri" + +module Invidious::Frontend::Pagination + extend self + + private def previous_page(str : String::Builder, locale : String?, url : String) + # Link + str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">) + + if locale_is_rtl?(locale) + # Inverted arrow ("previous" points to the right) + str << translate(locale, "Previous page") + str << " " + str << %(<i class="icon ion-ios-arrow-forward"></i>) + else + # Regular arrow ("previous" points to the left) + str << %(<i class="icon ion-ios-arrow-back"></i>) + str << " " + str << translate(locale, "Previous page") + end + + str << "</a>" + end + + private def next_page(str : String::Builder, locale : String?, url : String) + # Link + str << %(<a href=") << url << %(" class="pure-button pure-button-secondary">) + + if locale_is_rtl?(locale) + # Inverted arrow ("next" points to the left) + str << %(<i class="icon ion-ios-arrow-back"></i>) + str << " " + str << translate(locale, "Next page") + else + # Regular arrow ("next" points to the right) + str << translate(locale, "Next page") + str << " " + str << %(<i class="icon ion-ios-arrow-forward"></i>) + end + + str << "</a>" + end + + def nav_numeric(locale : String?, *, base_url : String | URI, current_page : Int, show_next : Bool = true) + return String.build do |str| + str << %(<div class="h-box">\n) + str << %(<div class="page-nav-container flexible">\n) + + str << %(<div class="page-prev-container flex-left">) + + if current_page > 1 + params_prev = URI::Params{"page" => (current_page - 1).to_s} + url_prev = HttpServer::Utils.add_params_to_url(base_url, params_prev) + + self.previous_page(str, locale, url_prev.to_s) + end + + str << %(</div>\n) + str << %(<div class="page-next-container flex-right">) + + if show_next + params_next = URI::Params{"page" => (current_page + 1).to_s} + url_next = HttpServer::Utils.add_params_to_url(base_url, params_next) + + self.next_page(str, locale, url_next.to_s) + end + + str << %(</div>\n) + + str << %(</div>\n) + str << %(</div>\n\n) + end + end + + def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?) + return String.build do |str| + str << %(<div class="h-box">\n) + str << %(<div class="page-nav-container flexible">\n) + + str << %(<div class="page-prev-container flex-left"></div>\n) + + str << %(<div class="page-next-container flex-right">) + + if !ctoken.nil? + params_next = URI::Params{"continuation" => ctoken} + url_next = HttpServer::Utils.add_params_to_url(base_url, params_next) + + self.next_page(str, locale, url_next.to_s) + end + + str << %(</div>\n) + + str << %(</div>\n) + str << %(</div>\n\n) + end + end +end diff --git a/src/invidious/frontend/search_filters.cr b/src/invidious/frontend/search_filters.cr new file mode 100644 index 00000000..8ac0af2e --- /dev/null +++ b/src/invidious/frontend/search_filters.cr @@ -0,0 +1,135 @@ +module Invidious::Frontend::SearchFilters + extend self + + # Generate the search filters collapsable widget. + def generate(filters : Search::Filters, query : String, page : Int, locale : String) : String + 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<div id='filters-box'><form action='/search' method='get'>\n" + + str << "\t\t\t<input type='hidden' name='q' value='" << HTML.escape(query) << "'>\n" + str << "\t\t\t<input type='hidden' name='page' value='" << page << "'>\n" + + str << "\t\t\t<div id='filters-flex'>" + + filter_wrapper(date) + filter_wrapper(type) + filter_wrapper(duration) + filter_wrapper(features) + filter_wrapper(sort) + + str << "\t\t\t</div>\n" + + 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 << "</button></div>\n" + + str << "\t\t</form></div>\n" + + str << "\t</details>\n" + str << "</div>\n" + end + end + + # Generate wrapper HTML (`<div>`, filter name, etc...) around the + # `<input>` elements of a search filter + macro filter_wrapper(name) + 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 << "</div></legend>\n" + + str << "\t\t\t\t\t<div class=\"filter-options\">\n" + make_{{name}}_filter_options(str, filters.{{name}}, locale) + str << "\t\t\t\t\t</div>" + + str << "\t\t\t\t</fieldset></div>\n" + end + + # Generates the HTML for the list of radio buttons of the "date" search filter + def make_date_filter_options(str : String::Builder, value : Search::Filters::Date, locale : String) + {% for value in Invidious::Search::Filters::Date.constants %} + {% date = value.underscore %} + + str << "\t\t\t\t\t\t<div>" + str << "<input type='radio' name='date' id='filter-date-{{date}}' value='{{date}}'" + str << " checked" if value.{{date}}? + str << '>' + + str << "<label for='filter-date-{{date}}'>" + str << translate(locale, "search_filters_date_option_{{date}}") + str << "</label></div>\n" + {% end %} + end + + # Generates the HTML for the list of radio buttons of the "type" search filter + def make_type_filter_options(str : String::Builder, value : Search::Filters::Type, locale : String) + {% for value in Invidious::Search::Filters::Type.constants %} + {% type = value.underscore %} + + str << "\t\t\t\t\t\t<div>" + str << "<input type='radio' name='type' id='filter-type-{{type}}' value='{{type}}'" + str << " checked" if value.{{type}}? + str << '>' + + str << "<label for='filter-type-{{type}}'>" + str << translate(locale, "search_filters_type_option_{{type}}") + str << "</label></div>\n" + {% end %} + end + + # Generates the HTML for the list of radio buttons of the "duration" search filter + def make_duration_filter_options(str : String::Builder, value : Search::Filters::Duration, locale : String) + {% for value in Invidious::Search::Filters::Duration.constants %} + {% duration = value.underscore %} + + str << "\t\t\t\t\t\t<div>" + str << "<input type='radio' name='duration' id='filter-duration-{{duration}}' value='{{duration}}'" + str << " checked" if value.{{duration}}? + str << '>' + + str << "<label for='filter-duration-{{duration}}'>" + str << translate(locale, "search_filters_duration_option_{{duration}}") + str << "</label></div>\n" + {% end %} + end + + # Generates the HTML for the list of checkboxes of the "features" search filter + def make_features_filter_options(str : String::Builder, value : Search::Filters::Features, locale : String) + {% for value in Invidious::Search::Filters::Features.constants %} + {% if value.stringify != "All" && value.stringify != "None" %} + {% feature = value.underscore %} + + str << "\t\t\t\t\t\t<div>" + str << "<input type='checkbox' name='features' id='filter-feature-{{feature}}' value='{{feature}}'" + str << " checked" if value.{{feature}}? + str << '>' + + str << "<label for='filter-feature-{{feature}}'>" + str << translate(locale, "search_filters_features_option_{{feature}}") + str << "</label></div>\n" + {% end %} + {% end %} + end + + # Generates the HTML for the list of radio buttons of the "sort" search filter + def make_sort_filter_options(str : String::Builder, value : Search::Filters::Sort, locale : String) + {% for value in Invidious::Search::Filters::Sort.constants %} + {% sort = value.underscore %} + + str << "\t\t\t\t\t\t<div>" + str << "<input type='radio' name='sort' id='filter-sort-{{sort}}' value='{{sort}}'" + str << " checked" if value.{{sort}}? + str << '>' + + str << "<label for='filter-sort-{{sort}}'>" + str << translate(locale, "search_filters_sort_option_{{sort}}") + str << "</label></div>\n" + {% end %} + end +end diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr new file mode 100644 index 00000000..c8cb7110 --- /dev/null +++ b/src/invidious/frontend/watch_page.cr @@ -0,0 +1,107 @@ +module Invidious::Frontend::WatchPage + extend self + + # A handy structure to pass many elements at + # once to the download widget function + struct VideoAssets + getter full_videos : Array(Hash(String, JSON::Any)) + getter video_streams : Array(Hash(String, JSON::Any)) + getter audio_streams : Array(Hash(String, JSON::Any)) + getter captions : Array(Invidious::Videos::Captions::Metadata) + + def initialize( + @full_videos, + @video_streams, + @audio_streams, + @captions + ) + end + end + + def download_widget(locale : String, video : Video, video_assets : VideoAssets) : String + if CONFIG.disabled?("downloads") + return "<p id=\"download\">#{translate(locale, "Download is disabled")}</p>" + end + + return String.build(4000) do |str| + str << "<form" + str << " class=\"pure-form pure-form-stacked\"" + str << " action='/download'" + str << " method='post'" + str << " rel='noopener'" + str << " target='_blank'>" + str << '\n' + + # Hidden inputs for video id and title + str << "<input type='hidden' name='id' value='" << video.id << "'/>\n" + str << "<input type='hidden' name='title' value='" << HTML.escape(video.title) << "'/>\n" + + str << "\t<div class=\"pure-control-group\">\n" + + str << "\t\t<label for='download_widget'>" + str << translate(locale, "Download as: ") + str << "</label>\n" + + str << "\t\t<select name='download_widget' id='download_widget'>\n" + + # Non-DASH videos (audio+video) + + video_assets.full_videos.each do |option| + mimetype = option["mimeType"].as_s.split(";")[0] + + height = Invidious::Videos::Formats.itag_to_metadata?(option["itag"]).try &.["height"]? + + value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json + + str << "\t\t\t<option value='" << value << "'>" + str << (height || "~240") << "p - " << mimetype + str << "</option>\n" + end + + # DASH video streams + + video_assets.video_streams.each do |option| + mimetype = option["mimeType"].as_s.split(";")[0] + + value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json + + str << "\t\t\t<option value='" << value << "'>" + str << option["qualityLabel"] << " - " << mimetype << " @ " << option["fps"] << "fps - video only" + str << "</option>\n" + end + + # DASH audio streams + + video_assets.audio_streams.each do |option| + mimetype = option["mimeType"].as_s.split(";")[0] + + value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json + + str << "\t\t\t<option value='" << value << "'>" + str << mimetype << " @ " << (option["bitrate"]?.try &.as_i./ 1000) << "k - audio only" + str << "</option>\n" + end + + # Subtitles (a.k.a "closed captions") + + video_assets.captions.each do |caption| + value = {"label": caption.name, "ext": "#{caption.language_code}.vtt"}.to_json + + str << "\t\t\t<option value='" << value << "'>" + str << translate(locale, "download_subtitles", translate(locale, caption.name)) + str << "</option>\n" + end + + # End of form + + str << "\t\t</select>\n" + str << "\t</div>\n" + + str << "\t<button type=\"submit\" class=\"pure-button pure-button-primary\">\n" + str << "\t\t<b>" << translate(locale, "Download") << "</b>\n" + str << "\t</button>\n" + + str << "</form>\n" + end + end +end diff --git a/src/invidious/hashtag.cr b/src/invidious/hashtag.cr new file mode 100644 index 00000000..d9d584c9 --- /dev/null +++ b/src/invidious/hashtag.cr @@ -0,0 +1,42 @@ +module Invidious::Hashtag + extend self + + def fetch(hashtag : String, page : Int, region : String? = nil) : Array(SearchItem) + cursor = (page - 1) * 60 + ctoken = generate_continuation(hashtag, cursor) + + client_config = YoutubeAPI::ClientConfig.new(region: region) + response = YoutubeAPI.browse(continuation: ctoken, client_config: client_config) + + items, _ = extract_items(response) + return items + end + + def generate_continuation(hashtag : String, cursor : Int) + object = { + "80226972:embedded" => { + "2:string" => "FEhashtag", + "3:base64" => { + "1:varint" => 60_i64, # result count + "15:base64" => { + "1:varint" => cursor.to_i64, + "2:varint" => 0_i64, + }, + "93:2:embedded" => { + "1:string" => hashtag, + "2:varint" => 0_i64, + "3:varint" => 1_i64, + }, + }, + "35:string" => "browse-feedFEhashtag", + }, + } + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation + end +end diff --git a/src/invidious/helpers/crystal_class_overrides.cr b/src/invidious/helpers/crystal_class_overrides.cr new file mode 100644 index 00000000..3040d7a0 --- /dev/null +++ b/src/invidious/helpers/crystal_class_overrides.cr @@ -0,0 +1,104 @@ +# Override of the TCPSocket and HTTP::Client classes in order to allow an +# IP family to be selected for domains that resolve to both IPv4 and +# IPv6 addresses. +# +class TCPSocket + def initialize(host, port, dns_timeout = nil, connect_timeout = nil, blocking = false, family = Socket::Family::UNSPEC) + Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo| + super(addrinfo.family, addrinfo.type, addrinfo.protocol, blocking) + connect(addrinfo, timeout: connect_timeout) do |error| + close + error + end + end + end +end + +# :ditto: +class HTTP::Client + property family : Socket::Family = Socket::Family::UNSPEC + + # Override stdlib to automatically initialize proxy if configured + # + # Accurate as of crystal 1.12.1 + + def initialize(@host : String, port = nil, tls : TLSContext = nil) + check_host_only(@host) + + {% if flag?(:without_openssl) %} + if tls + raise "HTTP::Client TLS is disabled because `-D without_openssl` was passed at compile time" + end + @tls = nil + {% else %} + @tls = case tls + when true + OpenSSL::SSL::Context::Client.new + when OpenSSL::SSL::Context::Client + tls + when false, nil + nil + end + {% end %} + + @port = (port || (@tls ? 443 : 80)).to_i + + self.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy + end + + def initialize(@io : IO, @host = "", @port = 80) + @reconnect = false + + self.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy + end + + private def io + io = @io + return io if io + unless @reconnect + raise "This HTTP::Client cannot be reconnected" + end + + hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host + io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, family: @family + io.read_timeout = @read_timeout if @read_timeout + io.write_timeout = @write_timeout if @write_timeout + io.sync = false + + {% if !flag?(:without_openssl) %} + if tls = @tls + tcp_socket = io + begin + io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host.rchop('.')) + rescue exc + # don't leak the TCP socket when the SSL connection failed + tcp_socket.close + raise exc + end + end + {% end %} + + @io = io + end +end + +# Mute the ClientError exception raised when a connection is flushed. +# This happends when the connection is unexpectedly closed by the client. +# +class HTTP::Server::Response + class Output + private def unbuffered_flush + @io.flush + rescue ex : IO::Error + unbuffered_close + end + end +end + +# TODO: Document this override +# +class PG::ResultSet + def field(index = @column_index) + @fields.not_nil![index] + end +end diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index 68ced430..b7643194 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -1,13 +1,9 @@ -# InfoExceptions are for displaying information to the user. -# -# An InfoException might or might not indicate that something went wrong. -# Historically Invidious didn't differentiate between these two options, so to -# maintain previous functionality InfoExceptions do not print backtraces. -class InfoException < Exception -end +# ------------------- +# Issue template +# ------------------- macro error_template(*args) - error_template_helper(env, locale, {{*args}}) + error_template_helper(env, {{args.splat}}) end def github_details(summary : String, content : String) @@ -22,84 +18,185 @@ def github_details(summary : String, content : String) return HTML.escape(details) end -def error_template_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) +def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception) if exception.is_a?(InfoException) - return error_template_helper(env, locale, status_code, exception.message || "") + return error_template_helper(env, status_code, exception.message || "") end + + locale = env.get("preferences").as(Preferences).locale + env.response.content_type = "text/html" env.response.status_code = status_code - issue_template = %(Title: `#{exception.message} (#{exception.class})`) - issue_template += %(\nDate: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}`) - issue_template += %(\nRoute: `#{env.request.resource}`) - issue_template += %(\nVersion: `#{SOFTWARE["version"]} @ #{SOFTWARE["branch"]}`) - # issue_template += github_details("Preferences", env.get("preferences").as(Preferences).to_pretty_json) + + issue_title = "#{exception.message} (#{exception.class})" + + issue_template = <<-TEXT + Title: `#{HTML.escape(issue_title)}` + Date: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}` + Route: `#{HTML.escape(env.request.resource)}` + Version: `#{SOFTWARE["version"]} @ #{SOFTWARE["branch"]}` + + TEXT + issue_template += github_details("Backtrace", exception.inspect_with_backtrace) + + # URLs for the error message below + url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md" + url_search_issues = "https://github.com/iv-org/invidious/issues" + url_search_issues += "?q=is:issue+is:open+" + url_search_issues += URI.encode_www_form("[Bug] #{issue_title}") + + url_switch = "https://redirect.invidious.io" + env.request.resource + + url_new_issue = "https://github.com/iv-org/invidious/issues/new" + url_new_issue += "?labels=bug&template=bug_report.md&title=" + url_new_issue += URI.encode_www_form("[Bug] " + issue_title) + error_message = <<-END_HTML - Looks like you've found a bug in Invidious. Please open a new issue - <a href="https://github.com/iv-org/invidious/issues">on GitHub</a> - and include the following text in your message: - <pre style="padding: 20px; background: rgba(0, 0, 0, 0.12345);">#{issue_template}</pre> + <div class="error_message"> + <h2>#{translate(locale, "crash_page_you_found_a_bug")}</h2> + <br/><br/> + + <p><b>#{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> + </ul> + + <br/> + <p>#{translate(locale, "crash_page_report_issue", url_new_issue)}</p> + + <!-- TODO: Add a "copy to clipboard" button --> + <pre style="padding: 20px; background: rgba(0, 0, 0, 0.12345);">#{issue_template}</pre> + </div> END_HTML + + # Don't show the usual "next steps" widget. The same options are + # proposed above the error message, just worded differently. + next_steps = "" + return templated "error" end -def error_template_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) +def error_template_helper(env : HTTP::Server::Context, status_code : Int32, message : String) env.response.content_type = "text/html" env.response.status_code = status_code + + locale = env.get("preferences").as(Preferences).locale + error_message = translate(locale, message) + next_steps = error_redirect_helper(env) + return templated "error" end +# ------------------- +# Atom feeds +# ------------------- + macro error_atom(*args) - error_atom_helper(env, locale, {{*args}}) + error_atom_helper(env, {{args.splat}}) end -def error_atom_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) +def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception) if exception.is_a?(InfoException) - return error_atom_helper(env, locale, status_code, exception.message || "") + return error_atom_helper(env, status_code, exception.message || "") end + env.response.content_type = "application/atom+xml" env.response.status_code = status_code + return "<error>#{exception.inspect_with_backtrace}</error>" end -def error_atom_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) +def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, message : String) env.response.content_type = "application/atom+xml" env.response.status_code = status_code + return "<error>#{message}</error>" end +# ------------------- +# JSON +# ------------------- + macro error_json(*args) - error_json_helper(env, locale, {{*args}}) + error_json_helper(env, {{args.splat}}) end -def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception, additional_fields : Hash(String, Object) | Nil) +def error_json_helper( + env : HTTP::Server::Context, + status_code : Int32, + exception : Exception, + additional_fields : Hash(String, Object) | Nil = nil +) if exception.is_a?(InfoException) - return error_json_helper(env, locale, status_code, exception.message || "", additional_fields) + return error_json_helper(env, status_code, exception.message || "", additional_fields) end + env.response.content_type = "application/json" env.response.status_code = status_code + error_message = {"error" => exception.message, "errorBacktrace" => exception.inspect_with_backtrace} + if additional_fields error_message = error_message.merge(additional_fields) end - return error_message.to_json -end -def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) - return error_json_helper(env, locale, status_code, exception, nil) + return error_message.to_json end -def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String, additional_fields : Hash(String, Object) | Nil) +def error_json_helper( + env : HTTP::Server::Context, + status_code : Int32, + message : String, + additional_fields : Hash(String, Object) | Nil = nil +) env.response.content_type = "application/json" env.response.status_code = status_code + error_message = {"error" => message} + if additional_fields error_message = error_message.merge(additional_fields) end + return error_message.to_json end -def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) - error_json_helper(env, locale, status_code, message, nil) +# ------------------- +# Redirect +# ------------------- + +def error_redirect_helper(env : HTTP::Server::Context) + request_path = env.request.path + + locale = env.get("preferences").as(Preferences).locale + + 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") + + return <<-END_HTML + <p style="margin-bottom: 4px;">#{next_steps_text}</p> + <ul> + <li> + <a href="#{env.request.resource}">#{refresh}</a> + </li> + <li> + <a href="/redirect?referer=#{env.get("current_page")}">#{switch_instance}</a> + </li> + <li> + <a rel="noreferrer noopener" href="https://youtube.com#{env.request.resource}">#{go_to_youtube}</a> + </li> + </ul> + END_HTML + else + return "" + end end diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 045b6701..f3e3b951 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -97,18 +97,18 @@ class AuthHandler < Kemal::Handler if token = env.request.headers["Authorization"]? token = JSON.parse(URI.decode_www_form(token.lchop("Bearer "))) session = URI.decode_www_form(token["session"].as_s) - scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, PG_DB, nil) + scopes, _, _ = validate_request(token, session, env.request, HMAC_KEY, nil) - if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", session, as: String) - user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) + if email = Invidious::Database::SessionIDs.select_email(session) + user = Invidious::Database::Users.select!(email: email) end elsif sid = env.request.cookies["SID"]?.try &.value if sid.starts_with? "v1:" raise "Cannot use token as SID" end - if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) - user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) + if email = Invidious::Database::SessionIDs.select_email(sid) + user = Invidious::Database::Users.select!(email: email) end scopes = [":*"] @@ -142,63 +142,8 @@ class APIHandler < Kemal::Handler exclude ["/api/v1/auth/notifications"], "POST" def call(env) - return call_next env unless only_match? env - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - # Since /api/v1/notifications is an event-stream, we don't want - # to wrap the response - return call_next env if exclude_match? env - - # Here we swap out the socket IO so we can modify the response as needed - output = env.response.output - env.response.output = IO::Memory.new - - begin - call_next env - - env.response.output.rewind - - if env.response.output.as(IO::Memory).size != 0 && - env.response.headers.includes_word?("Content-Type", "application/json") - response = JSON.parse(env.response.output) - - if fields_text = env.params.query["fields"]? - begin - JSONFilter.filter(response, fields_text) - rescue ex - env.response.status_code = 400 - response = {"error" => ex.message} - end - end - - if env.params.query["pretty"]?.try &.== "1" - response = response.to_pretty_json - else - response = response.to_json - end - else - response = env.response.output.gets_to_end - end - rescue ex - env.response.content_type = "application/json" if env.response.headers.includes_word?("Content-Type", "text/html") - env.response.status_code = 500 - - if env.response.headers.includes_word?("Content-Type", "application/json") - response = {"error" => ex.message || "Unspecified error"} - - if env.params.query["pretty"]?.try &.== "1" - response = response.to_pretty_json - else - response = response.to_json - end - end - ensure - env.response.output = output - env.response.print response - - env.response.flush - end + env.response.headers["Access-Control-Allow-Origin"] = "*" if only_match?(env) + call_next env end end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 6a5789a0..6add0237 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -22,216 +22,6 @@ struct Annotation property annotations : String end -struct ConfigPreferences - include YAML::Serializable - - property annotations : Bool = false - property annotations_subscribed : Bool = false - property autoplay : Bool = false - property captions : Array(String) = ["", "", ""] - property comments : Array(String) = ["youtube", ""] - property continue : Bool = false - property continue_autoplay : Bool = true - property dark_mode : String = "" - property latest_only : Bool = false - property listen : Bool = false - property local : Bool = false - property locale : String = "en-US" - property max_results : Int32 = 40 - property notifications_only : Bool = false - property player_style : String = "invidious" - property quality : String = "hd720" - property quality_dash : String = "auto" - property default_home : String? = "Popular" - property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"] - property related_videos : Bool = true - property sort : String = "published" - property speed : Float32 = 1.0_f32 - property thin_mode : Bool = false - property unseen_only : Bool = false - property video_loop : Bool = false - property extend_desc : Bool = false - property volume : Int32 = 100 - - def to_tuple - {% begin %} - { - {{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}} - } - {% end %} - end -end - -class Config - include YAML::Serializable - - property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) - property feed_threads : Int32 = 1 # Number of threads to use for updating feeds - property output : String = "STDOUT" # Log file path or STDOUT - property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr - property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc) - - @[YAML::Field(converter: Preferences::URIConverter)] - property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax - property decrypt_polling : Bool = true # Use polling to keep decryption function up to date - property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel - property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https:// - property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions - property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required - property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) - property popular_enabled : Bool = true - property captcha_enabled : Bool = true - property login_enabled : Bool = true - property registration_enabled : Bool = true - property statistics_enabled : Bool = false - property admins : Array(String) = [] of String - property external_port : Int32? = nil - property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("") - property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs - property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc. - property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards - property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc. - property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely - property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' - - @[YAML::Field(converter: Preferences::FamilyConverter)] - property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) - property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument) - property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument) - property bind_unix : String? = nil # Make Invidious listening on UNIX sockets - Example: /tmp/invidious.sock - property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) - property use_quic : Bool = true # Use quic transport for youtube api - - @[YAML::Field(converter: Preferences::StringToCookies)] - property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format - property captcha_key : String? = nil # Key for Anti-Captcha - property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha - - def disabled?(option) - case disabled = CONFIG.disable_proxy - when Bool - return disabled - when Array - if disabled.includes? option - return true - else - return false - end - else - return false - end - end - - def self.load - # Load config from file or YAML string env var - env_config_file = "INVIDIOUS_CONFIG_FILE" - env_config_yaml = "INVIDIOUS_CONFIG" - - config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml" - config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file) - - config = Config.from_yaml(config_yaml) - - # Update config from env vars (upcased and prefixed with "INVIDIOUS_") - {% for ivar in Config.instance_vars %} - {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %} - - if ENV.has_key?({{env_id}}) - # puts %(Config.{{ivar.id}} : Loading from env var {{env_id}}) - env_value = ENV.fetch({{env_id}}) - success = false - - # Use YAML converter if specified - {% ann = ivar.annotation(::YAML::Field) %} - {% if ann && ann[:converter] %} - puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter) - config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0]) - puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}}) - success = true - - # Use regular YAML parser otherwise - {% else %} - {% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %} - # Sort types to avoid parsing nulls and numbers as strings - {% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %} - {{ivar_types}}.each do |ivar_type| - if !success - begin - # puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type}) - config.{{ivar.id}} = ivar_type.from_yaml(env_value) - puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type})) - success = true - rescue - # nop - end - end - end - {% end %} - - # Exit on fail - if !success - puts %(Config.{{ivar.id}} failed to parse #{env_value} as {{ivar.type}}) - exit(1) - end - end - {% end %} - - # Build database_url from db.* if it's not set directly - if config.database_url.to_s.empty? - if db = config.db - config.database_url = URI.new( - scheme: "postgres", - user: db.user, - password: db.password, - host: db.host, - port: db.port, - path: db.dbname, - ) - else - puts "Config : Either database_url or db.* is required" - exit(1) - end - end - - return config - end -end - -struct DBConfig - include YAML::Serializable - - property user : String - property password : String - property host : String - property port : Int32 - property dbname : String -end - -def login_req(f_req) - data = { - # Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard - # Generally this is much longer (>1250 characters), see also - # https://github.com/ytdl-org/youtube-dl/commit/baf67a604d912722b0fe03a40e9dc5349a2208cb . - # For now this can be empty. - "bgRequest" => %|["identifier",""]|, - "pstMsg" => "1", - "checkConnection" => "youtube", - "checkedDomains" => "youtube", - "hl" => "en", - "deviceinfo" => %|[null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]|, - "f.req" => f_req, - "flowName" => "GlifWebSignIn", - "flowEntry" => "ServiceLogin", - # "cookiesDisabled" => "false", - # "gmscoreversion" => "undefined", - # "continue" => "https://accounts.google.com/ManageAccount", - # "azt" => "", - # "bgHash" => "", - } - - return HTTP::Params.encode(data) -end - def html_to_content(description_html : String) description = description_html.gsub(/(<br>)|(<br\/>)/, { "<br>": "\n", @@ -245,287 +35,7 @@ def html_to_content(description_html : String) return description end -def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) - extract_items(initial_data, author_fallback, author_id_fallback).select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) -end - -def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fallback : String? = nil) - if i = (item["videoRenderer"]? || item["gridVideoRenderer"]?) - video_id = i["videoId"].as_s - title = i["title"].try { |t| t["simpleText"]?.try &.as_s || t["runs"]?.try &.as_a.map(&.["text"].as_s).join("") } || "" - - author_info = i["ownerText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? - author = author_info.try &.["text"].as_s || author_fallback || "" - author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_id_fallback || "" - - published = i["publishedTimeText"]?.try &.["simpleText"]?.try { |t| decode_date(t.as_s) } || Time.local - view_count = i["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 - description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || "" - length_seconds = i["lengthText"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || - i["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?).try &.["thumbnailOverlayTimeStatusRenderer"]? - .try &.["text"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || 0 - - live_now = false - paid = false - premium = false - - premiere_timestamp = i["upcomingEventData"]?.try &.["startTime"]?.try { |t| Time.unix(t.as_s.to_i64) } - - i["badges"]?.try &.as_a.each do |badge| - b = badge["metadataBadgeRenderer"] - case b["label"].as_s - when "LIVE NOW" - live_now = true - when "New", "4K", "CC" - # TODO - when "Premium" - paid = true - - # TODO: Potentially available as i["topStandaloneBadge"]["metadataBadgeRenderer"] - premium = true - else nil # Ignore - end - end - - SearchVideo.new({ - title: title, - id: video_id, - author: author, - ucid: author_id, - published: published, - views: view_count, - description_html: description_html, - length_seconds: length_seconds, - live_now: live_now, - paid: paid, - premium: premium, - premiere_timestamp: premiere_timestamp, - }) - elsif i = item["channelRenderer"]? - author = i["title"]["simpleText"]?.try &.as_s || author_fallback || "" - author_id = i["channelId"]?.try &.as_s || author_id_fallback || "" - - author_thumbnail = i["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try &.["url"]?.try &.as_s || "" - subscriber_count = i["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s.try { |s| short_text_to_number(s.split(" ")[0]) } || 0 - - auto_generated = false - auto_generated = true if !i["videoCountText"]? - video_count = i["videoCountText"]?.try &.["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 - description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || "" - - SearchChannel.new({ - author: author, - ucid: author_id, - author_thumbnail: author_thumbnail, - subscriber_count: subscriber_count, - video_count: video_count, - description_html: description_html, - auto_generated: auto_generated, - }) - elsif i = item["gridPlaylistRenderer"]? - title = i["title"]["runs"].as_a[0]?.try &.["text"].as_s || "" - plid = i["playlistId"]?.try &.as_s || "" - - video_count = i["videoCountText"]["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 - playlist_thumbnail = i["thumbnail"]["thumbnails"][0]?.try &.["url"]?.try &.as_s || "" - - SearchPlaylist.new({ - title: title, - id: plid, - author: author_fallback || "", - ucid: author_id_fallback || "", - video_count: video_count, - videos: [] of SearchPlaylistVideo, - thumbnail: playlist_thumbnail, - }) - elsif i = item["playlistRenderer"]? - title = i["title"]["simpleText"]?.try &.as_s || "" - plid = i["playlistId"]?.try &.as_s || "" - - video_count = i["videoCount"]?.try &.as_s.to_i || 0 - playlist_thumbnail = i["thumbnails"].as_a[0]?.try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"].as_s || "" - - author_info = i["shortBylineText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? - author = author_info.try &.["text"].as_s || author_fallback || "" - author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_id_fallback || "" - - videos = i["videos"]?.try &.as_a.map do |v| - v = v["childVideoRenderer"] - v_title = v["title"]["simpleText"]?.try &.as_s || "" - v_id = v["videoId"]?.try &.as_s || "" - v_length_seconds = v["lengthText"]?.try &.["simpleText"]?.try { |t| decode_length_seconds(t.as_s) } || 0 - SearchPlaylistVideo.new({ - title: v_title, - id: v_id, - length_seconds: v_length_seconds, - }) - end || [] of SearchPlaylistVideo - - # TODO: i["publishedTimeText"]? - - SearchPlaylist.new({ - title: title, - id: plid, - author: author, - ucid: author_id, - video_count: video_count, - videos: videos, - thumbnail: playlist_thumbnail, - }) - elsif i = item["radioRenderer"]? # Mix - # TODO - elsif i = item["showRenderer"]? # Show - # TODO - elsif i = item["shelfRenderer"]? - elsif i = item["horizontalCardListRenderer"]? - elsif i = item["searchPyvRenderer"]? # Ad - end -end - -def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) - items = [] of SearchItem - - channel_v2_response = initial_data - .try &.["continuationContents"]? - .try &.["gridContinuation"]? - .try &.["items"]? - - if channel_v2_response - channel_v2_response.try &.as_a.each { |item| - extract_item(item, author_fallback, author_id_fallback) - .try { |t| items << t } - } - else - initial_data.try { |t| t["contents"]? || t["response"]? } - .try { |t| t["twoColumnBrowseResultsRenderer"]?.try &.["tabs"].as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]?.try &.["tabRenderer"]["content"] || - t["twoColumnSearchResultsRenderer"]?.try &.["primaryContents"] || - t["continuationContents"]? } - .try { |t| t["sectionListRenderer"]? || t["sectionListContinuation"]? } - .try &.["contents"].as_a - .each { |c| c.try &.["itemSectionRenderer"]?.try &.["contents"].as_a - .try { |t| t[0]?.try &.["shelfRenderer"]?.try &.["content"]["expandedShelfContentsRenderer"]?.try &.["items"].as_a || - t[0]?.try &.["gridRenderer"]?.try &.["items"].as_a || t } - .each { |item| - extract_item(item, author_fallback, author_id_fallback) - .try { |t| items << t } - } } - end - - items -end - -def check_enum(db, enum_name, struct_type = nil) - return # TODO - - if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) - LOGGER.info("check_enum: CREATE TYPE #{enum_name}") - - db.using_connection do |conn| - conn.as(PG::Connection).exec_all(File.read("config/sql/#{enum_name}.sql")) - end - end -end - -def check_table(db, table_name, struct_type = nil) - # Create table if it doesn't exist - begin - db.exec("SELECT * FROM #{table_name} LIMIT 0") - rescue ex - LOGGER.info("check_table: check_table: CREATE TABLE #{table_name}") - - db.using_connection do |conn| - conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql")) - end - end - - return if !struct_type - - struct_array = struct_type.type_array - column_array = get_column_array(db, table_name) - column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?<types>[\d\D]*?)\);/) - .try &.["types"].split(",").map { |line| line.strip }.reject &.starts_with?("CONSTRAINT") - - return if !column_types - - struct_array.each_with_index do |name, i| - if name != column_array[i]? - if !column_array[i]? - new_column = column_types.select { |line| line.starts_with? name }[0] - LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - next - end - - # Column doesn't exist - if !column_array.includes? name - new_column = column_types.select { |line| line.starts_with? name }[0] - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - end - - # Column exists but in the wrong position, rotate - if struct_array.includes? column_array[i] - until name == column_array[i] - new_column = column_types.select { |line| line.starts_with? column_array[i] }[0]?.try &.gsub("#{column_array[i]}", "#{column_array[i]}_new") - - # There's a column we didn't expect - if !new_column - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - - column_array = get_column_array(db, table_name) - next - end - - LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - - LOGGER.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") - db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") - - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - - LOGGER.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") - db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") - - column_array = get_column_array(db, table_name) - end - else - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - end - end - end - - return if column_array.size <= struct_array.size - - column_array.each do |column| - if !struct_array.includes? column - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") - end - end -end - -class PG::ResultSet - def field(index = @column_index) - @fields.not_nil![index] - end -end - -def get_column_array(db, table_name) - column_array = [] of String - db.query("SELECT * FROM #{table_name} LIMIT 0") do |rs| - rs.column_count.times do |i| - column = rs.as(PG::ResultSet).field(i) - column_array << column.name - end - end - - return column_array -end - -def cache_annotation(db, id, annotations) +def cache_annotation(id, annotations) if !CONFIG.cache_annotations return end @@ -543,14 +53,14 @@ def cache_annotation(db, id, annotations) end end - db.exec("INSERT INTO annotations VALUES ($1, $2) ON CONFLICT DO NOTHING", id, annotations) if has_legacy_annotations + 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 = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale since = env.params.query["since"]?.try &.to_i? id = 0 @@ -564,18 +74,9 @@ def create_notification_stream(env, topics, connection_channel) 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, PG_DB) + video = get_video(video_id) video.published = published - response = JSON.parse(video.to_json(locale)) - - if fields_text = env.params.query["fields"]? - begin - JSONFilter.filter(response, fields_text) - rescue ex - env.response.status_code = 400 - response = {"error" => ex.message} - end - end + response = JSON.parse(video.to_json(locale, nil)) env.response.puts "id: #{id}" env.response.puts "data: #{response.to_json}" @@ -595,22 +96,14 @@ def create_notification_stream(env, topics, connection_channel) 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}/) - PG_DB.query_all("SELECT * FROM channel_videos WHERE ucid = $1 AND published > $2 ORDER BY published DESC LIMIT 15", - topic, Time.unix(since.not_nil!), as: ChannelVideo).each do |video| + Invidious::Database::ChannelVideos.select_notfications(topic, since_unix).each do |video| response = JSON.parse(video.to_json(locale)) - if fields_text = env.params.query["fields"]? - begin - JSONFilter.filter(response, fields_text) - rescue ex - env.response.status_code = 400 - response = {"error" => ex.message} - end - end - env.response.puts "id: #{id}" env.response.puts "data: #{response.to_json}" env.response.puts @@ -640,18 +133,9 @@ def create_notification_stream(env, topics, connection_channel) next end - video = get_video(video_id, PG_DB) + video = get_video(video_id) video.published = Time.unix(published) - response = JSON.parse(video.to_json(locale)) - - if fields_text = env.params.query["fields"]? - begin - JSONFilter.filter(response, fields_text) - rescue ex - env.response.status_code = 400 - response = {"error" => ex.message} - end - end + response = JSON.parse(video.to_json(locale, nil)) env.response.puts "id: #{id}" env.response.puts "data: #{response.to_json}" @@ -698,84 +182,19 @@ def proxy_file(response, env) end end -# See https://github.com/kemalcr/kemal/pull/576 -class HTTP::Server::Response::Output - def close - return if closed? - - unless response.wrote_headers? - response.content_length = @out_count - end - - ensure_headers_written - - super - - if @chunked - @io << "0\r\n\r\n" - @io.flush - end - end -end - -class HTTP::Client::Response - def pipe(io) - HTTP.serialize_body(io, headers, @body, @body_io, @version) - end -end - -# Supports serialize_body without first writing headers -module HTTP - def self.serialize_body(io, headers, body, body_io, version) - if body - io << body - elsif body_io - content_length = content_length(headers) - if content_length - copied = IO.copy(body_io, io) - if copied != content_length - raise ArgumentError.new("Content-Length header is #{content_length} but body had #{copied} bytes") - end - elsif Client::Response.supports_chunked?(version) - headers["Transfer-Encoding"] = "chunked" - serialize_chunked_body(io, body_io) - else - io << body - end - end - end -end - -class HTTP::Client - property family : Socket::Family = Socket::Family::UNSPEC - - private def socket - socket = @socket - return socket if socket - - hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host - socket = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, @family - socket.read_timeout = @read_timeout if @read_timeout - socket.sync = false - - {% if !flag?(:without_openssl) %} - if tls = @tls - socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: @host) - 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, + } - @socket = socket + Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"] = tracker end -end -class TCPSocket - def initialize(host, port, dns_timeout = nil, connect_timeout = nil, family = Socket::Family::UNSPEC) - Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo| - super(addrinfo.family, addrinfo.type, addrinfo.protocol) - connect(addrinfo, timeout: connect_timeout) do |error| - close - error - end - end - end + return tracker.as(Hash(String, Int64 | Float64)) end diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 45a3f1ae..1ba3ea61 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,72 +1,185 @@ -LOCALES = { - "ar" => load_locale("ar"), - "de" => load_locale("de"), - "el" => load_locale("el"), - "en-US" => load_locale("en-US"), - "eo" => load_locale("eo"), - "es" => load_locale("es"), - "fa" => load_locale("fa"), - "fi" => load_locale("fi"), - "fr" => load_locale("fr"), - "he" => load_locale("he"), - "hr" => load_locale("hr"), - "id" => load_locale("id"), - "is" => load_locale("is"), - "it" => load_locale("it"), - "ja" => load_locale("ja"), - "nb-NO" => load_locale("nb-NO"), - "nl" => load_locale("nl"), - "pl" => load_locale("pl"), - "pt-BR" => load_locale("pt-BR"), - "pt-PT" => load_locale("pt-PT"), - "ro" => load_locale("ro"), - "ru" => load_locale("ru"), - "sv" => load_locale("sv-SE"), - "tr" => load_locale("tr"), - "uk" => load_locale("uk"), - "zh-CN" => load_locale("zh-CN"), - "zh-TW" => load_locale("zh-TW"), +# 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 + "tr" => "Türkçe", # Turkish + "uk" => "Українська", # Ukrainian + "vi" => "Tiếng Việt", # Vietnamese + "zh-CN" => "汉语", # Chinese (Simplified) + "zh-TW" => "漢語", # Chinese (Traditional) } -def load_locale(name) - return JSON.parse(File.read("locales/#{name}.json")).as_h +LOCALES = load_all_locales() + +CONTENT_REGIONS = { + "AE", "AR", "AT", "AU", "AZ", "BA", "BD", "BE", "BG", "BH", "BO", "BR", "BY", + "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "DZ", "EC", "EE", + "EG", "ES", "FI", "FR", "GB", "GE", "GH", "GR", "GT", "HK", "HN", "HR", "HU", + "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KR", "KW", + "KZ", "LB", "LI", "LK", "LT", "LU", "LV", "LY", "MA", "ME", "MK", "MT", "MX", + "MY", "NG", "NI", "NL", "NO", "NP", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", + "PL", "PR", "PT", "PY", "QA", "RO", "RS", "RU", "SA", "SE", "SG", "SI", "SK", + "SN", "SV", "TH", "TN", "TR", "TW", "TZ", "UA", "UG", "US", "UY", "VE", "VN", + "YE", "ZA", "ZW", +} + +# 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 + +def load_all_locales + locales = {} of String => Hash(String, JSON::Any) + + LOCALES_LIST.each_key do |name| + locales[name] = JSON.parse(File.read("locales/#{name}.json")).as_h + end + + return locales end -def translate(locale : Hash(String, JSON::Any) | Nil, translation : String, text : String | Nil = nil) - # if locale && !locale[translation]? - # puts "Could not find translation for #{translation.dump}" - # end +def translate(locale : String?, key : String, text : String | Hash(String, String) | Nil = nil) : String + # Log a warning if "key" doesn't exist in en-US locale and return + # that key as the text, so this is more or less transparent to the user. + if !LOCALES["en-US"].has_key?(key) + LOGGER.warn("i18n: Missing translation key \"#{key}\"") + return key + end + + # Default to english, whenever the locale doesn't exist, + # or the key requested has not been translated + if locale && LOCALES.has_key?(locale) && LOCALES[locale].has_key?(key) + raw_data = LOCALES[locale][key] + else + raw_data = LOCALES["en-US"][key] + end - if locale && locale[translation]? - case locale[translation] - when .as_h? - match_length = 0 + case raw_data + when .as_h? + # Init + translation = "" + match_length = 0 - locale[translation].as_h.each do |key, value| - if md = text.try &.match(/#{key}/) + raw_data.as_h.each do |hash_key, value| + if text.is_a?(String) + if md = text.try &.match(/#{hash_key}/) if md[0].size >= match_length translation = value.as_s match_length = md[0].size end end end - when .as_s? - if !locale[translation].as_s.empty? - translation = locale[translation].as_s - end - else - raise "Invalid translation #{translation}" end + when .as_s? + translation = raw_data.as_s + else + raise "Invalid translation \"#{raw_data}\"" end - if text + 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_bool(locale : Hash(String, JSON::Any) | Nil, translation : Bool) +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 + LOGGER.warn("i18n: Missing translation key \"#{key}\"") + return key + end + end + + case format + when .separator? then count_txt = number_with_separator(count) + when .short? then count_txt = number_to_short_text(count) + when .html_span? then count_txt = "<span id=\"count\">" + count.to_s + "</span>" + else count_txt = count.to_s + end + + return translation.gsub("{{count}}", count_txt) +end + +def translate_bool(locale : String?, translation : Bool) case translation when true return translate(locale, "Yes") @@ -74,3 +187,12 @@ def translate_bool(locale : Hash(String, JSON::Any) | Nil, translation : Bool) return translate(locale, "No") end end + +def locale_is_rtl?(locale : String?) + # Fallback to en-US + return false if locale.nil? + + # Arabic, Persian, Hebrew + # See https://en.wikipedia.org/wiki/Right-to-left_script#List_of_RTL_scripts + return {"ar", "fa", "he"}.includes? locale +end diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr new file mode 100644 index 00000000..684e6d14 --- /dev/null +++ b/src/invidious/helpers/i18next.cr @@ -0,0 +1,566 @@ +# I18next-compatible implementation of plural forms +# +module I18next::Plurals + # ----------------------------------- + # I18next plural forms definition + # ----------------------------------- + + enum PluralForms + # One singular, one plural forms + Single_gt_one = 1 # E.g: French + Single_not_one = 2 # E.g: English + + # No plural forms (E.g: Azerbaijani) + None = 3 + + # One singular, two plural forms + Dual_Slavic = 4 # E.g: Russian + + # Special cases (rules used by only one or two language(s)) + Special_Arabic = 5 + Special_Czech_Slovak = 6 + Special_Polish_Kashubian = 7 + Special_Welsh = 8 + Special_Irish = 10 + Special_Scottish_Gaelic = 11 + Special_Icelandic = 12 + Special_Javanese = 13 + Special_Cornish = 14 + Special_Lithuanian = 15 + Special_Latvian = 16 + Special_Macedonian = 17 + Special_Mandinka = 18 + Special_Maltese = 19 + Special_Romanian = 20 + Special_Slovenian = 21 + Special_Hebrew = 22 + Special_Odia = 23 + + # Mixed v3/v4 rules in Weblate + # `es`, `pt` and `pt-PT` doesn't seem to have been refreshed + # by weblate yet, but I suspect it will happen one day. + # See: https://github.com/translate/translate/issues/4873 + Special_French_Portuguese + Special_Hungarian_Serbian + Special_Spanish_Italian + end + + private PLURAL_SETS = { + PluralForms::Single_gt_one => [ + "ach", "ak", "am", "arn", "br", "fa", "fil", "gun", "ln", "mfe", "mg", + "mi", "oc", "pt-PT", "tg", "tl", "ti", "tr", "uz", "wa", + ], + PluralForms::Single_not_one => [ + "af", "an", "ast", "az", "bg", "bn", "ca", "da", "de", "dev", "el", "en", + "eo", "et", "eu", "fi", "fo", "fur", "fy", "gl", "gu", "ha", "hi", + "hu", "hy", "ia", "kk", "kn", "ku", "lb", "mai", "ml", "mn", "mr", + "nah", "nap", "nb", "ne", "nl", "nn", "no", "nso", "pa", "pap", "pms", + "ps", "rm", "sco", "se", "si", "so", "son", "sq", "sv", "sw", + "ta", "te", "tk", "ur", "yo", + ], + PluralForms::None => [ + "ay", "bo", "cgg", "ht", "id", "ja", "jbo", "ka", "km", "ko", "ky", + "lo", "ms", "sah", "su", "th", "tt", "ug", "vi", "wo", "zh", + ], + PluralForms::Dual_Slavic => [ + "be", "bs", "cnr", "dz", "ru", "uk", + ], + } + + private PLURAL_SINGLES = { + "ar" => PluralForms::Special_Arabic, + "cs" => PluralForms::Special_Czech_Slovak, + "csb" => PluralForms::Special_Polish_Kashubian, + "cy" => PluralForms::Special_Welsh, + "ga" => PluralForms::Special_Irish, + "gd" => PluralForms::Special_Scottish_Gaelic, + "he" => PluralForms::Special_Hebrew, + "is" => PluralForms::Special_Icelandic, + "iw" => PluralForms::Special_Hebrew, + "jv" => PluralForms::Special_Javanese, + "kw" => PluralForms::Special_Cornish, + "lt" => PluralForms::Special_Lithuanian, + "lv" => PluralForms::Special_Latvian, + "mk" => PluralForms::Special_Macedonian, + "mnk" => PluralForms::Special_Mandinka, + "mt" => PluralForms::Special_Maltese, + "or" => PluralForms::Special_Odia, + "pl" => PluralForms::Special_Polish_Kashubian, + "ro" => PluralForms::Special_Romanian, + "sk" => PluralForms::Special_Czech_Slovak, + "sl" => PluralForms::Special_Slovenian, + # Mixed v3/v4 rules + "es" => PluralForms::Special_Spanish_Italian, + "fr" => PluralForms::Special_French_Portuguese, + "hr" => PluralForms::Special_Hungarian_Serbian, + "it" => PluralForms::Special_Spanish_Italian, + "pt" => PluralForms::Special_French_Portuguese, + "sr" => PluralForms::Special_Hungarian_Serbian, + } + + # These are the v1 and v2 compatible suffixes. + # The array indices matches the PluralForms enum above. + private NUMBERS = [ + [1, 2], # 1 + [1, 2], # 2 + [1], # 3 + [1, 2, 5], # 4 + [0, 1, 2, 3, 11, 100], # 5 + [1, 2, 5], # 6 + [1, 2, 5], # 7 + [1, 2, 3, 8], # 8 + [1, 2], # 9 (not used) + [1, 2, 3, 7, 11], # 10 + [1, 2, 3, 20], # 11 + [1, 2], # 12 + [0, 1], # 13 + [1, 2, 3, 4], # 14 + [1, 2, 10], # 15 + [1, 2, 0], # 16 + [1, 2], # 17 + [0, 1, 2], # 18 + [1, 2, 11, 20], # 19 + [1, 2, 20], # 20 + [5, 1, 2, 3], # 21 + [1, 2, 20, 21], # 22 + [2, 1], # 23 (Odia) + ] + + # ----------------------------------- + # I18next plural resolver class + # ----------------------------------- + + RESOLVER = Resolver.new + + class Resolver + private property forms = {} of String => PluralForms + property version : UInt8 = 3 + + # Options + property simplify_plural_suffix : Bool = true + + def initialize(version : Int = 3) + # Sanity checks + # V4 isn't supported, as it requires a full CLDR database. + if version > 4 || version == 0 + raise "Invalid i18next version: v#{version}." + elsif version == 4 + # Logger.error("Unsupported i18next version: v4. Falling back to v3") + @version = 3_u8 + else + @version = version.to_u8 + end + + self.init_rules + end + + def init_rules + # Look into sets + PLURAL_SETS.each do |form, langs| + langs.each { |lang| self.forms[lang] = form } + end + + # Add plurals from the "singles" set + self.forms.merge!(PLURAL_SINGLES) + end + + def get_plural_form(locale : String) : PluralForms + # Extract the ISO 639-1 or 639-2 code from an RFC 5646 language code + if !locale.matches?(/^pt-PT$/) + locale = locale.split('-')[0] + end + + return self.forms[locale] if self.forms[locale]? + + # If nothing was found, then use the most common form, i.e + # one singular and one plural, as in english. Not perfect, + # but better than yielding an exception at the user. + return PluralForms::Single_not_one + end + + def get_suffix(locale : String, count : Int) : String + # Checked count must be absolute. In i18next, `rule.noAbs` is used to + # determine if comparison should be done on a signed or unsigned integer, + # but this variable is never set, resulting in the comparison always + # being done on absolute numbers. + return get_suffix_retrocompat(locale, count.abs) + end + + # Emulate the `rule.numbers.size == 2 && rule.numbers[0] == 1` check + # from original i18next code + private def simple_plural?(form : PluralForms) : Bool + case form + when .single_gt_one? then return true + when .single_not_one? then return true + when .special_icelandic? then return true + when .special_macedonian? then return true + else + return false + end + end + + private def get_suffix_retrocompat(locale : String, count : Int) : String + # Get plural form + plural_form = get_plural_form(locale) + + # Languages with no plural have the "_0" suffix + return "_0" if plural_form.none? + + # Get the index and suffix for this number + idx = SuffixIndex.get_index(plural_form, count) + + # Simple plurals are handled differently in all versions (but v4) + if @simplify_plural_suffix && simple_plural?(plural_form) + return (idx == 1) ? "_plural" : "" + end + + # More complex plurals + # TODO: support v1 and v2 + # TODO: support `options.prepend` (v2 and v3) + # this.options.prepend && suffix.toString() ? this.options.prepend + suffix.toString() : suffix.toString() + # + # case @version + # when 1 + # suffix = SUFFIXES_V1_V2[plural_form.to_i][idx] + # return (suffix == 1) ? "" : return "_plural_#{suffix}" + # when 2 + # return "_#{suffix}" + # else # v3 + return "_#{idx}" + # end + end + end + + # ----------------------------- + # Plural functions + # ----------------------------- + + module SuffixIndex + def self.get_index(plural_form : PluralForms, count : Int) : UInt8 + case plural_form + when .single_gt_one? then return (count > 1) ? 1_u8 : 0_u8 + when .single_not_one? then return (count != 1) ? 1_u8 : 0_u8 + when .none? then return 0_u8 + when .dual_slavic? then return dual_slavic(count) + when .special_arabic? then return special_arabic(count) + when .special_czech_slovak? then return special_czech_slovak(count) + when .special_polish_kashubian? then return special_polish_kashubian(count) + when .special_welsh? then return special_welsh(count) + when .special_irish? then return special_irish(count) + when .special_scottish_gaelic? then return special_scottish_gaelic(count) + when .special_icelandic? then return special_icelandic(count) + when .special_javanese? then return special_javanese(count) + when .special_cornish? then return special_cornish(count) + when .special_lithuanian? then return special_lithuanian(count) + when .special_latvian? then return special_latvian(count) + when .special_macedonian? then return special_macedonian(count) + when .special_mandinka? then return special_mandinka(count) + when .special_maltese? then return special_maltese(count) + when .special_romanian? then return special_romanian(count) + when .special_slovenian? then return special_slovenian(count) + when .special_hebrew? then return special_hebrew(count) + when .special_odia? then return special_odia(count) + # Mixed v3/v4 forms + when .special_spanish_italian? then return special_cldr_spanish_italian(count) + when .special_french_portuguese? then return special_cldr_french_portuguese(count) + when .special_hungarian_serbian? then return special_cldr_hungarian_serbian(count) + else + # default, if nothing matched above + return 0_u8 + end + end + + # Plural form of Slavic languages (E.g: Russian) + # + # Corresponds to i18next rule #4 + # Rule: (n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2) + # + def self.dual_slavic(count : Int) : UInt8 + n_mod_10 = count % 10 + n_mod_100 = count % 100 + + if n_mod_10 == 1 && n_mod_100 != 11 + return 0_u8 + elsif n_mod_10 >= 2 && n_mod_10 <= 4 && (n_mod_100 < 10 || n_mod_100 >= 20) + return 1_u8 + else + return 2_u8 + end + end + + # Plural form for Arabic language + # + # Corresponds to i18next rule #5 + # Rule: (n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5) + # + def self.special_arabic(count : Int) : UInt8 + return count.to_u8 if (count == 0 || count == 1 || count == 2) + + n_mod_100 = count % 100 + + return 3_u8 if (n_mod_100 >= 3 && n_mod_100 <= 10) + return 4_u8 if (n_mod_100 >= 11) + return 5_u8 + end + + # Plural form for Czech and Slovak languages + # + # Corresponds to i18next rule #6 + # Rule: ((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2) + # + def self.special_czech_slovak(count : Int) : UInt8 + return 0_u8 if (count == 1) + return 1_u8 if (count >= 2 && count <= 4) + return 2_u8 + end + + # Plural form for Polish and Kashubian languages + # + # Corresponds to i18next rule #7 + # Rule: (n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2) + # + def self.special_polish_kashubian(count : Int) : UInt8 + return 0_u8 if (count == 1) + + n_mod_10 = count % 10 + n_mod_100 = count % 100 + + if n_mod_10 >= 2 && n_mod_10 <= 4 && (n_mod_100 < 10 || n_mod_100 >= 20) + return 1_u8 + else + return 2_u8 + end + end + + # Plural form for Welsh language + # + # Corresponds to i18next rule #8 + # Rule: ((n==1) ? 0 : (n==2) ? 1 : (n != 8 && n != 11) ? 2 : 3) + # + def self.special_welsh(count : Int) : UInt8 + return 0_u8 if (count == 1) + return 1_u8 if (count == 2) + return 2_u8 if (count != 8 && count != 11) + return 3_u8 + end + + # Plural form for Irish language + # + # Corresponds to i18next rule #10 + # Rule: (n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4) + # + def self.special_irish(count : Int) : UInt8 + return 0_u8 if (count == 1) + return 1_u8 if (count == 2) + return 2_u8 if (count < 7) + return 3_u8 if (count < 11) + return 4_u8 + end + + # Plural form for Gaelic language + # + # Corresponds to i18next rule #11 + # Rule: ((n==1 || n==11) ? 0 : (n==2 || n==12) ? 1 : (n > 2 && n < 20) ? 2 : 3) + # + def self.special_scottish_gaelic(count : Int) : UInt8 + return 0_u8 if (count == 1 || count == 11) + return 1_u8 if (count == 2 || count == 12) + return 2_u8 if (count > 2 && count < 20) + return 3_u8 + end + + # Plural form for Icelandic language + # + # Corresponds to i18next rule #12 + # Rule: (n%10!=1 || n%100==11) + # + def self.special_icelandic(count : Int) : UInt8 + if (count % 10) != 1 || (count % 100) == 11 + return 1_u8 + else + return 0_u8 + end + end + + # Plural form for Javanese language + # + # Corresponds to i18next rule #13 + # Rule: (n !== 0) + # + def self.special_javanese(count : Int) : UInt8 + return (count != 0) ? 1_u8 : 0_u8 + end + + # Plural form for Cornish language + # + # Corresponds to i18next rule #14 + # Rule: ((n==1) ? 0 : (n==2) ? 1 : (n == 3) ? 2 : 3) + # + def self.special_cornish(count : Int) : UInt8 + return 0_u8 if count == 1 + return 1_u8 if count == 2 + return 2_u8 if count == 3 + return 3_u8 + end + + # Plural form for Lithuanian language + # + # Corresponds to i18next rule #15 + # Rule: (n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2) + # + def self.special_lithuanian(count : Int) : UInt8 + n_mod_10 = count % 10 + n_mod_100 = count % 100 + + if n_mod_10 == 1 && n_mod_100 != 11 + return 0_u8 + elsif n_mod_10 >= 2 && (n_mod_100 < 10 || n_mod_100 >= 20) + return 1_u8 + else + return 2_u8 + end + end + + # Plural form for Latvian language + # + # Corresponds to i18next rule #16 + # Rule: (n%10==1 && n%100!=11 ? 0 : n !== 0 ? 1 : 2) + # + def self.special_latvian(count : Int) : UInt8 + if (count % 10) == 1 && (count % 100) != 11 + return 0_u8 + elsif count != 0 + return 1_u8 + else + return 2_u8 + end + end + + # Plural form for Macedonian language + # + # Corresponds to i18next rule #17 + # Rule: (n==1 || n%10==1 && n%100!=11 ? 0 : 1) + # + def self.special_macedonian(count : Int) : UInt8 + if count == 1 || ((count % 10) == 1 && (count % 100) != 11) + return 0_u8 + else + return 1_u8 + end + end + + # Plural form for Mandinka language + # + # Corresponds to i18next rule #18 + # Rule: (n==0 ? 0 : n==1 ? 1 : 2) + # + def self.special_mandinka(count : Int) : UInt8 + return (count == 0 || count == 1) ? count.to_u8 : 2_u8 + end + + # Plural form for Maltese language + # + # Corresponds to i18next rule #19 + # Rule: (n==1 ? 0 : n==0 || ( n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20 ) ? 2 : 3) + # + def self.special_maltese(count : Int) : UInt8 + return 0_u8 if count == 1 + return 1_u8 if count == 0 + + n_mod_100 = count % 100 + return 1_u8 if (n_mod_100 > 1 && n_mod_100 < 11) + return 2_u8 if (n_mod_100 > 10 && n_mod_100 < 20) + return 3_u8 + end + + # Plural form for Romanian language + # + # Corresponds to i18next rule #20 + # Rule: (n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2) + # + def self.special_romanian(count : Int) : UInt8 + return 0_u8 if count == 1 + return 1_u8 if count == 0 + + n_mod_100 = count % 100 + return 1_u8 if (n_mod_100 > 0 && n_mod_100 < 20) + return 2_u8 + end + + # Plural form for Slovenian language + # + # Corresponds to i18next rule #21 + # Rule: (n%100==1 ? 1 : n%100==2 ? 2 : n%100==3 || n%100==4 ? 3 : 0) + # + def self.special_slovenian(count : Int) : UInt8 + n_mod_100 = count % 100 + return 1_u8 if (n_mod_100 == 1) + return 2_u8 if (n_mod_100 == 2) + return 3_u8 if (n_mod_100 == 3 || n_mod_100 == 4) + return 0_u8 + end + + # Plural form for Hebrew language + # + # Corresponds to i18next rule #22 + # Rule: (n==1 ? 0 : n==2 ? 1 : (n<0 || n>10) && n%10==0 ? 2 : 3) + # + def self.special_hebrew(count : Int) : UInt8 + return 0_u8 if (count == 1) + return 1_u8 if (count == 2) + + if (count < 0 || count > 10) && (count % 10) == 0 + return 2_u8 + else + return 3_u8 + end + end + + # Plural form for Odia ("or") language + # + # This one is a bit special. It should use rule #2 (like english) + # but the "numbers" (suffixes?) it has are inverted, so we'll make a + # special rule for it. + # + def self.special_odia(count : Int) : UInt8 + return (count == 1) ? 0_u8 : 1_u8 + end + + # ------------------- + # "v3.5" rules + # ------------------- + + # Plural form for Spanish & Italian languages + # + # This rule is mostly compliant to CLDR v42 + # + def self.special_cldr_spanish_italian(count : Int) : UInt8 + return 0_u8 if (count == 1) # one + return 1_u8 if (count != 0 && count % 1_000_000 == 0) # many + return 2_u8 # other + end + + # Plural form for French and Portuguese + # + # This rule is mostly compliant to CLDR v42 + # + def self.special_cldr_french_portuguese(count : Int) : UInt8 + return 0_u8 if (count == 0 || count == 1) # one + return 1_u8 if (count % 1_000_000 == 0) # many + return 2_u8 # other + end + + # Plural form for Hungarian and Serbian + # + # This rule is mostly compliant to CLDR v42 + # + def self.special_cldr_hungarian_serbian(count : Int) : UInt8 + n_mod_10 = count % 10 + n_mod_100 = count % 100 + + return 0_u8 if (n_mod_10 == 1 && n_mod_100 != 11) # one + return 1_u8 if (2 <= n_mod_10 <= 4 && (n_mod_100 < 12 || 14 < n_mod_100)) # few + return 2_u8 # other + end + end +end diff --git a/src/invidious/helpers/json_filter.cr b/src/invidious/helpers/json_filter.cr deleted file mode 100644 index e4b57cea..00000000 --- a/src/invidious/helpers/json_filter.cr +++ /dev/null @@ -1,248 +0,0 @@ -module JSONFilter - alias BracketIndex = Hash(Int64, Int64) - - alias GroupedFieldsValue = String | Array(GroupedFieldsValue) - alias GroupedFieldsList = Array(GroupedFieldsValue) - - class FieldsParser - class ParseError < Exception - end - - # Returns the `Regex` pattern used to match nest groups - def self.nest_group_pattern : Regex - # uses a '.' character to match json keys as they are allowed - # to contain any unicode codepoint - /(?:|,)(?<groupname>[^,\n]*?)\(/ - end - - # Returns the `Regex` pattern used to check if there are any empty nest groups - def self.unnamed_nest_group_pattern : Regex - /^\(|\(\(|\/\(/ - end - - def self.parse_fields(fields_text : String) : Nil - if fields_text.empty? - raise FieldsParser::ParseError.new "Fields is empty" - end - - opening_bracket_count = fields_text.count('(') - closing_bracket_count = fields_text.count(')') - - if opening_bracket_count != closing_bracket_count - bracket_type = opening_bracket_count > closing_bracket_count ? "opening" : "closing" - raise FieldsParser::ParseError.new "There are too many #{bracket_type} brackets (#{opening_bracket_count}:#{closing_bracket_count})" - elsif match_result = unnamed_nest_group_pattern.match(fields_text) - raise FieldsParser::ParseError.new "Unnamed nest group at position #{match_result.begin}" - end - - # first, handle top-level single nested properties: items/id, playlistItems/snippet, etc - parse_single_nests(fields_text) { |nest_list| yield nest_list } - - # next, handle nest groups: items(id, etag, etc) - parse_nest_groups(fields_text) { |nest_list| yield nest_list } - end - - def self.parse_single_nests(fields_text : String) : Nil - single_nests = remove_nest_groups(fields_text) - - if !single_nests.empty? - property_nests = single_nests.split(',') - - property_nests.each do |nest| - nest_list = nest.split('/') - if nest_list.includes? "" - raise FieldsParser::ParseError.new "Empty key in nest list: #{nest_list}" - end - yield nest_list - end - # else - # raise FieldsParser::ParseError.new "Empty key in nest list 22: #{fields_text} | #{single_nests}" - end - end - - def self.parse_nest_groups(fields_text : String) : Nil - nest_stack = [] of NamedTuple(group_name: String, closing_bracket_index: Int64) - bracket_pairs = get_bracket_pairs(fields_text, true) - - text_index = 0 - regex_index = 0 - - while regex_result = self.nest_group_pattern.match(fields_text, regex_index) - raw_match = regex_result[0] - group_name = regex_result["groupname"] - - text_index = regex_result.begin - regex_index = regex_result.end - - if text_index.nil? || regex_index.nil? - raise FieldsParser::ParseError.new "Received invalid index while parsing nest groups: text_index: #{text_index} | regex_index: #{regex_index}" - end - - offset = raw_match.starts_with?(',') ? 1 : 0 - - opening_bracket_index = (text_index + group_name.size) + offset - closing_bracket_index = bracket_pairs[opening_bracket_index] - content_start = opening_bracket_index + 1 - - content = fields_text[content_start...closing_bracket_index] - - if content.empty? - raise FieldsParser::ParseError.new "Empty nest group at position #{content_start}" - else - content = remove_nest_groups(content) - end - - while nest_stack.size > 0 && closing_bracket_index > nest_stack[nest_stack.size - 1][:closing_bracket_index] - if nest_stack.size - nest_stack.pop - end - end - - group_name.split('/').each do |group_name| - nest_stack.push({ - group_name: group_name, - closing_bracket_index: closing_bracket_index, - }) - end - - if !content.empty? - properties = content.split(',') - - properties.each do |prop| - nest_list = nest_stack.map { |nest_prop| nest_prop[:group_name] } - - if !prop.empty? - if prop.includes?('/') - parse_single_nests(prop) { |list| nest_list += list } - else - nest_list.push prop - end - else - raise FieldsParser::ParseError.new "Empty key in nest list: #{nest_list << prop}" - end - - yield nest_list - end - end - end - end - - def self.remove_nest_groups(text : String) : String - content_bracket_pairs = get_bracket_pairs(text, false) - - content_bracket_pairs.each_key.to_a.reverse.each do |opening_bracket| - closing_bracket = content_bracket_pairs[opening_bracket] - last_comma = text.rindex(',', opening_bracket) || 0 - - text = text[0...last_comma] + text[closing_bracket + 1...text.size] - end - - return text.starts_with?(',') ? text[1...text.size] : text - end - - def self.get_bracket_pairs(text : String, recursive = true) : BracketIndex - istart = [] of Int64 - bracket_index = BracketIndex.new - - text.each_char_with_index do |char, index| - if char == '(' - istart.push(index.to_i64) - end - - if char == ')' - begin - opening = istart.pop - if recursive || (!recursive && istart.size == 0) - bracket_index[opening] = index.to_i64 - end - rescue - raise FieldsParser::ParseError.new "No matching opening parenthesis at: #{index}" - end - end - end - - if istart.size != 0 - idx = istart.pop - raise FieldsParser::ParseError.new "No matching closing parenthesis at: #{idx}" - end - - return bracket_index - end - end - - class FieldsGrouper - alias SkeletonValue = Hash(String, SkeletonValue) - - def self.create_json_skeleton(fields_text : String) : SkeletonValue - root_hash = {} of String => SkeletonValue - - FieldsParser.parse_fields(fields_text) do |nest_list| - current_item = root_hash - nest_list.each do |key| - if current_item[key]? - current_item = current_item[key] - else - current_item[key] = {} of String => SkeletonValue - current_item = current_item[key] - end - end - end - root_hash - end - - def self.create_grouped_fields_list(json_skeleton : SkeletonValue) : GroupedFieldsList - grouped_fields_list = GroupedFieldsList.new - json_skeleton.each do |key, value| - grouped_fields_list.push key - - nested_keys = create_grouped_fields_list(value) - grouped_fields_list.push nested_keys unless nested_keys.empty? - end - return grouped_fields_list - end - end - - class FilterError < Exception - end - - def self.filter(item : JSON::Any, fields_text : String, in_place : Bool = true) - skeleton = FieldsGrouper.create_json_skeleton(fields_text) - grouped_fields_list = FieldsGrouper.create_grouped_fields_list(skeleton) - filter(item, grouped_fields_list, in_place) - end - - def self.filter(item : JSON::Any, grouped_fields_list : GroupedFieldsList, in_place : Bool = true) : JSON::Any - item = item.clone unless in_place - - if !item.as_h? && !item.as_a? - raise FilterError.new "Can't filter '#{item}' by #{grouped_fields_list}" - end - - top_level_keys = Array(String).new - grouped_fields_list.each do |value| - if value.is_a? String - top_level_keys.push value - elsif value.is_a? Array - if !top_level_keys.empty? - key_to_filter = top_level_keys.last - - if item.as_h? - filter(item[key_to_filter], value, in_place: true) - elsif item.as_a? - item.as_a.each { |arr_item| filter(arr_item[key_to_filter], value, in_place: true) } - end - else - raise FilterError.new "Tried to filter while top level keys list is empty" - end - end - end - - if item.as_h? - item.as_h.select! top_level_keys - elsif item.as_a? - item.as_a.map { |value| filter(value, top_level_keys, in_place: true) } - end - - item - end -end diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr index 5d91a258..03349595 100644 --- a/src/invidious/helpers/logger.cr +++ b/src/invidious/helpers/logger.cr @@ -1,3 +1,5 @@ +require "colorize" + enum LogLevel All = 0 Trace = 1 @@ -10,21 +12,30 @@ enum LogLevel end class Invidious::LogHandler < Kemal::BaseLogHandler - def initialize(@io : IO = STDOUT, @level = LogLevel::Debug) + def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, use_color : Bool = true) + Colorize.enabled = use_color + Colorize.on_tty_only! end def call(context : HTTP::Server::Context) elapsed_time = Time.measure { call_next(context) } elapsed_text = elapsed_text(elapsed_time) - info("#{context.response.status_code} #{context.request.method} #{context.request.resource} #{elapsed_text}") + # Default: full path with parameters + requested_url = context.request.resource - context - end + # Try not to log search queries passed as GET parameters during normal use + # (They will still be logged if log level is 'Debug' or 'Trace') + if @level > LogLevel::Debug && ( + requested_url.downcase.includes?("search") || requested_url.downcase.includes?("q=") + ) + # Log only the path + requested_url = context.request.path + end - def puts(message : String) - @io << message << '\n' - @io.flush + info("#{context.response.status_code} #{context.request.method} #{requested_url} #{elapsed_text}") + + context end def write(message : String) @@ -32,18 +43,22 @@ class Invidious::LogHandler < Kemal::BaseLogHandler @io.flush end - def set_log_level(level : String) - @level = LogLevel.parse(level) - end - - def set_log_level(level : LogLevel) - @level = level + def color(level) + case level + when LogLevel::Trace then :cyan + when LogLevel::Debug then :green + when LogLevel::Info then :white + when LogLevel::Warn then :yellow + when LogLevel::Error then :red + when LogLevel::Fatal then :magenta + else :default + end end {% for level in %w(trace debug info warn error fatal) %} def {{level.id}}(message : String) if LogLevel::{{level.id.capitalize}} >= @level - puts("#{Time.utc} [{{level.id}}] #{message}") + puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}}))) end end {% end %} diff --git a/src/invidious/helpers/macros.cr b/src/invidious/helpers/macros.cr index 5d426a8b..43e7171b 100644 --- a/src/invidious/helpers/macros.cr +++ b/src/invidious/helpers/macros.cr @@ -48,11 +48,26 @@ module JSON::Serializable end end -macro templated(filename, template = "template", navbar_search = true) +macro templated(_filename, template = "template", navbar_search = true) navbar_search = {{navbar_search}} - render "src/invidious/views/#{{{filename}}}.ecr", "src/invidious/views/#{{{template}}}.ecr" + + {{ filename = "src/invidious/views/" + _filename + ".ecr" }} + {{ layout = "src/invidious/views/" + template + ".ecr" }} + + __content_filename__ = {{filename}} + content = Kilt.render({{filename}}) + Kilt.render({{layout}}) end macro rendered(filename) - render "src/invidious/views/#{{{filename}}}.ecr" + Kilt.render("src/invidious/views/#{{{filename}}}.ecr") +end + +# Similar to Kemals halt method but works in a +# method. +macro haltf(env, status_code = 200, response = "") + {{env}}.response.status_code = {{status_code}} + {{env}}.response.print {{response}} + {{env}}.response.close + return end diff --git a/src/invidious/helpers/proxy.cr b/src/invidious/helpers/proxy.cr deleted file mode 100644 index 3418d887..00000000 --- a/src/invidious/helpers/proxy.cr +++ /dev/null @@ -1,316 +0,0 @@ -# See https://github.com/crystal-lang/crystal/issues/2963 -class HTTPProxy - getter proxy_host : String - getter proxy_port : Int32 - getter options : Hash(Symbol, String) - getter tls : OpenSSL::SSL::Context::Client? - - def initialize(@proxy_host, @proxy_port = 80, @options = {} of Symbol => String) - end - - def open(host, port, tls = nil, connection_options = {} of Symbol => Float64 | Nil) - dns_timeout = connection_options.fetch(:dns_timeout, nil) - connect_timeout = connection_options.fetch(:connect_timeout, nil) - read_timeout = connection_options.fetch(:read_timeout, nil) - - socket = TCPSocket.new @proxy_host, @proxy_port, dns_timeout, connect_timeout - socket.read_timeout = read_timeout if read_timeout - socket.sync = true - - socket << "CONNECT #{host}:#{port} HTTP/1.1\r\n" - - if options[:user]? - credentials = Base64.strict_encode("#{options[:user]}:#{options[:password]}") - credentials = "#{credentials}\n".gsub(/\s/, "") - socket << "Proxy-Authorization: Basic #{credentials}\r\n" - end - - socket << "\r\n" - - resp = parse_response(socket) - - if resp[:code]? == 200 - {% if !flag?(:without_openssl) %} - if tls - tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host) - socket = tls_socket - end - {% end %} - - return socket - else - socket.close - raise IO::Error.new(resp.inspect) - end - end - - private def parse_response(socket) - resp = {} of Symbol => Int32 | String | Hash(String, String) - - begin - version, code, reason = socket.gets.as(String).chomp.split(/ /, 3) - - headers = {} of String => String - - while (line = socket.gets.as(String)) && (line.chomp != "") - name, value = line.split(/:/, 2) - headers[name.strip] = value.strip - end - - resp[:version] = version - resp[:code] = code.to_i - resp[:reason] = reason - resp[:headers] = headers - rescue - end - - return resp - end -end - -class HTTPClient < HTTP::Client - def set_proxy(proxy : HTTPProxy) - begin - @io = proxy.open(host: @host, port: @port, tls: @tls, connection_options: proxy_connection_options) - rescue IO::Error - @io = nil - end - end - - def unset_proxy - @io = nil - end - - def proxy_connection_options - opts = {} of Symbol => Float64 | Nil - - opts[:dns_timeout] = @dns_timeout - opts[:connect_timeout] = @connect_timeout - opts[:read_timeout] = @read_timeout - - return opts - end -end - -def get_proxies(country_code = "US") - # return get_spys_proxies(country_code) - return get_nova_proxies(country_code) -end - -def filter_proxies(proxies) - proxies.select! do |proxy| - begin - client = HTTPClient.new(YT_URL) - client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" - client.read_timeout = 10.seconds - client.connect_timeout = 10.seconds - - proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) - client.set_proxy(proxy) - - status_ok = client.head("/").status_code == 200 - client.close - status_ok - rescue ex - false - end - end - - return proxies -end - -def get_nova_proxies(country_code = "US") - country_code = country_code.downcase - client = HTTP::Client.new(URI.parse("https://www.proxynova.com")) - client.read_timeout = 10.seconds - client.connect_timeout = 10.seconds - - headers = HTTP::Headers.new - headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" - headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" - headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" - headers["Host"] = "www.proxynova.com" - headers["Origin"] = "https://www.proxynova.com" - headers["Referer"] = "https://www.proxynova.com/proxy-server-list/country-#{country_code}/" - - response = client.get("/proxy-server-list/country-#{country_code}/", headers) - client.close - document = XML.parse_html(response.body) - - proxies = [] of {ip: String, port: Int32, score: Float64} - document.xpath_nodes(%q(//tr[@data-proxy-id])).each do |node| - ip = node.xpath_node(%q(.//td/abbr/script)).not_nil!.content - ip = ip.match(/document\.write\('(?<sub1>[^']+)'.substr\(8\) \+ '(?<sub2>[^']+)'/).not_nil! - ip = "#{ip["sub1"][8..-1]}#{ip["sub2"]}" - port = node.xpath_node(%q(.//td[2])).not_nil!.content.strip.to_i - - anchor = node.xpath_node(%q(.//td[4]/div)).not_nil! - speed = anchor["data-value"].to_f - latency = anchor["title"].to_f - uptime = node.xpath_node(%q(.//td[5]/span)).not_nil!.content.rchop("%").to_f - - # TODO: Tweak me - score = (uptime*4 + speed*2 + latency)/7 - proxies << {ip: ip, port: port, score: score} - end - - # proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse - return proxies -end - -def get_spys_proxies(country_code = "US") - client = HTTP::Client.new(URI.parse("http://spys.one")) - client.read_timeout = 10.seconds - client.connect_timeout = 10.seconds - - headers = HTTP::Headers.new - headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" - headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" - headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" - headers["Host"] = "spys.one" - headers["Origin"] = "http://spys.one" - headers["Referer"] = "http://spys.one/free-proxy-list/#{country_code}/" - headers["Content-Type"] = "application/x-www-form-urlencoded" - body = { - "xpp" => "5", - "xf1" => "0", - "xf2" => "0", - "xf4" => "0", - "xf5" => "1", - } - - response = client.post("/free-proxy-list/#{country_code}/", headers, form: body) - client.close - 20.times do - if response.status_code == 200 - break - end - response = client.post("/free-proxy-list/#{country_code}/", headers, form: body) - end - - response = XML.parse_html(response.body) - - mapping = response.xpath_node(%q(.//body/script)).not_nil!.content - mapping = mapping.match(/\}\('(?<p>[^']+)',\d+,\d+,'(?<x>[^']+)'/).not_nil! - p = mapping["p"].not_nil! - x = mapping["x"].not_nil! - mapping = decrypt_port(p, x) - - proxies = [] of {ip: String, port: Int32, score: Float64} - response = response.xpath_node(%q(//tr/td/table)).not_nil! - response.xpath_nodes(%q(.//tr)).each do |node| - if !node["onmouseover"]? - next - end - - ip = node.xpath_node(%q(.//td[1]/font[2])).to_s.match(/<font class="spy14">(?<address>[^<]+)</).not_nil!["address"] - encrypted_port = node.xpath_node(%q(.//td[1]/font[2]/script)).not_nil!.content - encrypted_port = encrypted_port.match(/<\\\/font>"\+(?<encrypted_port>[\d\D]+)\)$/).not_nil!["encrypted_port"] - - port = "" - encrypted_port.split("+").each do |number| - number = number.delete("()") - left_side, right_side = number.split("^") - result = mapping[left_side] ^ mapping[right_side] - port = "#{port}#{result}" - end - port = port.to_i - - latency = node.xpath_node(%q(.//td[6])).not_nil!.content.to_f - speed = node.xpath_node(%q(.//td[7]/font/table)).not_nil!["width"].to_f - uptime = node.xpath_node(%q(.//td[8]/font/acronym)).not_nil! - - # Skip proxies that are down - if uptime["title"].ends_with? "?" - next - end - - if md = uptime.content.match(/^\d+/) - uptime = md[0].to_f - else - next - end - - score = (uptime*4 + speed*2 + latency)/7 - - proxies << {ip: ip, port: port, score: score} - end - - proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse - return proxies -end - -def decrypt_port(p, x) - x = x.split("^") - s = {} of String => String - - 60.times do |i| - if x[i]?.try &.empty? - s[y_func(i)] = y_func(i) - else - s[y_func(i)] = x[i] - end - end - - x = s - p = p.gsub(/\b\w+\b/, x) - - p = p.split(";") - p = p.map { |item| item.split("=") } - - mapping = {} of String => Int32 - p.each do |item| - if item == [""] - next - end - - key = item[0] - value = item[1] - value = value.split("^") - - if value.size == 1 - value = value[0].to_i - else - left_side = value[0].to_i? - left_side ||= mapping[value[0]] - right_side = value[1].to_i? - right_side ||= mapping[value[1]] - - value = left_side ^ right_side - end - - mapping[key] = value - end - - return mapping -end - -def y_func(c) - return (c < 60 ? "" : y_func((c/60).to_i)) + ((c = c % 60) > 35 ? ((c.to_u8 + 29).unsafe_chr) : c.to_s(36)) -end - -PROXY_LIST = { - "GB" => [{ip: "147.135.206.233", port: 3128}, {ip: "167.114.180.102", port: 8080}, {ip: "176.35.250.108", port: 8080}, {ip: "5.148.128.44", port: 80}, {ip: "62.7.85.234", port: 8080}, {ip: "88.150.135.10", port: 36624}], - "DE" => [{ip: "138.201.223.250", port: 31288}, {ip: "138.68.73.59", port: 32574}, {ip: "159.69.211.173", port: 3128}, {ip: "173.249.43.105", port: 3128}, {ip: "212.202.244.90", port: 8080}, {ip: "5.56.18.35", port: 38827}], - "FR" => [{ip: "137.74.254.242", port: 3128}, {ip: "151.80.143.155", port: 53281}, {ip: "178.33.150.97", port: 3128}, {ip: "37.187.2.31", port: 3128}, {ip: "5.135.164.72", port: 3128}, {ip: "5.39.91.73", port: 3128}, {ip: "51.38.162.2", port: 32231}, {ip: "51.38.217.121", port: 808}, {ip: "51.75.109.81", port: 3128}, {ip: "51.75.109.82", port: 3128}, {ip: "51.75.109.83", port: 3128}, {ip: "51.75.109.84", port: 3128}, {ip: "51.75.109.86", port: 3128}, {ip: "51.75.109.88", port: 3128}, {ip: "51.75.109.90", port: 3128}, {ip: "62.210.167.3", port: 3128}, {ip: "90.63.218.232", port: 8080}, {ip: "91.134.165.198", port: 9999}], - "IN" => [{ip: "1.186.151.206", port: 36253}, {ip: "1.186.63.130", port: 39142}, {ip: "103.105.40.1", port: 16538}, {ip: "103.105.40.153", port: 16538}, {ip: "103.106.148.203", port: 60227}, {ip: "103.106.148.207", port: 51451}, {ip: "103.12.246.12", port: 8080}, {ip: "103.14.235.109", port: 8080}, {ip: "103.14.235.26", port: 8080}, {ip: "103.198.172.4", port: 50820}, {ip: "103.205.112.1", port: 23500}, {ip: "103.209.64.19", port: 6666}, {ip: "103.211.76.5", port: 8080}, {ip: "103.216.82.19", port: 6666}, {ip: "103.216.82.190", port: 6666}, {ip: "103.216.82.209", port: 54806}, {ip: "103.216.82.214", port: 6666}, {ip: "103.216.82.37", port: 6666}, {ip: "103.216.82.44", port: 8080}, {ip: "103.216.82.50", port: 53281}, {ip: "103.22.173.230", port: 8080}, {ip: "103.224.38.2", port: 83}, {ip: "103.226.142.90", port: 41386}, {ip: "103.236.114.38", port: 49638}, {ip: "103.240.161.107", port: 6666}, {ip: "103.240.161.108", port: 6666}, {ip: "103.240.161.109", port: 6666}, {ip: "103.240.161.59", port: 48809}, {ip: "103.245.198.101", port: 8080}, {ip: "103.250.148.82", port: 6666}, {ip: "103.251.58.51", port: 61489}, {ip: "103.253.169.115", port: 32731}, {ip: "103.253.211.182", port: 8080}, {ip: "103.253.211.182", port: 80}, {ip: "103.255.234.169", port: 39847}, {ip: "103.42.161.118", port: 8080}, {ip: "103.42.162.30", port: 8080}, {ip: "103.42.162.50", port: 8080}, {ip: "103.42.162.58", port: 8080}, {ip: "103.46.233.12", port: 83}, {ip: "103.46.233.13", port: 83}, {ip: "103.46.233.16", port: 83}, {ip: "103.46.233.17", port: 83}, {ip: "103.46.233.21", port: 83}, {ip: "103.46.233.23", port: 83}, {ip: "103.46.233.29", port: 81}, {ip: "103.46.233.29", port: 83}, {ip: "103.46.233.50", port: 83}, {ip: "103.47.153.87", port: 8080}, {ip: "103.47.66.2", port: 39804}, {ip: "103.49.53.1", port: 81}, {ip: "103.52.220.1", port: 49068}, {ip: "103.56.228.166", port: 53281}, {ip: "103.56.30.128", port: 8080}, {ip: "103.65.193.17", port: 50862}, {ip: "103.65.195.1", port: 33960}, {ip: "103.69.220.14", port: 3128}, {ip: "103.70.128.84", port: 8080}, {ip: "103.70.128.86", port: 8080}, {ip: "103.70.131.74", port: 8080}, {ip: "103.70.146.250", port: 59563}, {ip: "103.72.216.194", port: 38345}, {ip: "103.75.161.38", port: 21776}, {ip: "103.76.253.155", port: 3128}, {ip: "103.87.104.137", port: 8080}, {ip: "110.235.198.3", port: 57660}, {ip: "114.69.229.161", port: 8080}, {ip: "117.196.231.201", port: 37769}, {ip: "117.211.166.214", port: 3128}, {ip: "117.240.175.51", port: 3128}, {ip: "117.240.210.155", port: 53281}, {ip: "117.240.59.115", port: 36127}, {ip: "117.242.154.73", port: 33889}, {ip: "117.244.15.243", port: 3128}, {ip: "119.235.54.3", port: 8080}, {ip: "120.138.117.102", port: 59308}, {ip: "123.108.200.185", port: 83}, {ip: "123.108.200.217", port: 82}, {ip: "123.176.43.218", port: 40524}, {ip: "125.21.43.82", port: 8080}, {ip: "125.62.192.225", port: 82}, {ip: "125.62.192.33", port: 84}, {ip: "125.62.194.1", port: 83}, {ip: "125.62.213.134", port: 82}, {ip: "125.62.213.18", port: 83}, {ip: "125.62.213.201", port: 84}, {ip: "125.62.213.242", port: 83}, {ip: "125.62.214.185", port: 84}, {ip: "139.5.26.27", port: 53281}, {ip: "14.102.67.101", port: 30337}, {ip: "14.142.122.134", port: 8080}, {ip: "150.129.114.194", port: 6666}, {ip: "150.129.151.62", port: 6666}, {ip: "150.129.171.115", port: 6666}, {ip: "150.129.201.30", port: 6666}, {ip: "157.119.207.38", port: 53281}, {ip: "175.100.185.151", port: 53281}, {ip: "182.18.177.114", port: 56173}, {ip: "182.73.194.170", port: 8080}, {ip: "182.74.85.230", port: 51214}, {ip: "183.82.116.56", port: 8080}, {ip: "183.82.32.56", port: 49551}, {ip: "183.87.14.229", port: 53281}, {ip: "183.87.14.250", port: 44915}, {ip: "202.134.160.168", port: 8080}, {ip: "202.134.166.1", port: 8080}, {ip: "202.134.180.50", port: 8080}, {ip: "202.62.84.210", port: 53281}, {ip: "203.192.193.225", port: 8080}, {ip: "203.192.195.14", port: 31062}, {ip: "203.192.217.11", port: 8080}, {ip: "223.196.83.182", port: 53281}, {ip: "27.116.20.169", port: 36630}, {ip: "27.116.20.209", port: 36630}, {ip: "27.116.51.21", port: 36033}, {ip: "43.224.8.114", port: 50333}, {ip: "43.224.8.116", port: 6666}, {ip: "43.224.8.124", port: 6666}, {ip: "43.224.8.86", port: 6666}, {ip: "43.225.20.73", port: 8080}, {ip: "43.225.23.26", port: 8080}, {ip: "43.230.196.98", port: 36569}, {ip: "43.240.5.225", port: 31777}, {ip: "43.241.28.248", port: 8080}, {ip: "43.242.209.201", port: 8080}, {ip: "43.246.139.82", port: 8080}, {ip: "43.248.73.86", port: 53281}, {ip: "43.251.170.145", port: 54059}, {ip: "45.112.57.230", port: 61222}, {ip: "45.115.171.30", port: 47949}, {ip: "45.121.29.254", port: 54858}, {ip: "45.123.26.146", port: 53281}, {ip: "45.125.61.193", port: 32804}, {ip: "45.125.61.209", port: 32804}, {ip: "45.127.121.194", port: 53281}, {ip: "45.250.226.14", port: 3128}, {ip: "45.250.226.38", port: 8080}, {ip: "45.250.226.47", port: 8080}, {ip: "45.250.226.55", port: 8080}, {ip: "49.249.251.86", port: 53281}], - "CN" => [{ip: "182.61.170.45", port: 3128}], - "RU" => [{ip: "109.106.139.225", port: 45689}, {ip: "109.161.48.228", port: 53281}, {ip: "109.167.224.198", port: 51919}, {ip: "109.172.57.250", port: 23500}, {ip: "109.194.2.126", port: 61822}, {ip: "109.195.150.128", port: 37564}, {ip: "109.201.96.171", port: 31773}, {ip: "109.201.97.204", port: 41258}, {ip: "109.201.97.235", port: 39125}, {ip: "109.206.140.74", port: 45991}, {ip: "109.206.148.31", port: 30797}, {ip: "109.69.75.5", port: 46347}, {ip: "109.71.181.170", port: 53983}, {ip: "109.74.132.190", port: 42663}, {ip: "109.74.143.45", port: 36529}, {ip: "109.75.140.158", port: 59916}, {ip: "109.95.84.114", port: 52125}, {ip: "130.255.12.24", port: 31004}, {ip: "134.19.147.72", port: 44812}, {ip: "134.90.181.7", port: 54353}, {ip: "145.255.6.171", port: 31252}, {ip: "146.120.227.3", port: 8080}, {ip: "149.255.112.194", port: 48968}, {ip: "158.46.127.222", port: 52574}, {ip: "158.46.43.144", port: 39120}, {ip: "158.58.130.185", port: 50016}, {ip: "158.58.132.12", port: 56962}, {ip: "158.58.133.106", port: 41258}, {ip: "158.58.133.13", port: 21213}, {ip: "176.101.0.47", port: 34471}, {ip: "176.101.89.226", port: 33470}, {ip: "176.106.12.65", port: 30120}, {ip: "176.107.80.110", port: 58901}, {ip: "176.110.121.9", port: 46322}, {ip: "176.110.121.90", port: 21776}, {ip: "176.111.97.18", port: 8080}, {ip: "176.112.106.230", port: 33996}, {ip: "176.112.110.40", port: 61142}, {ip: "176.113.116.70", port: 55589}, {ip: "176.113.27.192", port: 47337}, {ip: "176.115.197.118", port: 8080}, {ip: "176.117.255.182", port: 53100}, {ip: "176.120.200.69", port: 44331}, {ip: "176.124.123.93", port: 41258}, {ip: "176.192.124.98", port: 60787}, {ip: "176.192.5.238", port: 61227}, {ip: "176.192.8.206", port: 39422}, {ip: "176.193.15.94", port: 8080}, {ip: "176.196.195.170", port: 48129}, {ip: "176.196.198.154", port: 35252}, {ip: "176.196.238.234", port: 44648}, {ip: "176.196.239.46", port: 35656}, {ip: "176.196.246.6", port: 53281}, {ip: "176.196.84.138", port: 51336}, {ip: "176.197.145.246", port: 32649}, {ip: "176.197.99.142", port: 47278}, {ip: "176.215.1.108", port: 60339}, {ip: "176.215.170.147", port: 35604}, {ip: "176.56.23.14", port: 35340}, {ip: "176.62.185.54", port: 53883}, {ip: "176.74.13.110", port: 8080}, {ip: "178.130.29.226", port: 53295}, {ip: "178.170.254.178", port: 46788}, {ip: "178.213.13.136", port: 53281}, {ip: "178.218.104.8", port: 49707}, {ip: "178.219.183.163", port: 8080}, {ip: "178.237.180.34", port: 57307}, {ip: "178.57.101.212", port: 38020}, {ip: "178.57.101.235", port: 31309}, {ip: "178.64.190.133", port: 46688}, {ip: "178.75.1.111", port: 50411}, {ip: "178.75.27.131", port: 41879}, {ip: "185.13.35.178", port: 40654}, {ip: "185.15.189.67", port: 30215}, {ip: "185.175.119.137", port: 41258}, {ip: "185.18.111.194", port: 41258}, {ip: "185.19.176.237", port: 53281}, {ip: "185.190.40.115", port: 31747}, {ip: "185.216.195.134", port: 61287}, {ip: "185.22.172.94", port: 10010}, {ip: "185.22.172.94", port: 1448}, {ip: "185.22.174.65", port: 10010}, {ip: "185.22.174.65", port: 1448}, {ip: "185.23.64.100", port: 3130}, {ip: "185.23.82.39", port: 59248}, {ip: "185.233.94.105", port: 59288}, {ip: "185.233.94.146", port: 57736}, {ip: "185.3.68.54", port: 53500}, {ip: "185.32.120.177", port: 60724}, {ip: "185.34.20.164", port: 53700}, {ip: "185.34.23.43", port: 63238}, {ip: "185.51.60.141", port: 39935}, {ip: "185.61.92.228", port: 33060}, {ip: "185.61.93.67", port: 49107}, {ip: "185.7.233.66", port: 53504}, {ip: "185.72.225.10", port: 56285}, {ip: "185.75.5.158", port: 60819}, {ip: "185.9.86.186", port: 39345}, {ip: "188.133.136.10", port: 47113}, {ip: "188.168.75.254", port: 56899}, {ip: "188.170.41.6", port: 60332}, {ip: "188.187.189.142", port: 38264}, {ip: "188.234.151.103", port: 8080}, {ip: "188.235.11.88", port: 57143}, {ip: "188.235.137.196", port: 23500}, {ip: "188.244.175.2", port: 8080}, {ip: "188.255.82.136", port: 53281}, {ip: "188.43.4.117", port: 60577}, {ip: "188.68.95.166", port: 41258}, {ip: "188.92.242.180", port: 52048}, {ip: "188.93.242.213", port: 49774}, {ip: "192.162.193.243", port: 36910}, {ip: "192.162.214.11", port: 41258}, {ip: "193.106.170.133", port: 38591}, {ip: "193.232.113.244", port: 40412}, {ip: "193.232.234.130", port: 61932}, {ip: "193.242.177.105", port: 53281}, {ip: "193.242.178.50", port: 52376}, {ip: "193.242.178.90", port: 8080}, {ip: "193.33.101.152", port: 34611}, {ip: "194.114.128.149", port: 61213}, {ip: "194.135.15.146", port: 59328}, {ip: "194.135.216.178", port: 56805}, {ip: "194.135.75.74", port: 41258}, {ip: "194.146.201.67", port: 53281}, {ip: "194.186.18.46", port: 56408}, {ip: "194.186.20.62", port: 21231}, {ip: "194.190.171.214", port: 43960}, {ip: "194.9.27.82", port: 42720}, {ip: "195.133.232.58", port: 41733}, {ip: "195.14.114.116", port: 59530}, {ip: "195.14.114.24", port: 56897}, {ip: "195.158.250.97", port: 41582}, {ip: "195.16.48.142", port: 36083}, {ip: "195.191.183.169", port: 47238}, {ip: "195.206.45.112", port: 53281}, {ip: "195.208.172.70", port: 8080}, {ip: "195.209.141.67", port: 31927}, {ip: "195.209.176.2", port: 8080}, {ip: "195.210.144.166", port: 30088}, {ip: "195.211.160.88", port: 44464}, {ip: "195.218.144.182", port: 31705}, {ip: "195.46.168.147", port: 8080}, {ip: "195.9.188.78", port: 53281}, {ip: "195.9.209.10", port: 35242}, {ip: "195.9.223.246", port: 52098}, {ip: "195.9.237.66", port: 8080}, {ip: "195.9.91.66", port: 33199}, {ip: "195.91.132.20", port: 19600}, {ip: "195.98.183.82", port: 30953}, {ip: "212.104.82.246", port: 36495}, {ip: "212.119.229.18", port: 33852}, {ip: "212.13.97.122", port: 30466}, {ip: "212.19.21.19", port: 53264}, {ip: "212.19.5.157", port: 58442}, {ip: "212.19.8.223", port: 30281}, {ip: "212.19.8.239", port: 55602}, {ip: "212.192.202.207", port: 4550}, {ip: "212.22.80.224", port: 34822}, {ip: "212.26.247.178", port: 38418}, {ip: "212.33.228.161", port: 37971}, {ip: "212.33.243.83", port: 38605}, {ip: "212.34.53.126", port: 44369}, {ip: "212.5.107.81", port: 56481}, {ip: "212.7.230.7", port: 51405}, {ip: "212.77.138.161", port: 41258}, {ip: "213.108.221.201", port: 32800}, {ip: "213.109.7.135", port: 59918}, {ip: "213.128.9.204", port: 35549}, {ip: "213.134.196.12", port: 38723}, {ip: "213.168.37.86", port: 8080}, {ip: "213.187.118.184", port: 53281}, {ip: "213.21.23.98", port: 53281}, {ip: "213.210.67.166", port: 53281}, {ip: "213.234.0.242", port: 56503}, {ip: "213.247.192.131", port: 41258}, {ip: "213.251.226.208", port: 56900}, {ip: "213.33.155.80", port: 44387}, {ip: "213.33.199.194", port: 36411}, {ip: "213.33.224.82", port: 8080}, {ip: "213.59.153.19", port: 53281}, {ip: "217.10.45.103", port: 8080}, {ip: "217.107.197.39", port: 33628}, {ip: "217.116.60.66", port: 21231}, {ip: "217.195.87.58", port: 41258}, {ip: "217.197.239.54", port: 34463}, {ip: "217.74.161.42", port: 34175}, {ip: "217.8.84.76", port: 46378}, {ip: "31.131.67.14", port: 8080}, {ip: "31.132.127.142", port: 35432}, {ip: "31.132.218.252", port: 32423}, {ip: "31.173.17.118", port: 51317}, {ip: "31.193.124.70", port: 53281}, {ip: "31.210.211.147", port: 8080}, {ip: "31.220.183.217", port: 53281}, {ip: "31.29.212.82", port: 35066}, {ip: "31.42.254.24", port: 30912}, {ip: "31.47.189.14", port: 38473}, {ip: "37.113.129.98", port: 41665}, {ip: "37.192.103.164", port: 34835}, {ip: "37.192.194.50", port: 50165}, {ip: "37.192.99.151", port: 51417}, {ip: "37.205.83.91", port: 35888}, {ip: "37.233.85.155", port: 53281}, {ip: "37.235.167.66", port: 53281}, {ip: "37.235.65.2", port: 47816}, {ip: "37.235.67.178", port: 34450}, {ip: "37.9.134.133", port: 41262}, {ip: "46.150.174.90", port: 53281}, {ip: "46.151.156.198", port: 56013}, {ip: "46.16.226.10", port: 8080}, {ip: "46.163.131.55", port: 48306}, {ip: "46.173.191.51", port: 53281}, {ip: "46.174.222.61", port: 34977}, {ip: "46.180.96.79", port: 42319}, {ip: "46.181.151.79", port: 39386}, {ip: "46.21.74.130", port: 8080}, {ip: "46.227.162.98", port: 51558}, {ip: "46.229.187.169", port: 53281}, {ip: "46.229.67.198", port: 47437}, {ip: "46.243.179.221", port: 41598}, {ip: "46.254.217.54", port: 53281}, {ip: "46.32.68.188", port: 39707}, {ip: "46.39.224.112", port: 36765}, {ip: "46.63.162.171", port: 8080}, {ip: "46.73.33.253", port: 8080}, {ip: "5.128.32.12", port: 51959}, {ip: "5.129.155.3", port: 51390}, {ip: "5.129.16.27", port: 48935}, {ip: "5.141.81.65", port: 61853}, {ip: "5.16.15.234", port: 8080}, {ip: "5.167.51.235", port: 8080}, {ip: "5.167.96.238", port: 3128}, {ip: "5.19.165.235", port: 30793}, {ip: "5.35.93.157", port: 31773}, {ip: "5.59.137.90", port: 8888}, {ip: "5.8.207.160", port: 57192}, {ip: "62.122.97.66", port: 59143}, {ip: "62.148.151.253", port: 53570}, {ip: "62.152.85.158", port: 31156}, {ip: "62.165.54.153", port: 55522}, {ip: "62.173.140.14", port: 8080}, {ip: "62.173.155.206", port: 41258}, {ip: "62.182.206.19", port: 37715}, {ip: "62.213.14.166", port: 8080}, {ip: "62.76.123.224", port: 8080}, {ip: "77.221.220.133", port: 44331}, {ip: "77.232.153.248", port: 60950}, {ip: "77.233.10.37", port: 54210}, {ip: "77.244.27.109", port: 47554}, {ip: "77.37.142.203", port: 53281}, {ip: "77.39.29.29", port: 49243}, {ip: "77.75.6.34", port: 8080}, {ip: "77.87.102.7", port: 42601}, {ip: "77.94.121.212", port: 36896}, {ip: "77.94.121.51", port: 45293}, {ip: "78.110.154.177", port: 59888}, {ip: "78.140.201.226", port: 8090}, {ip: "78.153.4.122", port: 9001}, {ip: "78.156.225.170", port: 41258}, {ip: "78.156.243.146", port: 59730}, {ip: "78.29.14.201", port: 39001}, {ip: "78.81.24.112", port: 8080}, {ip: "78.85.36.203", port: 8080}, {ip: "79.104.219.125", port: 3128}, {ip: "79.104.55.134", port: 8080}, {ip: "79.137.181.170", port: 8080}, {ip: "79.173.124.194", port: 47832}, {ip: "79.173.124.207", port: 53281}, {ip: "79.174.186.168", port: 45710}, {ip: "79.175.51.13", port: 54853}, {ip: "79.175.57.77", port: 55477}, {ip: "80.234.107.118", port: 56952}, {ip: "80.237.6.1", port: 34880}, {ip: "80.243.14.182", port: 49320}, {ip: "80.251.48.215", port: 45157}, {ip: "80.254.121.66", port: 41055}, {ip: "80.254.125.236", port: 80}, {ip: "80.72.121.185", port: 52379}, {ip: "80.89.133.210", port: 3128}, {ip: "80.91.17.113", port: 41258}, {ip: "81.162.61.166", port: 40392}, {ip: "81.163.57.121", port: 41258}, {ip: "81.163.57.46", port: 41258}, {ip: "81.163.62.136", port: 41258}, {ip: "81.23.112.98", port: 55269}, {ip: "81.23.118.106", port: 60427}, {ip: "81.23.177.245", port: 8080}, {ip: "81.24.126.166", port: 8080}, {ip: "81.30.216.147", port: 41258}, {ip: "81.95.131.10", port: 44292}, {ip: "82.114.125.22", port: 8080}, {ip: "82.151.208.20", port: 8080}, {ip: "83.221.216.110", port: 47326}, {ip: "83.246.139.24", port: 8080}, {ip: "83.97.108.8", port: 41258}, {ip: "84.22.154.76", port: 8080}, {ip: "84.52.110.36", port: 38674}, {ip: "84.52.74.194", port: 8080}, {ip: "84.52.77.227", port: 41806}, {ip: "84.52.79.166", port: 43548}, {ip: "84.52.84.157", port: 44331}, {ip: "84.52.88.125", port: 32666}, {ip: "85.113.48.148", port: 8080}, {ip: "85.113.49.220", port: 8080}, {ip: "85.12.193.210", port: 58470}, {ip: "85.15.179.5", port: 8080}, {ip: "85.173.244.102", port: 53281}, {ip: "85.174.227.52", port: 59280}, {ip: "85.192.184.133", port: 8080}, {ip: "85.192.184.133", port: 80}, {ip: "85.21.240.193", port: 55820}, {ip: "85.21.63.219", port: 53281}, {ip: "85.235.190.18", port: 42494}, {ip: "85.237.56.193", port: 8080}, {ip: "85.91.119.6", port: 8080}, {ip: "86.102.116.30", port: 8080}, {ip: "86.110.30.146", port: 38109}, {ip: "87.117.3.129", port: 3128}, {ip: "87.225.108.195", port: 8080}, {ip: "87.228.103.111", port: 8080}, {ip: "87.228.103.43", port: 8080}, {ip: "87.229.143.10", port: 48872}, {ip: "87.249.205.103", port: 8080}, {ip: "87.249.21.193", port: 43079}, {ip: "87.255.13.217", port: 8080}, {ip: "88.147.159.167", port: 53281}, {ip: "88.200.225.32", port: 38583}, {ip: "88.204.59.177", port: 32666}, {ip: "88.84.209.69", port: 30819}, {ip: "88.87.72.72", port: 8080}, {ip: "88.87.79.20", port: 8080}, {ip: "88.87.91.163", port: 48513}, {ip: "88.87.93.20", port: 33277}, {ip: "89.109.12.82", port: 47972}, {ip: "89.109.21.43", port: 9090}, {ip: "89.109.239.183", port: 41041}, {ip: "89.109.54.137", port: 36469}, {ip: "89.17.37.218", port: 52957}, {ip: "89.189.130.103", port: 32626}, {ip: "89.189.159.214", port: 42530}, {ip: "89.189.174.121", port: 52636}, {ip: "89.23.18.29", port: 53281}, {ip: "89.249.251.21", port: 3128}, {ip: "89.250.149.114", port: 60981}, {ip: "89.250.17.209", port: 8080}, {ip: "89.250.19.173", port: 8080}, {ip: "90.150.87.172", port: 81}, {ip: "90.154.125.173", port: 33078}, {ip: "90.188.38.81", port: 60585}, {ip: "90.189.151.183", port: 32601}, {ip: "91.103.208.114", port: 57063}, {ip: "91.122.100.222", port: 44331}, {ip: "91.122.207.229", port: 8080}, {ip: "91.144.139.93", port: 3128}, {ip: "91.144.142.19", port: 44617}, {ip: "91.146.16.54", port: 57902}, {ip: "91.190.116.194", port: 38783}, {ip: "91.190.80.100", port: 31659}, {ip: "91.190.85.97", port: 34286}, {ip: "91.203.36.188", port: 8080}, {ip: "91.205.131.102", port: 8080}, {ip: "91.205.146.25", port: 37501}, {ip: "91.210.94.212", port: 52635}, {ip: "91.213.23.110", port: 8080}, {ip: "91.215.22.51", port: 53305}, {ip: "91.217.42.3", port: 8080}, {ip: "91.217.42.4", port: 8080}, {ip: "91.220.135.146", port: 41258}, {ip: "91.222.167.213", port: 38057}, {ip: "91.226.140.71", port: 33199}, {ip: "91.235.7.216", port: 59067}, {ip: "92.124.195.22", port: 3128}, {ip: "92.126.193.180", port: 8080}, {ip: "92.241.110.223", port: 53281}, {ip: "92.252.240.1", port: 53281}, {ip: "92.255.164.187", port: 3128}, {ip: "92.255.195.57", port: 53281}, {ip: "92.255.229.146", port: 55785}, {ip: "92.255.5.2", port: 41012}, {ip: "92.38.32.36", port: 56113}, {ip: "92.39.138.98", port: 31150}, {ip: "92.51.16.155", port: 46202}, {ip: "92.55.59.63", port: 33030}, {ip: "93.170.112.200", port: 47995}, {ip: "93.183.86.185", port: 53281}, {ip: "93.188.45.157", port: 8080}, {ip: "93.81.246.5", port: 53281}, {ip: "93.91.112.247", port: 41258}, {ip: "94.127.217.66", port: 40115}, {ip: "94.154.85.214", port: 8080}, {ip: "94.180.106.94", port: 32767}, {ip: "94.180.249.187", port: 38051}, {ip: "94.230.243.6", port: 8080}, {ip: "94.232.57.231", port: 51064}, {ip: "94.24.244.170", port: 48936}, {ip: "94.242.55.108", port: 10010}, {ip: "94.242.55.108", port: 1448}, {ip: "94.242.57.136", port: 10010}, {ip: "94.242.57.136", port: 1448}, {ip: "94.242.58.108", port: 10010}, {ip: "94.242.58.108", port: 1448}, {ip: "94.242.58.14", port: 10010}, {ip: "94.242.58.14", port: 1448}, {ip: "94.242.58.142", port: 10010}, {ip: "94.242.58.142", port: 1448}, {ip: "94.242.59.245", port: 10010}, {ip: "94.242.59.245", port: 1448}, {ip: "94.247.241.70", port: 53640}, {ip: "94.247.62.165", port: 33176}, {ip: "94.253.13.228", port: 54935}, {ip: "94.253.14.187", port: 55045}, {ip: "94.28.94.154", port: 46966}, {ip: "94.73.217.125", port: 40858}, {ip: "95.140.19.9", port: 8080}, {ip: "95.140.20.94", port: 33994}, {ip: "95.154.137.66", port: 41258}, {ip: "95.154.159.119", port: 44242}, {ip: "95.154.82.254", port: 52484}, {ip: "95.161.157.227", port: 43170}, {ip: "95.161.182.146", port: 33877}, {ip: "95.161.189.26", port: 61522}, {ip: "95.165.163.146", port: 8888}, {ip: "95.165.172.90", port: 60496}, {ip: "95.165.182.18", port: 38950}, {ip: "95.165.203.222", port: 33805}, {ip: "95.165.244.122", port: 58162}, {ip: "95.167.123.54", port: 58664}, {ip: "95.167.241.242", port: 49636}, {ip: "95.171.1.92", port: 35956}, {ip: "95.172.52.230", port: 35989}, {ip: "95.181.35.30", port: 40804}, {ip: "95.181.56.178", port: 39144}, {ip: "95.181.75.228", port: 53281}, {ip: "95.188.74.194", port: 57122}, {ip: "95.189.112.214", port: 35508}, {ip: "95.31.10.247", port: 30711}, {ip: "95.31.197.77", port: 41651}, {ip: "95.31.2.199", port: 33632}, {ip: "95.71.125.50", port: 49882}, {ip: "95.73.62.13", port: 32185}, {ip: "95.79.36.55", port: 44861}, {ip: "95.79.55.196", port: 53281}, {ip: "95.79.99.148", port: 3128}, {ip: "95.80.65.39", port: 43555}, {ip: "95.80.93.44", port: 41258}, {ip: "95.80.98.41", port: 8080}, {ip: "95.83.156.250", port: 58438}, {ip: "95.84.128.25", port: 33765}, {ip: "95.84.154.73", port: 57423}], - "CA" => [{ip: "144.217.161.149", port: 8080}, {ip: "24.37.9.6", port: 54154}, {ip: "54.39.138.144", port: 3128}, {ip: "54.39.138.145", port: 3128}, {ip: "54.39.138.151", port: 3128}, {ip: "54.39.138.152", port: 3128}, {ip: "54.39.138.153", port: 3128}, {ip: "54.39.138.154", port: 3128}, {ip: "54.39.138.155", port: 3128}, {ip: "54.39.138.156", port: 3128}, {ip: "54.39.138.157", port: 3128}, {ip: "54.39.53.104", port: 3128}, {ip: "66.70.167.113", port: 3128}, {ip: "66.70.167.116", port: 3128}, {ip: "66.70.167.117", port: 3128}, {ip: "66.70.167.119", port: 3128}, {ip: "66.70.167.120", port: 3128}, {ip: "66.70.167.125", port: 3128}, {ip: "66.70.188.148", port: 3128}, {ip: "70.35.213.229", port: 36127}, {ip: "70.65.233.174", port: 8080}, {ip: "72.139.24.66", port: 38861}, {ip: "74.15.191.160", port: 41564}], - "JP" => [{ip: "47.91.20.67", port: 8080}, {ip: "61.118.35.94", port: 55725}], - "IT" => [{ip: "109.70.201.97", port: 53517}, {ip: "176.31.82.212", port: 8080}, {ip: "185.132.228.118", port: 55583}, {ip: "185.49.58.88", port: 56006}, {ip: "185.94.89.179", port: 41258}, {ip: "213.203.134.10", port: 41258}, {ip: "217.61.172.12", port: 41369}, {ip: "46.232.143.126", port: 41258}, {ip: "46.232.143.253", port: 41258}, {ip: "93.67.154.125", port: 8080}, {ip: "93.67.154.125", port: 80}, {ip: "95.169.95.242", port: 53803}], - "TH" => [{ip: "1.10.184.166", port: 57330}, {ip: "1.10.186.100", port: 55011}, {ip: "1.10.186.209", port: 32431}, {ip: "1.10.186.245", port: 34360}, {ip: "1.10.186.93", port: 53711}, {ip: "1.10.187.118", port: 62000}, {ip: "1.10.187.34", port: 51635}, {ip: "1.10.187.43", port: 38715}, {ip: "1.10.188.181", port: 51093}, {ip: "1.10.188.83", port: 31940}, {ip: "1.10.188.95", port: 30593}, {ip: "1.10.189.58", port: 48564}, {ip: "1.179.157.237", port: 46178}, {ip: "1.179.164.213", port: 8080}, {ip: "1.179.198.37", port: 8080}, {ip: "1.20.100.99", port: 53794}, {ip: "1.20.101.221", port: 55707}, {ip: "1.20.101.254", port: 35394}, {ip: "1.20.101.80", port: 36234}, {ip: "1.20.102.133", port: 40296}, {ip: "1.20.103.13", port: 40544}, {ip: "1.20.103.56", port: 55422}, {ip: "1.20.96.234", port: 53142}, {ip: "1.20.97.54", port: 60122}, {ip: "1.20.99.63", port: 32123}, {ip: "101.108.92.20", port: 8080}, {ip: "101.109.143.71", port: 36127}, {ip: "101.51.141.110", port: 42860}, {ip: "101.51.141.60", port: 60417}, {ip: "103.246.17.237", port: 3128}, {ip: "110.164.73.131", port: 8080}, {ip: "110.164.87.80", port: 35844}, {ip: "110.77.134.106", port: 8080}, {ip: "113.53.29.92", port: 47297}, {ip: "113.53.83.192", port: 32780}, {ip: "113.53.83.195", port: 35686}, {ip: "113.53.91.214", port: 8080}, {ip: "115.87.27.0", port: 53276}, {ip: "118.172.211.3", port: 58535}, {ip: "118.172.211.40", port: 30430}, {ip: "118.174.196.174", port: 23500}, {ip: "118.174.196.203", port: 23500}, {ip: "118.174.220.107", port: 41222}, {ip: "118.174.220.110", port: 39025}, {ip: "118.174.220.115", port: 41011}, {ip: "118.174.220.118", port: 59556}, {ip: "118.174.220.136", port: 55041}, {ip: "118.174.220.163", port: 31561}, {ip: "118.174.220.168", port: 47455}, {ip: "118.174.220.231", port: 40924}, {ip: "118.174.220.238", port: 46326}, {ip: "118.174.234.13", port: 53084}, {ip: "118.174.234.26", port: 41926}, {ip: "118.174.234.32", port: 57403}, {ip: "118.174.234.59", port: 59149}, {ip: "118.174.234.68", port: 42626}, {ip: "118.174.234.83", port: 38006}, {ip: "118.175.207.104", port: 38959}, {ip: "118.175.244.111", port: 8080}, {ip: "118.175.93.207", port: 50738}, {ip: "122.154.38.53", port: 8080}, {ip: "122.154.59.6", port: 8080}, {ip: "122.154.72.102", port: 8080}, {ip: "122.155.222.98", port: 3128}, {ip: "124.121.22.121", port: 61699}, {ip: "125.24.156.16", port: 44321}, {ip: "125.25.165.105", port: 33850}, {ip: "125.25.165.111", port: 40808}, {ip: "125.25.165.42", port: 47221}, {ip: "125.25.201.14", port: 30100}, {ip: "125.26.99.135", port: 55637}, {ip: "125.26.99.141", port: 38537}, {ip: "125.26.99.148", port: 31818}, {ip: "134.236.247.137", port: 8080}, {ip: "159.192.98.224", port: 3128}, {ip: "171.100.2.154", port: 8080}, {ip: "171.100.9.126", port: 49163}, {ip: "180.180.156.116", port: 48431}, {ip: "180.180.156.46", port: 48507}, {ip: "180.180.156.87", port: 36628}, {ip: "180.180.218.204", port: 51565}, {ip: "180.180.8.34", port: 8080}, {ip: "182.52.238.125", port: 58861}, {ip: "182.52.74.73", port: 36286}, {ip: "182.52.74.76", port: 34084}, {ip: "182.52.74.77", port: 34825}, {ip: "182.52.74.78", port: 48708}, {ip: "182.52.90.45", port: 53799}, {ip: "182.53.206.155", port: 34307}, {ip: "182.53.206.43", port: 45330}, {ip: "182.53.206.49", port: 54228}, {ip: "183.88.212.141", port: 8080}, {ip: "183.88.212.184", port: 8080}, {ip: "183.88.213.85", port: 8080}, {ip: "183.88.214.47", port: 8080}, {ip: "184.82.128.211", port: 8080}, {ip: "202.183.201.13", port: 8081}, {ip: "202.29.20.151", port: 43083}, {ip: "203.150.172.151", port: 8080}, {ip: "27.131.157.94", port: 8080}, {ip: "27.145.100.22", port: 8080}, {ip: "27.145.100.243", port: 8080}, {ip: "49.231.196.114", port: 53281}, {ip: "58.97.72.83", port: 8080}, {ip: "61.19.145.66", port: 8080}], - "ES" => [{ip: "185.198.184.14", port: 48122}, {ip: "185.26.226.241", port: 36012}, {ip: "194.224.188.82", port: 3128}, {ip: "195.235.68.61", port: 3128}, {ip: "195.53.237.122", port: 3128}, {ip: "195.53.86.82", port: 3128}, {ip: "213.96.245.47", port: 8080}, {ip: "217.125.71.214", port: 33950}, {ip: "62.14.178.72", port: 53281}, {ip: "80.35.254.42", port: 53281}, {ip: "81.33.4.214", port: 61711}, {ip: "83.175.238.170", port: 53281}, {ip: "85.217.137.77", port: 3128}, {ip: "90.170.205.178", port: 33680}, {ip: "93.156.177.91", port: 53281}, {ip: "95.60.152.139", port: 37995}], - "AE" => [{ip: "178.32.5.90", port: 36159}], - "KR" => [{ip: "112.217.219.179", port: 3128}, {ip: "114.141.229.2", port: 58115}, {ip: "121.139.218.165", port: 31409}, {ip: "122.49.112.2", port: 38592}, {ip: "61.42.18.132", port: 53281}], - "BR" => [{ip: "128.201.97.157", port: 53281}, {ip: "128.201.97.158", port: 53281}, {ip: "131.0.246.157", port: 35252}, {ip: "131.161.26.90", port: 8080}, {ip: "131.72.143.100", port: 41396}, {ip: "138.0.24.66", port: 53281}, {ip: "138.121.130.50", port: 50600}, {ip: "138.121.155.127", port: 61932}, {ip: "138.121.32.133", port: 23492}, {ip: "138.185.176.63", port: 53281}, {ip: "138.204.233.190", port: 53281}, {ip: "138.204.233.242", port: 53281}, {ip: "138.219.71.74", port: 52688}, {ip: "138.36.107.24", port: 41184}, {ip: "138.94.115.166", port: 8080}, {ip: "143.0.188.161", port: 53281}, {ip: "143.202.218.135", port: 8080}, {ip: "143.208.2.42", port: 53281}, {ip: "143.208.79.223", port: 8080}, {ip: "143.255.52.102", port: 40687}, {ip: "143.255.52.116", port: 57856}, {ip: "143.255.52.117", port: 37279}, {ip: "144.217.22.128", port: 8080}, {ip: "168.0.8.225", port: 8080}, {ip: "168.0.8.55", port: 8080}, {ip: "168.121.139.54", port: 40056}, {ip: "168.181.168.23", port: 53281}, {ip: "168.181.170.198", port: 31935}, {ip: "168.232.198.25", port: 32009}, {ip: "168.232.198.35", port: 42267}, {ip: "168.232.207.145", port: 46342}, {ip: "170.0.104.107", port: 60337}, {ip: "170.0.112.2", port: 50359}, {ip: "170.0.112.229", port: 50359}, {ip: "170.238.118.107", port: 34314}, {ip: "170.239.144.9", port: 3128}, {ip: "170.247.29.138", port: 8080}, {ip: "170.81.237.36", port: 37124}, {ip: "170.84.51.74", port: 53281}, {ip: "170.84.60.222", port: 42981}, {ip: "177.10.202.67", port: 8080}, {ip: "177.101.60.86", port: 80}, {ip: "177.103.231.211", port: 55091}, {ip: "177.12.80.50", port: 50556}, {ip: "177.131.13.9", port: 20183}, {ip: "177.135.178.115", port: 42510}, {ip: "177.135.248.75", port: 20183}, {ip: "177.184.206.238", port: 39508}, {ip: "177.185.148.46", port: 58623}, {ip: "177.200.83.238", port: 8080}, {ip: "177.21.24.146", port: 666}, {ip: "177.220.188.120", port: 47556}, {ip: "177.220.188.213", port: 8080}, {ip: "177.222.229.243", port: 23500}, {ip: "177.234.161.42", port: 8080}, {ip: "177.36.11.241", port: 3128}, {ip: "177.36.12.193", port: 23500}, {ip: "177.37.199.175", port: 49608}, {ip: "177.39.187.70", port: 37315}, {ip: "177.44.175.199", port: 8080}, {ip: "177.46.148.126", port: 3128}, {ip: "177.46.148.142", port: 3128}, {ip: "177.47.194.98", port: 21231}, {ip: "177.5.98.58", port: 20183}, {ip: "177.52.55.19", port: 60901}, {ip: "177.54.200.66", port: 57526}, {ip: "177.55.255.74", port: 37147}, {ip: "177.67.217.94", port: 53281}, {ip: "177.73.248.6", port: 54381}, {ip: "177.73.4.234", port: 23500}, {ip: "177.75.143.211", port: 35955}, {ip: "177.75.161.206", port: 3128}, {ip: "177.75.86.49", port: 20183}, {ip: "177.8.216.106", port: 8080}, {ip: "177.8.216.114", port: 8080}, {ip: "177.8.37.247", port: 56052}, {ip: "177.84.216.17", port: 50569}, {ip: "177.85.200.254", port: 53095}, {ip: "177.87.169.1", port: 53281}, {ip: "179.107.97.178", port: 3128}, {ip: "179.109.144.25", port: 8080}, {ip: "179.109.193.137", port: 53281}, {ip: "179.189.125.206", port: 8080}, {ip: "179.97.30.46", port: 53100}, {ip: "186.192.195.220", port: 38983}, {ip: "186.193.11.226", port: 48999}, {ip: "186.193.26.106", port: 3128}, {ip: "186.208.220.248", port: 3128}, {ip: "186.209.243.142", port: 3128}, {ip: "186.209.243.233", port: 3128}, {ip: "186.211.106.227", port: 34334}, {ip: "186.211.160.178", port: 36756}, {ip: "186.215.133.170", port: 20183}, {ip: "186.216.81.21", port: 31773}, {ip: "186.219.214.13", port: 32708}, {ip: "186.224.94.6", port: 48957}, {ip: "186.225.97.246", port: 43082}, {ip: "186.226.171.163", port: 48698}, {ip: "186.226.179.2", port: 56089}, {ip: "186.226.234.67", port: 33834}, {ip: "186.228.147.58", port: 20183}, {ip: "186.233.97.163", port: 8888}, {ip: "186.248.170.82", port: 53281}, {ip: "186.249.213.101", port: 53482}, {ip: "186.249.213.65", port: 52018}, {ip: "186.250.213.225", port: 60774}, {ip: "186.250.96.70", port: 8080}, {ip: "186.250.96.77", port: 8080}, {ip: "187.1.43.246", port: 53396}, {ip: "187.108.36.250", port: 20183}, {ip: "187.108.38.10", port: 20183}, {ip: "187.109.36.251", port: 20183}, {ip: "187.109.40.9", port: 20183}, {ip: "187.109.56.101", port: 20183}, {ip: "187.111.90.89", port: 53281}, {ip: "187.115.10.50", port: 20183}, {ip: "187.19.62.7", port: 59010}, {ip: "187.33.79.61", port: 33469}, {ip: "187.35.158.150", port: 38872}, {ip: "187.44.1.167", port: 8080}, {ip: "187.45.127.87", port: 20183}, {ip: "187.45.156.109", port: 8080}, {ip: "187.5.218.215", port: 20183}, {ip: "187.58.65.225", port: 3128}, {ip: "187.63.111.37", port: 3128}, {ip: "187.72.166.10", port: 8080}, {ip: "187.73.68.14", port: 53281}, {ip: "187.84.177.6", port: 45903}, {ip: "187.84.191.170", port: 43936}, {ip: "187.87.204.210", port: 45597}, {ip: "187.87.39.247", port: 31793}, {ip: "189.1.16.162", port: 23500}, {ip: "189.113.124.162", port: 8080}, {ip: "189.124.195.185", port: 37318}, {ip: "189.3.196.18", port: 61595}, {ip: "189.37.33.59", port: 35532}, {ip: "189.7.49.66", port: 42700}, {ip: "189.90.194.35", port: 30843}, {ip: "189.90.248.75", port: 8080}, {ip: "189.91.231.43", port: 3128}, {ip: "191.239.243.156", port: 3128}, {ip: "191.240.154.246", port: 23500}, {ip: "191.240.156.154", port: 36127}, {ip: "191.240.99.142", port: 9090}, {ip: "191.241.226.230", port: 53281}, {ip: "191.241.228.74", port: 20183}, {ip: "191.241.228.78", port: 20183}, {ip: "191.241.33.238", port: 39188}, {ip: "191.241.36.170", port: 8080}, {ip: "191.241.36.218", port: 3128}, {ip: "191.242.182.132", port: 8081}, {ip: "191.243.221.130", port: 3128}, {ip: "191.255.207.231", port: 20183}, {ip: "191.36.192.196", port: 3128}, {ip: "191.36.244.230", port: 51377}, {ip: "191.5.0.79", port: 53281}, {ip: "191.6.228.6", port: 53281}, {ip: "191.7.193.18", port: 38133}, {ip: "191.7.20.134", port: 3128}, {ip: "192.140.91.173", port: 20183}, {ip: "200.150.86.138", port: 44677}, {ip: "200.155.36.185", port: 3128}, {ip: "200.155.36.188", port: 3128}, {ip: "200.155.39.41", port: 3128}, {ip: "200.174.158.26", port: 34112}, {ip: "200.187.177.105", port: 20183}, {ip: "200.187.87.138", port: 20183}, {ip: "200.192.252.201", port: 8080}, {ip: "200.192.255.102", port: 8080}, {ip: "200.203.144.2", port: 50262}, {ip: "200.229.238.42", port: 20183}, {ip: "200.233.134.85", port: 43172}, {ip: "200.233.136.177", port: 20183}, {ip: "200.241.44.3", port: 20183}, {ip: "200.255.122.170", port: 8080}, {ip: "200.255.122.174", port: 8080}, {ip: "201.12.21.57", port: 8080}, {ip: "201.131.224.21", port: 56200}, {ip: "201.182.223.16", port: 37492}, {ip: "201.20.89.126", port: 8080}, {ip: "201.22.95.10", port: 8080}, {ip: "201.57.167.34", port: 8080}, {ip: "201.59.200.246", port: 80}, {ip: "201.6.167.178", port: 3128}, {ip: "201.90.36.194", port: 3128}, {ip: "45.226.20.6", port: 8080}, {ip: "45.234.139.129", port: 20183}, {ip: "45.234.200.18", port: 53281}, {ip: "45.235.87.4", port: 51996}, {ip: "45.6.136.38", port: 53281}, {ip: "45.6.80.131", port: 52080}, {ip: "45.6.93.10", port: 8080}, {ip: "45.71.108.162", port: 53281}], - "PK" => [{ip: "103.18.243.154", port: 8080}, {ip: "110.36.218.126", port: 36651}, {ip: "110.36.234.210", port: 8080}, {ip: "110.39.162.74", port: 53281}, {ip: "110.39.174.58", port: 8080}, {ip: "111.68.108.34", port: 8080}, {ip: "125.209.116.182", port: 31653}, {ip: "125.209.78.21", port: 8080}, {ip: "125.209.82.78", port: 35087}, {ip: "180.92.156.150", port: 8080}, {ip: "202.142.158.114", port: 8080}, {ip: "202.147.173.10", port: 8080}, {ip: "202.147.173.10", port: 80}, {ip: "202.69.38.82", port: 8080}, {ip: "203.128.16.126", port: 59538}, {ip: "203.128.16.154", port: 33002}, {ip: "27.255.4.170", port: 8080}], - "ID" => [{ip: "101.128.68.113", port: 8080}, {ip: "101.255.116.113", port: 53281}, {ip: "101.255.120.170", port: 6969}, {ip: "101.255.121.74", port: 8080}, {ip: "101.255.124.242", port: 8080}, {ip: "101.255.124.242", port: 80}, {ip: "101.255.56.138", port: 53560}, {ip: "103.10.171.132", port: 41043}, {ip: "103.10.81.172", port: 80}, {ip: "103.108.158.3", port: 48196}, {ip: "103.111.219.159", port: 53281}, {ip: "103.111.54.26", port: 49781}, {ip: "103.111.54.74", port: 8080}, {ip: "103.19.110.177", port: 8080}, {ip: "103.2.146.66", port: 49089}, {ip: "103.206.168.177", port: 53281}, {ip: "103.206.253.58", port: 49573}, {ip: "103.21.92.254", port: 33929}, {ip: "103.226.49.83", port: 23500}, {ip: "103.227.147.142", port: 37581}, {ip: "103.23.101.58", port: 8080}, {ip: "103.24.107.2", port: 8181}, {ip: "103.245.19.222", port: 53281}, {ip: "103.247.122.38", port: 8080}, {ip: "103.247.218.166", port: 3128}, {ip: "103.248.219.26", port: 53634}, {ip: "103.253.2.165", port: 33543}, {ip: "103.253.2.168", port: 51229}, {ip: "103.253.2.174", port: 30827}, {ip: "103.28.114.134", port: 8080}, {ip: "103.28.220.73", port: 53281}, {ip: "103.30.246.47", port: 3128}, {ip: "103.31.45.169", port: 57655}, {ip: "103.41.122.14", port: 53281}, {ip: "103.75.101.97", port: 8080}, {ip: "103.76.17.151", port: 23500}, {ip: "103.76.50.181", port: 8080}, {ip: "103.76.50.181", port: 80}, {ip: "103.76.50.182", port: 8080}, {ip: "103.78.74.170", port: 3128}, {ip: "103.78.80.194", port: 33442}, {ip: "103.8.122.5", port: 53297}, {ip: "103.80.236.107", port: 53281}, {ip: "103.80.238.203", port: 53281}, {ip: "103.86.140.74", port: 59538}, {ip: "103.94.122.254", port: 8080}, {ip: "103.94.125.244", port: 41508}, {ip: "103.94.169.19", port: 8080}, {ip: "103.94.7.254", port: 53281}, {ip: "106.0.51.50", port: 17385}, {ip: "110.93.13.202", port: 34881}, {ip: "112.78.37.6", port: 54791}, {ip: "114.199.110.58", port: 55898}, {ip: "114.199.112.170", port: 23500}, {ip: "114.199.123.194", port: 8080}, {ip: "114.57.33.162", port: 46935}, {ip: "114.57.33.214", port: 8080}, {ip: "114.6.197.254", port: 8080}, {ip: "114.7.15.146", port: 8080}, {ip: "114.7.162.254", port: 53281}, {ip: "115.124.75.226", port: 53990}, {ip: "115.124.75.228", port: 3128}, {ip: "117.102.78.42", port: 8080}, {ip: "117.102.93.251", port: 8080}, {ip: "117.102.94.186", port: 8080}, {ip: "117.102.94.186", port: 80}, {ip: "117.103.2.249", port: 58276}, {ip: "117.54.13.174", port: 34190}, {ip: "117.74.124.129", port: 8088}, {ip: "118.97.100.83", port: 35220}, {ip: "118.97.191.162", port: 80}, {ip: "118.97.191.203", port: 8080}, {ip: "118.97.36.18", port: 8080}, {ip: "118.97.73.85", port: 53281}, {ip: "118.99.105.226", port: 8080}, {ip: "119.252.168.53", port: 53281}, {ip: "122.248.45.35", port: 53281}, {ip: "122.50.6.186", port: 8080}, {ip: "122.50.6.186", port: 80}, {ip: "123.231.226.114", port: 47562}, {ip: "123.255.202.83", port: 32523}, {ip: "124.158.164.195", port: 8080}, {ip: "124.81.99.30", port: 3128}, {ip: "137.59.162.10", port: 3128}, {ip: "139.0.29.20", port: 59532}, {ip: "139.255.123.194", port: 4550}, {ip: "139.255.16.171", port: 31773}, {ip: "139.255.17.2", port: 47421}, {ip: "139.255.19.162", port: 42371}, {ip: "139.255.7.81", port: 53281}, {ip: "139.255.91.115", port: 8080}, {ip: "139.255.92.26", port: 53281}, {ip: "158.140.181.140", port: 54041}, {ip: "160.202.40.20", port: 55655}, {ip: "175.103.42.147", port: 8080}, {ip: "180.178.98.198", port: 8080}, {ip: "180.250.101.146", port: 8080}, {ip: "182.23.107.212", port: 3128}, {ip: "182.23.2.101", port: 49833}, {ip: "182.23.7.226", port: 8080}, {ip: "182.253.209.203", port: 3128}, {ip: "183.91.66.210", port: 80}, {ip: "202.137.10.179", port: 57338}, {ip: "202.137.25.53", port: 3128}, {ip: "202.137.25.8", port: 8080}, {ip: "202.138.242.76", port: 4550}, {ip: "202.138.249.202", port: 43108}, {ip: "202.148.2.254", port: 8000}, {ip: "202.162.201.94", port: 53281}, {ip: "202.165.47.26", port: 8080}, {ip: "202.43.167.130", port: 8080}, {ip: "202.51.126.10", port: 53281}, {ip: "202.59.171.164", port: 58567}, {ip: "202.93.128.98", port: 3128}, {ip: "203.142.72.114", port: 808}, {ip: "203.153.117.65", port: 54144}, {ip: "203.189.89.1", port: 53281}, {ip: "203.77.239.18", port: 37002}, {ip: "203.99.123.25", port: 61502}, {ip: "220.247.168.163", port: 53281}, {ip: "220.247.173.154", port: 53281}, {ip: "220.247.174.206", port: 53445}, {ip: "222.124.131.211", port: 47343}, {ip: "222.124.173.146", port: 53281}, {ip: "222.124.2.131", port: 8080}, {ip: "222.124.2.186", port: 8080}, {ip: "222.124.215.187", port: 38913}, {ip: "222.124.221.179", port: 53281}, {ip: "223.25.101.242", port: 59504}, {ip: "223.25.97.62", port: 8080}, {ip: "223.25.99.38", port: 80}, {ip: "27.111.44.202", port: 80}, {ip: "27.111.47.3", port: 51144}, {ip: "36.37.124.234", port: 36179}, {ip: "36.37.124.235", port: 36179}, {ip: "36.37.81.135", port: 8080}, {ip: "36.37.89.98", port: 32323}, {ip: "36.66.217.179", port: 8080}, {ip: "36.66.98.6", port: 53281}, {ip: "36.67.143.183", port: 48746}, {ip: "36.67.206.187", port: 8080}, {ip: "36.67.32.87", port: 8080}, {ip: "36.67.93.220", port: 3128}, {ip: "36.67.93.220", port: 80}, {ip: "36.89.10.51", port: 34115}, {ip: "36.89.119.149", port: 8080}, {ip: "36.89.157.23", port: 37728}, {ip: "36.89.181.155", port: 60165}, {ip: "36.89.188.11", port: 39507}, {ip: "36.89.194.113", port: 37811}, {ip: "36.89.226.254", port: 8081}, {ip: "36.89.232.138", port: 23500}, {ip: "36.89.39.10", port: 3128}, {ip: "36.89.65.253", port: 60997}, {ip: "43.243.141.114", port: 8080}, {ip: "43.245.184.202", port: 41102}, {ip: "43.245.184.238", port: 80}, {ip: "66.96.233.225", port: 35053}, {ip: "66.96.237.253", port: 8080}], - "BD" => [{ip: "103.103.88.91", port: 8080}, {ip: "103.106.119.154", port: 8080}, {ip: "103.106.236.1", port: 8080}, {ip: "103.106.236.41", port: 8080}, {ip: "103.108.144.139", port: 53281}, {ip: "103.109.57.218", port: 8080}, {ip: "103.109.58.242", port: 8080}, {ip: "103.112.129.106", port: 31094}, {ip: "103.112.129.82", port: 53281}, {ip: "103.114.10.177", port: 8080}, {ip: "103.114.10.250", port: 8080}, {ip: "103.15.245.26", port: 8080}, {ip: "103.195.204.73", port: 21776}, {ip: "103.197.49.106", port: 49688}, {ip: "103.198.168.29", port: 21776}, {ip: "103.214.200.6", port: 59008}, {ip: "103.218.25.161", port: 8080}, {ip: "103.218.25.41", port: 8080}, {ip: "103.218.26.204", port: 8080}, {ip: "103.218.27.221", port: 8080}, {ip: "103.231.229.90", port: 53281}, {ip: "103.239.252.233", port: 8080}, {ip: "103.239.252.50", port: 8080}, {ip: "103.239.253.193", port: 8080}, {ip: "103.250.68.193", port: 51370}, {ip: "103.5.232.146", port: 8080}, {ip: "103.73.224.53", port: 23500}, {ip: "103.9.134.73", port: 65301}, {ip: "113.11.47.242", port: 40071}, {ip: "113.11.5.67", port: 40071}, {ip: "114.31.5.34", port: 52606}, {ip: "115.127.51.226", port: 42764}, {ip: "115.127.64.62", port: 39611}, {ip: "115.127.91.106", port: 8080}, {ip: "119.40.85.198", port: 36899}, {ip: "123.200.29.110", port: 23500}, {ip: "123.49.51.42", port: 55124}, {ip: "163.47.36.90", port: 3128}, {ip: "180.211.134.158", port: 23500}, {ip: "180.211.193.74", port: 40536}, {ip: "180.92.238.226", port: 53451}, {ip: "182.160.104.213", port: 8080}, {ip: "202.191.126.58", port: 23500}, {ip: "202.4.126.170", port: 8080}, {ip: "202.5.37.241", port: 33623}, {ip: "202.5.57.5", port: 61729}, {ip: "202.79.17.65", port: 60122}, {ip: "203.188.248.52", port: 23500}, {ip: "27.147.146.78", port: 52220}, {ip: "27.147.164.10", port: 52344}, {ip: "27.147.212.38", port: 53281}, {ip: "27.147.217.154", port: 43252}, {ip: "27.147.219.102", port: 49464}, {ip: "43.239.74.137", port: 8080}, {ip: "43.240.103.252", port: 8080}, {ip: "45.125.223.57", port: 8080}, {ip: "45.125.223.81", port: 8080}, {ip: "45.251.228.122", port: 41418}, {ip: "45.64.132.137", port: 8080}, {ip: "45.64.132.137", port: 80}, {ip: "61.247.186.137", port: 8080}], - "MX" => [{ip: "148.217.94.54", port: 3128}, {ip: "177.244.28.77", port: 53281}, {ip: "187.141.73.147", port: 53281}, {ip: "187.185.15.35", port: 53281}, {ip: "187.188.46.172", port: 53455}, {ip: "187.216.83.185", port: 8080}, {ip: "187.216.90.46", port: 53281}, {ip: "187.243.253.182", port: 33796}, {ip: "189.195.132.86", port: 43286}, {ip: "189.204.158.161", port: 8080}, {ip: "200.79.180.115", port: 8080}, {ip: "201.140.113.90", port: 37193}, {ip: "201.144.14.229", port: 53281}, {ip: "201.163.73.93", port: 53281}], - "PH" => [{ip: "103.86.187.242", port: 23500}, {ip: "122.54.101.69", port: 8080}, {ip: "122.54.65.150", port: 8080}, {ip: "125.5.20.134", port: 53281}, {ip: "146.88.77.51", port: 8080}, {ip: "182.18.200.92", port: 8080}, {ip: "219.90.87.91", port: 53281}, {ip: "58.69.12.210", port: 8080}], - "EG" => [{ip: "41.65.0.167", port: 8080}], - "VN" => [{ip: "1.55.240.156", port: 53281}, {ip: "101.99.23.136", port: 3128}, {ip: "103.15.51.160", port: 8080}, {ip: "113.161.128.169", port: 60427}, {ip: "113.161.161.143", port: 57967}, {ip: "113.161.173.10", port: 3128}, {ip: "113.161.35.108", port: 30028}, {ip: "113.164.79.177", port: 46281}, {ip: "113.190.235.50", port: 34619}, {ip: "115.78.160.247", port: 8080}, {ip: "117.2.155.29", port: 47228}, {ip: "117.2.17.26", port: 53281}, {ip: "117.2.22.41", port: 41973}, {ip: "117.4.145.16", port: 51487}, {ip: "118.69.219.185", port: 55184}, {ip: "118.69.61.212", port: 53281}, {ip: "118.70.116.227", port: 61651}, {ip: "118.70.219.124", port: 53281}, {ip: "221.121.12.238", port: 36077}, {ip: "27.2.7.59", port: 52148}], - "CD" => [{ip: "41.79.233.45", port: 8080}], - "TR" => [{ip: "151.80.65.175", port: 3128}, {ip: "176.235.186.242", port: 37043}, {ip: "178.250.92.18", port: 8080}, {ip: "185.203.170.92", port: 8080}, {ip: "185.203.170.94", port: 8080}, {ip: "185.203.170.95", port: 8080}, {ip: "185.51.36.152", port: 41258}, {ip: "195.137.223.50", port: 41336}, {ip: "195.155.98.70", port: 52598}, {ip: "212.156.146.22", port: 40080}, {ip: "213.14.31.122", port: 44621}, {ip: "31.145.137.139", port: 31871}, {ip: "31.145.138.129", port: 31871}, {ip: "31.145.138.146", port: 34159}, {ip: "31.145.187.172", port: 30636}, {ip: "78.188.4.124", port: 34514}, {ip: "88.248.23.216", port: 36426}, {ip: "93.182.72.36", port: 8080}, {ip: "95.0.194.241", port: 9090}], -} diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr new file mode 100644 index 00000000..1fef5f93 --- /dev/null +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -0,0 +1,317 @@ +@[Flags] +enum VideoBadges + LiveNow + Premium + ThreeD + FourK + New + EightK + VR180 + VR360 + ClosedCaptions +end + +struct SearchVideo + include DB::Serializable + + property title : String + property id : String + property author : String + property ucid : String + property published : Time + property views : Int64 + property description_html : String + property length_seconds : Int32 + property premiere_timestamp : Time? + property author_verified : Bool + property badges : VideoBadges + + def to_xml(auto_generated, query_params, xml : XML::Builder) + query_params["v"] = self.id + + xml.element("entry") do + xml.element("id") { xml.text "yt:video:#{self.id}" } + xml.element("yt:videoId") { xml.text self.id } + xml.element("yt:channelId") { xml.text self.ucid } + xml.element("title") { xml.text self.title } + xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") + + xml.element("author") do + if auto_generated + xml.element("name") { xml.text self.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } + else + xml.element("name") { xml.text author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } + end + end + + xml.element("content", type: "xhtml") do + xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do + xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do + 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) } + end + end + + xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } + + xml.element("media:group") do + 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) } + end + + xml.element("media:community") do + xml.element("media:statistics", views: self.views) + end + end + end + + def to_xml(auto_generated, query_params, _xml : Nil) + XML.build do |xml| + to_xml(auto_generated, query_params, xml) + end + end + + def to_json(locale : String?, json : JSON::Builder) + json.object do + json.field "type", "video" + json.field "title", self.title + json.field "videoId", self.id + + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + json.field "authorVerified", self.author_verified + + json.field "videoThumbnails" do + Invidious::JSONify::APIv1.thumbnails(json, self.id) + end + + json.field "description", 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 "published", self.published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) + json.field "lengthSeconds", self.length_seconds + json.field "liveNow", self.badges.live_now? + json.field "premium", self.badges.premium? + json.field "isUpcoming", self.upcoming? + + if self.premiere_timestamp + json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix + end + json.field "isNew", self.badges.new? + json.field "is4k", self.badges.four_k? + json.field "is8k", self.badges.eight_k? + json.field "isVr180", self.badges.vr180? + json.field "isVr360", self.badges.vr360? + json.field "is3d", self.badges.three_d? + json.field "hasCaptions", self.badges.closed_captions? + end + end + + # TODO: remove the locale and follow the crystal convention + def to_json(locale : String?, _json : Nil) + JSON.build do |json| + to_json(locale, json) + end + end + + def to_json(json : JSON::Builder) + to_json(nil, json) + end + + def upcoming? + premiere_timestamp ? true : false + end +end + +struct SearchPlaylistVideo + include DB::Serializable + + property title : String + property id : String + property length_seconds : Int32 +end + +struct SearchPlaylist + include DB::Serializable + + property title : String + property id : String + property author : String + property ucid : String + property video_count : Int32 + property videos : Array(SearchPlaylistVideo) + property thumbnail : String? + property author_verified : Bool + + def to_json(locale : String?, json : JSON::Builder) + json.object do + json.field "type", "playlist" + json.field "title", self.title + json.field "playlistId", self.id + json.field "playlistThumbnail", self.thumbnail + + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + + json.field "authorVerified", self.author_verified + + json.field "videoCount", self.video_count + json.field "videos" do + json.array do + self.videos.each do |video| + json.object do + json.field "title", video.title + json.field "videoId", video.id + json.field "lengthSeconds", video.length_seconds + + json.field "videoThumbnails" do + Invidious::JSONify::APIv1.thumbnails(json, video.id) + end + end + end + end + end + end + end + + # TODO: remove the locale and follow the crystal convention + def to_json(locale : String?, _json : Nil) + JSON.build do |json| + to_json(locale, json) + end + end + + def to_json(json : JSON::Builder) + to_json(nil, json) + end +end + +struct SearchChannel + include DB::Serializable + + property author : String + property ucid : String + property author_thumbnail : String + property subscriber_count : Int32 + property video_count : Int32 + property channel_handle : String? + property description_html : String + property auto_generated : Bool + property author_verified : Bool + + def to_json(locale : String?, json : JSON::Builder) + json.object do + json.field "type", "channel" + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + json.field "authorVerified", self.author_verified + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "autoGenerated", self.auto_generated + json.field "subCount", self.subscriber_count + json.field "videoCount", self.video_count + json.field "channelHandle", self.channel_handle + + json.field "description", html_to_content(self.description_html) + json.field "descriptionHtml", self.description_html + end + end + + # TODO: remove the locale and follow the crystal convention + def to_json(locale : String?, _json : Nil) + JSON.build do |json| + to_json(locale, json) + end + end + + def to_json(json : JSON::Builder) + to_json(nil, json) + end +end + +struct SearchHashtag + include DB::Serializable + + property title : String + property url : String + property video_count : Int64 + property channel_count : Int64 + + def to_json(locale : String?, json : JSON::Builder) + json.object do + json.field "type", "hashtag" + json.field "title", self.title + json.field "url", self.url + json.field "videoCount", self.video_count + json.field "channelCount", self.channel_count + end + end +end + +class Category + include DB::Serializable + + property title : String + property contents : Array(SearchItem) | Array(Video) + property url : String? + property description_html : String + property badges : Array(Tuple(String, String))? + + def to_json(locale : String?, json : JSON::Builder) + json.object do + json.field "type", "category" + json.field "title", self.title + json.field "contents" do + json.array do + self.contents.each do |item| + item.to_json(locale, json) + end + end + end + end + end + + # TODO: remove the locale and follow the crystal convention + def to_json(locale : String?, _json : Nil) + JSON.build do |json| + to_json(locale, json) + end + end + + def to_json(json : JSON::Builder) + to_json(nil, json) + end +end + +struct Continuation + getter token + + def initialize(@token : String) + end +end + +alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr new file mode 100644 index 00000000..6d198a42 --- /dev/null +++ b/src/invidious/helpers/sig_helper.cr @@ -0,0 +1,349 @@ +require "uri" +require "socket" +require "socket/tcp_socket" +require "socket/unix_socket" + +{% if flag?(:advanced_debug) %} + require "io/hexdump" +{% end %} + +private alias NetworkEndian = IO::ByteFormat::NetworkEndian + +module Invidious::SigHelper + enum UpdateStatus + Updated + UpdateNotRequired + Error + end + + # ------------------- + # Payload types + # ------------------- + + abstract struct Payload + end + + struct StringPayload < Payload + getter string : String + + def initialize(str : String) + raise Exception.new("SigHelper: String can't be empty") if str.empty? + @string = str + end + + def self.from_bytes(slice : Bytes) + size = IO::ByteFormat::NetworkEndian.decode(UInt16, slice) + if size == 0 # Error code + raise Exception.new("SigHelper: Server encountered an error") + end + + if (slice.bytesize - 2) != size + raise Exception.new("SigHelper: String size mismatch") + end + + if str = String.new(slice[2..]) + return self.new(str) + else + raise Exception.new("SigHelper: Can't read string from socket") + end + end + + def to_io(io) + # `.to_u16` raises if there is an overflow during the conversion + io.write_bytes(@string.bytesize.to_u16, NetworkEndian) + io.write(@string.to_slice) + end + end + + private enum Opcode + FORCE_UPDATE = 0 + DECRYPT_N_SIGNATURE = 1 + DECRYPT_SIGNATURE = 2 + GET_SIGNATURE_TIMESTAMP = 3 + GET_PLAYER_STATUS = 4 + PLAYER_UPDATE_TIMESTAMP = 5 + end + + private record Request, + opcode : Opcode, + payload : Payload? + + # ---------------------- + # High-level functions + # ---------------------- + + class Client + @mux : Multiplexor + + def initialize(uri_or_path) + @mux = Multiplexor.new(uri_or_path) + end + + # Forces the server to re-fetch the YouTube player, and extract the necessary + # components from it (nsig function code, sig function code, signature timestamp). + def force_update : UpdateStatus + request = Request.new(Opcode::FORCE_UPDATE, nil) + + value = send_request(request) do |bytes| + IO::ByteFormat::NetworkEndian.decode(UInt16, bytes) + end + + case value + when 0x0000 then return UpdateStatus::Error + when 0xFFFF then return UpdateStatus::UpdateNotRequired + when 0xF44F then return UpdateStatus::Updated + else + code = value.nil? ? "nil" : value.to_s(base: 16) + raise Exception.new("SigHelper: Invalid status code received #{code}") + end + end + + # Decrypt a provided n signature using the server's current nsig function + # code, and return the result (or an error). + def decrypt_n_param(n : String) : String? + request = Request.new(Opcode::DECRYPT_N_SIGNATURE, StringPayload.new(n)) + + n_dec = self.send_request(request) do |bytes| + StringPayload.from_bytes(bytes).string + end + + return n_dec + end + + # Decrypt a provided s signature using the server's current sig function + # code, and return the result (or an error). + def decrypt_sig(sig : String) : String? + request = Request.new(Opcode::DECRYPT_SIGNATURE, StringPayload.new(sig)) + + sig_dec = self.send_request(request) do |bytes| + StringPayload.from_bytes(bytes).string + end + + return sig_dec + end + + # Return the signature timestamp from the server's current player + def get_signature_timestamp : UInt64? + request = Request.new(Opcode::GET_SIGNATURE_TIMESTAMP, nil) + + return self.send_request(request) do |bytes| + IO::ByteFormat::NetworkEndian.decode(UInt64, bytes) + end + end + + # Return the current player's version + def get_player : UInt32? + request = Request.new(Opcode::GET_PLAYER_STATUS, nil) + + return self.send_request(request) do |bytes| + has_player = (bytes[0] == 0xFF) + player_version = IO::ByteFormat::NetworkEndian.decode(UInt32, bytes[1..4]) + has_player ? player_version : nil + end + end + + # Return when the player was last updated + def get_player_timestamp : UInt64? + request = Request.new(Opcode::PLAYER_UPDATE_TIMESTAMP, nil) + + return self.send_request(request) do |bytes| + IO::ByteFormat::NetworkEndian.decode(UInt64, bytes) + end + end + + private def send_request(request : Request, &) + channel = @mux.send(request) + slice = channel.receive + return yield slice + rescue ex + LOGGER.debug("SigHelper: Error when sending a request") + LOGGER.trace(ex.inspect_with_backtrace) + return nil + end + end + + # --------------------- + # Low level functions + # --------------------- + + class Multiplexor + alias TransactionID = UInt32 + record Transaction, channel = ::Channel(Bytes).new + + @prng = Random.new + @mutex = Mutex.new + @queue = {} of TransactionID => Transaction + + @conn : Connection + @uri_or_path : String + + def initialize(@uri_or_path) + @conn = Connection.new(uri_or_path) + listen + end + + def listen : Nil + raise "Socket is closed" if @conn.closed? + + LOGGER.debug("SigHelper: Multiplexor listening") + + spawn do + loop do + begin + receive_data + rescue ex + LOGGER.info("SigHelper: Connection to helper died with '#{ex.message}' trying to reconnect...") + # We close the socket because for some reason is not closed. + @conn.close + loop do + begin + @conn = Connection.new(@uri_or_path) + LOGGER.info("SigHelper: Reconnected to SigHelper!") + rescue ex + LOGGER.debug("SigHelper: Reconnection to helper unsuccessful with error '#{ex.message}'. Retrying") + sleep 500.milliseconds + next + end + break if !@conn.closed? + end + end + Fiber.yield + end + end + end + + def send(request : Request) + transaction = Transaction.new + transaction_id = @prng.rand(TransactionID) + + # Add transaction to queue + @mutex.synchronize do + # On a 32-bits random integer, this should never happen. Though, just in case, ... + if @queue[transaction_id]? + raise Exception.new("SigHelper: Duplicate transaction ID! You got a shiny pokemon!") + end + + @queue[transaction_id] = transaction + end + + write_packet(transaction_id, request) + + return transaction.channel + end + + def receive_data + transaction_id, slice = read_packet + + @mutex.synchronize do + if transaction = @queue.delete(transaction_id) + # Remove transaction from queue and send data to the channel + transaction.channel.send(slice) + LOGGER.trace("SigHelper: Transaction unqueued and data sent to channel") + else + raise Exception.new("SigHelper: Received transaction was not in queue") + end + end + end + + # Read a single packet from the socket + private def read_packet : {TransactionID, Bytes} + # Header + transaction_id = @conn.read_bytes(UInt32, NetworkEndian) + length = @conn.read_bytes(UInt32, NetworkEndian) + + LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} / length #{length}") + + if length > 67_000 + raise Exception.new("SigHelper: Packet longer than expected (#{length})") + end + + # Payload + slice = Bytes.new(length) + @conn.read(slice) if length > 0 + + LOGGER.trace("SigHelper: payload = #{slice}") + LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} - Done") + + return transaction_id, slice + end + + # Write a single packet to the socket + private def write_packet(transaction_id : TransactionID, request : Request) + LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} / opcode #{request.opcode}") + + io = IO::Memory.new(1024) + io.write_bytes(request.opcode.to_u8, NetworkEndian) + io.write_bytes(transaction_id, NetworkEndian) + + if payload = request.payload + payload.to_io(io) + end + + @conn.send(io) + @conn.flush + + LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} - Done") + end + end + + class Connection + @socket : UNIXSocket | TCPSocket + + {% if flag?(:advanced_debug) %} + @io : IO::Hexdump + {% end %} + + def initialize(host_or_path : String) + case host_or_path + when .starts_with?('/') + # Make sure that the file exists + if File.exists?(host_or_path) + @socket = UNIXSocket.new(host_or_path) + else + raise Exception.new("SigHelper: '#{host_or_path}' no such file") + end + when .starts_with?("tcp://") + uri = URI.parse(host_or_path) + @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) + else + uri = URI.parse("tcp://#{host_or_path}") + @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) + end + LOGGER.info("SigHelper: Using helper at '#{host_or_path}'") + + {% if flag?(:advanced_debug) %} + @io = IO::Hexdump.new(@socket, output: STDERR, read: true, write: true) + {% end %} + + @socket.sync = false + @socket.blocking = false + end + + def closed? : Bool + return @socket.closed? + end + + def close : Nil + @socket.close if !@socket.closed? + end + + def flush(*args, **options) + @socket.flush(*args, **options) + end + + def send(*args, **options) + @socket.send(*args, **options) + end + + # Wrap IO functions, with added debug tooling if needed + {% for function in %w(read read_bytes write write_bytes) %} + def {{function.id}}(*args, **options) + {% if flag?(:advanced_debug) %} + @io.{{function.id}}(*args, **options) + {% else %} + @socket.{{function.id}}(*args, **options) + {% end %} + end + {% end %} + end +end diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index d8b1de65..82a28fc0 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -1,73 +1,53 @@ -alias SigProc = Proc(Array(String), Int32, Array(String)) +require "http/params" +require "./sig_helper" -struct DecryptFunction - @decrypt_function = [] of {SigProc, Int32} - @decrypt_time = Time.monotonic +class Invidious::DecryptFunction + @last_update : Time = Time.utc - 42.days - def initialize(@use_polling = true) + def initialize(uri_or_path) + @client = SigHelper::Client.new(uri_or_path) + self.check_update end - def update_decrypt_function - @decrypt_function = fetch_decrypt_function - end - - private def fetch_decrypt_function(id = "CvFH_6DNRCY") - document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body - url = document.match(/src="(?<url>\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"] - player = YT_POOL.client &.get(url).body - - function_name = player.match(/^(?<name>[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"] - function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?<body>[^}]+)}/m).not_nil!["body"] - function_body = function_body.split(";")[1..-2] - - var_name = function_body[0][0, 2] - var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?<body>(.*?))};/).not_nil!["body"] - - operations = {} of String => SigProc - var_body.split("},").each do |operation| - op_name = operation.match(/^[^:]+/).not_nil![0] - op_body = operation.match(/\{[^}]+/).not_nil![0] - - case op_body - when "{a.reverse()" - operations[op_name] = ->(a : Array(String), b : Int32) { a.reverse } - when "{a.splice(0,b)" - operations[op_name] = ->(a : Array(String), b : Int32) { a.delete_at(0..(b - 1)); a } - else - operations[op_name] = ->(a : Array(String), b : Int32) { c = a[0]; a[0] = a[b % a.size]; a[b % a.size] = c; a } - end - end + def check_update + # If we have updated in the last 5 minutes, do nothing + return if (Time.utc - @last_update) < 5.minutes - decrypt_function = [] of {SigProc, Int32} - function_body.each do |function| - function = function.lchop(var_name).delete("[].") + # Get the amount of time elapsed since when the player was updated, in the + # event where multiple invidious processes are run in parallel. + update_time_elapsed = (@client.get_player_timestamp || 301).seconds - op_name = function.match(/[^\(]+/).not_nil![0] - value = function.match(/\(\w,(?<value>[\d]+)\)/).not_nil!["value"].to_i - - decrypt_function << {operations[op_name], value} + if update_time_elapsed > 5.minutes + LOGGER.debug("Signature: Player might be outdated, updating") + @client.force_update + @last_update = Time.utc end - - return decrypt_function end - def decrypt_signature(fmt : Hash(String, JSON::Any)) - return "" if !fmt["s"]? || !fmt["sp"]? - - sp = fmt["sp"].as_s - sig = fmt["s"].as_s.split("") - if !@use_polling - now = Time.monotonic - if now - @decrypt_time > 60.seconds || @decrypt_function.size == 0 - @decrypt_function = fetch_decrypt_function - @decrypt_time = Time.monotonic - end - end + def decrypt_nsig(n : String) : String? + self.check_update + return @client.decrypt_n_param(n) + rescue ex + LOGGER.debug(ex.message || "Signature: Unknown error") + LOGGER.trace(ex.inspect_with_backtrace) + return nil + end - @decrypt_function.each do |proc, value| - sig = proc.call(sig, value) - end + def decrypt_signature(str : String) : String? + self.check_update + return @client.decrypt_sig(str) + rescue ex + LOGGER.debug(ex.message || "Signature: Unknown error") + LOGGER.trace(ex.inspect_with_backtrace) + return nil + end - return "&#{sp}=#{sig.join("")}" + def get_sts : UInt64? + self.check_update + return @client.get_signature_timestamp + rescue ex + LOGGER.debug(ex.message || "Signature: Unknown error") + LOGGER.trace(ex.inspect_with_backtrace) + return nil end end diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr index a09ce90b..a44988cd 100644 --- a/src/invidious/helpers/tokens.cr +++ b/src/invidious/helpers/tokens.cr @@ -1,8 +1,8 @@ require "crypto/subtle" -def generate_token(email, scopes, expire, key, db) +def generate_token(email, scopes, expire, key) session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}" - PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.utc) + Invidious::Database::SessionIDs.insert(session, email) token = { "session" => session, @@ -19,7 +19,7 @@ def generate_token(email, scopes, expire, key, db) return token.to_json end -def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = false) +def generate_response(session, scopes, key, expire = 6.hours, use_nonce = false) expire = Time.utc + expire token = { @@ -30,7 +30,7 @@ def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = fa if use_nonce nonce = Random::Secure.hex(16) - db.exec("INSERT INTO nonces VALUES ($1, $2) ON CONFLICT DO NOTHING", nonce, expire) + Invidious::Database::Nonces.insert(nonce, expire) token["nonce"] = nonce end @@ -42,11 +42,14 @@ end def sign_token(key, hash) string_to_sign = [] of String + # TODO: figure out which "key" variable is used + # Ameba reports a warning for "Lint/ShadowingOuterLocalVar" on this + # variable, but it's preferable to not touch that (works fine atm). hash.each do |key, value| next if key == "signature" if value.is_a?(JSON::Any) && value.as_a? - value = value.as_a.map { |i| i.as_s } + value = value.as_a.map(&.as_s) end case value @@ -63,7 +66,7 @@ def sign_token(key, hash) return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip end -def validate_request(token, session, request, key, db, locale = nil) +def validate_request(token, session, request, key, locale = nil) case token when String token = JSON.parse(URI.decode_www_form(token)).as_h @@ -82,7 +85,7 @@ def validate_request(token, session, request, key, db, locale = nil) raise InfoException.new("Erroneous token") end - scopes = token["scopes"].as_a.map { |v| v.as_s } + scopes = token["scopes"].as_a.map(&.as_s) scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}" if !scopes_include_scope(scopes, scope) raise InfoException.new("Invalid scope") @@ -92,9 +95,9 @@ def validate_request(token, session, request, key, db, locale = nil) raise InfoException.new("Invalid signature") end - if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time})) + if token["nonce"]? && (nonce = Invidious::Database::Nonces.select(token["nonce"].as_s)) if nonce[1] > Time.utc - db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.utc(1990, 1, 1), nonce[0]) + Invidious::Database::Nonces.update_set_expired(nonce[0]) else raise InfoException.new("Erroneous token") end @@ -105,11 +108,11 @@ end def scope_includes_scope(scope, subset) methods, endpoint = scope.split(":") - methods = methods.split(";").map { |method| method.upcase }.reject { |method| method.empty? }.sort + methods = methods.split(";").map(&.upcase).reject(&.empty?).sort! endpoint = endpoint.downcase subset_methods, subset_endpoint = subset.split(":") - subset_methods = subset_methods.split(";").map { |method| method.upcase }.sort + subset_methods = subset_methods.split(";").map(&.upcase).sort! subset_endpoint = subset_endpoint.downcase if methods.empty? diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 10d4e6b6..4d9bb28d 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -1,70 +1,3 @@ -require "lsquic" -require "pool/connection" - -def add_yt_headers(request) - request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36" - request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" - request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" - request.headers["accept-language"] ||= "en-us,en;q=0.5" - return if request.resource.starts_with? "/sorry/index" - request.headers["x-youtube-client-name"] ||= "1" - request.headers["x-youtube-client-version"] ||= "2.20200609" - # Preserve original cookies and add new YT consent cookie for EU servers - request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" - if !CONFIG.cookies.empty? - request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" - end -end - -struct YoutubeConnectionPool - property! url : URI - property! capacity : Int32 - property! timeout : Float64 - property pool : ConnectionPool(QUIC::Client | HTTP::Client) - - def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true) - @url = url - @pool = build_pool(use_quic) - end - - def client(region = nil, &block) - if region - conn = make_client(url, region) - response = yield conn - else - conn = pool.checkout - begin - response = yield conn - rescue ex - conn.close - conn = QUIC::Client.new(url) - conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET - conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC - conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" - response = yield conn - ensure - pool.checkin(conn) - end - end - - response - end - - private def build_pool(use_quic) - ConnectionPool(QUIC::Client | HTTP::Client).new(capacity: capacity, timeout: timeout) do - if use_quic - conn = QUIC::Client.new(url) - else - conn = HTTP::Client.new(url) - end - conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET - conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC - conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" - conn - end - end -end - # See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html def ci_lower_bound(pos, n) if n == 0 @@ -85,42 +18,18 @@ def elapsed_text(elapsed) "#{(millis * 1000).round(2)}µs" end -def make_client(url : URI, region = nil) - # TODO: Migrate any applicable endpoints to QUIC - client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure) - client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC - client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" - client.read_timeout = 10.seconds - client.connect_timeout = 10.seconds - - if region - PROXY_LIST[region]?.try &.sample(40).each do |proxy| - begin - proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) - client.set_proxy(proxy) - break - rescue ex - end - end - end - - return client -end - -def make_client(url : URI, region = nil, &block) - client = make_client(url, region) - begin - yield client - ensure - client.close - end -end - def decode_length_seconds(string) - length_seconds = string.gsub(/[^0-9:]/, "").split(":").map &.to_i + length_seconds = string.gsub(/[^0-9:]/, "") + return 0_i32 if length_seconds.empty? + + length_seconds = length_seconds.split(":").map { |x| x.to_i? || 0 } length_seconds = [0] * (3 - length_seconds.size) + length_seconds - length_seconds = Time::Span.new hours: length_seconds[0], minutes: length_seconds[1], seconds: length_seconds[2] - length_seconds = length_seconds.total_seconds.to_i + + length_seconds = Time::Span.new( + hours: length_seconds[0], + minutes: length_seconds[1], + seconds: length_seconds[2] + ).total_seconds.to_i32 return length_seconds end @@ -142,6 +51,24 @@ def recode_length_seconds(time) end end +def decode_interval(string : String) : Time::Span + raw_minutes = string.try &.to_i32? + + if !raw_minutes + hours = /(?<hours>\d+)h/.match(string).try &.["hours"].try &.to_i32 + hours ||= 0 + + minutes = /(?<minutes>\d+)m(?!s)/.match(string).try &.["minutes"].try &.to_i32 + minutes ||= 0 + + time = Time::Span.new(hours: hours, minutes: minutes) + else + time = Time::Span.new(minutes: raw_minutes) + end + + return time +end + def decode_time(string) time = string.try &.to_f? @@ -184,24 +111,27 @@ def decode_date(string : String) else nil # Continue end - # String matches format "20 hours ago", "4 months ago"... - date = string.split(" ")[-3, 3] - delta = date[0].to_i + # String matches format "20 hours ago", "4 months ago", "20s ago", "15min ago"... + match = string.match(/(?<count>\d+) ?(?<span>[smhdwy]\w*) ago/) - case date[1] - when .includes? "second" + raise "Could not parse #{string}" if match.nil? + + delta = match["count"].to_i + + case match["span"] + when .starts_with? "s" # second(s) delta = delta.seconds - when .includes? "minute" + when .starts_with? "mi" # minute(s) delta = delta.minutes - when .includes? "hour" + when .starts_with? "h" # hour(s) delta = delta.hours - when .includes? "day" + when .starts_with? "d" # day(s) delta = delta.days - when .includes? "week" + when .starts_with? "w" # week(s) delta = delta.weeks - when .includes? "month" + when .starts_with? "mo" # month(s) delta = delta.months - when .includes? "year" + when .starts_with? "y" # year(s) delta = delta.years else raise "Could not parse #{string}" @@ -214,51 +144,47 @@ def recode_date(time : Time, locale) span = Time.utc - time if span.total_days > 365.0 - span = translate(locale, "`x` years", (span.total_days.to_i // 365).to_s) + return translate_count(locale, "generic_count_years", span.total_days.to_i // 365) elsif span.total_days > 30.0 - span = translate(locale, "`x` months", (span.total_days.to_i // 30).to_s) + return translate_count(locale, "generic_count_months", span.total_days.to_i // 30) elsif span.total_days > 7.0 - span = translate(locale, "`x` weeks", (span.total_days.to_i // 7).to_s) + return translate_count(locale, "generic_count_weeks", span.total_days.to_i // 7) elsif span.total_hours > 24.0 - span = translate(locale, "`x` days", (span.total_days.to_i).to_s) + return translate_count(locale, "generic_count_days", span.total_days.to_i) elsif span.total_minutes > 60.0 - span = translate(locale, "`x` hours", (span.total_hours.to_i).to_s) + return translate_count(locale, "generic_count_hours", span.total_hours.to_i) elsif span.total_seconds > 60.0 - span = translate(locale, "`x` minutes", (span.total_minutes.to_i).to_s) + return translate_count(locale, "generic_count_minutes", span.total_minutes.to_i) else - span = translate(locale, "`x` seconds", (span.total_seconds.to_i).to_s) + return translate_count(locale, "generic_count_seconds", span.total_seconds.to_i) end - - return span end def number_with_separator(number) number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse end -def short_text_to_number(short_text : String) : Int32 - case short_text - when .ends_with? "M" - number = short_text.rstrip(" mM").to_f - number *= 1000000 - when .ends_with? "K" - number = short_text.rstrip(" kK").to_f - number *= 1000 - else - number = short_text.rstrip(" ") - end +def short_text_to_number(short_text : String) : Int64 + matches = /(?<number>\d+(\.\d+)?)\s?(?<suffix>[mMkKbB]?)/.match(short_text) + number = matches.try &.["number"].to_f || 0.0 - number = number.to_i + case matches.try &.["suffix"].downcase + when "k" then number *= 1_000 + when "m" then number *= 1_000_000 + when "b" then number *= 1_000_000_000 + end - return number + return number.to_i64 +rescue ex + return 0_i64 end def number_to_short_text(number) - seperated = number_with_separator(number).gsub(",", ".").split("") - text = seperated.first(2).join + separated = number_with_separator(number).gsub(",", ".").split("") + text = separated.first(2).join - if seperated[2]? && seperated[2] != "." - text += seperated[2] + if separated[2]? && separated[2] != "." + text += separated[2] end text = text.rchop(".0") @@ -298,7 +224,7 @@ def make_host_url(kemal_config) # Add if non-standard port if port != 80 && port != 443 - port = ":#{kemal_config.port}" + port = ":#{port}" else port = "" end @@ -336,7 +262,7 @@ def get_referer(env, fallback = "/", unroll = true) end referer = referer.request_target - referer = "/" + referer.gsub(/[^\/?@&%=\-_.0-9a-zA-Z]/, "").lstrip("/\\") + referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,*0-9a-zA-Z]/, "").lstrip("/\\") if referer == env.request.path referer = fallback @@ -385,8 +311,8 @@ def parse_range(range) end ranges = range.lchop("bytes=").split(',') - ranges.each do |range| - start_range, end_range = range.split('-') + ranges.each do |r| + start_range, end_range = r.split('-') start_range = start_range.to_i64? || 0_i64 end_range = end_range.to_i64? @@ -397,15 +323,63 @@ def parse_range(range) return 0_i64, nil end -def convert_theme(theme) - case theme - when "true" - "dark" - when "false" - "light" - when "", nil - nil - else - theme +def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = "…") : String + str = uri.to_s.sub(/^https?:\/\//, "") + if str.size > max_length + str = "#{str[0, max_length]}#{suffix}" + end + return str +end + +# Get the html link from a NavigationEndpoint or an innertubeCommand +def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String) + if url = endpoint.dig?("urlEndpoint", "url").try &.as_s + url = URI.parse(url) + displayed_url = text + + if url.host == "youtu.be" + url = "/watch?v=#{url.request_target.lstrip('/')}" + elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") + if url.path == "/redirect" + # Sometimes, links can be corrupted (why?) so make sure to fallback + # nicely. See https://github.com/iv-org/invidious/issues/2682 + url = url.query_params["q"]? || "" + displayed_url = url + else + url = url.request_target + displayed_url = "youtube.com#{url}" + end + end + + text = %(<a href="#{url}">#{reduce_uri(displayed_url)}</a>) + elsif watch_endpoint = endpoint.dig?("watchEndpoint") + start_time = watch_endpoint["startTimeSeconds"]?.try &.as_i + link_video_id = watch_endpoint["videoId"].as_s + + url = "/watch?v=#{link_video_id}" + url += "&t=#{start_time}" if !start_time.nil? + + # If the current video ID (passed through from the caller function) + # is the same as the video ID in the link, add HTML attributes for + # the JS handler function that bypasses page reload. + # + # See: https://github.com/iv-org/invidious/issues/3063 + if link_video_id == video_id + start_time ||= 0 + text = %(<a href="#{url}" data-onclick="jump_to_time" data-jump-time="#{start_time}">#{reduce_uri(text)}</a>) + else + text = %(<a href="#{url}">#{text}</a>) + end + elsif url = endpoint.dig?("commandMetadata", "webCommandMetadata", "url").try &.as_s + if text.starts_with?(/\s?[@#]/) + # Handle "pings" in comments and hasthags differently + # See: + # - https://github.com/iv-org/invidious/issues/3038 + # - https://github.com/iv-org/invidious/issues/3062 + text = %(<a href="#{url}">#{text}</a>) + else + text = %(<a href="#{url}">#{reduce_uri(text)}</a>) + end end + return text end diff --git a/src/invidious/helpers/webvtt.cr b/src/invidious/helpers/webvtt.cr new file mode 100644 index 00000000..260d250f --- /dev/null +++ b/src/invidious/helpers/webvtt.cr @@ -0,0 +1,81 @@ +# Namespace for logic relating to generating WebVTT files +# +# Probably not compliant to WebVTT's specs but it is enough for Invidious. +module WebVTT + # A WebVTT builder generates WebVTT files + private class Builder + # See https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API#cue_payload + private ESCAPE_SUBSTITUTIONS = { + '&' => "&", + '<' => "<", + '>' => ">", + '\u200E' => "‎", + '\u200F' => "‏", + '\u00A0' => " ", + } + + def initialize(@io : IO) + end + + # Writes an vtt cue with the specified time stamp and contents + def cue(start_time : Time::Span, end_time : Time::Span, text : String) + timestamp(start_time, end_time) + @io << self.escape(text) + @io << "\n\n" + end + + private def timestamp(start_time : Time::Span, end_time : Time::Span) + timestamp_component(start_time) + @io << " --> " + timestamp_component(end_time) + + @io << '\n' + end + + private def timestamp_component(timestamp : Time::Span) + @io << timestamp.hours.to_s.rjust(2, '0') + @io << ':' << timestamp.minutes.to_s.rjust(2, '0') + @io << ':' << timestamp.seconds.to_s.rjust(2, '0') + @io << '.' << timestamp.milliseconds.to_s.rjust(3, '0') + end + + private def escape(text : String) : String + return text.gsub(ESCAPE_SUBSTITUTIONS) + end + + def document(setting_fields : Hash(String, String)? = nil, &) + @io << "WEBVTT\n" + + if setting_fields + setting_fields.each do |name, value| + @io << name << ": " << value << '\n' + end + end + + @io << '\n' + + yield + end + end + + # Returns the resulting `String` of writing WebVTT to the yielded `WebVTT::Builder` + # + # ``` + # string = WebVTT.build do |vtt| + # vtt.cue(Time::Span.new(seconds: 1), Time::Span.new(seconds: 2), "Line 1") + # vtt.cue(Time::Span.new(seconds: 2), Time::Span.new(seconds: 3), "Line 2") + # end + # + # string # => "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nLine 1\n\n00:00:02.000 --> 00:00:03.000\nLine 2\n\n" + # ``` + # + # Accepts an optional settings fields hash to add settings attribute to the resulting vtt file. + def self.build(setting_fields : Hash(String, String)? = nil, &) + String.build do |str| + builder = Builder.new(str) + builder.document(setting_fields) do + yield builder + end + end + end +end diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr deleted file mode 100644 index 30413532..00000000 --- a/src/invidious/helpers/youtube_api.cr +++ /dev/null @@ -1,31 +0,0 @@ -# -# This file contains youtube API wrappers -# - -# Hard-coded constants required by the API -HARDCODED_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" -HARDCODED_CLIENT_VERS = "2.20210318.08.00" - -def request_youtube_api_browse(continuation) - # JSON Request data, required by the API - data = { - "context": { - "client": { - "hl": "en", - "gl": "US", - "clientName": "WEB", - "clientVersion": HARDCODED_CLIENT_VERS, - }, - }, - "continuation": continuation, - } - - # Send the POST request and return result - response = YT_POOL.client &.post( - "/youtubei/v1/browse?key=#{HARDCODED_API_KEY}", - headers: HTTP::Headers{"content-type" => "application/json"}, - body: data.to_json - ) - - return response.body -end diff --git a/src/invidious/http_server/utils.cr b/src/invidious/http_server/utils.cr new file mode 100644 index 00000000..623a9177 --- /dev/null +++ b/src/invidious/http_server/utils.cr @@ -0,0 +1,41 @@ +require "uri" + +module Invidious::HttpServer + module Utils + extend self + + def proxy_video_url(raw_url : String, *, region : String? = nil, absolute : Bool = false) + url = URI.parse(raw_url) + + # Add some URL parameters + params = url.query_params + params["host"] = url.host.not_nil! # Should never be nil, in theory + params["region"] = region if !region.nil? + url.query_params = params + + if absolute + return "#{HOST_URL}#{url.request_target}" + else + return url.request_target + end + end + + def add_params_to_url(url : String | URI, params : URI::Params) : URI + url = URI.parse(url) if url.is_a?(String) + + url_query = url.query || "" + + # Append the parameters + url.query = String.build do |str| + if !url_query.empty? + str << url_query + str << '&' + end + + str << params + end + + return url + end + end +end diff --git a/src/invidious/jobs.cr b/src/invidious/jobs.cr index ec0cad64..b6b673f7 100644 --- a/src/invidious/jobs.cr +++ b/src/invidious/jobs.cr @@ -1,12 +1,39 @@ module Invidious::Jobs JOBS = [] of BaseJob + # Automatically generate a structure that wraps the various + # jobs' configs, so that the following YAML config can be used: + # + # jobs: + # job_name: + # enabled: true + # some_property: "value" + # + macro finished + struct JobsConfig + include YAML::Serializable + + {% for sc in BaseJob.subclasses %} + # Voodoo macro to transform `Some::Module::CustomJob` to `custom` + {% class_name = sc.id.split("::").last.id.gsub(/Job$/, "").underscore %} + + getter {{ class_name }} = {{ sc.name }}::Config.new + {% end %} + + def initialize + end + end + end + def self.register(job : BaseJob) JOBS << job end def self.start_all JOBS.each do |job| + # Don't run the main rountine if the job is disabled by config + next if job.disabled? + spawn { job.begin } end end diff --git a/src/invidious/jobs/base_job.cr b/src/invidious/jobs/base_job.cr index 47e75864..f90f0bfe 100644 --- a/src/invidious/jobs/base_job.cr +++ b/src/invidious/jobs/base_job.cr @@ -1,3 +1,33 @@ abstract class Invidious::Jobs::BaseJob abstract def begin + + # When this base job class is inherited, make sure to define + # a basic "Config" structure, that contains the "enable" property, + # and to create the associated instance property. + # + macro inherited + macro finished + # This config structure can be expanded as required. + struct Config + include YAML::Serializable + + property enable = true + + def initialize + end + end + + property cfg = Config.new + + # Return true if job is enabled by config + protected def enabled? : Bool + return (@cfg.enable == true) + end + + # Return true if job is disabled by config + protected def disabled? : Bool + return (@cfg.enable == false) + end + end + end end diff --git a/src/invidious/jobs/bypass_captcha_job.cr b/src/invidious/jobs/bypass_captcha_job.cr deleted file mode 100644 index 4269e123..00000000 --- a/src/invidious/jobs/bypass_captcha_job.cr +++ /dev/null @@ -1,131 +0,0 @@ -class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob - def begin - loop do - begin - {"/watch?v=jNQXAC9IVRw&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: "UC4QobU6STFB0P71PMvOGN5A")}.each do |path| - response = YT_POOL.client &.get(path) - if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") - html = XML.parse_html(response.body) - form = html.xpath_node(%(//form[@action="/das_captcha"])).not_nil! - site_key = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-sitekey"] - s_value = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-s"] - - inputs = {} of String => String - form.xpath_nodes(%(.//input[@name])).map do |node| - inputs[node["name"]] = node["value"] - end - - headers = response.cookies.add_request_headers(HTTP::Headers.new) - - response = JSON.parse(HTTP::Client.post(CONFIG.captcha_api_url + "/createTask", - headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { - "clientKey" => CONFIG.captcha_key, - "task" => { - "type" => "NoCaptchaTaskProxyless", - "websiteURL" => "https://www.youtube.com#{path}", - "websiteKey" => site_key, - "recaptchaDataSValue" => s_value, - }, - }.to_json).body) - - raise response["error"].as_s if response["error"]? - task_id = response["taskId"].as_i - - loop do - sleep 10.seconds - - response = JSON.parse(HTTP::Client.post(CONFIG.captcha_api_url + "/getTaskResult", - headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { - "clientKey" => CONFIG.captcha_key, - "taskId" => task_id, - }.to_json).body) - - if response["status"]?.try &.== "ready" - break - elsif response["errorId"]?.try &.as_i != 0 - raise response["errorDescription"].as_s - end - end - - inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s - headers["Cookies"] = response["solution"]["cookies"].as_h?.try &.map { |k, v| "#{k}=#{v}" }.join("; ") || "" - response = YT_POOL.client &.post("/das_captcha", headers, form: inputs) - - response.cookies - .select { |cookie| cookie.name != "PREF" } - .each { |cookie| CONFIG.cookies << cookie } - - # Persist cookies between runs - File.write("config/config.yml", CONFIG.to_yaml) - elsif response.headers["Location"]?.try &.includes?("/sorry/index") - location = response.headers["Location"].try { |u| URI.parse(u) } - headers = HTTP::Headers{":authority" => location.host.not_nil!} - response = YT_POOL.client &.get(location.request_target, headers) - - html = XML.parse_html(response.body) - form = html.xpath_node(%(//form[@action="index"])).not_nil! - site_key = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-sitekey"] - s_value = form.xpath_node(%(.//div[@id="recaptcha"])).try &.["data-s"] - - inputs = {} of String => String - form.xpath_nodes(%(.//input[@name])).map do |node| - inputs[node["name"]] = node["value"] - end - - captcha_client = HTTPClient.new(URI.parse(CONFIG.captcha_api_url)) - captcha_client.family = CONFIG.force_resolve || Socket::Family::INET - response = JSON.parse(captcha_client.post("/createTask", - headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { - "clientKey" => CONFIG.captcha_key, - "task" => { - "type" => "NoCaptchaTaskProxyless", - "websiteURL" => location.to_s, - "websiteKey" => site_key, - "recaptchaDataSValue" => s_value, - }, - }.to_json).body) - - captcha_client.close - - raise response["error"].as_s if response["error"]? - task_id = response["taskId"].as_i - - loop do - sleep 10.seconds - - response = JSON.parse(captcha_client.post("/getTaskResult", - headers: HTTP::Headers{"Content-Type" => "application/json"}, body: { - "clientKey" => CONFIG.captcha_key, - "taskId" => task_id, - }.to_json).body) - - if response["status"]?.try &.== "ready" - break - elsif response["errorId"]?.try &.as_i != 0 - raise response["errorDescription"].as_s - end - end - - inputs["g-recaptcha-response"] = response["solution"]["gRecaptchaResponse"].as_s - headers["Cookies"] = response["solution"]["cookies"].as_h?.try &.map { |k, v| "#{k}=#{v}" }.join("; ") || "" - response = YT_POOL.client &.post("/sorry/index", headers: headers, form: inputs) - headers = HTTP::Headers{ - "Cookie" => URI.parse(response.headers["location"]).query_params["google_abuse"].split(";")[0], - } - cookies = HTTP::Cookies.from_headers(headers) - - cookies.each { |cookie| CONFIG.cookies << cookie } - - # Persist cookies between runs - File.write("config/config.yml", CONFIG.to_yaml) - end - end - rescue ex - LOGGER.error("BypassCaptchaJob: #{ex.message}") - ensure - sleep 1.minute - Fiber.yield - end - end - end -end diff --git a/src/invidious/jobs/clear_expired_items_job.cr b/src/invidious/jobs/clear_expired_items_job.cr new file mode 100644 index 00000000..17191aac --- /dev/null +++ b/src/invidious/jobs/clear_expired_items_job.cr @@ -0,0 +1,27 @@ +class Invidious::Jobs::ClearExpiredItemsJob < Invidious::Jobs::BaseJob + # Remove items (videos, nonces, etc..) whose cache is outdated every hour. + # Removes the need for a cron job. + def begin + loop do + failed = false + + LOGGER.info("jobs: running ClearExpiredItems job") + + begin + Invidious::Database::Videos.delete_expired + Invidious::Database::Nonces.delete_expired + rescue DB::Error + failed = true + end + + # Retry earlier than scheduled on DB error + if failed + LOGGER.info("jobs: ClearExpiredItems failed. Retrying in 10 minutes.") + sleep 10.minutes + else + LOGGER.info("jobs: ClearExpiredItems done.") + sleep 1.hour + end + end + end +end diff --git a/src/invidious/jobs/instance_refresh_job.cr b/src/invidious/jobs/instance_refresh_job.cr new file mode 100644 index 00000000..cb4280b9 --- /dev/null +++ b/src/invidious/jobs/instance_refresh_job.cr @@ -0,0 +1,97 @@ +class Invidious::Jobs::InstanceListRefreshJob < Invidious::Jobs::BaseJob + # We update the internals of a constant as so it can be accessed from anywhere + # within the codebase + # + # "INSTANCES" => Array(Tuple(String, String)) # region, instance + + INSTANCES = {"INSTANCES" => [] of Tuple(String, String)} + + def initialize + end + + def begin + loop do + refresh_instances + LOGGER.info("InstanceListRefreshJob: Done, sleeping for 30 minutes") + sleep 30.minute + Fiber.yield + end + end + + # Refreshes the list of instances used for redirects. + # + # Does the following three checks for each instance + # - Is it a clear-net instance? + # - Is it an instance with a good uptime? + # - Is it an updated instance? + private def refresh_instances + raw_instance_list = self.fetch_instances + filtered_instance_list = [] of Tuple(String, String) + + raw_instance_list.each do |instance_data| + # TODO allow Tor hidden service instances when the current instance + # is also a hidden service. Same for i2p and any other non-clearnet instances. + begin + domain = instance_data[0] + info = instance_data[1] + stats = info["stats"] + + next unless info["type"] == "https" + next if bad_uptime?(info["monitor"]) + next if outdated?(stats["software"]["version"]) + + filtered_instance_list << {info["region"].as_s, domain.as_s} + rescue ex + if domain + LOGGER.info("InstanceListRefreshJob: failed to parse information from '#{domain}' because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ") + else + LOGGER.info("InstanceListRefreshJob: failed to parse information from an instance because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ") + end + end + end + + if !filtered_instance_list.empty? + INSTANCES["INSTANCES"] = filtered_instance_list + end + end + + # Fetches information regarding instances from api.invidious.io or an otherwise configured URL + private def fetch_instances : Array(JSON::Any) + begin + # We directly call the stdlib HTTP::Client here as it allows us to negate the effects + # of the force_resolve config option. This is needed as api.invidious.io does not support ipv6 + # and as such the following request raises if we were to use force_resolve with the ipv6 value. + instance_api_client = HTTP::Client.new(URI.parse("https://api.invidious.io")) + + # Timeouts + instance_api_client.connect_timeout = 10.seconds + instance_api_client.dns_timeout = 10.seconds + + raw_instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a + instance_api_client.close + rescue ex : Socket::ConnectError | IO::TimeoutError | JSON::ParseException + raw_instance_list = [] of JSON::Any + end + + return raw_instance_list + end + + # Checks if the given target instance is outdated + private def outdated?(target_instance_version) : Bool + remote_commit_date = target_instance_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/) + return false if !remote_commit_date + + remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC) + local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC) + + return (remote_commit_date - local_commit_date).abs.days > 30 + end + + # Checks if the uptime of the target instance is greater than 90% over a 30 day period + private def bad_uptime?(target_instance_health_monitor) : Bool + return true if !target_instance_health_monitor["down"].as_bool == false + return true if target_instance_health_monitor["uptime"].as_f < 90 + + return false + end +end diff --git a/src/invidious/jobs/notification_job.cr b/src/invidious/jobs/notification_job.cr index 2f525e08..b445107b 100644 --- a/src/invidious/jobs/notification_job.cr +++ b/src/invidious/jobs/notification_job.cr @@ -1,12 +1,12 @@ class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob - private getter connection_channel : Channel({Bool, Channel(PQ::Notification)}) + private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)}) private getter pg_url : URI def initialize(@connection_channel, @pg_url) end def begin - connections = [] of Channel(PQ::Notification) + connections = [] of ::Channel(PQ::Notification) PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) } diff --git a/src/invidious/jobs/pull_popular_videos_job.cr b/src/invidious/jobs/pull_popular_videos_job.cr index 7a8ab84e..dc785bae 100644 --- a/src/invidious/jobs/pull_popular_videos_job.cr +++ b/src/invidious/jobs/pull_popular_videos_job.cr @@ -1,11 +1,4 @@ class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob - QUERY = <<-SQL - SELECT DISTINCT ON (ucid) * - FROM channel_videos - WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d - GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40) - ORDER BY ucid, published DESC - SQL POPULAR_VIDEOS = Atomic.new([] of ChannelVideo) private getter db : DB::Database @@ -14,9 +7,9 @@ class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob def begin loop do - videos = db.query_all(QUERY, as: ChannelVideo) - .sort_by(&.published) - .reverse + videos = Invidious::Database::ChannelVideos.select_popular_videos + .sort_by!(&.published) + .reverse! POPULAR_VIDEOS.set(videos) diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index fbe6d381..80812a63 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -8,12 +8,12 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob max_fibers = CONFIG.channel_threads lim_fibers = max_fibers active_fibers = 0 - active_channel = Channel(Bool).new - backoff = 1.seconds + active_channel = ::Channel(Bool).new + backoff = 2.minutes loop do LOGGER.debug("RefreshChannelsJob: Refreshing all channels") - db.query("SELECT id FROM channels ORDER BY updated") do |rs| + PG_DB.query("SELECT id FROM channels ORDER BY updated") do |rs| rs.each do id = rs.read(String) @@ -30,16 +30,16 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob spawn do begin LOGGER.trace("RefreshChannelsJob: #{id} fiber : Fetching channel") - channel = fetch_channel(id, db, CONFIG.full_refresh) + channel = fetch_channel(id, pull_all_videos: CONFIG.full_refresh) lim_fibers = max_fibers LOGGER.trace("RefreshChannelsJob: #{id} fiber : Updating DB") - db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id) + Invidious::Database::Channels.update_author(id, channel.author) rescue ex LOGGER.error("RefreshChannelsJob: #{id} : #{ex.message}") if ex.message == "Deleted or invalid channel" - db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id) + Invidious::Database::Channels.update_mark_deleted(id) else lim_fibers = 1 LOGGER.error("RefreshChannelsJob: #{id} fiber : backing off for #{backoff}s") @@ -58,8 +58,8 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob end end - LOGGER.debug("RefreshChannelsJob: Done, sleeping for one minute") - sleep 1.minute + LOGGER.debug("RefreshChannelsJob: Done, sleeping for #{CONFIG.channel_refresh_interval}") + sleep CONFIG.channel_refresh_interval Fiber.yield end end diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr index 926c27fa..4f8130df 100644 --- a/src/invidious/jobs/refresh_feeds_job.cr +++ b/src/invidious/jobs/refresh_feeds_job.cr @@ -7,7 +7,7 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob def begin max_fibers = CONFIG.feed_threads active_fibers = 0 - active_channel = Channel(Bool).new + active_channel = ::Channel(Bool).new loop do db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs| @@ -25,7 +25,7 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob spawn do begin # Drop outdated views - column_array = get_column_array(db, view_name) + column_array = Invidious::Database.get_column_array(db, view_name) ChannelVideo.type_array.each_with_index do |name, i| if name != column_array[i]? LOGGER.info("RefreshFeedsJob: DROP MATERIALIZED VIEW #{view_name}") diff --git a/src/invidious/jobs/statistics_refresh_job.cr b/src/invidious/jobs/statistics_refresh_job.cr index 6569c0a1..66c91ad5 100644 --- a/src/invidious/jobs/statistics_refresh_job.cr +++ b/src/invidious/jobs/statistics_refresh_job.cr @@ -18,6 +18,13 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob "updatedAt" => Time.utc.to_unix, "lastChannelRefreshedAt" => 0_i64, }, + + # + # "totalRequests" => 0_i64, + # "successfulRequests" => 0_i64 + # "ratio" => 0_i64 + # + "playback" => {} of String => Int64 | Float64, } private getter db : DB::Database @@ -30,7 +37,7 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob loop do refresh_stats - sleep 1.minute + sleep 10.minute Fiber.yield end end @@ -47,12 +54,17 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob private def refresh_stats users = STATISTICS.dig("usage", "users").as(Hash(String, Int64)) - users["total"] = db.query_one("SELECT count(*) FROM users", as: Int64) - users["activeHalfyear"] = db.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '6 months'", as: Int64) - users["activeMonth"] = db.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '1 month'", as: Int64) + + users["total"] = Invidious::Database::Statistics.count_users_total + users["activeHalfyear"] = Invidious::Database::Statistics.count_users_active_6m + users["activeMonth"] = Invidious::Database::Statistics.count_users_active_1m + STATISTICS["metadata"] = { "updatedAt" => Time.utc.to_unix, - "lastChannelRefreshedAt" => db.query_one?("SELECT updated FROM channels ORDER BY updated DESC LIMIT 1", as: Time).try &.to_unix || 0_i64, + "lastChannelRefreshedAt" => Invidious::Database::Statistics.channel_last_update.try &.to_unix || 0_i64, } + + # Reset playback requests tracker + STATISTICS["playback"] = {} of String => Int64 | Float64 end end diff --git a/src/invidious/jobs/subscribe_to_feeds_job.cr b/src/invidious/jobs/subscribe_to_feeds_job.cr index a431a48a..8584fb9c 100644 --- a/src/invidious/jobs/subscribe_to_feeds_job.cr +++ b/src/invidious/jobs/subscribe_to_feeds_job.cr @@ -12,7 +12,7 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob end active_fibers = 0 - active_channel = Channel(Bool).new + active_channel = ::Channel(Bool).new loop do db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > interval '4 days' OR subscribed IS NULL") do |rs| diff --git a/src/invidious/jobs/update_decrypt_function_job.cr b/src/invidious/jobs/update_decrypt_function_job.cr deleted file mode 100644 index 6fa0ae1b..00000000 --- a/src/invidious/jobs/update_decrypt_function_job.cr +++ /dev/null @@ -1,14 +0,0 @@ -class Invidious::Jobs::UpdateDecryptFunctionJob < Invidious::Jobs::BaseJob - def begin - loop do - begin - DECRYPT_FUNCTION.update_decrypt_function - rescue ex - LOGGER.error("UpdateDecryptFunctionJob : #{ex.message}") - ensure - sleep 1.minute - Fiber.yield - end - end - end -end diff --git a/src/invidious/jsonify/api_v1/common.cr b/src/invidious/jsonify/api_v1/common.cr new file mode 100644 index 00000000..64b06465 --- /dev/null +++ b/src/invidious/jsonify/api_v1/common.cr @@ -0,0 +1,18 @@ +require "json" + +module Invidious::JSONify::APIv1 + extend self + + def thumbnails(json : JSON::Builder, id : String) + json.array do + build_thumbnails(id).each do |thumbnail| + json.object do + json.field "quality", thumbnail[:name] + json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" + json.field "width", thumbnail[:width] + json.field "height", thumbnail[:height] + end + end + end + end +end diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr new file mode 100644 index 00000000..08cd533f --- /dev/null +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -0,0 +1,295 @@ +require "json" + +module Invidious::JSONify::APIv1 + extend self + + def video(video : Video, json : JSON::Builder, *, locale : String?, proxy : Bool = false) + json.object do + json.field "type", video.video_type + + json.field "title", video.title + json.field "videoId", video.id + + json.field "error", video.info["reason"] if video.info["reason"]? + + json.field "videoThumbnails" do + self.thumbnails(json, video.id) + end + json.field "storyboards" do + self.storyboards(json, video.id, video.storyboards) + end + + 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 "keywords", video.keywords + + json.field "viewCount", video.views + json.field "likeCount", video.likes + json.field "dislikeCount", 0_i64 + + json.field "paid", video.paid + json.field "premium", video.premium + json.field "isFamilyFriendly", video.is_family_friendly + json.field "allowedRegions", video.allowed_regions + json.field "genre", video.genre + json.field "genreUrl", video.genre_url + + json.field "author", video.author + json.field "authorId", video.ucid + json.field "authorUrl", "/channel/#{video.ucid}" + json.field "authorVerified", video.author_verified + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", video.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "subCountText", video.sub_count_text + + json.field "lengthSeconds", video.length_seconds + json.field "allowRatings", video.allow_ratings + json.field "rating", 0_i64 + json.field "isListed", video.is_listed + json.field "liveNow", video.live_now + json.field "isPostLiveDvr", video.post_live_dvr + json.field "isUpcoming", video.upcoming? + + if video.premiere_timestamp + json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix + end + + if hlsvp = video.hls_manifest_url + hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL) + json.field "hlsUrl", hlsvp + end + + json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{video.id}" + + json.field "adaptiveFormats" do + json.array do + video.adaptive_fmts.each do |fmt| + json.object do + # Only available on regular videos, not livestreams/OTF streams + if init_range = fmt["initRange"]? + json.field "init", "#{init_range["start"]}-#{init_range["end"]}" + end + if index_range = fmt["indexRange"]? + json.field "index", "#{index_range["start"]}-#{index_range["end"]}" + end + + # Not available on MPEG-4 Timed Text (`text/mp4`) streams (livestreams only) + json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? + + if proxy + json.field "url", Invidious::HttpServer::Utils.proxy_video_url( + fmt["url"].to_s, absolute: true + ) + else + json.field "url", fmt["url"] + end + + json.field "itag", fmt["itag"].as_i.to_s + json.field "type", fmt["mimeType"] + json.field "clen", fmt["contentLength"]? || "-1" + + # Last modified is a unix timestamp with µS, with the dot omitted. + # E.g: 1638056732(.)141582 + # + # On livestreams, it's not present, so always fall back to the + # current unix timestamp (up to mS precision) for compatibility. + last_modified = fmt["lastModified"]? + last_modified ||= "#{Time.utc.to_unix_ms}000" + json.field "lmt", last_modified + + json.field "projectionType", fmt["projectionType"] + + height = fmt["height"]?.try &.as_i + width = fmt["width"]?.try &.as_i + + fps = fmt["fps"]?.try &.as_i + + if fps + json.field "fps", fps + end + + if height && width + json.field "size", "#{width}x#{height}" + json.field "resolution", "#{height}p" + + quality_label = "#{width > height ? height : width}p" + + if fps && fps > 30 + quality_label += fps.to_s + end + + json.field "qualityLabel", quality_label + end + + if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) + json.field "container", fmt_info["ext"] + json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] + end + + # Livestream chunk infos + json.field "targetDurationSec", fmt["targetDurationSec"].as_i if fmt.has_key?("targetDurationSec") + json.field "maxDvrDurationSec", fmt["maxDvrDurationSec"].as_i if fmt.has_key?("maxDvrDurationSec") + + # Audio-related data + json.field "audioQuality", fmt["audioQuality"] if fmt.has_key?("audioQuality") + json.field "audioSampleRate", fmt["audioSampleRate"].as_s.to_i if fmt.has_key?("audioSampleRate") + json.field "audioChannels", fmt["audioChannels"] if fmt.has_key?("audioChannels") + + # Extra misc stuff + json.field "colorInfo", fmt["colorInfo"] if fmt.has_key?("colorInfo") + json.field "captionTrack", fmt["captionTrack"] if fmt.has_key?("captionTrack") + end + end + end + end + + json.field "formatStreams" do + json.array do + video.fmt_stream.each do |fmt| + json.object do + if proxy + json.field "url", Invidious::HttpServer::Utils.proxy_video_url( + fmt["url"].to_s, absolute: true + ) + else + json.field "url", fmt["url"] + end + json.field "itag", fmt["itag"].as_i.to_s + json.field "type", fmt["mimeType"] + json.field "quality", fmt["quality"] + + json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? + + height = fmt["height"]?.try &.as_i + width = fmt["width"]?.try &.as_i + + fps = fmt["fps"]?.try &.as_i + + if fps + json.field "fps", fps + end + + if height && width + json.field "size", "#{width}x#{height}" + json.field "resolution", "#{height}p" + + quality_label = "#{width > height ? height : width}p" + + if fps && fps > 30 + quality_label += fps.to_s + end + + json.field "qualityLabel", quality_label + end + + if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) + json.field "container", fmt_info["ext"] + json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] + end + end + end + end + end + + json.field "captions" do + json.array do + video.captions.each do |caption| + json.object do + json.field "label", caption.name + json.field "language_code", caption.language_code + json.field "url", "/api/v1/captions/#{video.id}?label=#{URI.encode_www_form(caption.name)}" + end + end + end + end + + if !video.music.empty? + json.field "musicTracks" do + json.array do + video.music.each do |music| + json.object do + json.field "song", music.song + json.field "artist", music.artist + json.field "album", music.album + json.field "license", music.license + end + end + end + end + end + + json.field "recommendedVideos" do + json.array do + video.related_videos.each do |rv| + if rv["id"]? + json.object do + json.field "videoId", rv["id"] + json.field "title", rv["title"] + json.field "videoThumbnails" do + self.thumbnails(json, rv["id"]) + end + + json.field "author", rv["author"] + json.field "authorUrl", "/channel/#{rv["ucid"]?}" + json.field "authorId", rv["ucid"]? + json.field "authorVerified", rv["author_verified"] == "true" + if rv["author_thumbnail"]? + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-") + json.field "width", quality + json.field "height", quality + end + end + end + end + end + + json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i + json.field "viewCountText", rv["short_view_count"]? + json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 + end + end + end + end + end + end + end + + def storyboards(json, id, storyboards) + json.array do + storyboards.each do |sb| + json.object do + json.field "url", "/api/v1/storyboards/#{id}?width=#{sb.width}&height=#{sb.height}" + json.field "templateUrl", sb.url.to_s + json.field "width", sb.width + json.field "height", sb.height + json.field "count", sb.count + json.field "interval", sb.interval + json.field "storyboardWidth", sb.columns + json.field "storyboardHeight", sb.rows + json.field "storyboardCount", sb.images_count + end + end + end + end +end diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index 55b01174..823ca85b 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -72,7 +72,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil) videos += next_page.videos end - videos.uniq! { |video| video.id } + videos.uniq!(&.id) videos = videos.first(50) return Mix.new({ title: mix_title, @@ -97,7 +97,7 @@ def template_mix(mix) <li class="pure-menu-item"> <a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}"> <div class="thumbnail"> - <img class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg"> + <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" /> <p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p> </div> <p style="width:100%">#{video["title"]}</p> diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 073a9986..a51e88b4 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -11,7 +11,7 @@ struct PlaylistVideo property index : Int64 property live_now : Bool - def to_xml(auto_generated, xml : XML::Builder) + def to_xml(xml : XML::Builder) xml.element("entry") do xml.element("id") { xml.text "yt:video:#{self.id}" } xml.element("yt:videoId") { xml.text self.id } @@ -20,13 +20,8 @@ struct PlaylistVideo xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?v=#{self.id}") xml.element("author") do - if auto_generated - xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } - else - xml.element("name") { xml.text author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } - end + xml.element("name") { xml.text self.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } end xml.element("content", type: "xhtml") do @@ -47,18 +42,18 @@ struct PlaylistVideo end end - def to_xml(auto_generated, xml : XML::Builder? = nil) - if xml - to_xml(auto_generated, xml) - else - XML.build do |json| - to_xml(auto_generated, xml) - end - end + def to_xml(_xml : Nil = nil) + XML.build { |xml| to_xml(xml) } end - def to_json(locale, json : JSON::Builder, index : Int32?) + def to_json(locale : String?, json : JSON::Builder) + to_json(json) + end + + def to_json(json : JSON::Builder, index : Int32? = nil) json.object do + json.field "type", "video" + json.field "title", self.title json.field "videoId", self.id @@ -67,7 +62,7 @@ struct PlaylistVideo json.field "authorUrl", "/channel/#{self.ucid}" json.field "videoThumbnails" do - generate_thumbnails(json, self.id) + Invidious::JSONify::APIv1.thumbnails(json, self.id) end if index @@ -78,17 +73,12 @@ struct PlaylistVideo end json.field "lengthSeconds", self.length_seconds + json.field "liveNow", self.live_now end end - def to_json(locale, json : JSON::Builder? = nil, index : Int32? = nil) - if json - to_json(locale, json, index: index) - else - JSON.build do |json| - to_json(locale, json, index: index) - end - end + def to_json(_json : Nil, index : Int32? = nil) + JSON.build { |json| to_json(json, index: index) } end end @@ -106,8 +96,9 @@ struct Playlist property views : Int64 property updated : Time property thumbnail : String? + property subtitle : String? - def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil) + def to_json(offset, json : JSON::Builder, video_id : String? = nil) json.object do json.field "type", "playlist" json.field "title", self.title @@ -117,6 +108,7 @@ struct Playlist json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" + json.field "subtitle", self.subtitle json.field "authorThumbnails" do json.array do @@ -142,22 +134,18 @@ struct Playlist json.field "videos" do json.array do - videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) - videos.each_with_index do |video, index| - video.to_json(locale, json) + videos = get_playlist_videos(self, offset: offset, video_id: video_id) + videos.each do |video| + video.to_json(json) end end end end end - def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil) - if json - to_json(offset, locale, json, continuation: continuation) - else - JSON.build do |json| - to_json(offset, locale, json, continuation: continuation) - end + def to_json(offset, _json : Nil = nil, video_id : String? = nil) + JSON.build do |json| + to_json(offset, json, video_id: video_id) end end @@ -196,7 +184,7 @@ struct InvidiousPlaylist end end - def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil) + def to_json(offset, json : JSON::Builder, video_id : String? = nil) json.object do json.field "type", "invidiousPlaylist" json.field "title", self.title @@ -217,32 +205,29 @@ struct InvidiousPlaylist json.field "videos" do json.array do - if !offset || offset == 0 - index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", self.id, continuation, as: Int64) + if (!offset || offset == 0) && !video_id.nil? + index = Invidious::Database::PlaylistVideos.select_index(self.id, video_id) offset = self.index.index(index) || 0 end - videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) - videos.each_with_index do |video, index| - video.to_json(locale, json, offset + index) + videos = get_playlist_videos(self, offset: offset, video_id: video_id) + videos.each_with_index do |video, idx| + video.to_json(json, offset + idx) end end end end end - def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil) - if json - to_json(offset, locale, json, continuation: continuation) - else - JSON.build do |json| - to_json(offset, locale, json, continuation: continuation) - end + def to_json(offset, _json : Nil = nil, video_id : String? = nil) + JSON.build do |json| + to_json(offset, json, video_id: video_id) end end def thumbnail - @thumbnail_id ||= PG_DB.query_one?("SELECT id FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 1", self.id, self.index, as: String) || "-----------" + # TODO: Get playlist thumbnail from playlist data rather than first video + @thumbnail_id ||= Invidious::Database::PlaylistVideos.select_one_id(self.id, self.index) || "-----------" "/vi/#{@thumbnail_id}/mqdefault.jpg" end @@ -259,11 +244,11 @@ struct InvidiousPlaylist end def description_html - HTML.escape(self.description).gsub("\n", "<br>") + HTML.escape(self.description) end end -def create_playlist(db, title, privacy, user) +def create_playlist(title, privacy, user) plid = "IVPL#{Random::Secure.urlsafe_base64(24)[0, 31]}" playlist = InvidiousPlaylist.new({ @@ -278,17 +263,14 @@ def create_playlist(db, title, privacy, user) index: [] of Int64, }) - playlist_array = playlist.to_a - args = arg_array(playlist_array) - - db.exec("INSERT INTO playlists VALUES (#{args})", args: playlist_array) + Invidious::Database::Playlists.insert(playlist) return playlist end -def subscribe_playlist(db, user, playlist) +def subscribe_playlist(user, playlist) playlist = InvidiousPlaylist.new({ - title: playlist.title.byte_slice(0, 150), + title: playlist.title[..150], id: playlist.id, author: user.email, description: "", # Max 5000 characters @@ -299,10 +281,7 @@ def subscribe_playlist(db, user, playlist) index: [] of Int64, }) - playlist_array = playlist.to_a - args = arg_array(playlist_array) - - db.exec("INSERT INTO playlists VALUES (#{args})", args: playlist_array) + Invidious::Database::Playlists.insert(playlist) return playlist end @@ -322,21 +301,19 @@ def produce_playlist_continuation(id, index) .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i, padding: false) } - data_wrapper = {"1:varint" => request_count, "15:string" => "PT:#{data}"} - .try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - object = { "80226972:embedded" => { - "2:string" => plid, - "3:string" => data_wrapper, + "2:string" => plid, + "3:base64" => { + "1:varint" => request_count, + "15:string" => "PT:#{data}", + "104:embedded" => {"1:0:varint" => 0_i64}, + }, "35:string" => id, }, } - continuation = object.try { |i| Protodec::Any.cast_json(object) } + continuation = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } @@ -344,41 +321,32 @@ def produce_playlist_continuation(id, index) return continuation end -def get_playlist(db, plid, locale, refresh = true, force_refresh = false) +def get_playlist(plid : String) if plid.starts_with? "IV" - if playlist = db.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if playlist = Invidious::Database::Playlists.select(id: plid) return playlist else - raise InfoException.new("Playlist does not exist.") + raise NotFoundException.new("Playlist does not exist.") end else - return fetch_playlist(plid, locale) + return fetch_playlist(plid) end end -def fetch_playlist(plid, locale) +def fetch_playlist(plid : String) if plid.starts_with? "UC" plid = "UU#{plid.lchop("UC")}" end - response = YT_POOL.client &.get("/playlist?list=#{plid}&hl=en") - if response.status_code != 200 - if response.headers["location"]?.try &.includes? "/sorry/index" - raise InfoException.new("Could not extract playlist info. Instance is likely blocked.") - else - raise InfoException.new("Not a playlist.") - end - end - - initial_data = extract_initial_data(response.body) + initial_data = YoutubeAPI.browse("VL" + plid, params: "") - playlist_sidebar_renderer = initial_data["sidebar"]?.try &.["playlistSidebarRenderer"]?.try &.["items"]? + playlist_sidebar_renderer = initial_data.dig?("sidebar", "playlistSidebarRenderer", "items") raise InfoException.new("Could not extract playlistSidebarRenderer.") if !playlist_sidebar_renderer - playlist_info = playlist_sidebar_renderer[0]["playlistSidebarPrimaryInfoRenderer"]? + playlist_info = playlist_sidebar_renderer.dig?(0, "playlistSidebarPrimaryInfoRenderer") raise InfoException.new("Could not extract playlist info") if !playlist_info - title = playlist_info["title"]?.try &.["runs"][0]?.try &.["text"]?.try &.as_s || "" + title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || "" desc_item = playlist_info["description"]? @@ -388,18 +356,25 @@ def fetch_playlist(plid, locale) description_html = desc_item.try &.["runs"]?.try &.as_a .try { |run| content_to_comment_html(run).try &.to_s } || "<p></p>" - thumbnail = playlist_info["thumbnailRenderer"]?.try &.["playlistVideoThumbnailRenderer"]? - .try &.["thumbnail"]["thumbnails"][0]["url"]?.try &.as_s + thumbnail = playlist_info.dig?( + "thumbnailRenderer", "playlistVideoThumbnailRenderer", + "thumbnail", "thumbnails", 0, "url" + ).try &.as_s views = 0_i64 updated = Time.utc video_count = 0 + + subtitle = extract_text(initial_data.dig?("header", "playlistHeaderRenderer", "subtitle")) + playlist_info["stats"]?.try &.as_a.each do |stat| text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s next if !text if text.includes? "video" video_count = text.gsub(/\D/, "").to_i? || 0 + elsif text.includes? "episode" + video_count = text.gsub(/\D/, "").to_i? || 0 elsif text.includes? "view" views = text.gsub(/\D/, "").to_i64? || 0_i64 else @@ -412,12 +387,15 @@ def fetch_playlist(plid, locale) author_thumbnail = "" ucid = "" else - author_info = playlist_sidebar_renderer[1]["playlistSidebarSecondaryInfoRenderer"]?.try &.["videoOwner"]["videoOwnerRenderer"]? + author_info = playlist_sidebar_renderer[1].dig?( + "playlistSidebarSecondaryInfoRenderer", "videoOwner", "videoOwnerRenderer" + ) + raise InfoException.new("Could not extract author info") if !author_info - author = author_info["title"]["runs"][0]["text"]?.try &.as_s || "" - author_thumbnail = author_info["thumbnail"]["thumbnails"][0]["url"]?.try &.as_s || "" - ucid = author_info["title"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"]?.try &.as_s || "" + author = author_info.dig?("title", "runs", 0, "text").try &.as_s || "" + author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s || "" + ucid = author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || "" end return Playlist.new({ @@ -432,36 +410,40 @@ def fetch_playlist(plid, locale) views: views, updated: updated, thumbnail: thumbnail, + subtitle: subtitle, }) end -def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) - # Show empy playlist if requested page is out of range +def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, video_id = nil) + # Show empty playlist if requested page is out of range # (e.g, when a new playlist has been created, offset will be negative) if offset >= playlist.video_count || offset < 0 return [] of PlaylistVideo end if playlist.is_a? InvidiousPlaylist - db.query_all("SELECT * FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 100 OFFSET $3", - playlist.id, playlist.index, offset, as: PlaylistVideo) + Invidious::Database::PlaylistVideos.select(playlist.id, playlist.index, offset, limit: 100) else - if offset >= 100 - # Normalize offset to match youtube's behavior (100 videos chunck per request) - offset = (offset / 100).to_i64 * 100_i64 + if video_id + initial_data = YoutubeAPI.next({ + "videoId" => video_id, + "playlistId" => playlist.id, + }) + offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset + end + + videos = [] of PlaylistVideo + until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count + # 100 videos per request ctoken = produce_playlist_continuation(playlist.id, offset) - initial_data = JSON.parse(request_youtube_api_browse(ctoken)).as_h - else - response = YT_POOL.client &.get("/playlist?list=#{playlist.id}&gl=US&hl=en") - initial_data = extract_initial_data(response.body) - end + initial_data = YoutubeAPI.browse(ctoken) + videos += extract_playlist_videos(initial_data) - if initial_data - return extract_playlist_videos(initial_data) - else - return [] of PlaylistVideo + offset += 100 end + + return videos end end @@ -495,7 +477,6 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any)) plid = i["navigationEndpoint"]["watchEndpoint"]["playlistId"].as_s index = i["navigationEndpoint"]["watchEndpoint"]["index"].as_i64 - thumbnail = i["thumbnail"]["thumbnails"][0]["url"].as_s title = i["title"].try { |t| t["simpleText"]? || t["runs"]?.try &.[0]["text"]? }.try &.as_s || "" author = i["shortBylineText"]?.try &.["runs"][0]["text"].as_s || "" ucid = i["shortBylineText"]?.try &.["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s || "" @@ -537,10 +518,10 @@ def template_playlist(playlist) playlist["videos"].as_a.each do |video| html += <<-END_HTML - <li class="pure-menu-item"> - <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}"> + <li class="pure-menu-item" id="#{video["videoId"]}"> + <a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}"> <div class="thumbnail"> - <img class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg"> + <img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg" alt="" /> <p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p> </div> <p style="width:100%">#{video["title"]}</p> diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr new file mode 100644 index 00000000..dd65e7a6 --- /dev/null +++ b/src/invidious/routes/account.cr @@ -0,0 +1,354 @@ +{% skip_file if flag?(:api_only) %} + +module Invidious::Routes::Account + extend self + + # ------------------- + # Password update + # ------------------- + + # Show the password change interface (GET request) + def get_change_password(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY) + + templated "user/change_password" + end + + # Handle the password change (POST request) + def post_change_password(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + return error_template(400, ex) + end + + password = env.params.body["password"]? + if password.nil? || password.empty? + return error_template(401, "Password is a required field") + end + + new_passwords = env.params.body.select { |k, _| k.match(/^new_password\[\d+\]$/) }.map { |_, v| v } + + if new_passwords.size <= 1 || new_passwords.uniq.size != 1 + return error_template(400, "New passwords must match") + end + + new_password = new_passwords.uniq[0] + if new_password.empty? + return error_template(401, "Password cannot be empty") + end + + if new_password.bytesize > 55 + return error_template(400, "Password cannot be longer than 55 characters") + end + + if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) + return error_template(401, "Incorrect password") + end + + new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10) + Invidious::Database::Users.update_password(user, new_password.to_s) + + env.redirect referer + end + + # ------------------- + # Account deletion + # ------------------- + + # Show the account deletion confirmation prompt (GET request) + def get_delete(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY) + + templated "user/delete_account" + end + + # Handle the account deletion (POST request) + def post_delete(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + return error_template(400, ex) + end + + view_name = "subscriptions_#{sha256(user.email)}" + Invidious::Database::Users.delete(user) + Invidious::Database::SessionIDs.delete(email: user.email) + PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}") + + env.request.cookies.each do |cookie| + cookie.expires = Time.utc(1990, 1, 1) + env.response.cookies << cookie + end + + env.redirect referer + end + + # ------------------- + # Clear history + # ------------------- + + # Show the watch history deletion confirmation prompt (GET request) + def get_clear_history(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY) + + templated "user/clear_watch_history" + end + + # Handle the watch history clearing (POST request) + def post_clear_history(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + return error_template(400, ex) + end + + Invidious::Database::Users.clear_watch_history(user) + env.redirect referer + end + + # ------------------- + # Authorize tokens + # ------------------- + + # Show the "authorize token?" confirmation prompt (GET request) + def get_authorize_token(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect "/login?referer=#{URI.encode_path_segment(env.request.resource)}" + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY) + + scopes = env.params.query["scopes"]?.try &.split(",") + scopes ||= [] of String + + callback_url = env.params.query["callback_url"]? + if callback_url + callback_url = URI.parse(callback_url) + end + + expire = env.params.query["expire"]?.try &.to_i? + + templated "user/authorize_token" + end + + # Handle token authorization (POST request) + def post_authorize_token(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = env.get("user").as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + return error_template(400, ex) + end + + scopes = env.params.body.select { |k, _| k.match(/^scopes\[\d+\]$/) }.map { |_, v| v } + callback_url = env.params.body["callbackUrl"]? + expire = env.params.body["expire"]?.try &.to_i? + + access_token = generate_token(user.email, scopes, expire, HMAC_KEY) + + if callback_url + access_token = URI.encode_www_form(access_token) + url = URI.parse(callback_url) + + if url.query + query = HTTP::Params.parse(url.query.not_nil!) + else + query = HTTP::Params.new + end + + query["token"] = access_token + query["username"] = URI.encode_path_segment(user.email) + url.query = query.to_s + + env.redirect url.to_s + else + csrf_token = "" + env.set "access_token", access_token + templated "user/authorize_token" + end + end + + # ------------------- + # Manage tokens + # ------------------- + + # Show the token manager page (GET request) + def token_manager(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, "/subscription_manager") + + if !user + return env.redirect referer + end + + user = user.as(User) + tokens = Invidious::Database::SessionIDs.select_all(user.email) + + templated "user/token_manager" + end + + # ------------------- + # AJAX for tokens + # ------------------- + + # Handle internal (non-API) token actions (POST request) + def token_ajax(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + redirect = env.params.query["redirect"]? + redirect ||= "true" + redirect = redirect == "true" + + if !user + if redirect + return env.redirect referer + else + return error_json(403, "No such user") + end + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + if redirect + return error_template(400, ex) + else + return error_json(400, ex) + end + end + + if env.params.query["action_revoke_token"]? + action = "action_revoke_token" + else + return env.redirect referer + end + + session = env.params.query["session"]? + session ||= "" + + case action + when .starts_with? "action_revoke_token" + Invidious::Database::SessionIDs.delete(sid: session, email: user.email) + else + return error_json(400, "Unsupported action #{action}") + end + + if redirect + return env.redirect referer + else + env.response.content_type = "application/json" + return "{}" + end + end +end diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr new file mode 100644 index 00000000..d89e752c --- /dev/null +++ b/src/invidious/routes/api/manifest.cr @@ -0,0 +1,241 @@ +module Invidious::Routes::API::Manifest + # /api/manifest/dash/id/:id + def self.get_dash_video_id(env) + env.response.headers.add("Access-Control-Allow-Origin", "*") + env.response.content_type = "application/dash+xml" + + local = env.params.query["local"]?.try &.== "true" + id = env.params.url["id"] + region = env.params.query["region"]? + + # Since some implementations create playlists based on resolution regardless of different codecs, + # we can opt to only add a source to a representation if it has a unique height within that representation + unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } + + begin + video = get_video(id, region: region) + rescue ex : NotFoundException + haltf env, status_code: 404 + rescue ex + haltf env, status_code: 403 + end + + if dashmpd = video.dash_manifest_url + response = YT_POOL.client &.get(URI.parse(dashmpd).request_target) + + if response.status_code != 200 + haltf env, status_code: response.status_code + end + + manifest = response.body + + manifest = manifest.gsub(/<BaseURL>[^<]+<\/BaseURL>/) do |baseurl| + url = baseurl.lchop("<BaseURL>") + url = url.rchop("</BaseURL>") + + if local + uri = URI.parse(url) + url = "#{HOST_URL}#{uri.request_target}host/#{uri.host}/" + end + + "<BaseURL>#{url}</BaseURL>" + end + + return manifest + end + + adaptive_fmts = video.adaptive_fmts + + if local + adaptive_fmts.each do |fmt| + fmt["url"] = JSON::Any.new("#{HOST_URL}#{URI.parse(fmt["url"].as_s).request_target}") + end + end + + audio_streams = video.audio_streams.sort_by { |stream| {stream["bitrate"].as_i} }.reverse! + video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse! + + manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml| + xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", + "profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static", + mediaPresentationDuration: "PT#{video.length_seconds}S") do + xml.element("Period") do + i = 0 + + {"audio/mp4"}.each do |mime_type| + mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } + next if mime_streams.empty? + + mime_streams.each do |fmt| + # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) + next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) + + # Different representations of the same audio should be groupped into one AdaptationSet. + # However, most players don't support auto quality switching, so we have to trick them + # into providing a quality selector. + # See https://github.com/iv-org/invidious/issues/3074 for more details. + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do + codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') + bandwidth = fmt["bitrate"].as_i + itag = fmt["itag"].as_i + url = fmt["url"].as_s + + xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate") + + xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do + xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", + value: "2") + xml.element("BaseURL") { xml.text url } + xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do + xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") + end + end + end + i += 1 + end + end + + potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144} + + {"video/mp4"}.each do |mime_type| + mime_streams = video_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } + next if mime_streams.empty? + + heights = [] of Int32 + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do + mime_streams.each do |fmt| + # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) + next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) + + codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') + bandwidth = fmt["bitrate"].as_i + itag = fmt["itag"].as_i + url = fmt["url"].as_s + width = fmt["width"].as_i + height = fmt["height"].as_i + + # Resolutions reported by YouTube player (may not accurately reflect source) + height = potential_heights.min_by { |x| (height - x).abs } + next if unique_res && heights.includes? height + heights << height + + xml.element("Representation", id: itag, codecs: codecs, width: width, height: height, + startWithSAP: "1", maxPlayoutRate: "1", + bandwidth: bandwidth, frameRate: fmt["fps"]) do + xml.element("BaseURL") { xml.text url } + xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do + xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") + end + end + end + end + + i += 1 + end + end + end + end + + return manifest + end + + # /api/manifest/dash/id/videoplayback + def self.get_dash_video_playback(env) + env.response.headers.delete("Content-Type") + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.redirect "/videoplayback?#{env.params.query}" + end + + # /api/manifest/dash/id/videoplayback/* + def self.get_dash_video_playback_greedy(env) + env.response.headers.delete("Content-Type") + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.redirect env.request.path.lchop("/api/manifest/dash/id") + end + + # /api/manifest/dash/id/videoplayback && /api/manifest/dash/id/videoplayback/* + def self.options_dash_video_playback(env) + env.response.headers.delete("Content-Type") + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" + env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" + end + + # /api/manifest/hls_playlist/* + def self.get_hls_playlist(env) + response = YT_POOL.client &.get(env.request.path) + + if response.status_code != 200 + haltf env, status_code: response.status_code + end + + local = env.params.query["local"]?.try &.== "true" + + env.response.content_type = "application/x-mpegURL" + env.response.headers.add("Access-Control-Allow-Origin", "*") + + manifest = response.body + + if local + manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match| + path = URI.parse(match).path + + path = path.lchop("/videoplayback/") + path = path.rchop("/") + + path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| + mimetype = mimetype.split("/") + mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] + end + + path = path.split("/") + + raw_params = {} of String => Array(String) + path.each_slice(2) do |pair| + key, value = pair + value = URI.decode_www_form(value) + + if raw_params[key]? + raw_params[key] << value + else + raw_params[key] = [value] + end + end + + raw_params = HTTP::Params.new(raw_params) + if fvip = raw_params["hls_chunk_host"].match(/r(?<fvip>\d+)---/) + raw_params["fvip"] = fvip["fvip"] + end + + raw_params["local"] = "true" + + "#{HOST_URL}/videoplayback?#{raw_params}" + end + end + + manifest + end + + # /api/manifest/hls_variant/* + def self.get_hls_variant(env) + response = YT_POOL.client &.get(env.request.path) + + if response.status_code != 200 + haltf env, status_code: response.status_code + end + + local = env.params.query["local"]?.try &.== "true" + + env.response.content_type = "application/x-mpegURL" + env.response.headers.add("Access-Control-Allow-Origin", "*") + + manifest = response.body + + if local + manifest = manifest.gsub("https://www.youtube.com", HOST_URL) + manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true") + end + + manifest + end +end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr new file mode 100644 index 00000000..a35d2f2b --- /dev/null +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -0,0 +1,490 @@ +module Invidious::Routes::API::V1::Authenticated + # The notification APIs cannot be extracted yet! + # They require the *local* notifications constant defined in invidious.cr + # + # def self.notifications(env) + # env.response.content_type = "text/event-stream" + + # topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000) + # topics ||= [] of String + + # create_notification_stream(env, topics, connection_channel) + # end + + def self.get_preferences(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + user.preferences.to_json + end + + def self.set_preferences(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + begin + user.preferences = Preferences.from_json(env.request.body || "{}") + rescue + end + + Invidious::Database::Users.update_preferences(user) + + env.response.status_code = 204 + end + + def self.export_invidious(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + return Invidious::User::Export.to_invidious(user) + end + + def self.import_invidious(env) + user = env.get("user").as(User) + + begin + if body = env.request.body + body = env.request.body.not_nil!.gets_to_end + else + body = "{}" + end + Invidious::User::Import.from_invidious(user, body) + rescue + end + + env.response.status_code = 204 + end + + def self.get_history(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + page = env.params.query["page"]?.try &.to_i?.try &.clamp(0, Int32::MAX) + page ||= 1 + + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + start_index = (page - 1) * max_results + if user.watched[start_index]? + watched = user.watched.reverse[start_index, max_results] + end + watched ||= [] of String + + return watched.to_json + end + + def self.mark_watched(env) + user = env.get("user").as(User) + + if !user.preferences.watch_history + return error_json(409, "Watch history is disabled in preferences.") + end + + id = env.params.url["id"] + if !id.match(/^[a-zA-Z0-9_-]{11}$/) + return error_json(400, "Invalid video id.") + end + + Invidious::Database::Users.mark_watched(user, id) + env.response.status_code = 204 + end + + def self.mark_unwatched(env) + user = env.get("user").as(User) + + if !user.preferences.watch_history + return error_json(409, "Watch history is disabled in preferences.") + end + + id = env.params.url["id"] + if !id.match(/^[a-zA-Z0-9_-]{11}$/) + return error_json(400, "Invalid video id.") + end + + Invidious::Database::Users.mark_unwatched(user, id) + env.response.status_code = 204 + end + + def self.clear_history(env) + user = env.get("user").as(User) + + Invidious::Database::Users.clear_watch_history(user) + env.response.status_code = 204 + end + + def self.feed(env) + env.response.content_type = "application/json" + + user = env.get("user").as(User) + locale = env.get("preferences").as(Preferences).locale + + max_results = env.params.query["max_results"]?.try &.to_i? + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + videos, notifications = get_subscription_feed(user, max_results, page) + + JSON.build do |json| + json.object do + json.field "notifications" do + json.array do + notifications.each do |video| + video.to_json(locale, json) + end + end + end + + json.field "videos" do + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + end + end + end + + def self.get_subscriptions(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + subscriptions = Invidious::Database::Channels.select(user.subscriptions) + + JSON.build do |json| + json.array do + subscriptions.each do |subscription| + json.object do + json.field "author", subscription.author + json.field "authorId", subscription.id + end + end + end + end + end + + def self.subscribe_channel(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + ucid = env.params.url["ucid"] + + if !user.subscriptions.includes? ucid + get_channel(ucid) + Invidious::Database::Users.subscribe_channel(user, ucid) + end + + env.response.status_code = 204 + end + + def self.unsubscribe_channel(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + ucid = env.params.url["ucid"] + + Invidious::Database::Users.unsubscribe_channel(user, ucid) + + env.response.status_code = 204 + end + + def self.list_playlists(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + playlists = Invidious::Database::Playlists.select_all(author: user.email) + + JSON.build do |json| + json.array do + playlists.each do |playlist| + playlist.to_json(0, json) + end + end + end + end + + def self.create_playlist(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150) + if !title + return error_json(400, "Invalid title.") + end + + privacy = env.params.json["privacy"]?.try { |p| PlaylistPrivacy.parse(p.as(String).downcase) } + if !privacy + return error_json(400, "Invalid privacy setting.") + end + + if Invidious::Database::Playlists.count_owned_by(user.email) >= 100 + return error_json(400, "User cannot have more than 100 playlists.") + end + + playlist = create_playlist(title, privacy, user) + env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}" + env.response.status_code = 201 + { + "title" => title, + "playlistId" => playlist.id, + }.to_json + end + + def self.update_playlist_attribute(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"]? + if !plid || plid.empty? + return error_json(400, "A playlist ID is required") + end + + playlist = Invidious::Database::Playlists.select(id: plid) + if !playlist || playlist.author != user.email && playlist.privacy.private? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + end + + title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title + privacy = env.params.json["privacy"]?.try { |p| PlaylistPrivacy.parse(p.as(String).downcase) } || playlist.privacy + description = env.params.json["description"]?.try &.as(String).delete("\r") || playlist.description + + if title != playlist.title || + privacy != playlist.privacy || + description != playlist.description + updated = Time.utc + else + updated = playlist.updated + end + + Invidious::Database::Playlists.update(plid, title, privacy, description, updated) + + env.response.status_code = 204 + end + + def self.delete_playlist(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + + playlist = Invidious::Database::Playlists.select(id: plid) + if !playlist || playlist.author != user.email && playlist.privacy.private? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + end + + Invidious::Database::Playlists.delete(plid) + + env.response.status_code = 204 + end + + def self.insert_video_into_playlist(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + + playlist = Invidious::Database::Playlists.select(id: plid) + if !playlist || playlist.author != user.email && playlist.privacy.private? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + end + + if playlist.index.size >= CONFIG.playlist_length_limit + return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") + end + + video_id = env.params.json["videoId"].try &.as(String) + if !video_id + return error_json(403, "Invalid videoId") + end + + begin + video = get_video(video_id) + rescue ex : NotFoundException + return error_json(404, ex) + rescue ex + return error_json(500, ex) + end + + playlist_video = PlaylistVideo.new({ + title: video.title, + id: video.id, + author: video.author, + ucid: video.ucid, + length_seconds: video.length_seconds, + published: video.published, + plid: plid, + live_now: video.live_now, + index: Random::Secure.rand(0_i64..Int64::MAX), + }) + + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(plid, playlist_video.index) + + env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{plid}/videos/#{playlist_video.index.to_u64.to_s(16).upcase}" + env.response.status_code = 201 + + JSON.build do |json| + playlist_video.to_json(json, index: playlist.index.size) + end + end + + def self.delete_video_in_playlist(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + plid = env.params.url["plid"] + index = env.params.url["index"].to_i64(16) + + playlist = Invidious::Database::Playlists.select(id: plid) + if !playlist || playlist.author != user.email && playlist.privacy.private? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + end + + if !playlist.index.includes? index + return error_json(404, "Playlist does not contain index") + end + + Invidious::Database::PlaylistVideos.delete(index) + Invidious::Database::Playlists.update_video_removed(plid, index) + + env.response.status_code = 204 + end + + # Invidious::Routing.patch "/api/v1/auth/playlists/:plid/videos/:index" + # def modify_playlist_at(env) + # TODO + # end + + def self.get_tokens(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + scopes = env.get("scopes").as(Array(String)) + + tokens = Invidious::Database::SessionIDs.select_all(user.email) + + JSON.build do |json| + json.array do + tokens.each do |token| + json.object do + json.field "session", token[:session] + json.field "issued", token[:issued].to_unix + end + end + end + end + end + + def self.register_token(env) + user = env.get("user").as(User) + locale = env.get("preferences").as(Preferences).locale + + case env.request.headers["Content-Type"]? + when "application/x-www-form-urlencoded" + scopes = env.params.body.select { |k, _| k.match(/^scopes\[\d+\]$/) }.map { |_, v| v } + callback_url = env.params.body["callbackUrl"]? + expire = env.params.body["expire"]?.try &.to_i? + when "application/json" + scopes = env.params.json["scopes"].as(Array).map(&.as_s) + callback_url = env.params.json["callbackUrl"]?.try &.as(String) + expire = env.params.json["expire"]?.try &.as(Int64) + else + return error_json(400, "Invalid or missing header 'Content-Type'") + end + + if callback_url && callback_url.empty? + callback_url = nil + end + + if callback_url + callback_url = URI.parse(callback_url) + end + + if sid = env.get?("sid").try &.as(String) + env.response.content_type = "text/html" + + csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true) + return templated "user/authorize_token" + else + env.response.content_type = "application/json" + + superset_scopes = env.get("scopes").as(Array(String)) + + authorized_scopes = [] of String + scopes.each do |scope| + if scopes_include_scope(superset_scopes, scope) + authorized_scopes << scope + end + end + + access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY) + + if callback_url + access_token = URI.encode_www_form(access_token) + + if query = callback_url.query + query = HTTP::Params.parse(query.not_nil!) + else + query = HTTP::Params.new + end + + query["token"] = access_token + callback_url.query = query.to_s + + env.redirect callback_url.to_s + else + access_token + end + end + end + + def self.unregister_token(env) + env.response.content_type = "application/json" + + user = env.get("user").as(User) + scopes = env.get("scopes").as(Array(String)) + + session = env.params.json["session"]?.try &.as(String) + session ||= env.get("session").as(String) + + # Allow tokens to revoke other tokens with correct scope + if session == env.get("session").as(String) + Invidious::Database::SessionIDs.delete(sid: session) + elsif scopes_include_scope(scopes, "GET:tokens") + Invidious::Database::SessionIDs.delete(sid: session) + else + return error_json(400, "Cannot revoke session #{session}") + end + + env.response.status_code = 204 + end + + def self.notifications(env) + env.response.content_type = "text/event-stream" + + raw_topics = env.params.body["topics"]? || env.params.query["topics"]? + topics = raw_topics.try &.split(",").uniq.first(1000) + topics ||= [] of String + + create_notification_stream(env, topics, CONNECTION_CHANNEL) + end +end diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr new file mode 100644 index 00000000..588bbc2a --- /dev/null +++ b/src/invidious/routes/api/v1/channels.cr @@ -0,0 +1,516 @@ +module Invidious::Routes::API::V1::Channels + # Macro to avoid duplicating some code below + # This sets the `channel` variable, or handles Exceptions. + private macro get_channel + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex : NotFoundException + return error_json(404, ex) + rescue ex + return error_json(500, ex) + end + end + + def self.home(env) + locale = env.get("preferences").as(Preferences).locale + ucid = env.params.url["ucid"] + + env.response.content_type = "application/json" + + # Use the private macro defined above. + channel = nil # Make the compiler happy + get_channel() + + # Retrieve "sort by" setting from URL parameters + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + + if channel.is_age_gated + begin + playlist = get_playlist(channel.ucid.sub("UC", "UULF")) + videos = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + videos = [] of PlaylistVideo + end + next_continuation = nil + else + begin + videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by) + rescue ex + return error_json(500, ex) + end + end + + JSON.build do |json| + # TODO: Refactor into `to_json` for InvidiousChannel + json.object do + json.field "author", channel.author + json.field "authorId", channel.ucid + json.field "authorUrl", channel.author_url + + json.field "authorBanners" do + json.array do + if channel.banner + qualities = { + {width: 2560, height: 424}, + {width: 2120, height: 351}, + {width: 1060, height: 175}, + } + qualities.each do |quality| + json.object do + json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-") + json.field "width", quality[:width] + json.field "height", quality[:height] + end + end + + json.object do + json.field "url", channel.banner.not_nil!.split("=w1060-")[0] + json.field "width", 512 + json.field "height", 288 + end + end + end + end + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "subCount", channel.sub_count + json.field "totalViews", channel.total_views + json.field "joined", channel.joined.to_unix + + 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 "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 "latestVideos" do + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + + json.field "relatedChannels" do + json.array do + # Fetch related channels + begin + related_channels, _ = fetch_related_channels(channel) + rescue ex + related_channels = [] of SearchChannel + end + + related_channels.each do |related_channel| + related_channel.to_json(locale, json) + end + end + end # relatedChannels + + end + end + end + + def self.latest(env) + # Remove parameters that could affect this endpoint's behavior + env.params.query.delete("sort_by") if env.params.query.has_key?("sort_by") + env.params.query.delete("continuation") if env.params.query.has_key?("continuation") + + return self.videos(env) + end + + def self.videos(env) + locale = env.get("preferences").as(Preferences).locale + ucid = env.params.url["ucid"] + + env.response.content_type = "application/json" + + # Use the private macro defined above. + channel = nil # Make the compiler happy + get_channel() + + # Retrieve some URL parameters + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + continuation = env.params.query["continuation"]? + + if channel.is_age_gated + begin + playlist = get_playlist(channel.ucid.sub("UC", "UULF")) + videos = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + videos = [] of PlaylistVideo + end + next_continuation = nil + else + begin + videos, next_continuation = Channel::Tabs.get_60_videos( + channel, continuation: continuation, sort_by: sort_by + ) + rescue ex + return error_json(500, ex) + end + end + + return JSON.build do |json| + json.object do + json.field "videos" do + json.array do + videos.each &.to_json(locale, json) + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.shorts(env) + locale = env.get("preferences").as(Preferences).locale + ucid = env.params.url["ucid"] + + env.response.content_type = "application/json" + + # Use the private macro defined above. + channel = nil # Make the compiler happy + get_channel() + + # Retrieve continuation from URL parameters + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + continuation = env.params.query["continuation"]? + + if channel.is_age_gated + begin + playlist = get_playlist(channel.ucid.sub("UC", "UUSH")) + videos = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + videos = [] of PlaylistVideo + end + next_continuation = nil + else + begin + videos, next_continuation = Channel::Tabs.get_shorts( + channel, continuation: continuation, sort_by: sort_by + ) + rescue ex + return error_json(500, ex) + end + end + + return JSON.build do |json| + json.object do + json.field "videos" do + json.array do + videos.each &.to_json(locale, json) + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.streams(env) + locale = env.get("preferences").as(Preferences).locale + ucid = env.params.url["ucid"] + + env.response.content_type = "application/json" + + # Use the private macro defined above. + channel = nil # Make the compiler happy + get_channel() + + # Retrieve continuation from URL parameters + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + continuation = env.params.query["continuation"]? + + if channel.is_age_gated + begin + playlist = get_playlist(channel.ucid.sub("UC", "UULV")) + videos = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + videos = [] of PlaylistVideo + end + next_continuation = nil + else + begin + videos, next_continuation = Channel::Tabs.get_60_livestreams( + channel, continuation: continuation, sort_by: sort_by + ) + rescue ex + return error_json(500, ex) + end + end + + return JSON.build do |json| + json.object do + json.field "videos" do + json.array do + videos.each &.to_json(locale, json) + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.playlists(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + sort_by = env.params.query["sort"]?.try &.downcase || + env.params.query["sort_by"]?.try &.downcase || + "last" + + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() + + items, next_continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.podcasts(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() + + items, next_continuation = fetch_channel_podcasts(channel.ucid, channel.author, continuation) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.releases(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() + + items, next_continuation = fetch_channel_releases(channel.ucid, channel.author, continuation) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.community(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + continuation = env.params.query["continuation"]? + # sort_by = env.params.query["sort_by"]?.try &.downcase + + begin + fetch_channel_community(ucid, continuation, locale, format, thin_mode) + rescue ex + return error_json(500, ex) + end + end + + def self.post(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + id = env.params.url["id"].to_s + ucid = env.params.query["ucid"]? + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + if ucid.nil? + response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") + return error_json(400, "Invalid post ID") if response["error"]? + ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s + else + ucid = ucid.to_s + end + + begin + fetch_channel_community_post(ucid, id, locale, format, thin_mode) + rescue ex + return error_json(500, ex) + end + end + + def self.post_comments(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + id = env.params.url["id"] + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + continuation = env.params.query["continuation"]? + + case continuation + when nil, "" + ucid = env.params.query["ucid"] + comments = Comments.fetch_community_post_comments(ucid, id) + else + comments = YoutubeAPI.browse(continuation: continuation) + end + return Comments.parse_youtube(id, comments, format, locale, thin_mode, is_post: true) + end + + def self.channels(env) + locale = env.get("preferences").as(Preferences).locale + ucid = env.params.url["ucid"] + + env.response.content_type = "application/json" + + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() + + continuation = env.params.query["continuation"]? + + begin + items, next_continuation = fetch_related_channels(channel, continuation) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.object do + json.field "relatedChannels" do + json.array do + items.each &.to_json(locale, json) + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.search(env) + locale = env.get("preferences").as(Preferences).locale + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + query = Invidious::Search::Query.new(env.params.query, :channel, region) + + # Required because we can't (yet) pass multiple parameter to the + # `Search::Query` initializer (in this case, an URL segment) + query.channel = env.params.url["ucid"] + + begin + search_results = query.process + rescue ex + return error_json(400, ex) + end + + JSON.build do |json| + json.array do + search_results.each do |item| + item.to_json(locale, json) + end + end + end + end + + # 301 redirect from /api/v1/channels/comments/:ucid + # and /api/v1/channels/:ucid/comments to new /api/v1/channels/:ucid/community and + # corresponding equivalent URL structure of the other one. + def self.channel_comments_redirect(env) + env.response.content_type = "application/json" + ucid = env.params.url["ucid"] + + env.response.headers["Location"] = "/api/v1/channels/#{ucid}/community?#{env.params.query}" + env.response.status_code = 301 + return + end +end diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr new file mode 100644 index 00000000..fea2993c --- /dev/null +++ b/src/invidious/routes/api/v1/feeds.cr @@ -0,0 +1,45 @@ +module Invidious::Routes::API::V1::Feeds + def self.trending(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + region = env.params.query["region"]? + trending_type = env.params.query["type"]? + + begin + trending, plid = fetch_trending(trending_type, region, locale) + rescue ex + return error_json(500, ex) + end + + videos = JSON.build do |json| + json.array do + trending.each do |video| + video.to_json(locale, json) + end + end + end + + videos + end + + def self.popular(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + if !CONFIG.popular_enabled + error_message = {"error" => "Administrator has disabled this endpoint."}.to_json + haltf env, 403, error_message + end + + JSON.build do |json| + json.array do + popular_videos.each do |video| + video.to_json(locale, json) + end + end + end + end +end diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr new file mode 100644 index 00000000..093669fe --- /dev/null +++ b/src/invidious/routes/api/v1/misc.cr @@ -0,0 +1,203 @@ +module Invidious::Routes::API::V1::Misc + # Stats API endpoint for Invidious + def self.stats(env) + env.response.content_type = "application/json" + + if !CONFIG.statistics_enabled + return {"software" => SOFTWARE}.to_json + else + # Calculate playback success rate + if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]?) + tracker = tracker.as(Hash(String, Int64 | Float64)) + + if !tracker.empty? + total_requests = tracker["totalRequests"] + success_count = tracker["successfulRequests"] + + if total_requests.zero? + tracker["ratio"] = 1_i64 + else + tracker["ratio"] = (success_count / (total_requests)).round(2) + end + end + end + + return Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json + end + end + + # APIv1 currently uses the same logic for both + # user playlists and Invidious playlists. This means that we can't + # reasonably split them yet. This should be addressed in APIv2 + def self.get_playlist(env : HTTP::Server::Context) + env.response.content_type = "application/json" + plid = env.params.url["plid"] + + offset = env.params.query["index"]?.try &.to_i? + offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 } + offset ||= 0 + + video_id = env.params.query["continuation"]? + + format = env.params.query["format"]? + format ||= "json" + + if plid.starts_with? "RD" + return env.redirect "/api/v1/mixes/#{plid}" + end + + begin + playlist = get_playlist(plid) + rescue ex : InfoException + return error_json(404, ex) + rescue ex + return error_json(404, "Playlist does not exist.") + end + + user = env.get?("user").try &.as(User) + if !playlist || playlist.privacy.private? && playlist.author != user.try &.email + return error_json(404, "Playlist does not exist.") + end + + # includes into the playlist a maximum of 20 videos, before the offset + if offset > 0 + lookback = offset < 50 ? offset : 50 + response = playlist.to_json(offset - lookback) + json_response = JSON.parse(response) + else + # Unless the continuation is really the offset 0, it becomes expensive. + # It happens when the offset is not set. + # First we find the actual offset, and then we lookback + # it shouldn't happen often though + + lookback = 0 + response = playlist.to_json(offset, video_id: video_id) + json_response = JSON.parse(response) + + if json_response["videos"].as_a.empty? + json_response = JSON.parse(response) + elsif json_response["videos"].as_a[0]["index"] != offset + offset = json_response["videos"].as_a[0]["index"].as_i + lookback = offset < 50 ? offset : 50 + response = playlist.to_json(offset - lookback) + json_response = JSON.parse(response) + end + end + + if format == "html" + playlist_html = template_playlist(json_response) + index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil} + + response = { + "playlistHtml" => playlist_html, + "index" => index, + "nextVideo" => next_video, + }.to_json + end + + response + end + + def self.mixes(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + rdid = env.params.url["rdid"] + + continuation = env.params.query["continuation"]? + continuation ||= rdid.lchop("RD")[0, 11] + + format = env.params.query["format"]? + format ||= "json" + + begin + mix = fetch_mix(rdid, continuation, locale: locale) + + if !rdid.ends_with? continuation + mix = fetch_mix(rdid, mix.videos[1].id) + index = mix.videos.index(mix.videos.select { |video| video.id == continuation }[0]?) + end + + mix.videos = mix.videos[index..-1] + rescue ex + return error_json(500, ex) + end + + response = JSON.build do |json| + json.object do + json.field "title", mix.title + json.field "mixId", mix.id + + json.field "videos" do + json.array do + mix.videos.each do |video| + json.object do + json.field "title", video.title + json.field "videoId", video.id + json.field "author", video.author + + json.field "authorId", video.ucid + json.field "authorUrl", "/channel/#{video.ucid}" + + json.field "videoThumbnails" do + json.array do + Invidious::JSONify::APIv1.thumbnails(json, video.id) + end + end + + json.field "index", video.index + json.field "lengthSeconds", video.length_seconds + end + end + end + end + end + end + + if format == "html" + response = JSON.parse(response) + playlist_html = template_mix(response) + next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"] + + response = { + "playlistHtml" => playlist_html, + "nextVideo" => next_video, + }.to_json + end + + response + end + + # resolve channel and clip urls, return the UCID + def self.resolve_url(env) + env.response.content_type = "application/json" + url = env.params.query["url"]? + + return error_json(400, "Missing URL to resolve") if !url + + begin + resolved_url = YoutubeAPI.resolve_url(url.as(String)) + endpoint = resolved_url["endpoint"] + page_type = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || "" + if page_type == "WEB_PAGE_TYPE_UNKNOWN" + return error_json(400, "Unknown url") + end + + sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint + params = sub_endpoint.try &.dig?("params") + rescue ex + return error_json(500, ex) + end + JSON.build do |json| + json.object do + json.field "ucid", sub_endpoint["browseId"].as_s if sub_endpoint["browseId"]? + json.field "videoId", sub_endpoint["videoId"].as_s if sub_endpoint["videoId"]? + json.field "playlistId", sub_endpoint["playlistId"].as_s if sub_endpoint["playlistId"]? + json.field "startTimeSeconds", sub_endpoint["startTimeSeconds"].as_i if sub_endpoint["startTimeSeconds"]? + json.field "params", params.try &.as_s + json.field "pageType", page_type + end + end + end +end diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr new file mode 100644 index 00000000..59a30745 --- /dev/null +++ b/src/invidious/routes/api/v1/search.cr @@ -0,0 +1,87 @@ +module Invidious::Routes::API::V1::Search + def self.search(env) + locale = env.get("preferences").as(Preferences).locale + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + query = Invidious::Search::Query.new(env.params.query, :regular, region) + + begin + search_results = query.process + rescue ex + return error_json(400, ex) + end + + JSON.build do |json| + json.array do + search_results.each do |item| + item.to_json(locale, json) + end + end + end + end + + def self.search_suggestions(env) + preferences = env.get("preferences").as(Preferences) + region = env.params.query["region"]? || preferences.region + + env.response.content_type = "application/json" + + query = env.params.query["q"]? || "" + + begin + client = make_client(URI.parse("https://suggestqueries-clients6.youtube.com"), force_youtube_headers: true) + url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&gs_ri=youtube&ds=yt" + + response = client.get(url).body + client.close + + body = JSON.parse(response[19..-2]).as_a + suggestions = body[1].as_a[0..-2] + + JSON.build do |json| + json.object do + json.field "query", body[0].as_s + json.field "suggestions" do + json.array do + suggestions.each do |suggestion| + json.string suggestion[0].as_s + end + end + end + end + end + rescue ex + return error_json(500, ex) + end + end + + def self.hashtag(env) + hashtag = env.params.url["hashtag"] + + page = env.params.query["page"]?.try &.to_i? || 1 + + locale = env.get("preferences").as(Preferences).locale + region = env.params.query["region"]? + env.response.content_type = "application/json" + + begin + results = Invidious::Hashtag.fetch(hashtag, page, region) + rescue ex + return error_json(400, ex) + end + + JSON.build do |json| + json.object do + json.field "results" do + json.array do + results.each do |item| + item.to_json(locale, json) + end + end + end + end + end + end +end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr new file mode 100644 index 00000000..368304ac --- /dev/null +++ b/src/invidious/routes/api/v1/videos.cr @@ -0,0 +1,432 @@ +require "html" + +module Invidious::Routes::API::V1::Videos + def self.videos(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + proxy = {"1", "true"}.any? &.== env.params.query["local"]? + + begin + video = get_video(id, region: region) + rescue ex : NotFoundException + return error_json(404, ex) + rescue ex + return error_json(500, ex) + end + + return JSON.build do |json| + Invidious::JSONify::APIv1.video(video, json, locale: locale, proxy: proxy) + end + end + + def self.captions(env) + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? || env.params.body["region"]? + + if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/) + return error_json(400, "Invalid video ID") + end + + # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 + # It is possible to use `/api/timedtext?type=list&v=#{id}` and + # `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly, + # but this does not provide links for auto-generated captions. + # + # In future this should be investigated as an alternative, since it does not require + # getting video info. + + begin + video = get_video(id, region: region) + rescue ex : NotFoundException + haltf env, 404 + rescue ex + haltf env, 500 + end + + captions = video.captions + + label = env.params.query["label"]? + lang = env.params.query["lang"]? + tlang = env.params.query["tlang"]? + + if !label && !lang + response = JSON.build do |json| + json.object do + json.field "captions" do + json.array do + captions.each do |caption| + json.object do + json.field "label", caption.name + json.field "languageCode", caption.language_code + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" + end + end + end + end + end + end + + return response + end + + env.response.content_type = "text/vtt; charset=UTF-8" + + if lang + caption = captions.select(&.language_code.== lang) + else + caption = captions.select(&.name.== label) + end + + if caption.empty? + haltf env, 404 + else + caption = caption[0] + end + + if CONFIG.use_innertube_for_captions + params = Invidious::Videos::Transcript.generate_param(id, caption.language_code, caption.auto_generated) + + transcript = Invidious::Videos::Transcript.from_raw( + YoutubeAPI.get_transcript(params), + caption.language_code, + caption.auto_generated + ) + + webvtt = transcript.to_vtt + else + # Timedtext API handling + url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target + + # Auto-generated captions often have cues that aren't aligned properly with the video, + # as well as some other markup that makes it cumbersome, so we try to fix that here + if caption.name.includes? "auto-generated" + caption_xml = YT_POOL.client &.get(url).body + + settings_field = { + "Kind" => "captions", + "Language" => "#{tlang || caption.language_code}", + } + + if caption_xml.starts_with?("<?xml") + webvtt = caption.timedtext_to_vtt(caption_xml, tlang) + else + caption_xml = XML.parse(caption_xml) + + webvtt = WebVTT.build(settings_field) do |builder| + caption_nodes = caption_xml.xpath_nodes("//transcript/text") + caption_nodes.each_with_index do |node, i| + start_time = node["start"].to_f.seconds + duration = node["dur"]?.try &.to_f.seconds + duration ||= start_time + + if caption_nodes.size > i + 1 + end_time = caption_nodes[i + 1]["start"].to_f.seconds + else + end_time = start_time + duration + end + + text = HTML.unescape(node.content) + text = text.gsub(/<font color="#[a-fA-F0-9]{6}">/, "") + text = text.gsub(/<\/font>/, "") + if md = text.match(/(?<name>.*) : (?<text>.*)/) + text = "<v #{md["name"]}>#{md["text"]}</v>" + end + + builder.cue(start_time, end_time, text) + end + end + end + else + uri = URI.parse(url) + query_params = uri.query_params + query_params["fmt"] = "vtt" + uri.query_params = query_params + webvtt = YT_POOL.client &.get(uri.request_target).body + + if webvtt.starts_with?("<?xml") + webvtt = caption.timedtext_to_vtt(webvtt) + else + # Some captions have "align:[start/end]" and "position:[num]%" + # attributes. Those are causing issues with VideoJS, which is unable + # to properly align the captions on the video, so we remove them. + # + # See: https://github.com/iv-org/invidious/issues/2391 + webvtt = webvtt.gsub(/([0-9:.]{12} --> [0-9:.]{12}).+/, "\\1") + end + end + end + + if title = env.params.query["title"]? + # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ + env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" + end + + webvtt + end + + # Fetches YouTube storyboards + # + # Which are sprites containing x * y preview + # thumbnails for individual scenes in a video. + # See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails + def self.storyboards(env) + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + + begin + video = get_video(id, region: region) + rescue ex : NotFoundException + haltf env, 404 + rescue ex + haltf env, 500 + end + + width = env.params.query["width"]?.try &.to_i + height = env.params.query["height"]?.try &.to_i + + if !width && !height + response = JSON.build do |json| + json.object do + json.field "storyboards" do + Invidious::JSONify::APIv1.storyboards(json, id, video.storyboards) + end + end + end + + return response + end + + env.response.content_type = "text/vtt" + + # Select a storyboard matching the user's provided width/height + storyboard = video.storyboards.select { |x| x.width == width || x.height == height } + haltf env, 404 if storyboard.empty? + + # Alias variable, to make the code below esaier to read + sb = storyboard[0] + + # Some base URL segments that we'll use to craft the final URLs + work_url = sb.proxied_url.dup + template_path = sb.proxied_url.path + + # Initialize cue timing variables + # NOTE: videojs-vtt-thumbnails gets lost when the cue times don't overlap + # (i.e: if cue[n] end time is 1:06:25.000, cue[n+1] start time should be 1:06:25.000) + time_delta = sb.interval.milliseconds + start_time = 0.milliseconds + end_time = time_delta + + # Build a VTT file for VideoJS-vtt plugin + vtt_file = WebVTT.build do |vtt| + sb.images_count.times do |i| + # Replace the variable component part of the path + work_url.path = template_path.sub("$M", i) + + sb.rows.times do |j| + sb.columns.times do |k| + # The URL fragment represents the offset of the thumbnail inside the storyboard image + work_url.fragment = "xywh=#{sb.width * k},#{sb.height * j},#{sb.width - 2},#{sb.height}" + + vtt.cue(start_time, end_time, work_url.to_s) + + start_time += time_delta + end_time += time_delta + end + end + end + end + + # videojs-vtt-thumbnails is not compliant to the VTT specification, it + # doesn't unescape the HTML entities, so we have to do it here: + # TODO: remove this when we migrate to VideoJS 8 + return HTML.unescape(vtt_file) + end + + def self.annotations(env) + env.response.content_type = "text/xml" + + id = env.params.url["id"] + source = env.params.query["source"]? + source ||= "archive" + + if !id.match(/[a-zA-Z0-9_-]{11}/) + haltf env, 400 + end + + annotations = "" + + case source + when "archive" + if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id)) + annotations = cached_annotation.annotations + else + index = CHARS_SAFE.index!(id[0]).to_s.rjust(2, '0') + + # IA doesn't handle leading hyphens, + # so we use https://archive.org/details/youtubeannotations_64 + if index == "62" + index = "64" + id = id.sub(/^-/, 'A') + end + + file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") + + location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) + + if !location.headers["Location"]? + env.response.status_code = location.status_code + end + + response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) + + if response.body.empty? + haltf env, 404 + end + + if response.status_code != 200 + haltf env, response.status_code + end + + annotations = response.body + + cache_annotation(id, annotations) + end + else # "youtube" + response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") + + if response.status_code != 200 + haltf env, response.status_code + end + + annotations = response.body + end + + etag = sha256(annotations)[0, 16] + if env.request.headers["If-None-Match"]?.try &.== etag + haltf env, 304 + else + env.response.headers["ETag"] = etag + annotations + end + end + + def self.comments(env) + locale = env.get("preferences").as(Preferences).locale + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + + source = env.params.query["source"]? + source ||= "youtube" + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + action = env.params.query["action"]? + action ||= "action_get_comments" + + continuation = env.params.query["continuation"]? + sort_by = env.params.query["sort_by"]?.try &.downcase + + if source == "youtube" + sort_by ||= "top" + + begin + comments = Comments.fetch_youtube(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) + rescue ex : NotFoundException + return error_json(404, ex) + rescue ex + return error_json(500, ex) + end + + return comments + elsif source == "reddit" + sort_by ||= "confidence" + + begin + comments, reddit_thread = Comments.fetch_reddit(id, sort_by: sort_by) + rescue ex + comments = nil + reddit_thread = nil + end + + if !reddit_thread || !comments + return error_json(404, "No reddit threads found") + end + + if format == "json" + reddit_thread = JSON.parse(reddit_thread.to_json).as_h + reddit_thread["comments"] = JSON.parse(comments.to_json) + + return reddit_thread.to_json + else + content_html = Frontend::Comments.template_reddit(comments, locale) + content_html = Comments.fill_links(content_html, "https", "www.reddit.com") + content_html = Comments.replace_links(content_html) + response = { + "title" => reddit_thread.title, + "permalink" => reddit_thread.permalink, + "contentHtml" => content_html, + } + + return response.to_json + end + end + end + + def self.clips(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + clip_id = env.params.url["id"] + region = env.params.query["region"]? + proxy = {"1", "true"}.any? &.== env.params.query["local"]? + + response = YoutubeAPI.resolve_url("https://www.youtube.com/clip/#{clip_id}") + return error_json(400, "Invalid clip ID") if response["error"]? + + video_id = response.dig?("endpoint", "watchEndpoint", "videoId").try &.as_s + return error_json(400, "Invalid clip ID") if video_id.nil? + + start_time = nil + end_time = nil + clip_title = nil + + if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s + start_time, end_time, clip_title = parse_clip_parameters(params) + end + + begin + video = get_video(video_id, region: region) + rescue ex : NotFoundException + return error_json(404, ex) + rescue ex + return error_json(500, ex) + end + + return JSON.build do |json| + json.object do + json.field "startTime", start_time + json.field "endTime", end_time + json.field "clipTitle", clip_title + json.field "video" do + Invidious::JSONify::APIv1.video(video, json, locale: locale, proxy: proxy) + end + end + end + end +end diff --git a/src/invidious/routes/base_route.cr b/src/invidious/routes/base_route.cr deleted file mode 100644 index 07c6f15b..00000000 --- a/src/invidious/routes/base_route.cr +++ /dev/null @@ -1,2 +0,0 @@ -abstract class Invidious::Routes::BaseRoute -end diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr new file mode 100644 index 00000000..5695dee9 --- /dev/null +++ b/src/invidious/routes/before_all.cr @@ -0,0 +1,126 @@ +module Invidious::Routes::BeforeAll + def self.handle(env) + preferences = Preferences.from_json("{}") + + begin + if prefs_cookie = env.request.cookies["PREFS"]? + 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) + preferences.locale = language.header + end + end + end + rescue + preferences = Preferences.from_json("{}") + end + + env.set "preferences", preferences + env.response.headers["X-XSS-Protection"] = "1; mode=block" + env.response.headers["X-Content-Type-Options"] = "nosniff" + + # Allow media resources to be loaded from google servers + # TODO: check if *.youtube.com can be removed + if CONFIG.disabled?("local") || !preferences.local + extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443" + else + extra_media_csp = "" + end + + # Only allow the pages at /embed/* to be embedded + if env.request.resource.starts_with?("/embed") + frame_ancestors = "'self' file: http: https:" + else + frame_ancestors = "'none'" + end + + # TODO: Remove style-src's 'unsafe-inline', requires to remove all + # inline styles (<style> [..] </style>, style=" [..] ") + env.response.headers["Content-Security-Policy"] = { + "default-src 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self' data:", + "connect-src 'self'", + "manifest-src 'self'", + "media-src 'self' blob:" + extra_media_csp, + "child-src 'self' blob:", + "frame-src 'self'", + "frame-ancestors " + frame_ancestors, + }.join("; ") + + env.response.headers["Referrer-Policy"] = "same-origin" + + # Ask the chrom*-based browsers to disable FLoC + # See: https://blog.runcloud.io/google-floc/ + env.response.headers["Permissions-Policy"] = "interest-cohort=()" + + if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts + env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" + end + + return if { + "/sb/", + "/vi/", + "/s_p/", + "/yts/", + "/ggpht/", + "/api/manifest/", + "/videoplayback", + "/latest_version", + "/download", + }.any? { |r| env.request.resource.starts_with? r } + + if env.request.cookies.has_key? "SID" + sid = env.request.cookies["SID"].value + + if sid.starts_with? "v1:" + raise "Cannot use token as SID" + end + + if email = Database::SessionIDs.select_email(sid) + user = Database::Users.select!(email: email) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, 1.week) + + preferences = user.preferences + env.set "preferences", preferences + + env.set "sid", sid + env.set "csrf_token", csrf_token + env.set "user", user + end + 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" + locale = env.params.query["hl"]? || preferences.locale + + preferences.dark_mode = dark_mode + preferences.thin_mode = thin_mode + preferences.locale = locale + env.set "preferences", preferences + + current_page = env.request.path + if env.request.query + query = HTTP::Params.parse(env.request.query.not_nil!) + + if query["referer"]? + query["referer"] = get_referer(env, "/") + end + + current_page += "?#{query}" + end + + env.set "current_page", URI.encode_www_form(current_page) + end +end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr new file mode 100644 index 00000000..7d634cbb --- /dev/null +++ b/src/invidious/routes/channels.cr @@ -0,0 +1,423 @@ +{% skip_file if flag?(:api_only) %} + +module Invidious::Routes::Channels + # Redirection for unsupported routes ("tabs") + def self.redirect_home(env) + ucid = env.params.url["ucid"] + return env.redirect "/channel/#{URI.encode_www_form(ucid)}" + end + + def self.home(env) + self.videos(env) + end + + def self.videos(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + sort_by = env.params.query["sort_by"]?.try &.downcase + + if channel.auto_generated + sort_by ||= "last" + sort_options = {"last", "oldest", "newest"} + + items, next_continuation = fetch_channel_playlists( + channel.ucid, channel.author, continuation, sort_by + ) + + items.uniq! do |item| + if item.responds_to?(:title) + item.title + elsif item.responds_to?(:author) + item.author + end + end + items = items.select(SearchPlaylist) + items.each(&.author = "") + else + # Fetch items and continuation token + if channel.is_age_gated + sort_by = "" + sort_options = [] of String + begin + playlist = get_playlist(channel.ucid.sub("UC", "UULF")) + items = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + items = [] of PlaylistVideo + end + next_continuation = nil + else + sort_by ||= "newest" + sort_options = {"newest", "oldest", "popular"} + + items, next_continuation = Channel::Tabs.get_60_videos( + channel, continuation: continuation, sort_by: sort_by + ) + end + end + + selected_tab = Frontend::ChannelPage::TabsAvailable::Videos + templated "channel" + end + + def self.shorts(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + if !channel.tabs.includes? "shorts" + return env.redirect "/channel/#{channel.ucid}" + end + + if channel.is_age_gated + sort_by = "" + sort_options = [] of String + begin + playlist = get_playlist(channel.ucid.sub("UC", "UUSH")) + items = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + items = [] of PlaylistVideo + end + next_continuation = nil + else + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + sort_options = {"newest", "oldest", "popular"} + + # Fetch items and continuation token + items, next_continuation = Channel::Tabs.get_shorts( + channel, continuation: continuation, sort_by: sort_by + ) + end + + selected_tab = Frontend::ChannelPage::TabsAvailable::Shorts + templated "channel" + end + + def self.streams(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + if !channel.tabs.includes? "streams" + return env.redirect "/channel/#{channel.ucid}" + end + + if channel.is_age_gated + sort_by = "" + sort_options = [] of String + begin + playlist = get_playlist(channel.ucid.sub("UC", "UULV")) + items = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + items = [] of PlaylistVideo + end + next_continuation = nil + else + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + sort_options = {"newest", "oldest", "popular"} + + # Fetch items and continuation token + items, next_continuation = Channel::Tabs.get_60_livestreams( + channel, continuation: continuation, sort_by: sort_by + ) + end + + selected_tab = Frontend::ChannelPage::TabsAvailable::Streams + templated "channel" + end + + def self.playlists(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + sort_options = {"last", "oldest", "newest"} + sort_by = env.params.query["sort_by"]?.try &.downcase + + if channel.auto_generated + return env.redirect "/channel/#{channel.ucid}" + end + + items, next_continuation = fetch_channel_playlists( + channel.ucid, channel.author, continuation, (sort_by || "last") + ) + + items = items.select(SearchPlaylist) + items.each(&.author = "") + + selected_tab = Frontend::ChannelPage::TabsAvailable::Playlists + templated "channel" + end + + def self.podcasts(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + sort_by = "" + sort_options = [] of String + + items, next_continuation = fetch_channel_podcasts( + channel.ucid, channel.author, continuation + ) + + items = items.select(SearchPlaylist) + items.each(&.author = "") + + selected_tab = Frontend::ChannelPage::TabsAvailable::Podcasts + templated "channel" + end + + def self.releases(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + sort_by = "" + sort_options = [] of String + + items, next_continuation = fetch_channel_releases( + channel.ucid, channel.author, continuation + ) + + items = items.select(SearchPlaylist) + items.each(&.author = "") + + selected_tab = Frontend::ChannelPage::TabsAvailable::Releases + templated "channel" + end + + def self.community(env) + data = self.fetch_basic_information(env) + if !data.is_a?(Tuple) + return data + end + locale, user, subscriptions, continuation, ucid, channel = data + + # redirect to post page + if lb = env.params.query["lb"]? + 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" + + continuation = env.params.query["continuation"]? + + if !channel.tabs.includes? "community" + return env.redirect "/channel/#{channel.ucid}" + end + + # TODO: support sort options for community posts + sort_by = "" + sort_options = [] of String + + begin + items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode)) + rescue ex : InfoException + env.response.status_code = 500 + error_message = ex.message + rescue ex : NotFoundException + env.response.status_code = 404 + error_message = ex.message + rescue ex + return error_template(500, ex) + end + + templated "community" + end + + def self.post(env) + # /post/{postId} + id = env.params.url["id"] + ucid = env.params.query["ucid"]? + + prefs = env.get("preferences").as(Preferences) + + locale = prefs.locale + + thin_mode = env.params.query["thin_mode"]? || prefs.thin_mode + thin_mode = thin_mode == "true" + + nojs = env.params.query["nojs"]? + + nojs ||= "0" + nojs = nojs == "1" + + if !ucid.nil? + ucid = ucid.to_s + post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode) + else + # resolve the url to get the author's UCID + response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") + return error_template(400, "Invalid post ID") if response["error"]? + + ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s + post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode) + end + + post_response = JSON.parse(post_response) + + if nojs + comments = Comments.fetch_community_post_comments(ucid, id) + comment_html = JSON.parse(Comments.parse_youtube(id, comments, "html", locale, thin_mode, is_post: true))["contentHtml"] + end + templated "post" + end + + def self.channels(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + if channel.auto_generated + return env.redirect "/channel/#{channel.ucid}" + end + + items, next_continuation = fetch_related_channels(channel, continuation) + + # Featured/related channels can't be sorted + sort_options = [] of String + sort_by = nil + + selected_tab = Frontend::ChannelPage::TabsAvailable::Channels + templated "channel" + end + + def self.about(env) + data = self.fetch_basic_information(env) + if !data.is_a?(Tuple) + return data + end + locale, user, subscriptions, continuation, ucid, channel = data + + env.redirect "/channel/#{ucid}" + end + + private KNOWN_TABS = { + "home", "videos", "shorts", "streams", "podcasts", + "releases", "playlists", "community", "channels", "about", + } + + # Redirects brand url channels to a normal /channel/:ucid route + def self.brand_redirect(env) + locale = env.get("preferences").as(Preferences).locale + + # /attribution_link endpoint needs both the `a` and `u` parameter + # and in order to avoid detection from YouTube we should only send the required ones + # without any of the additional url parameters that only Invidious uses. + yt_url_params = URI::Params.encode(env.params.query.to_h.select(["a", "u", "user"])) + + # Retrieves URL params that only Invidious uses + invidious_url_params = env.params.query.dup + invidious_url_params.delete_all("a") + invidious_url_params.delete_all("u") + invidious_url_params.delete_all("user") + + begin + resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}") + ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"] + rescue ex : InfoException | KeyError + return error_template(404, translate(locale, "This channel does not exist.")) + end + + selected_tab = env.params.url["tab"]? + + if KNOWN_TABS.includes? selected_tab + url = "/channel/#{ucid}/#{selected_tab}" + else + url = "/channel/#{ucid}" + end + + url += "?#{invidious_url_params}" if !invidious_url_params.empty? + + return env.redirect url + end + + # Handles redirects for the /profile endpoint + def self.profile(env) + # The /profile endpoint is special. If passed into the resolve_url + # endpoint YouTube would return a sign in page instead of an /channel/:ucid + # thus we'll add an edge case and handle it here. + + uri_params = env.params.query.size > 0 ? "?#{env.params.query}" : "" + + user = env.params.query["user"]? + if !user + return error_template(404, "This channel does not exist.") + else + env.redirect "/user/#{user}#{uri_params}" + end + end + + def self.live(env) + locale = env.get("preferences").as(Preferences).locale + + # Appears to be a bug in routing, having several routes configured + # as `/a/:a`, `/b/:a`, `/c/:a` results in 404 + value = env.request.resource.split("/")[2] + body = "" + {"channel", "user", "c"}.each do |type| + response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1") + if response.status_code == 200 + body = response.body + end + end + + video_id = body.match(/'VIDEO_ID': "(?<id>[a-zA-Z0-9_-]{11})"/).try &.["id"]? + if video_id + params = [] of String + env.params.query.each do |k, v| + params << "#{k}=#{v}" + end + params = params.join("&") + + url = "/watch?v=#{video_id}" + if !params.empty? + url += "&#{params}" + end + + env.redirect url + else + env.redirect "/channel/#{value}" + end + end + + private def self.fetch_basic_information(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + if user + user = user.as(User) + subscriptions = user.subscriptions + end + subscriptions ||= [] of String + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + return env.redirect env.request.resource.gsub(ucid, ex.channel_id) + rescue ex : NotFoundException + return error_template(404, ex) + rescue ex + return error_template(500, ex) + end + + env.set "search", "channel:#{ucid} " + return {locale, user, subscriptions, continuation, ucid, channel} + end +end diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 5db32788..266f7ba4 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -1,12 +1,19 @@ -class Invidious::Routes::Embed < Invidious::Routes::BaseRoute - def redirect(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? +{% skip_file if flag?(:api_only) %} +module Invidious::Routes::Embed + def self.redirect(env) + locale = env.get("preferences").as(Preferences).locale if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") begin - playlist = get_playlist(PG_DB, plid, locale: locale) + playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 - videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale) + 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)) + end + rescue ex : NotFoundException + return error_template(404, ex) rescue ex return error_template(500, ex) end @@ -23,12 +30,12 @@ class Invidious::Routes::Embed < Invidious::Routes::BaseRoute env.redirect url end - def show(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.show(env) + locale = env.get("preferences").as(Preferences).locale id = env.params.url["id"] plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") - continuation = process_continuation(PG_DB, env.params.query, plid, id) + continuation = process_continuation(env.params.query, plid, id) if md = env.params.query["playlist"]? .try &.match(/[a-zA-Z0-9_-]{11}(,[a-zA-Z0-9_-]{11})*/) @@ -58,9 +65,15 @@ class Invidious::Routes::Embed < Invidious::Routes::BaseRoute if plid begin - playlist = get_playlist(PG_DB, plid, locale: locale) + playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 - videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale) + 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)) + end + rescue ex : NotFoundException + return error_template(404, ex) rescue ex return error_template(500, ex) end @@ -117,9 +130,9 @@ class Invidious::Routes::Embed < Invidious::Routes::BaseRoute subscriptions ||= [] of String begin - video = get_video(id, PG_DB, region: params.region) - rescue ex : VideoRedirect - return env.redirect env.request.resource.gsub(id, ex.video_id) + video = get_video(id, region: params.region) + rescue ex : NotFoundException + return error_template(404, ex) rescue ex return error_template(500, ex) end @@ -134,8 +147,8 @@ class Invidious::Routes::Embed < Invidious::Routes::BaseRoute # PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.as(User).email) # end - if notifications && notifications.includes? id - PG_DB.exec("UPDATE users SET notifications = array_remove(notifications, $1) WHERE email = $2", id, user.as(User).email) + if CONFIG.enable_user_notifications && notifications && notifications.includes? id + Invidious::Database::Users.remove_notification(user.as(User), id) env.get("user").as(User).notifications.delete(id) notifications.delete(id) end @@ -165,12 +178,12 @@ class Invidious::Routes::Embed < Invidious::Routes::BaseRoute captions = video.captions preferred_captions = captions.select { |caption| - params.preferred_captions.includes?(caption.name.simpleText) || - params.preferred_captions.includes?(caption.languageCode.split("-")[0]) + params.preferred_captions.includes?(caption.name) || + params.preferred_captions.includes?(caption.language_code.split("-")[0]) } preferred_captions.sort_by! { |caption| - (params.preferred_captions.index(caption.name.simpleText) || - params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil! + (params.preferred_captions.index(caption.name) || + params.preferred_captions.index(caption.language_code.split("-")[0])).not_nil! } captions = captions - preferred_captions diff --git a/src/invidious/routes/errors.cr b/src/invidious/routes/errors.cr new file mode 100644 index 00000000..1e9ab44e --- /dev/null +++ b/src/invidious/routes/errors.cr @@ -0,0 +1,52 @@ +module Invidious::Routes::ErrorRoutes + def self.error_404(env) + # Workaround for #3117 + if HOST_URL.empty? && env.request.path.starts_with?("/v1/storyboards/sb") + return env.redirect "#{env.request.path[15..]}?#{env.params.query}" + end + + if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/) + item = md["id"] + + # Check if item is branding URL e.g. https://youtube.com/gaming + response = YT_POOL.client &.get("/#{item}") + + if response.status_code == 301 + response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target) + end + + if response.body.empty? + env.response.headers["Location"] = "/" + haltf env, status_code: 302 + end + + html = XML.parse_html(response.body) + ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] + + if ucid + env.response.headers["Location"] = "/channel/#{ucid}" + haltf env, status_code: 302 + end + + params = [] of String + env.params.query.each do |k, v| + params << "#{k}=#{v}" + end + params = params.join("&") + + url = "/watch?v=#{item}" + if !params.empty? + url += "&#{params}" + end + + # Check if item is video ID + if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404 + env.response.headers["Location"] = url + haltf env, status_code: 302 + end + end + + env.response.headers["Location"] = "/" + haltf env, status_code: 302 + end +end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr new file mode 100644 index 00000000..ea7fb396 --- /dev/null +++ b/src/invidious/routes/feeds.cr @@ -0,0 +1,462 @@ +{% skip_file if flag?(:api_only) %} + +module Invidious::Routes::Feeds + def self.view_all_playlists_redirect(env) + env.redirect "/feed/playlists" + end + + def self.playlists(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + referer = get_referer(env) + + return env.redirect "/" if user.nil? + + user = user.as(User) + + # TODO: make a single DB call and separate the items here? + items_created = Invidious::Database::Playlists.select_like_iv(user.email) + items_created.map! do |item| + item.author = "" + item + end + + items_saved = Invidious::Database::Playlists.select_not_like_iv(user.email) + items_saved.map! do |item| + item.author = "" + item + end + + templated "feeds/playlists" + end + + def self.popular(env) + locale = env.get("preferences").as(Preferences).locale + + if CONFIG.popular_enabled + templated "feeds/popular" + else + message = translate(locale, "The Popular feed has been disabled by the administrator.") + templated "message" + end + end + + def self.trending(env) + locale = env.get("preferences").as(Preferences).locale + + trending_type = env.params.query["type"]? + trending_type ||= "Default" + + region = env.params.query["region"]? + region ||= env.get("preferences").as(Preferences).region + + begin + trending, plid = fetch_trending(trending_type, region, locale) + rescue ex + return error_template(500, ex) + end + + templated "feeds/trending" + end + + def self.subscriptions(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = user.token + + if user.preferences.unseen_only + env.set "show_watched", true + end + + # Refresh account + headers = HTTP::Headers.new + headers["Cookie"] = env.request.headers["Cookie"] + + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + videos, notifications = get_subscription_feed(user, max_results, page) + + if CONFIG.enable_user_notifications + # "updated" here is used for delivering new notifications, so if + # we know a user has looked at their feed e.g. in the past 10 minutes, + # they've already seen a video posted 20 minutes ago, and don't need + # to be notified. + Invidious::Database::Users.clear_notifications(user) + user.notifications = [] of String + end + env.set "user", user + + # Used for pagination links + base_url = "/feed/subscriptions" + base_url += "?max_results=#{max_results}" if env.params.query.has_key?("max_results") + + templated "feeds/subscriptions" + end + + def self.history(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + referer = get_referer(env) + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + if !user + return env.redirect referer + end + + user = user.as(User) + + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + if user.watched[(page - 1) * max_results]? + watched = user.watched.reverse[(page - 1) * max_results, max_results] + end + watched ||= [] of String + + # Used for pagination links + base_url = "/feed/history" + base_url += "?max_results=#{max_results}" if env.params.query.has_key?("max_results") + + templated "feeds/history" + end + + # RSS feeds + + def self.rss_channel(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.headers["Content-Type"] = "application/atom+xml" + env.response.content_type = "application/atom+xml" + + ucid = env.params.url["ucid"] + + params = HTTP::Params.parse(env.params.query["params"]? || "") + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + return env.redirect env.request.resource.gsub(ucid, ex.channel_id) + rescue ex : NotFoundException + return error_atom(404, ex) + rescue ex + return error_atom(500, ex) + end + + namespaces = { + "yt" => "http://www.youtube.com/xml/schemas/2015", + "media" => "http://search.yahoo.com/mrss/", + "default" => "http://www.w3.org/2005/Atom", + } + + response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") + rss = XML.parse(response.body) + + videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry| + video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content + title = entry.xpath_node("default:title", namespaces).not_nil!.content + + published = Time.parse_rfc3339(entry.xpath_node("default:published", namespaces).not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) + + author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content + ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content + description_html = entry.xpath_node("media:group/media:description", namespaces).not_nil!.to_s + views = entry.xpath_node("media:group/media:community/media:statistics", namespaces).not_nil!.["views"].to_i64 + + SearchVideo.new({ + title: title, + id: video_id, + author: author, + ucid: ucid, + published: published, + views: views, + description_html: description_html, + length_seconds: 0, + premiere_timestamp: nil, + author_verified: false, + badges: VideoBadges::None, + }) + end + + XML.build(indent: " ", encoding: "UTF-8") do |xml| + xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", + "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", + "xml:lang": "en-US") do + xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") + xml.element("id") { xml.text "yt:channel:#{channel.ucid}" } + xml.element("yt:channelId") { xml.text channel.ucid } + xml.element("icon") { xml.text channel.author_thumbnail } + xml.element("title") { xml.text channel.author } + xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}") + + xml.element("author") do + xml.element("name") { xml.text channel.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } + end + + xml.element("image") do + xml.element("url") { xml.text channel.author_thumbnail } + xml.element("title") { xml.text channel.author } + xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") + end + + videos.each do |video| + video.to_xml(channel.auto_generated, params, xml) + end + end + end + end + + def self.rss_private(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.headers["Content-Type"] = "application/atom+xml" + env.response.content_type = "application/atom+xml" + + token = env.params.query["token"]? + + if !token + haltf env, status_code: 403 + end + + user = Invidious::Database::Users.select(token: token.strip) + if !user + haltf env, status_code: 403 + end + + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + params = HTTP::Params.parse(env.params.query["params"]? || "") + + videos, notifications = get_subscription_feed(user, max_results, page) + + XML.build(indent: " ", encoding: "UTF-8") do |xml| + xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", + "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", + "xml:lang": "en-US") do + 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) } + + (notifications + videos).each do |video| + video.to_xml(locale, params, xml) + end + end + end + end + + def self.rss_playlist(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.headers["Content-Type"] = "application/atom+xml" + env.response.content_type = "application/atom+xml" + + plid = env.params.url["plid"] + + params = HTTP::Params.parse(env.params.query["params"]? || "") + path = env.request.path + + if plid.starts_with? "IV" + if playlist = Invidious::Database::Playlists.select(id: plid) + videos = get_playlist_videos(playlist, offset: 0) + + return XML.build(indent: " ", encoding: "UTF-8") do |xml| + xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", + "xmlns:media": "http://search.yahoo.com/mrss/", xmlns: "http://www.w3.org/2005/Atom", + "xml:lang": "en-US") do + xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") + xml.element("id") { xml.text "iv:playlist:#{plid}" } + xml.element("iv:playlistId") { xml.text plid } + xml.element("title") { xml.text playlist.title } + xml.element("link", rel: "alternate", href: "#{HOST_URL}/playlist?list=#{plid}") + + xml.element("author") do + xml.element("name") { xml.text playlist.author } + end + + videos.each &.to_xml(xml) + end + end + else + haltf env, status_code: 404 + end + end + + response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}") + document = XML.parse(response.body) + + document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node| + node.attributes.each do |attribute| + case attribute.name + when "url", "href" + request_target = URI.parse(node[attribute.name]).request_target + query_string_opt = request_target.starts_with?("/watch?v=") ? "&#{params}" : "" + node[attribute.name] = "#{HOST_URL}#{request_target}#{query_string_opt}" + else nil # Skip + end + end + end + + document = document.to_xml(options: XML::SaveOptions::NO_DECL) + + document.scan(/<uri>(?<url>[^<]+)<\/uri>/).each do |match| + content = "#{HOST_URL}#{URI.parse(match["url"]).request_target}" + document = document.gsub(match[0], "<uri>#{content}</uri>") + end + document + end + + def self.rss_videos(env) + if ucid = env.params.query["channel_id"]? + env.redirect "/feed/channel/#{ucid}" + elsif user = env.params.query["user"]? + env.redirect "/feed/channel/#{user}" + elsif plid = env.params.query["playlist_id"]? + env.redirect "/feed/playlist/#{plid}" + end + end + + # Push notifications via PubSub + + def self.push_notifications_get(env) + verify_token = env.params.url["token"] + + mode = env.params.query["hub.mode"]? + topic = env.params.query["hub.topic"]? + challenge = env.params.query["hub.challenge"]? + + if !mode || !topic || !challenge + haltf env, status_code: 400 + else + mode = mode.not_nil! + topic = topic.not_nil! + challenge = challenge.not_nil! + end + + case verify_token + when .starts_with? "v1" + _, time, nonce, signature = verify_token.split(":") + data = "#{time}:#{nonce}" + when .starts_with? "v2" + time, signature = verify_token.split(":") + data = "#{time}" + else + haltf env, status_code: 400 + end + + # The hub will sometimes check if we're still subscribed after delivery errors, + # so we reply with a 200 as long as the request hasn't expired + if Time.utc.to_unix - time.to_i > 432000 + haltf env, status_code: 400 + end + + if OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, data) != signature + haltf env, status_code: 400 + end + + if ucid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["channel_id"]? + Invidious::Database::Channels.update_subscription_time(ucid) + elsif plid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["playlist_id"]? + Invidious::Database::Playlists.update_subscription_time(plid) + else + haltf env, status_code: 400 + end + + env.response.status_code = 200 + challenge + end + + def self.push_notifications_post(env) + locale = env.get("preferences").as(Preferences).locale + + token = env.params.url["token"] + body = env.request.body.not_nil!.gets_to_end + signature = env.request.headers["X-Hub-Signature"].lchop("sha1=") + + if signature != OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, body) + LOGGER.error("/feed/webhook/#{token} : Invalid signature") + haltf env, status_code: 200 + end + + spawn do + # TODO: unify this with the other almost identical looking parts in this and channels.cr somehow? + namespaces = { + "yt" => "http://www.youtube.com/xml/schemas/2015", + "default" => "http://www.w3.org/2005/Atom", + } + rss = XML.parse(body) + rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry| + id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content + author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content + published = Time.parse_rfc3339(entry.xpath_node("default:published", namespaces).not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) + + begin + video = get_video(id, force_refresh: true) + rescue + next # skip this video since it raised an exception (e.g. it is a scheduled live event) + end + + if CONFIG.enable_user_notifications + # Deliver notifications to `/api/v1/auth/notifications` + payload = { + "topic" => video.ucid, + "videoId" => video.id, + "published" => published.to_unix, + }.to_json + PG_DB.exec("NOTIFY notifications, E'#{payload}'") + end + + video = ChannelVideo.new({ + id: id, + title: video.title, + published: published, + updated: updated, + ucid: video.ucid, + author: author, + length_seconds: video.length_seconds, + live_now: video.live_now, + premiere_timestamp: video.premiere_timestamp, + views: video.views, + }) + + was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) + if was_insert + if CONFIG.enable_user_notifications + Invidious::Database::Users.add_notification(video) + else + Invidious::Database::Users.feed_needs_update(video) + end + end + end + end + + env.response.status_code = 200 + end +end diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr new file mode 100644 index 00000000..639697db --- /dev/null +++ b/src/invidious/routes/images.cr @@ -0,0 +1,153 @@ +module Invidious::Routes::Images + # Avatars, banners and other large image assets. + def self.ggpht(env) + url = env.request.path.lchop("/ggpht") + + headers = HTTP::Headers.new + + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + GGPHT_POOL.client &.get(url, headers) do |resp| + return self.proxy_image(env, resp) + end + rescue ex + end + end + + def self.options_storyboard(env) + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" + env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" + end + + def self.get_storyboard(env) + authority = env.params.url["authority"] + id = env.params.url["id"] + storyboard = env.params.url["storyboard"] + index = env.params.url["index"] + + url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}" + + headers = HTTP::Headers.new + + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + get_ytimg_pool(authority).client &.get(url, headers) do |resp| + env.response.headers["Connection"] = "close" + return self.proxy_image(env, resp) + end + rescue ex + end + end + + # ??? maybe also for storyboards? + def self.s_p_image(env) + id = env.params.url["id"] + name = env.params.url["name"] + url = env.request.resource + + headers = HTTP::Headers.new + + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + get_ytimg_pool("i9").client &.get(url, headers) do |resp| + return self.proxy_image(env, resp) + end + rescue ex + end + end + + def self.yts_image(env) + headers = HTTP::Headers.new + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + YT_POOL.client &.get(env.request.resource, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end + end + + def self.thumbnails(env) + id = env.params.url["id"] + name = env.params.url["name"] + + headers = HTTP::Headers.new + + if name == "maxres.jpg" + build_thumbnails(id).each do |thumb| + thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg" + if get_ytimg_pool("i9").client &.head(thumbnail_resource_path, headers).status_code == 200 + name = thumb[:url] + ".jpg" + break + end + end + end + + url = "/vi/#{id}/#{name}" + + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + get_ytimg_pool("i").client &.get(url, headers) do |resp| + return self.proxy_image(env, resp) + end + rescue ex + end + end + + private def self.proxy_image(env, response) + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 + return env.response.headers.delete("Transfer-Encoding") + end + + return proxy_file(response, env) + end +end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index ffe5f568..d0f7ac22 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -1,17 +1,19 @@ -class Invidious::Routes::Login < Invidious::Routes::BaseRoute - def login_page(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? +{% skip_file if flag?(:api_only) %} + +module Invidious::Routes::Login + def self.login_page(env) + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" - return env.redirect "/feed/subscriptions" if user + referer = get_referer(env, "/feed/subscriptions") + + return env.redirect referer if user if !CONFIG.login_enabled return error_template(400, "Login has been disabled by administrator.") end - referer = get_referer(env, "/feed/subscriptions") - email = nil password = nil captcha = nil @@ -22,14 +24,11 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute captcha_type = env.params.query["captcha"]? captcha_type ||= "image" - tfa = env.params.query["tfa"]? - prompt = nil - - templated "login" + templated "user/login" end - def login(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.login(env) + locale = env.get("preferences").as(Preferences).locale referer = get_referer(env, "/feed/subscriptions") @@ -45,304 +44,23 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute account_type ||= "invidious" case account_type - when "google" - tfa_code = env.params.body["tfa"]?.try &.lchop("G-") - traceback = IO::Memory.new - - # See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82 - begin - client = QUIC::Client.new(LOGIN_URL) - headers = HTTP::Headers.new - - login_page = client.get("/ServiceLogin") - headers = login_page.cookies.add_request_headers(headers) - - lookup_req = { - email, nil, [] of String, nil, "US", nil, nil, 2, false, true, - {nil, nil, - {2, 1, nil, 1, - "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", - nil, [] of String, 4}, - 1, - {nil, nil, [] of String}, - nil, nil, nil, true, - }, - email, - }.to_json - - traceback << "Getting lookup..." - - headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8" - headers["Google-Accounts-XSRF"] = "1" - - response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req)) - lookup_results = JSON.parse(response.body[5..-1]) - - traceback << "done, returned #{response.status_code}.<br/>" - - user_hash = lookup_results[0][2] - - if token = env.params.body["token"]? - answer = env.params.body["answer"]? - captcha = {token, answer} - else - captcha = nil - end - - challenge_req = { - user_hash, nil, 1, nil, - {1, nil, nil, nil, - {password, captcha, true}, - }, - {nil, nil, - {2, 1, nil, 1, - "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", - nil, [] of String, 4}, - 1, - {nil, nil, [] of String}, - nil, nil, nil, true, - }, - }.to_json - - traceback << "Getting challenge..." - - response = client.post("/_/signin/sl/challenge", headers, login_req(challenge_req)) - headers = response.cookies.add_request_headers(headers) - challenge_results = JSON.parse(response.body[5..-1]) - - traceback << "done, returned #{response.status_code}.<br/>" - - headers["Cookie"] = URI.decode_www_form(headers["Cookie"]) - - if challenge_results[0][3]?.try &.== 7 - return error_template(423, "Account has temporarily been disabled") - end - - if token = challenge_results[0][-1]?.try &.[-1]?.try &.as_h?.try &.["5001"]?.try &.[-1].as_a?.try &.[-1].as_s - account_type = "google" - captcha_type = "image" - prompt = nil - tfa = tfa_code - captcha = {tokens: [token], question: ""} - - return templated "login" - end - - if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" - return error_template(401, "Incorrect password") - end - - prompt_type = challenge_results[0][-1]?.try &.[0].as_a?.try &.[0][2]? - if {"TWO_STEP_VERIFICATION", "LOGIN_CHALLENGE"}.includes? prompt_type - traceback << "Handling prompt #{prompt_type}.<br/>" - case prompt_type - when "TWO_STEP_VERIFICATION" - prompt_type = 2 - else # "LOGIN_CHALLENGE" - prompt_type = 4 - end - - # Prefer Authenticator app and SMS over unsupported protocols - if !{6, 9, 12, 15}.includes?(challenge_results[0][-1][0][0][8].as_i) && prompt_type == 2 - tfa = challenge_results[0][-1][0].as_a.select { |auth_type| {6, 9, 12, 15}.includes? auth_type[8] }[0] - - traceback << "Selecting challenge #{tfa[8]}..." - select_challenge = {prompt_type, nil, nil, nil, {tfa[8]}}.to_json - - tl = challenge_results[1][2] - - tfa = client.post("/_/signin/selectchallenge?TL=#{tl}", headers, login_req(select_challenge)).body - tfa = tfa[5..-1] - tfa = JSON.parse(tfa)[0][-1] - - traceback << "done.<br/>" - else - traceback << "Using challenge #{challenge_results[0][-1][0][0][8]}.<br/>" - tfa = challenge_results[0][-1][0][0] - end - - if tfa[5] == "QUOTA_EXCEEDED" - return error_template(423, "Quota exceeded, try again in a few hours") - end - - if !tfa_code - account_type = "google" - captcha_type = "image" - - case tfa[8] - when 6, 9 - prompt = "Google verification code" - when 12 - prompt = "Login verification, recovery email: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" - when 15 - prompt = "Login verification, security question: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" - else - prompt = "Google verification code" - end - - tfa = nil - captcha = nil - return templated "login" - end - - tl = challenge_results[1][2] - - request_type = tfa[8] - case request_type - when 6 # Authenticator app - tfa_req = { - user_hash, nil, 2, nil, - {6, nil, nil, nil, nil, - {tfa_code, false}, - }, - }.to_json - when 9 # Voice or text message - tfa_req = { - user_hash, nil, 2, nil, - {9, nil, nil, nil, nil, nil, nil, nil, - {nil, tfa_code, false, 2}, - }, - }.to_json - when 12 # Recovery email - tfa_req = { - user_hash, nil, 4, nil, - {12, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - {tfa_code}, - }, - }.to_json - when 15 # Security question - tfa_req = { - user_hash, nil, 5, nil, - {15, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - {tfa_code}, - }, - }.to_json - else - return error_template(500, "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.") - end - - traceback << "Submitting challenge..." - - response = client.post("/_/signin/challenge?hl=en&TL=#{tl}", headers, login_req(tfa_req)) - headers = response.cookies.add_request_headers(headers) - challenge_results = JSON.parse(response.body[5..-1]) - - if (challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED") || - (challenge_results[0][-1]?.try &.[5] == "INVALID_INPUT") - return error_template(401, "Invalid TFA code") - end - - traceback << "done.<br/>" - end - - traceback << "Logging in..." - - location = URI.parse(challenge_results[0][-1][2].to_s) - cookies = HTTP::Cookies.from_headers(headers) - - headers.delete("Content-Type") - headers.delete("Google-Accounts-XSRF") - - loop do - if !location || location.path == "/ManageAccount" - break - end - - # Occasionally there will be a second page after login confirming - # the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently don't handle. - - if location.path.starts_with? "/b/0/SmsAuthInterstitial" - traceback << "Unhandled dialog /b/0/SmsAuthInterstitial." - end - - login = client.get(location.request_target, headers) - - headers = login.cookies.add_request_headers(headers) - location = login.headers["Location"]?.try { |u| URI.parse(u) } - end - - cookies = HTTP::Cookies.from_headers(headers) - sid = cookies["SID"]?.try &.value - if !sid - raise "Couldn't get SID." - end - - user, sid = get_user(sid, headers, PG_DB) - - # We are now logged in - traceback << "done.<br/>" - - host = URI.parse(env.request.headers["Host"]).host - - if Kemal.config.ssl || CONFIG.https_only - secure = true - else - secure = false - end - - cookies.each do |cookie| - if Kemal.config.ssl || CONFIG.https_only - cookie.secure = secure - else - cookie.secure = secure - end - - if cookie.extension - cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host) - cookie.extension = cookie.extension.not_nil!.gsub("Secure; ", "") - end - env.response.cookies << cookie - end - - if env.request.cookies["PREFS"]? - preferences = env.get("preferences").as(Preferences) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) - - cookie = env.request.cookies["PREFS"] - cookie.expires = Time.utc(1990, 1, 1) - env.response.cookies << cookie - end - - env.redirect referer - rescue ex - traceback.rewind - # error_message = translate(locale, "Login failed. This may be because two-factor authentication is not turned on for your account.") - error_message = %(#{ex.message}<br/>Traceback:<br/><div style="padding-left:2em" id="traceback">#{traceback.gets_to_end}</div>) - return error_template(500, error_message) - end when "invidious" - if !email + if email.nil? || email.empty? return error_template(401, "User ID is a required field") end - if !password + if password.nil? || password.empty? return error_template(401, "Password is a required field") end - user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1", email, as: User) + user = Invidious::Database::Users.select(email: email) if user - if !user.password - return error_template(400, "Please sign in using 'Log in with Google'") - end - if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) - - if Kemal.config.ssl || CONFIG.https_only - secure = true - else - secure = false - end + Invidious::Database::SessionIDs.insert(sid, email) - if CONFIG.domain - env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years, - secure: secure, http_only: true) - else - env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years, - secure: secure, http_only: true) - end + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) else return error_template(401, "Wrong username or password") end @@ -381,19 +99,17 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute captcha_type ||= "image" account_type = "invidious" - tfa = false - prompt = "" if captcha_type == "image" - captcha = generate_captcha(HMAC_KEY, PG_DB) + captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) else - captcha = generate_text_captcha(HMAC_KEY, PG_DB) + captcha = Invidious::User::Captcha.generate_text(HMAC_KEY) end - return templated "login" + return templated "user/login" end - tokens = env.params.body.select { |k, v| k.match(/^token\[\d+\]$/) }.map { |k, v| v } + tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v } answer ||= "" captcha_type ||= "image" @@ -404,7 +120,7 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) begin - validate_request(tokens[0], answer, env.request, HMAC_KEY, PG_DB, locale) + validate_request(tokens[0], answer, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end @@ -417,9 +133,9 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute found_valid_captcha = false error_exception = Exception.new - tokens.each_with_index do |token, i| + tokens.each do |tok| begin - validate_request(token, answer, env.request, HMAC_KEY, PG_DB, locale) + validate_request(tok, answer, env.request, HMAC_KEY, locale) found_valid_captcha = true rescue ex error_exception = ex @@ -434,34 +150,24 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) user, sid = create_user(sid, email, password) - user_array = user.to_a - user_array[4] = user_array[4].to_json # User preferences - args = arg_array(user_array) + if language_header = env.request.headers["Accept-Language"]? + if language = ANG.language_negotiator.best(language_header, LOCALES.keys) + user.preferences.locale = language.header + end + end - PG_DB.exec("INSERT INTO users VALUES (#{args})", args: user_array) - PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) + Invidious::Database::Users.insert(user) + Invidious::Database::SessionIDs.insert(sid, email) view_name = "subscriptions_#{sha256(user.email)}" PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") - if Kemal.config.ssl || CONFIG.https_only - secure = true - else - secure = false - end - - if CONFIG.domain - env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years, - secure: secure, http_only: true) - else - env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years, - secure: secure, http_only: true) - end + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) if env.request.cookies["PREFS"]? - preferences = env.get("preferences").as(Preferences) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) + user.preferences = env.get("preferences").as(Preferences) + Invidious::Database::Users.update_preferences(user) cookie = env.request.cookies["PREFS"] cookie.expires = Time.utc(1990, 1, 1) @@ -475,8 +181,8 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute end end - def signout(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.signout(env) + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -491,12 +197,12 @@ class Invidious::Routes::Login < Invidious::Routes::BaseRoute token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", sid) + Invidious::Database::SessionIDs.delete(sid: sid) env.request.cookies.each do |cookie| cookie.expires = Time.utc(1990, 1, 1) diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index d32ba892..8b620d63 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -1,7 +1,9 @@ -class Invidious::Routes::Misc < Invidious::Routes::BaseRoute - def home(env) +{% skip_file if flag?(:api_only) %} + +module Invidious::Routes::Misc + def self.home(env) preferences = env.get("preferences").as(Preferences) - locale = LOCALES[preferences.locale]? + locale = preferences.locale user = env.get? "user" case preferences.default_home @@ -17,7 +19,7 @@ class Invidious::Routes::Misc < Invidious::Routes::BaseRoute end when "Playlists" if user - env.redirect "/view_all_playlists" + env.redirect "/feed/playlists" else env.redirect "/feed/popular" end @@ -26,13 +28,28 @@ class Invidious::Routes::Misc < Invidious::Routes::BaseRoute end end - def privacy(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.privacy(env) + locale = env.get("preferences").as(Preferences).locale templated "privacy" end - def licenses(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.licenses(env) + locale = env.get("preferences").as(Preferences).locale rendered "licenses" end + + def self.cross_instance_redirect(env) + referer = get_referer(env) + + instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"] + if instance_list.empty? + instance_url = "redirect.invidious.io" + else + # Sample returns an array + # Instances are packaged as {region, domain} in the instance list + instance_url = instance_list.sample(1)[0][1] + end + + env.redirect "https://#{instance_url}#{referer}" + end end diff --git a/src/invidious/routes/notifications.cr b/src/invidious/routes/notifications.cr new file mode 100644 index 00000000..8922b740 --- /dev/null +++ b/src/invidious/routes/notifications.cr @@ -0,0 +1,34 @@ +module Invidious::Routes::Notifications + # /modify_notifications + # will "ding" all subscriptions. + # /modify_notifications?receive_all_updates=false&receive_no_updates=false + # will "unding" all subscriptions. + def self.modify(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, "/") + + redirect = env.params.query["redirect"]? + redirect ||= "false" + redirect = redirect == "true" + + if !user + if redirect + return env.redirect referer + else + return error_json(403, "No such user") + end + end + + user = user.as(User) + + if redirect + env.redirect referer + else + env.response.content_type = "application/json" + "{}" + end + end +end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 1f7fa27d..9c6843e9 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -1,31 +1,8 @@ -class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute - def index(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? +{% skip_file if flag?(:api_only) %} - user = env.get? "user" - referer = get_referer(env) - - return env.redirect "/" if user.nil? - - user = user.as(User) - - items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) - items_created.map! do |item| - item.author = "" - item - end - - items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) - items_saved.map! do |item| - item.author = "" - item - end - - templated "view_all_playlists" - end - - def new(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? +module Invidious::Routes::Playlists + def self.new(env) + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -35,13 +12,13 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute user = user.as(User) sid = sid.as(String) - csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY) templated "create_playlist" end - def create(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.create(env) + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -54,7 +31,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end @@ -69,17 +46,17 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute return error_template(400, "Invalid privacy setting.") end - if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 + if Invidious::Database::Playlists.count_owned_by(user.email) >= 100 return error_template(400, "User cannot have more than 100 playlists.") end - playlist = create_playlist(PG_DB, title, privacy, user) + playlist = create_playlist(title, privacy, user) env.redirect "/playlist?list=#{playlist.id}" end - def subscribe(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.subscribe(env) + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" referer = get_referer(env) @@ -89,14 +66,20 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute user = user.as(User) playlist_id = env.params.query["list"] - playlist = get_playlist(PG_DB, playlist_id, locale) - subscribe_playlist(PG_DB, user, playlist) + begin + playlist = get_playlist(playlist_id) + rescue ex : NotFoundException + return error_template(404, ex) + rescue ex + return error_template(500, ex) + end + subscribe_playlist(user, playlist) env.redirect "/playlist?list=#{playlist.id}" end - def delete_page(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.delete_page(env) + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -108,18 +91,22 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute sid = sid.as(String) plid = env.params.query["list"]? - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !plid || plid.empty? + return error_template(400, "A playlist ID is required") + end + + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email return env.redirect referer end - csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY) templated "delete_playlist" end - def delete(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.delete(env) + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -135,24 +122,23 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email return env.redirect referer end - PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) - PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) + Invidious::Database::Playlists.delete(plid) - env.redirect "/view_all_playlists" + env.redirect "/feed/playlists" end - def edit(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.edit(env) + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -171,28 +157,31 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute page = env.params.query["page"]?.try &.to_i? page ||= 1 - begin - playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email - return env.redirect referer - end - rescue ex + playlist = Invidious::Database::Playlists.select(id: plid) + if !playlist || playlist.author != user.email return env.redirect referer end begin - videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) + items = get_playlist_videos(playlist, offset: (page - 1) * 100) rescue ex - videos = [] of PlaylistVideo + items = [] of PlaylistVideo end - csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY) + + # Pagination + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/playlist?list=#{playlist.id}", + current_page: page, + show_next: (items.size == 100) + ) templated "edit_playlist" end - def update(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.update(env) + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -208,12 +197,12 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email return env.redirect referer end @@ -230,13 +219,16 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute updated = playlist.updated end - PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid) + Invidious::Database::Playlists.update(plid, title, privacy, description, updated) env.redirect "/playlist?list=#{plid}" end - def add_playlist_items_page(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.add_playlist_items_page(env) + prefs = env.get("preferences").as(Preferences) + locale = prefs.locale + + region = env.params.query["region"]? || prefs.region user = env.get? "user" sid = env.get? "sid" @@ -255,35 +247,32 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute page = env.params.query["page"]?.try &.to_i? page ||= 1 - begin - playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email - return env.redirect referer - end - rescue ex + playlist = Invidious::Database::Playlists.select(id: plid) + if !playlist || playlist.author != user.email return env.redirect referer end - query = env.params.query["q"]? - if query - begin - search_query, count, items, operators = process_search_query(query, page, user, region: nil) - videos = items.select { |item| item.is_a? SearchVideo }.map { |item| item.as(SearchVideo) } - rescue ex - videos = [] of SearchVideo - count = 0 - end - else - videos = [] of SearchVideo - count = 0 + begin + query = Invidious::Search::Query.new(env.params.query, :playlist, region) + items = query.process.select(SearchVideo).map(&.as(SearchVideo)) + rescue ex + items = [] of SearchVideo end + # Pagination + query_encoded = URI.encode_www_form(query.try &.text || "", space_to_plus: true) + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/add_playlist_items?list=#{playlist.id}&q=#{query_encoded}", + current_page: page, + show_next: (items.size >= 20) + ) + env.set "add_playlist_items", plid templated "add_playlist_items" end - def playlist_ajax(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.playlist_ajax(env) + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -306,7 +295,7 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex if redirect return error_template(400, ex) @@ -334,8 +323,10 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute begin playlist_id = env.params.query["playlist_id"] - playlist = get_playlist(PG_DB, playlist_id, locale).as(InvidiousPlaylist) + playlist = get_playlist(playlist_id).as(InvidiousPlaylist) raise "Invalid user" if playlist.author != user.email + rescue ex : NotFoundException + return error_json(404, ex) rescue ex if redirect return error_template(400, ex) @@ -344,28 +335,26 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute end end - if !user.password - # TODO: Playlist stub, sync with YouTube for Google accounts - # playlist_ajax(playlist_id, action, env.request.headers) - end email = user.email case action when "action_edit_playlist" # TODO: Playlist stub when "action_add_video" - if playlist.index.size >= 500 + if playlist.index.size >= CONFIG.playlist_length_limit if redirect - return error_template(400, "Playlist cannot have more than 500 videos") + return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") else - return error_json(400, "Playlist cannot have more than 500 videos") + return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") end end video_id = env.params.query["video_id"] begin - video = get_video(video_id, PG_DB) + video = get_video(video_id) + rescue ex : NotFoundException + return error_json(404, ex) rescue ex if redirect return error_template(500, ex) @@ -386,15 +375,12 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute index: Random::Secure.rand(0_i64..Int64::MAX), }) - video_array = playlist_video.to_a - args = arg_array(video_array) - - PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) - PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist_id) + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index) when "action_remove_video" index = env.params.query["set_video_id"] - PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) - PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, playlist_id) + Invidious::Database::PlaylistVideos.delete(index) + Invidious::Database::Playlists.update_video_removed(playlist_id, index) when "action_move_video_before" # TODO: Playlist stub else @@ -409,8 +395,8 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute end end - def show(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.show(env) + locale = env.get("preferences").as(Preferences).locale user = env.get?("user").try &.as(User) referer = get_referer(env) @@ -428,13 +414,20 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute end begin - playlist = get_playlist(PG_DB, plid, locale) + playlist = get_playlist(plid) + rescue ex : NotFoundException + return error_template(404, ex) rescue ex return error_template(500, ex) end - page_count = (playlist.video_count / 100).to_i - page_count += 1 if (playlist.video_count % 100) > 0 + if playlist.is_a? InvidiousPlaylist + page_count = (playlist.video_count / 100).to_i + page_count += 1 if (playlist.video_count % 100) > 0 + else + page_count = (playlist.video_count / 200).to_i + page_count += 1 if (playlist.video_count % 200) > 0 + end if page > page_count return env.redirect "/playlist?list=#{plid}&page=#{page_count}" @@ -445,7 +438,11 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute end begin - videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) + if playlist.is_a? InvidiousPlaylist + items = get_playlist_videos(playlist, offset: (page - 1) * 100) + else + items = get_playlist_videos(playlist, offset: (page - 1) * 200) + end rescue ex return error_template(500, "Error encountered while retrieving playlist videos.<br>#{ex.message}") end @@ -454,11 +451,18 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute env.set "remove_playlist_items", plid end + # Pagination + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/playlist?list=#{playlist.id}", + current_page: page, + show_next: (page_count != 1 && page < page_count) + ) + templated "playlist" end - def mix(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.mix(env) + locale = env.get("preferences").as(Preferences).locale rdid = env.params.query["list"]? if !rdid @@ -476,4 +480,15 @@ class Invidious::Routes::Playlists < Invidious::Routes::BaseRoute templated "mix" end + + # Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos + def self.watch_videos(env) + response = YT_POOL.client &.get(env.request.resource) + if url = response.headers["Location"]? + url = URI.parse(url).request_target + return env.redirect url + end + + env.response.status_code = response.status_code + end end diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 4901d22b..39ca77c0 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -1,16 +1,18 @@ -class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute - def show(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? +{% skip_file if flag?(:api_only) %} + +module Invidious::Routes::PreferencesRoute + def self.show(env) + locale = env.get("preferences").as(Preferences).locale referer = get_referer(env) preferences = env.get("preferences").as(Preferences) - templated "preferences" + templated "user/preferences" end - def update(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.update(env) + locale = env.get("preferences").as(Preferences).locale referer = get_referer(env) video_loop = env.params.body["video_loop"]?.try &.as(String) @@ -25,6 +27,10 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute annotations_subscribed ||= "off" annotations_subscribed = annotations_subscribed == "on" + preload = env.params.body["preload"]?.try &.as(String) + preload ||= "off" + preload = preload == "on" + autoplay = env.params.body["autoplay"]?.try &.as(String) autoplay ||= "off" autoplay = autoplay == "on" @@ -45,6 +51,10 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute local ||= "off" local = local == "on" + watch_history = env.params.body["watch_history"]?.try &.as(String) + watch_history ||= "off" + watch_history = watch_history == "on" + speed = env.params.body["speed"]?.try &.as(String).to_f32? speed ||= CONFIG.default_user_preferences.speed @@ -60,6 +70,22 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute volume = env.params.body["volume"]?.try &.as(String).to_i? volume ||= CONFIG.default_user_preferences.volume + extend_desc = env.params.body["extend_desc"]?.try &.as(String) + extend_desc ||= "off" + extend_desc = extend_desc == "on" + + vr_mode = env.params.body["vr_mode"]?.try &.as(String) + vr_mode ||= "off" + vr_mode = vr_mode == "on" + + save_player_pos = env.params.body["save_player_pos"]?.try &.as(String) + save_player_pos ||= "off" + save_player_pos = save_player_pos == "on" + + show_nick = env.params.body["show_nick"]?.try &.as(String) + show_nick ||= "off" + show_nick = show_nick == "on" + comments = [] of String 2.times do |i| comments << (env.params.body["comments[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.comments[i]) @@ -84,6 +110,12 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute end end + automatic_instance_redirect = env.params.body["automatic_instance_redirect"]?.try &.as(String) + automatic_instance_redirect ||= "off" + automatic_instance_redirect = automatic_instance_redirect == "on" + + region = env.params.body["region"]?.try &.as(String) + locale = env.params.body["locale"]?.try &.as(String) locale ||= CONFIG.default_user_preferences.locale @@ -112,39 +144,48 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute notifications_only ||= "off" notifications_only = notifications_only == "on" - # Convert to JSON and back again to take advantage of converters used for compatability + # Convert to JSON and back again to take advantage of converters used for compatibility preferences = Preferences.from_json({ - annotations: annotations, - annotations_subscribed: annotations_subscribed, - autoplay: autoplay, - captions: captions, - comments: comments, - continue: continue, - continue_autoplay: continue_autoplay, - dark_mode: dark_mode, - latest_only: latest_only, - listen: listen, - local: local, - locale: locale, - max_results: max_results, - notifications_only: notifications_only, - player_style: player_style, - quality: quality, - quality_dash: quality_dash, - default_home: default_home, - feed_menu: feed_menu, - related_videos: related_videos, - sort: sort, - speed: speed, - thin_mode: thin_mode, - unseen_only: unseen_only, - video_loop: video_loop, - volume: volume, - }.to_json).to_json + annotations: annotations, + annotations_subscribed: annotations_subscribed, + preload: preload, + autoplay: autoplay, + captions: captions, + comments: comments, + continue: continue, + continue_autoplay: continue_autoplay, + dark_mode: dark_mode, + latest_only: latest_only, + listen: listen, + local: local, + watch_history: watch_history, + locale: locale, + max_results: max_results, + notifications_only: notifications_only, + player_style: player_style, + quality: quality, + quality_dash: quality_dash, + default_home: default_home, + feed_menu: feed_menu, + automatic_instance_redirect: automatic_instance_redirect, + region: region, + related_videos: related_videos, + sort: sort, + speed: speed, + thin_mode: thin_mode, + unseen_only: unseen_only, + video_loop: video_loop, + volume: volume, + extend_desc: extend_desc, + vr_mode: vr_mode, + show_nick: show_nick, + save_player_pos: save_player_pos, + }.to_json) if user = env.get? "user" user = user.as(User) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) + user.preferences = preferences + Invidious::Database::Users.update_preferences(user) if CONFIG.admins.includes? user.email CONFIG.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home @@ -178,29 +219,19 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute statistics_enabled ||= "off" CONFIG.statistics_enabled = statistics_enabled == "on" + CONFIG.modified_source_code_url = env.params.body["modified_source_code_url"]?.presence + File.write("config/config.yml", CONFIG.to_yaml) end else - if Kemal.config.ssl || CONFIG.https_only - secure = true - else - secure = false - end - - if CONFIG.domain - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: preferences, expires: Time.utc + 2.years, - secure: secure, http_only: true) - else - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: preferences, expires: Time.utc + 2.years, - secure: secure, http_only: true) - end + env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences) end env.redirect referer end - def toggle_theme(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.toggle_theme(env) + locale = env.get("preferences").as(Preferences).locale referer = get_referer(env, unroll: false) redirect = env.params.query["redirect"]? @@ -209,18 +240,15 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute if user = env.get? "user" user = user.as(User) - preferences = user.preferences - case preferences.dark_mode + case user.preferences.dark_mode when "dark" - preferences.dark_mode = "light" + user.preferences.dark_mode = "light" else - preferences.dark_mode = "dark" + user.preferences.dark_mode = "dark" end - preferences = preferences.to_json - - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) + Invidious::Database::Users.update_preferences(user) else preferences = env.get("preferences").as(Preferences) @@ -231,21 +259,7 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute preferences.dark_mode = "dark" end - preferences = preferences.to_json - - if Kemal.config.ssl || CONFIG.https_only - secure = true - else - secure = false - end - - if CONFIG.domain - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: preferences, expires: Time.utc + 2.years, - secure: secure, http_only: true) - else - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: preferences, expires: Time.utc + 2.years, - secure: secure, http_only: true) - end + env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences) end if redirect @@ -255,4 +269,87 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute "{}" end end + + def self.data_control(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + + templated "user/data_control" + end + + def self.update_data_control(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + referer = get_referer(env) + + if user + user = user.as(User) + + # TODO: Find a way to prevent browser timeout + + HTTP::FormData.parse(env.request) do |part| + body = part.body.gets_to_end + type = part.headers["Content-Type"] + + next if body.empty? + + # TODO: Unify into single import based on content-type + case part.name + when "import_invidious" + Invidious::User::Import.from_invidious(user, body) + when "import_youtube" + filename = part.filename || "" + success = Invidious::User::Import.from_youtube(user, body, filename, type) + + if !success + haltf(env, status_code: 415, + response: error_template(415, "Invalid subscription file uploaded") + ) + end + when "import_youtube_pl" + filename = part.filename || "" + success = Invidious::User::Import.from_youtube_pl(user, body, filename, type) + + if !success + haltf(env, status_code: 415, + response: error_template(415, "Invalid playlist file uploaded") + ) + end + when "import_youtube_wh" + filename = part.filename || "" + success = Invidious::User::Import.from_youtube_wh(user, body, filename, type) + + if !success + haltf(env, status_code: 415, + response: error_template(415, "Invalid watch history file uploaded") + ) + end + when "import_freetube" + Invidious::User::Import.from_freetube(user, body) + when "import_newpipe_subscriptions" + Invidious::User::Import.from_newpipe_subs(user, body) + when "import_newpipe" + success = Invidious::User::Import.from_newpipe(user, body) + + if !success + haltf(env, status_code: 415, + response: error_template(415, "Uploaded file is too large") + ) + end + else nil # Ignore + end + end + end + + env.redirect referer + end end diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index a993a17a..44970922 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -1,6 +1,8 @@ -class Invidious::Routes::Search < Invidious::Routes::BaseRoute - def opensearch(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? +{% skip_file if flag?(:api_only) %} + +module Invidious::Routes::Search + def self.opensearch(env) + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/opensearchdescription+xml" XML.build(indent: " ", encoding: "UTF-8") do |xml| @@ -15,51 +17,103 @@ class Invidious::Routes::Search < Invidious::Routes::BaseRoute end end - def results(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + def self.results(env) + locale = env.get("preferences").as(Preferences).locale query = env.params.query["search_query"]? query ||= env.params.query["q"]? - query ||= "" - page = env.params.query["page"]?.try &.to_i? - page ||= 1 + page = env.params.query["page"]? - if query - env.redirect "/search?q=#{URI.encode_www_form(query)}&page=#{page}" + if query && !query.empty? + if page && !page.empty? + env.redirect "/search?q=" + URI.encode_www_form(query) + "&page=" + page + else + env.redirect "/search?q=" + URI.encode_www_form(query) + end else - env.redirect "/" + env.redirect "/search" end end - def search(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? + def self.search(env) + prefs = env.get("preferences").as(Preferences) + locale = prefs.locale - query = env.params.query["search_query"]? - query ||= env.params.query["q"]? - query ||= "" + region = env.params.query["region"]? || prefs.region + + query = Invidious::Search::Query.new(env.params.query, :regular, region) + + if query.empty? + # Display the full page search box implemented in #1977 + env.set "search", "" + templated "search_homepage", navbar_search: false + else + user = env.get? "user" + + # An URL was copy/pasted in the search box. + # Redirect the user to the appropriate page. + if query.url? + return env.redirect UrlSanitizer.process(query.text).to_s + end + + begin + items = query.process + rescue ex : ChannelSearchException + return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.") + rescue ex + return error_template(500, ex) + end - return env.redirect "/" if query.empty? + redirect_url = Invidious::Frontend::Misc.redirect_url(env) - page = env.params.query["page"]?.try &.to_i? - page ||= 1 + # Pagination + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/search?#{query.to_http_params}", + current_page: query.page, + show_next: (items.size >= 20) + ) - user = env.get? "user" + if query.type == Invidious::Search::Query::Type::Channel + env.set "search", "channel:#{query.channel} #{query.text}" + else + env.set "search", query.text + end + + templated "search" + end + end + + def self.hashtag(env : HTTP::Server::Context) + locale = env.get("preferences").as(Preferences).locale + + hashtag = env.params.url["hashtag"]? + if hashtag.nil? || hashtag.empty? + return error_template(400, "Invalid request") + end + + page = env.params.query["page"]? + if page.nil? + page = 1 + else + page = Math.max(1, page.to_i) + env.params.query.delete_all("page") + end begin - search_query, count, videos, operators = process_search_query(query, page, user, region: nil) + items = Invidious::Hashtag.fetch(hashtag, page) rescue ex return error_template(500, ex) end - operator_hash = {} of String => String - operators.each do |operator| - key, value = operator.downcase.split(":") - operator_hash[key] = value - end + # Pagination + hashtag_encoded = URI.encode_www_form(hashtag, space_to_plus: false) + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/hashtag/#{hashtag_encoded}", + current_page: page, + show_next: (items.size >= 60) + ) - env.set "search", query - templated "search" + templated "hashtag" end end diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr new file mode 100644 index 00000000..7f9ec592 --- /dev/null +++ b/src/invidious/routes/subscriptions.cr @@ -0,0 +1,130 @@ +module Invidious::Routes::Subscriptions + def self.toggle_subscription(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, "/") + + redirect = env.params.query["redirect"]? + redirect ||= "true" + redirect = redirect == "true" + + if !user + if redirect + return env.redirect referer + else + return error_json(403, "No such user") + end + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + if redirect + return error_template(400, ex) + else + return error_json(400, ex) + end + end + + if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1 + action = "action_create_subscription_to_channel" + elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1 + action = "action_remove_subscriptions" + else + return env.redirect referer + end + + channel_id = env.params.query["c"]? + channel_id ||= "" + + case action + when "action_create_subscription_to_channel" + if !user.subscriptions.includes? channel_id + get_channel(channel_id) + Invidious::Database::Users.subscribe_channel(user, channel_id) + end + when "action_remove_subscriptions" + Invidious::Database::Users.unsubscribe_channel(user, channel_id) + else + return error_json(400, "Unsupported action #{action}") + end + + if redirect + env.redirect referer + else + env.response.content_type = "application/json" + "{}" + end + end + + def self.subscription_manager(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + + action_takeout = env.params.query["action_takeout"]?.try &.to_i? + action_takeout ||= 0 + action_takeout = action_takeout == 1 + + format = env.params.query["format"]? + format ||= "rss" + + subscriptions = Invidious::Database::Channels.select(user.subscriptions) + subscriptions.sort_by!(&.author.downcase) + + if action_takeout + if format == "json" + env.response.content_type = "application/json" + env.response.headers["content-disposition"] = "attachment" + + return Invidious::User::Export.to_invidious(user) + else + env.response.content_type = "application/xml" + env.response.headers["content-disposition"] = "attachment" + export = XML.build do |xml| + xml.element("opml", version: "1.1") do + xml.element("body") do + if format == "newpipe" + title = "YouTube Subscriptions" + else + title = "Invidious Subscriptions" + end + + xml.element("outline", text: title, title: title) do + subscriptions.each do |channel| + if format == "newpipe" + xml_url = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}" + else + xml_url = "#{HOST_URL}/feed/channel/#{channel.id}" + end + + xml.element("outline", text: channel.author, title: channel.author, + "type": "rss", xmlUrl: xml_url) + end + end + end + end + end + + return export.gsub(%(<?xml version="1.0"?>\n), "") + end + end + + templated "user/subscription_manager" + end +end diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr new file mode 100644 index 00000000..26852d06 --- /dev/null +++ b/src/invidious/routes/video_playback.cr @@ -0,0 +1,303 @@ +module Invidious::Routes::VideoPlayback + # /videoplayback + def self.get_video_playback(env) + locale = env.get("preferences").as(Preferences).locale + query_params = env.params.query + + fvip = query_params["fvip"]? || "3" + mns = query_params["mn"]?.try &.split(",") + mns ||= [] of String + + if query_params["region"]? + region = query_params["region"] + query_params.delete("region") + end + + if query_params["host"]? && !query_params["host"].empty? + host = query_params["host"] + query_params.delete("host") + else + host = "r#{fvip}---#{mns.pop}.googlevideo.com" + end + + # Sanity check, to avoid being used as an open proxy + if !host.matches?(/[\w-]+.googlevideo.com/) + return error_template(400, "Invalid \"host\" parameter.") + end + + host = "https://#{host}" + url = "/videoplayback?#{query_params}" + + headers = HTTP::Headers.new + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + # See: https://github.com/iv-org/invidious/issues/3302 + range_header = env.request.headers["Range"]? + if range_header.nil? + range_for_head = query_params["range"]? || "0-640" + headers["Range"] = "bytes=#{range_for_head}" + end + + client = make_client(URI.parse(host), region, force_resolve: true) + response = HTTP::Client::Response.new(500) + error = "" + 5.times do + begin + response = client.head(url, headers) + + if response.headers["Location"]? + location = URI.parse(response.headers["Location"]) + env.response.headers["Access-Control-Allow-Origin"] = "*" + + new_host = "#{location.scheme}://#{location.host}" + if new_host != host + host = new_host + client.close + client = make_client(URI.parse(new_host), region, force_resolve: true) + end + + url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" + else + break + end + rescue Socket::Addrinfo::Error + if !mns.empty? + mn = mns.pop + end + fvip = "3" + + host = "https://r#{fvip}---#{mn}.googlevideo.com" + client = make_client(URI.parse(host), region, force_resolve: true) + rescue ex + error = ex.message + end + end + + # Remove the Range header added previously. + headers.delete("Range") if range_header.nil? + + playback_statistics = get_playback_statistic() + playback_statistics["totalRequests"] += 1 + + if response.status_code >= 400 + env.response.content_type = "text/plain" + haltf env, response.status_code + else + playback_statistics["successfulRequests"] += 1 + end + + if url.includes? "&file=seg.ts" + if CONFIG.disabled?("livestreams") + return error_template(403, "Administrator has disabled this endpoint.") + end + + begin + client.get(url, headers) do |resp| + resp.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if location = resp.headers["Location"]? + url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region) + return env.redirect url + end + + IO.copy(resp.body_io, env.response) + end + rescue ex + end + else + if query_params["title"]? && CONFIG.disabled?("downloads") || + CONFIG.disabled?("dash") + return error_template(403, "Administrator has disabled this endpoint.") + end + + content_length = nil + first_chunk = true + range_start, range_end = parse_range(env.request.headers["Range"]?) + chunk_start = range_start + chunk_end = range_end + + if !chunk_end || chunk_end - chunk_start > HTTP_CHUNK_SIZE + chunk_end = chunk_start + HTTP_CHUNK_SIZE - 1 + end + + # TODO: Record bytes written so we can restart after a chunk fails + loop do + if !range_end && content_length + range_end = content_length + end + + if range_end && chunk_start > range_end + break + end + + if range_end && chunk_end > range_end + chunk_end = range_end + end + + headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}" + + begin + client.get(url, headers) do |resp| + if first_chunk + if !env.request.headers["Range"]? && resp.status_code == 206 + env.response.status_code = 200 + else + env.response.status_code = resp.status_code + end + + resp.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) && key.downcase != "content-range" + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if location = resp.headers["Location"]? + location = URI.parse(location) + location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" + + env.redirect location + break + end + + if title = query_params["title"]? + # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ + filename = URI.encode_www_form(title, space_to_plus: false) + header = "attachment; filename=\"#{filename}\"; filename*=UTF-8''#{filename}" + env.response.headers["Content-Disposition"] = header + end + + if !resp.headers.includes_word?("Transfer-Encoding", "chunked") + content_length = resp.headers["Content-Range"].split("/")[-1].to_i64 + if env.request.headers["Range"]? + env.response.headers["Content-Range"] = "bytes #{range_start}-#{range_end || (content_length - 1)}/#{content_length}" + env.response.content_length = ((range_end.try &.+ 1) || content_length) - range_start + else + env.response.content_length = content_length + end + end + end + + proxy_file(resp, env) + end + rescue ex + if ex.message != "Error reading socket: Connection reset by peer" + break + else + client.close + client = make_client(URI.parse(host), region, force_resolve: true) + end + end + + chunk_start = chunk_end + 1 + chunk_end += HTTP_CHUNK_SIZE + first_chunk = false + end + end + client.close + end + + # /videoplayback/* + def self.get_video_playback_greedy(env) + path = env.request.path + + path = path.lchop("/videoplayback/") + path = path.rchop("/") + + path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| + mimetype = mimetype.split("/") + mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] + end + + path = path.split("/") + + raw_params = {} of String => Array(String) + path.each_slice(2) do |pair| + key, value = pair + value = URI.decode_www_form(value) + + if raw_params[key]? + raw_params[key] << value + else + raw_params[key] = [value] + end + end + + query_params = HTTP::Params.new(raw_params) + + env.response.headers["Access-Control-Allow-Origin"] = "*" + return env.redirect "/videoplayback?#{query_params}" + end + + # /videoplayback/* && /videoplayback/* + def self.options_video_playback(env) + env.response.headers.delete("Content-Type") + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" + env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" + end + + # /latest_version + # + # YouTube /videoplayback links expire after 6 hours, + # so we have a mechanism here to redirect to the latest version + def self.latest_version(env) + id = env.params.query["id"]? + itag = env.params.query["itag"]?.try &.to_i? + + # Sanity checks + if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/) + return error_template(400, "Invalid video ID") + end + + if !itag.nil? && (itag <= 0 || itag >= 1000) + return error_template(400, "Invalid itag") + end + + region = env.params.query["region"]? + local = (env.params.query["local"]? == "true") + + title = env.params.query["title"]? + + if title && CONFIG.disabled?("downloads") + return error_template(403, "Administrator has disabled this endpoint.") + end + + begin + video = get_video(id, region: region) + rescue ex : NotFoundException + return error_template(404, ex) + rescue ex + return error_template(500, ex) + end + + if itag.nil? + fmt = video.fmt_stream[-1]? + else + fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } + end + url = fmt.try &.["url"]?.try &.as_s + + if !url + haltf env, status_code: 404 + end + + if local + url = URI.parse(url).request_target.not_nil! + url += "&title=#{URI.encode_www_form(title, space_to_plus: false)}" if title + end + + return env.redirect url + end +end diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index d0338882..aabe8dfc 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -1,6 +1,8 @@ -class Invidious::Routes::Watch < Invidious::Routes::BaseRoute - def handle(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? +{% skip_file if flag?(:api_only) %} + +module Invidious::Routes::Watch + def self.handle(env) + locale = env.get("preferences").as(Preferences).locale region = env.params.query["region"]? if env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+") @@ -28,16 +30,8 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute return env.redirect "/" end - embed_link = "/embed/#{id}" - if env.params.query.size > 1 - embed_params = HTTP::Params.parse(env.params.query.to_s) - embed_params.delete_all("v") - embed_link += "?" - embed_link += embed_params.to_s - end - plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") - continuation = process_continuation(PG_DB, env.params.query, plid, id) + continuation = process_continuation(env.params.query, plid, id) nojs = env.params.query["nojs"]? @@ -58,9 +52,10 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute env.params.query.delete_all("listen") begin - video = get_video(id, PG_DB, region: params.region) - rescue ex : VideoRedirect - return env.redirect env.request.resource.gsub(id, ex.video_id) + video = get_video(id, region: params.region) + rescue ex : NotFoundException + LOGGER.error("get_video not found: #{id} : #{ex.message}") + return error_template(404, ex) rescue ex LOGGER.error("get_video: #{id} : #{ex.message}") return error_template(500, ex) @@ -73,12 +68,12 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute end env.params.query.delete_all("iv_load_policy") - if watched && !watched.includes? id - PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.as(User).email) + if watched && preferences.watch_history + Invidious::Database::Users.mark_watched(user.as(User), id) end - if notifications && notifications.includes? id - PG_DB.exec("UPDATE users SET notifications = array_remove(notifications, $1) WHERE email = $2", id, user.as(User).email) + if CONFIG.enable_user_notifications && notifications && notifications.includes? id + Invidious::Database::Users.remove_notification(user.as(User), id) env.get("user").as(User).notifications.delete(id) notifications.delete(id) end @@ -92,31 +87,31 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute if source == "youtube" begin - comment_html = JSON.parse(fetch_youtube_comments(id, PG_DB, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] rescue ex if preferences.comments[1] == "reddit" - comments, reddit_thread = fetch_reddit_comments(id) - comment_html = template_reddit_comments(comments, locale) + comments, reddit_thread = Comments.fetch_reddit(id) + comment_html = Frontend::Comments.template_reddit(comments, locale) - comment_html = fill_links(comment_html, "https", "www.reddit.com") - comment_html = replace_links(comment_html) + comment_html = Comments.fill_links(comment_html, "https", "www.reddit.com") + comment_html = Comments.replace_links(comment_html) end end elsif source == "reddit" begin - comments, reddit_thread = fetch_reddit_comments(id) - comment_html = template_reddit_comments(comments, locale) + comments, reddit_thread = Comments.fetch_reddit(id) + comment_html = Frontend::Comments.template_reddit(comments, locale) - comment_html = fill_links(comment_html, "https", "www.reddit.com") - comment_html = replace_links(comment_html) + comment_html = Comments.fill_links(comment_html, "https", "www.reddit.com") + comment_html = Comments.replace_links(comment_html) rescue ex if preferences.comments[1] == "youtube" - comment_html = JSON.parse(fetch_youtube_comments(id, PG_DB, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] end end end else - comment_html = JSON.parse(fetch_youtube_comments(id, PG_DB, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] end comment_html ||= "" @@ -150,12 +145,12 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute captions = video.captions preferred_captions = captions.select { |caption| - params.preferred_captions.includes?(caption.name.simpleText) || - params.preferred_captions.includes?(caption.languageCode.split("-")[0]) + params.preferred_captions.includes?(caption.name) || + params.preferred_captions.includes?(caption.language_code.split("-")[0]) } preferred_captions.sort_by! { |caption| - (params.preferred_captions.index(caption.name.simpleText) || - params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil! + (params.preferred_captions.index(caption.name) || + params.preferred_captions.index(caption.language_code.split("-")[0])).not_nil! } captions = captions - preferred_captions @@ -167,9 +162,11 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute if params.listen url = audio_streams[0]["url"].as_s - audio_streams.each do |fmt| - if fmt["bitrate"].as_i == params.quality.rchop("k").to_i - url = fmt["url"].as_s + if params.quality.ends_with? "k" + audio_streams.each do |fmt| + if fmt["bitrate"].as_i == params.quality.rchop("k").to_i + url = fmt["url"].as_s + end end end else @@ -185,10 +182,18 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute return env.redirect url end + # Structure used for the download widget + video_assets = Invidious::Frontend::WatchPage::VideoAssets.new( + full_videos: fmt_stream, + video_streams: video_streams, + audio_streams: audio_streams, + captions: video.captions + ) + templated "watch" end - def redirect(env) + def self.redirect(env) url = "/watch?v=#{env.params.url["id"]}" if env.params.query.size > 0 url += "&#{env.params.query}" @@ -196,4 +201,135 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute return env.redirect url end + + def self.mark_watched(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, "/feed/subscriptions") + + redirect = env.params.query["redirect"]? + redirect ||= "true" + redirect = redirect == "true" + + if !user + if redirect + return env.redirect referer + else + return error_json(403, "No such user") + end + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + id = env.params.query["id"]? + if !id + env.response.status_code = 400 + return + end + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + if redirect + return error_template(400, ex) + else + return error_json(400, ex) + end + end + + if env.params.query["action_mark_watched"]? + action = "action_mark_watched" + elsif env.params.query["action_mark_unwatched"]? + action = "action_mark_unwatched" + else + return env.redirect referer + end + + case action + when "action_mark_watched" + Invidious::Database::Users.mark_watched(user, id) + when "action_mark_unwatched" + Invidious::Database::Users.mark_unwatched(user, id) + else + return error_json(400, "Unsupported action #{action}") + end + + if redirect + env.redirect referer + else + env.response.content_type = "application/json" + "{}" + end + end + + def self.clip(env) + clip_id = env.params.url["clip"]? + + return error_template(400, "A clip ID is required") if !clip_id + + response = YoutubeAPI.resolve_url("https://www.youtube.com/clip/#{clip_id}") + return error_template(400, "Invalid clip ID") if response["error"]? + + 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) + 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 + + return env.redirect "/watch?v=#{video_id}&#{env.params.query}" + else + return error_template(404, "The requested clip doesn't exist") + end + end + + def self.download(env) + if CONFIG.disabled?("downloads") + return error_template(403, "Administrator has disabled this endpoint.") + end + + title = env.params.body["title"]? || "" + video_id = env.params.body["id"]? || "" + selection = env.params.body["download_widget"]? + + if title.empty? || video_id.empty? || selection.nil? + return error_template(400, "Missing form data") + end + + download_widget = JSON.parse(selection) + + extension = download_widget["ext"].as_s + filename = "#{title}-#{video_id}.#{extension}" + + # Delete the now useless URL parameters + env.params.body.delete("id") + env.params.body.delete("title") + env.params.body.delete("download_widget") + + # Pass form parameters as URL parameters for the handlers of both + # /latest_version and /api/v1/captions. This avoids an un-necessary + # redirect and duplicated (and hazardous) sanity checks. + if label = download_widget["label"]? + # URL params specific to /api/v1/captions/:id + env.params.url["id"] = video_id + env.params.query["title"] = filename + env.params.query["label"] = URI.decode_www_form(label.as_s) + + return Invidious::Routes::API::V1::Videos.captions(env) + elsif itag = download_widget["itag"]?.try &.as_i + # URL params specific to /latest_version + env.params.query["id"] = video_id + env.params.query["itag"] = itag.to_s + env.params.query["title"] = filename + env.params.query["local"] = "true" + + return Invidious::Routes::VideoPlayback.latest_version(env) + else + return error_template(400, "Invalid label or itag") + end + end end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 82d0028b..9009062f 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -1,15 +1,314 @@ module Invidious::Routing - macro get(path, controller, method = :handle) - get {{ path }} do |env| - controller_instance = {{ controller }}.new - controller_instance.{{ method.id }}(env) + extend self + + {% for http_method in {"get", "post", "delete", "options", "patch", "put"} %} + + macro {{http_method.id}}(path, controller, method = :handle) + unless Kemal::Utils.path_starts_with_slash?(\{{path}}) + raise Kemal::Exceptions::InvalidPathStartException.new({{http_method}}, \{{path}}) + end + + Kemal::RouteHandler::INSTANCE.add_route({{http_method.upcase}}, \{{path}}) do |env| + \{{ controller }}.\{{ method.id }}(env) + end end + + {% end %} + + def register_all + {% unless flag?(:api_only) %} + get "/", Routes::Misc, :home + get "/privacy", Routes::Misc, :privacy + get "/licenses", Routes::Misc, :licenses + get "/redirect", Routes::Misc, :cross_instance_redirect + + self.register_channel_routes + self.register_watch_routes + + self.register_iv_playlist_routes + self.register_yt_playlist_routes + + self.register_search_routes + + self.register_user_routes + self.register_feed_routes + + # Support push notifications via PubSubHubbub + get "/feed/webhook/:token", Routes::Feeds, :push_notifications_get + post "/feed/webhook/:token", Routes::Feeds, :push_notifications_post + + if CONFIG.enable_user_notifications + get "/modify_notifications", Routes::Notifications, :modify + end + {% end %} + + self.register_image_routes + self.register_api_v1_routes + self.register_api_manifest_routes + self.register_video_playback_routes end - macro post(path, controller, method = :handle) - post {{ path }} do |env| - controller_instance = {{ controller }}.new - controller_instance.{{ method.id }}(env) - end + # ------------------- + # Invidious routes + # ------------------- + + def register_user_routes + # User login/out + get "/login", Routes::Login, :login_page + post "/login", Routes::Login, :login + post "/signout", Routes::Login, :signout + + # User preferences + get "/preferences", Routes::PreferencesRoute, :show + post "/preferences", Routes::PreferencesRoute, :update + get "/toggle_theme", Routes::PreferencesRoute, :toggle_theme + get "/data_control", Routes::PreferencesRoute, :data_control + post "/data_control", Routes::PreferencesRoute, :update_data_control + + # User account management + get "/change_password", Routes::Account, :get_change_password + post "/change_password", Routes::Account, :post_change_password + get "/delete_account", Routes::Account, :get_delete + post "/delete_account", Routes::Account, :post_delete + get "/clear_watch_history", Routes::Account, :get_clear_history + post "/clear_watch_history", Routes::Account, :post_clear_history + get "/authorize_token", Routes::Account, :get_authorize_token + post "/authorize_token", Routes::Account, :post_authorize_token + get "/token_manager", Routes::Account, :token_manager + post "/token_ajax", Routes::Account, :token_ajax + post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription + get "/subscription_manager", Routes::Subscriptions, :subscription_manager + end + + def register_iv_playlist_routes + get "/create_playlist", Routes::Playlists, :new + post "/create_playlist", Routes::Playlists, :create + get "/subscribe_playlist", Routes::Playlists, :subscribe + get "/delete_playlist", Routes::Playlists, :delete_page + post "/delete_playlist", Routes::Playlists, :delete + get "/edit_playlist", Routes::Playlists, :edit + post "/edit_playlist", Routes::Playlists, :update + get "/add_playlist_items", Routes::Playlists, :add_playlist_items_page + post "/playlist_ajax", Routes::Playlists, :playlist_ajax + end + + def register_feed_routes + # Feeds + get "/view_all_playlists", Routes::Feeds, :view_all_playlists_redirect + get "/feed/playlists", Routes::Feeds, :playlists + get "/feed/popular", Routes::Feeds, :popular + get "/feed/trending", Routes::Feeds, :trending + get "/feed/subscriptions", Routes::Feeds, :subscriptions + get "/feed/history", Routes::Feeds, :history + + # RSS Feeds + get "/feed/channel/:ucid", Routes::Feeds, :rss_channel + get "/feed/private", Routes::Feeds, :rss_private + get "/feed/playlist/:plid", Routes::Feeds, :rss_playlist + get "/feeds/videos.xml", Routes::Feeds, :rss_videos + end + + # ------------------- + # Youtube routes + # ------------------- + + def register_channel_routes + get "/channel/:ucid", Routes::Channels, :home + get "/channel/:ucid/home", Routes::Channels, :home + get "/channel/:ucid/videos", Routes::Channels, :videos + get "/channel/:ucid/shorts", Routes::Channels, :shorts + get "/channel/:ucid/streams", Routes::Channels, :streams + get "/channel/:ucid/podcasts", Routes::Channels, :podcasts + get "/channel/:ucid/releases", Routes::Channels, :releases + get "/channel/:ucid/playlists", Routes::Channels, :playlists + get "/channel/:ucid/community", Routes::Channels, :community + get "/channel/:ucid/channels", Routes::Channels, :channels + get "/channel/:ucid/about", Routes::Channels, :about + + get "/channel/:ucid/live", Routes::Channels, :live + get "/user/:user/live", Routes::Channels, :live + get "/c/:user/live", Routes::Channels, :live + get "/post/:id", Routes::Channels, :post + + # Channel catch-all, to redirect future routes to the channel's home + # NOTE: defined last in order to be processed after the other routes + get "/channel/:ucid/*", Routes::Channels, :redirect_home + + # /c/LinusTechTips + get "/c/:user", Routes::Channels, :brand_redirect + get "/c/:user/:tab", Routes::Channels, :brand_redirect + + # /user/linustechtips (Not always the same as /c/) + get "/user/:user", Routes::Channels, :brand_redirect + get "/user/:user/:tab", Routes::Channels, :brand_redirect + + # /@LinusTechTips (Handle) + get "/@:user", Routes::Channels, :brand_redirect + get "/@:user/:tab", Routes::Channels, :brand_redirect + + # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow + get "/attribution_link", Routes::Channels, :brand_redirect + get "/attribution_link/:tab", Routes::Channels, :brand_redirect + + # /profile?user=linustechtips + get "/profile", Routes::Channels, :profile + get "/profile/*", Routes::Channels, :profile + end + + def register_watch_routes + get "/watch", Routes::Watch, :handle + post "/watch_ajax", Routes::Watch, :mark_watched + get "/watch/:id", Routes::Watch, :redirect + get "/live/:id", Routes::Watch, :redirect + get "/shorts/:id", Routes::Watch, :redirect + get "/clip/:clip", Routes::Watch, :clip + get "/w/:id", Routes::Watch, :redirect + get "/v/:id", Routes::Watch, :redirect + get "/e/:id", Routes::Watch, :redirect + + post "/download", Routes::Watch, :download + + get "/embed/", Routes::Embed, :redirect + get "/embed/:id", Routes::Embed, :show + end + + def register_yt_playlist_routes + get "/playlist", Routes::Playlists, :show + get "/mix", Routes::Playlists, :mix + get "/watch_videos", Routes::Playlists, :watch_videos + end + + def register_search_routes + get "/opensearch.xml", Routes::Search, :opensearch + get "/results", Routes::Search, :results + get "/search", Routes::Search, :search + get "/hashtag/:hashtag", Routes::Search, :hashtag + end + + # ------------------- + # Media proxy routes + # ------------------- + + def register_api_manifest_routes + get "/api/manifest/dash/id/:id", Routes::API::Manifest, :get_dash_video_id + + get "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :get_dash_video_playback + get "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :get_dash_video_playback_greedy + + options "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :options_dash_video_playback + options "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :options_dash_video_playback + + get "/api/manifest/hls_playlist/*", Routes::API::Manifest, :get_hls_playlist + get "/api/manifest/hls_variant/*", Routes::API::Manifest, :get_hls_variant + end + + def register_video_playback_routes + get "/videoplayback", Routes::VideoPlayback, :get_video_playback + get "/videoplayback/*", Routes::VideoPlayback, :get_video_playback_greedy + + options "/videoplayback", Routes::VideoPlayback, :options_video_playback + options "/videoplayback/*", Routes::VideoPlayback, :options_video_playback + + get "/latest_version", Routes::VideoPlayback, :latest_version + end + + def register_image_routes + get "/ggpht/*", Routes::Images, :ggpht + options "/sb/:authority/:id/:storyboard/:index", Routes::Images, :options_storyboard + get "/sb/:authority/:id/:storyboard/:index", Routes::Images, :get_storyboard + 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 + end + + # ------------------- + # API routes + # ------------------- + + def register_api_v1_routes + {% begin %} + {{namespace = Routes::API::V1}} + + # Videos + get "/api/v1/videos/:id", {{namespace}}::Videos, :videos + get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards + get "/api/v1/captions/:id", {{namespace}}::Videos, :captions + get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations + get "/api/v1/comments/:id", {{namespace}}::Videos, :comments + get "/api/v1/clips/:id", {{namespace}}::Videos, :clips + + # Feeds + get "/api/v1/trending", {{namespace}}::Feeds, :trending + get "/api/v1/popular", {{namespace}}::Feeds, :popular + + # Channels + get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home + get "/api/v1/channels/:ucid/latest", {{namespace}}::Channels, :latest + get "/api/v1/channels/:ucid/videos", {{namespace}}::Channels, :videos + get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts + get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams + get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts + get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases + get "/api/v1/channels/:ucid/playlists", {{namespace}}::Channels, :playlists + get "/api/v1/channels/:ucid/community", {{namespace}}::Channels, :community + get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels + get "/api/v1/channels/:ucid/search", {{namespace}}::Channels, :search + + # Posts + get "/api/v1/post/:id", {{namespace}}::Channels, :post + get "/api/v1/post/:id/comments", {{namespace}}::Channels, :post_comments + + # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community + get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect + get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect + + # Search + get "/api/v1/search", {{namespace}}::Search, :search + get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions + get "/api/v1/hashtag/:hashtag", {{namespace}}::Search, :hashtag + + + # Authenticated + + get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences + post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences + + get "/api/v1/auth/export/invidious", {{namespace}}::Authenticated, :export_invidious + post "/api/v1/auth/import/invidious", {{namespace}}::Authenticated, :import_invidious + + get "/api/v1/auth/history", {{namespace}}::Authenticated, :get_history + post "/api/v1/auth/history/:id", {{namespace}}::Authenticated, :mark_watched + delete "/api/v1/auth/history/:id", {{namespace}}::Authenticated, :mark_unwatched + delete "/api/v1/auth/history", {{namespace}}::Authenticated, :clear_history + + get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed + + get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions + post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel + delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel + + get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists + post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist + patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute + delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist + post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist + delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist + + get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens + post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token + post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token + + if CONFIG.enable_user_notifications + get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + end + + # Misc + get "/api/v1/stats", {{namespace}}::Misc, :stats + get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist + get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist + get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes + get "/api/v1/resolveurl", {{namespace}}::Misc, :resolve_url + {% end %} end end diff --git a/src/invidious/search.cr b/src/invidious/search.cr deleted file mode 100644 index 4b216613..00000000 --- a/src/invidious/search.cr +++ /dev/null @@ -1,473 +0,0 @@ -struct SearchVideo - include DB::Serializable - - property title : String - property id : String - property author : String - property ucid : String - property published : Time - property views : Int64 - property description_html : String - property length_seconds : Int32 - property live_now : Bool - property paid : Bool - property premium : Bool - property premiere_timestamp : Time? - - def to_xml(auto_generated, query_params, xml : XML::Builder) - query_params["v"] = self.id - - xml.element("entry") do - xml.element("id") { xml.text "yt:video:#{self.id}" } - xml.element("yt:videoId") { xml.text self.id } - xml.element("yt:channelId") { xml.text self.ucid } - xml.element("title") { xml.text self.title } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/watch?#{query_params}") - - xml.element("author") do - if auto_generated - xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } - else - xml.element("name") { xml.text author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{ucid}" } - end - end - - xml.element("content", type: "xhtml") do - xml.element("div", xmlns: "http://www.w3.org/1999/xhtml") do - xml.element("a", href: "#{HOST_URL}/watch?#{query_params}") do - 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) } - end - end - - xml.element("published") { xml.text self.published.to_s("%Y-%m-%dT%H:%M:%S%:z") } - - xml.element("media:group") do - 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) } - end - - xml.element("media:community") do - xml.element("media:statistics", views: self.views) - end - end - end - - def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil) - if xml - to_xml(HOST_URL, auto_generated, query_params, xml) - else - XML.build do |json| - to_xml(HOST_URL, auto_generated, query_params, xml) - end - end - end - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "video" - json.field "title", self.title - json.field "videoId", self.id - - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - - json.field "videoThumbnails" do - generate_thumbnails(json, self.id) - end - - json.field "description", html_to_content(self.description_html) - json.field "descriptionHtml", self.description_html - - json.field "viewCount", self.views - json.field "published", self.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) - json.field "lengthSeconds", self.length_seconds - json.field "liveNow", self.live_now - json.field "paid", self.paid - json.field "premium", self.premium - json.field "isUpcoming", self.is_upcoming - - if self.premiere_timestamp - json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix - end - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end - - def is_upcoming - premiere_timestamp ? true : false - end -end - -struct SearchPlaylistVideo - include DB::Serializable - - property title : String - property id : String - property length_seconds : Int32 -end - -struct SearchPlaylist - include DB::Serializable - - property title : String - property id : String - property author : String - property ucid : String - property video_count : Int32 - property videos : Array(SearchPlaylistVideo) - property thumbnail : String? - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "playlist" - json.field "title", self.title - json.field "playlistId", self.id - json.field "playlistThumbnail", self.thumbnail - - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - - json.field "videoCount", self.video_count - json.field "videos" do - json.array do - self.videos.each do |video| - json.object do - json.field "title", video.title - json.field "videoId", video.id - json.field "lengthSeconds", video.length_seconds - - json.field "videoThumbnails" do - generate_thumbnails(json, video.id) - end - end - end - end - end - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end -end - -struct SearchChannel - include DB::Serializable - - property author : String - property ucid : String - property author_thumbnail : String - property subscriber_count : Int32 - property video_count : Int32 - property description_html : String - property auto_generated : Bool - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "channel" - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", self.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "autoGenerated", self.auto_generated - json.field "subCount", self.subscriber_count - json.field "videoCount", self.video_count - - json.field "description", html_to_content(self.description_html) - json.field "descriptionHtml", self.description_html - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end -end - -alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist - -def channel_search(query, page, channel) - response = YT_POOL.client &.get("/channel/#{channel}") - - if response.status_code == 404 - response = YT_POOL.client &.get("/user/#{channel}") - response = YT_POOL.client &.get("/c/#{channel}") if response.status_code == 404 - initial_data = extract_initial_data(response.body) - ucid = initial_data["header"]["c4TabbedHeaderRenderer"]?.try &.["channelId"].as_s? - raise InfoException.new("Impossible to extract channel ID from page") if !ucid - else - ucid = channel - end - - continuation = produce_channel_search_continuation(ucid, query, page) - response_json = request_youtube_api_browse(continuation) - - result = JSON.parse(response_json) - continuationItems = result["onResponseReceivedActions"]? - .try &.[0]["appendContinuationItemsAction"]["continuationItems"] - - return 0, [] of SearchItem if !continuationItems - - items = [] of SearchItem - continuationItems.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each { |item| - extract_item(item["itemSectionRenderer"]["contents"].as_a[0]) - .try { |t| items << t } - } - - return items.size, items -end - -def search(query, search_params = produce_search_params(content_type: "all"), region = nil) - return 0, [] of SearchItem if query.empty? - - body = YT_POOL.client(region, &.get("/results?search_query=#{URI.encode_www_form(query)}&sp=#{search_params}&hl=en").body) - return 0, [] of SearchItem if body.empty? - - initial_data = extract_initial_data(body) - items = extract_items(initial_data) - - # initial_data["estimatedResults"]?.try &.as_s.to_i64 - - return items.size, items -end - -def produce_search_params(page = 1, sort : String = "relevance", date : String = "", content_type : String = "", - duration : String = "", features : Array(String) = [] of String) - object = { - "1:varint" => 0_i64, - "2:embedded" => {} of String => Int64, - "9:varint" => ((page - 1) * 20).to_i64, - } - - case sort - when "relevance" - object["1:varint"] = 0_i64 - when "rating" - object["1:varint"] = 1_i64 - when "upload_date", "date" - object["1:varint"] = 2_i64 - when "view_count", "views" - object["1:varint"] = 3_i64 - else - raise "No sort #{sort}" - end - - case date - when "hour" - object["2:embedded"].as(Hash)["1:varint"] = 1_i64 - when "today" - object["2:embedded"].as(Hash)["1:varint"] = 2_i64 - when "week" - object["2:embedded"].as(Hash)["1:varint"] = 3_i64 - when "month" - object["2:embedded"].as(Hash)["1:varint"] = 4_i64 - when "year" - object["2:embedded"].as(Hash)["1:varint"] = 5_i64 - else nil # Ignore - end - - case content_type - when "video" - object["2:embedded"].as(Hash)["2:varint"] = 1_i64 - when "channel" - object["2:embedded"].as(Hash)["2:varint"] = 2_i64 - when "playlist" - object["2:embedded"].as(Hash)["2:varint"] = 3_i64 - when "movie" - object["2:embedded"].as(Hash)["2:varint"] = 4_i64 - when "show" - object["2:embedded"].as(Hash)["2:varint"] = 5_i64 - when "all" - # - else - object["2:embedded"].as(Hash)["2:varint"] = 1_i64 - end - - case duration - when "short" - object["2:embedded"].as(Hash)["3:varint"] = 1_i64 - when "long" - object["2:embedded"].as(Hash)["3:varint"] = 2_i64 - else nil # Ignore - end - - features.each do |feature| - case feature - when "hd" - object["2:embedded"].as(Hash)["4:varint"] = 1_i64 - when "subtitles" - object["2:embedded"].as(Hash)["5:varint"] = 1_i64 - when "creative_commons", "cc" - object["2:embedded"].as(Hash)["6:varint"] = 1_i64 - when "3d" - object["2:embedded"].as(Hash)["7:varint"] = 1_i64 - when "live", "livestream" - object["2:embedded"].as(Hash)["8:varint"] = 1_i64 - when "purchased" - object["2:embedded"].as(Hash)["9:varint"] = 1_i64 - when "4k" - object["2:embedded"].as(Hash)["14:varint"] = 1_i64 - when "360" - object["2:embedded"].as(Hash)["15:varint"] = 1_i64 - when "location" - object["2:embedded"].as(Hash)["23:varint"] = 1_i64 - when "hdr" - object["2:embedded"].as(Hash)["25:varint"] = 1_i64 - else nil # Ignore - end - end - - if object["2:embedded"].as(Hash).empty? - object.delete("2:embedded") - end - - params = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return params -end - -def produce_channel_search_continuation(ucid, query, page) - if page <= 1 - idx = 0_i64 - else - idx = 30_i64 * (page - 1) - end - - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:base64" => { - "2:string" => "search", - "6:varint" => 1_i64, - "7:varint" => 1_i64, - "12:varint" => 1_i64, - "15:base64" => { - "3:varint" => idx, - }, - "23:varint" => 0_i64, - }, - "11:string" => query, - "35:string" => "browse-feed#{ucid}search", - }, - } - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end - -def process_search_query(query, page, user, region) - if user - user = user.as(User) - view_name = "subscriptions_#{sha256(user.email)}" - end - - channel = nil - content_type = "all" - date = "" - duration = "" - features = [] of String - sort = "relevance" - subscriptions = nil - - operators = query.split(" ").select { |a| a.match(/\w+:[\w,]+/) } - operators.each do |operator| - key, value = operator.downcase.split(":") - - case key - when "channel", "user" - channel = operator.split(":")[-1] - when "content_type", "type" - content_type = value - when "date" - date = value - when "duration" - duration = value - when "feature", "features" - features = value.split(",") - when "sort" - sort = value - when "subscriptions" - subscriptions = value == "true" - else - operators.delete(operator) - end - end - - search_query = (query.split(" ") - operators).join(" ") - - if channel - count, items = channel_search(search_query, page, channel) - elsif subscriptions - if view_name - items = PG_DB.query_all("SELECT id,title,published,updated,ucid,author,length_seconds FROM ( - SELECT *, - to_tsvector(#{view_name}.title) || - to_tsvector(#{view_name}.author) - as document - FROM #{view_name} - ) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", search_query, (page - 1) * 20, as: ChannelVideo) - count = items.size - else - items = [] of ChannelVideo - count = 0 - end - else - search_params = produce_search_params(page: page, sort: sort, date: date, content_type: content_type, - duration: duration, features: features) - - count, items = search(search_query, search_params, region).as(Tuple) - end - - {search_query, count, items, operators} -end diff --git a/src/invidious/search/ctoken.cr b/src/invidious/search/ctoken.cr new file mode 100644 index 00000000..161065e0 --- /dev/null +++ b/src/invidious/search/ctoken.cr @@ -0,0 +1,32 @@ +def produce_channel_search_continuation(ucid, query, page) + if page <= 1 + idx = 0_i64 + else + idx = 30_i64 * (page - 1) + end + + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:base64" => { + "2:string" => "search", + "6:varint" => 1_i64, + "7:varint" => 1_i64, + "12:varint" => 1_i64, + "15:base64" => { + "3:varint" => idx, + }, + "23:varint" => 0_i64, + }, + "11:string" => query, + "35:string" => "browse-feed#{ucid}search", + }, + } + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation +end diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr new file mode 100644 index 00000000..bf968734 --- /dev/null +++ b/src/invidious/search/filters.cr @@ -0,0 +1,376 @@ +require "protodec/utils" +require "http/params" + +module Invidious::Search + struct Filters + # Values correspond to { "2:embedded": { "1:varint": <X> }} + # except for "None" which is only used by us (= nothing selected) + enum Date + None = 0 + Hour = 1 + Today = 2 + Week = 3 + Month = 4 + Year = 5 + end + + # Values correspond to { "2:embedded": { "2:varint": <X> }} + # except for "All" which is only used by us (= nothing selected) + enum Type + All = 0 + Video = 1 + Channel = 2 + Playlist = 3 + Movie = 4 + + # Has it been removed? + # (Not available on youtube's UI) + Show = 5 + end + + # Values correspond to { "2:embedded": { "3:varint": <X> }} + # except for "None" which is only used by us (= nothing selected) + enum Duration + None = 0 + Short = 1 # "Under 4 minutes" + Long = 2 # "Over 20 minutes" + Medium = 3 # "4 - 20 minutes" + end + + # Note: flag enums automatically generate + # "none" and "all" members + @[Flags] + enum Features + Live + FourK # "4K" + HD + Subtitles # "Subtitles/CC" + CCommons # "Creative Commons" + ThreeSixty # "360°" + VR180 + ThreeD # "3D" + HDR + Location + Purchased + end + + # Values correspond to { "1:varint": <X> } + enum Sort + Relevance = 0 + Rating = 1 + Date = 2 + Views = 3 + end + + # Parameters are sorted as on Youtube + property date : Date + property type : Type + property duration : Duration + property features : Features + property sort : Sort + + def initialize( + *, # All parameters must be named + @date : Date = Date::None, + @type : Type = Type::All, + @duration : Duration = Duration::None, + @features : Features = Features::None, + @sort : Sort = Sort::Relevance + ) + end + + def default? : Bool + return @date.none? && @type.all? && @duration.none? && \ + @features.none? && @sort.relevance? + end + + # ------------------- + # Invidious params + # ------------------- + + def self.parse_features(raw : Array(String)) : Features + # Initialize return variable + features = Features.new(0) + + raw.each do |ft| + case ft.downcase + when "live", "livestream" + features = features | Features::Live + when "4k" then features = features | Features::FourK + when "hd" then features = features | Features::HD + when "subtitles" then features = features | Features::Subtitles + when "creative_commons", "commons", "cc" + features = features | Features::CCommons + when "360" then features = features | Features::ThreeSixty + when "vr180" then features = features | Features::VR180 + when "3d" then features = features | Features::ThreeD + when "hdr" then features = features | Features::HDR + when "location" then features = features | Features::Location + when "purchased" then features = features | Features::Purchased + end + end + + return features + end + + def self.format_features(features : Features) : String + # Directly return an empty string if there are no features + return "" if features.none? + + # Initialize return variable + str = [] of String + + str << "live" if features.live? + str << "4k" if features.four_k? + str << "hd" if features.hd? + str << "subtitles" if features.subtitles? + str << "commons" if features.c_commons? + str << "360" if features.three_sixty? + str << "vr180" if features.vr180? + str << "3d" if features.three_d? + str << "hdr" if features.hdr? + str << "location" if features.location? + str << "purchased" if features.purchased? + + return str.join(',') + end + + def self.from_legacy_filters(str : String) : {Filters, String, String, Bool} + # Split search query on spaces + members = str.split(' ') + + # Output variables + channel = "" + filters = Filters.new + subscriptions = false + + # Array to hold the non-filter members + query = [] of String + + # Parse! + members.each do |substr| + # Separator operators + operators = substr.split(':') + + case operators[0] + when "user", "channel" + next if operators.size != 2 + channel = operators[1] + # + when "type", "content_type" + next if operators.size != 2 + type = Type.parse?(operators[1]) + filters.type = type if !type.nil? + # + when "date" + next if operators.size != 2 + date = Date.parse?(operators[1]) + filters.date = date if !date.nil? + # + when "duration" + next if operators.size != 2 + duration = Duration.parse?(operators[1]) + filters.duration = duration if !duration.nil? + # + when "feature", "features" + next if operators.size != 2 + features = parse_features(operators[1].split(',')) + filters.features = features if !features.nil? + # + when "sort" + next if operators.size != 2 + sort = Sort.parse?(operators[1]) + filters.sort = sort if !sort.nil? + # + when "subscriptions" + next if operators.size != 2 + subscriptions = {"true", "on", "yes", "1"}.any?(&.== operators[1]) + # + else + query << substr + end + end + + # Re-assemble query (without filters) + cleaned_query = query.join(' ') + + return {filters, channel, cleaned_query, subscriptions} + end + + def self.from_iv_params(params : HTTP::Params) : Filters + # Temporary variables + filters = Filters.new + + if type = params["type"]? + filters.type = Type.parse?(type) || Type::All + params.delete("type") + end + + if date = params["date"]? + filters.date = Date.parse?(date) || Date::None + params.delete("date") + end + + if duration = params["duration"]? + filters.duration = Duration.parse?(duration) || Duration::None + params.delete("duration") + end + + features = params.fetch_all("features") + if !features.empty? + # Un-array input so it can be treated as a comma-separated list + features = features[0].split(',') if features.size == 1 + + filters.features = parse_features(features) || Features::None + params.delete_all("features") + end + + if sort = params["sort"]? + filters.sort = Sort.parse?(sort) || Sort::Relevance + params.delete("sort") + end + + return filters + end + + def to_iv_params : HTTP::Params + # Temporary variables + raw_params = {} of String => Array(String) + + raw_params["date"] = [@date.to_s.underscore] if !@date.none? + raw_params["type"] = [@type.to_s.underscore] if !@type.all? + raw_params["sort"] = [@sort.to_s.underscore] if !@sort.relevance? + + if !@duration.none? + raw_params["duration"] = [@duration.to_s.underscore] + end + + if !@features.none? + raw_params["features"] = [Filters.format_features(@features)] + end + + return HTTP::Params.new(raw_params) + end + + # ------------------- + # Youtube params + # ------------------- + + # Produce the youtube search parameters for the + # innertube API (base64-encoded protobuf object). + def to_yt_params(page : Int = 1) : String + # Initialize the embedded protobuf object + embedded = {} of String => Int64 + + # Add these field only if associated parameter is selected + embedded["1:varint"] = @date.to_i64 if !@date.none? + embedded["2:varint"] = @type.to_i64 if !@type.all? + embedded["3:varint"] = @duration.to_i64 if !@duration.none? + + if !@features.none? + # All features have a value of "1" when enabled, and + # the field is omitted when the feature is no selected. + embedded["4:varint"] = 1_i64 if @features.includes?(Features::HD) + embedded["5:varint"] = 1_i64 if @features.includes?(Features::Subtitles) + embedded["6:varint"] = 1_i64 if @features.includes?(Features::CCommons) + embedded["7:varint"] = 1_i64 if @features.includes?(Features::ThreeD) + embedded["8:varint"] = 1_i64 if @features.includes?(Features::Live) + embedded["9:varint"] = 1_i64 if @features.includes?(Features::Purchased) + embedded["14:varint"] = 1_i64 if @features.includes?(Features::FourK) + embedded["15:varint"] = 1_i64 if @features.includes?(Features::ThreeSixty) + embedded["23:varint"] = 1_i64 if @features.includes?(Features::Location) + embedded["25:varint"] = 1_i64 if @features.includes?(Features::HDR) + embedded["26:varint"] = 1_i64 if @features.includes?(Features::VR180) + end + + # Initialize an empty protobuf object + object = {} of String => (Int64 | String | Hash(String, Int64)) + + # As usual, everything can be omitted if it has no value + object["2:embedded"] = embedded if !embedded.empty? + + # Default sort is "relevance", so when this option is selected, + # the associated field can be omitted. + if !@sort.relevance? + object["1:varint"] = @sort.to_i64 + end + + # Add page number (if provided) + if page > 1 + object["9:varint"] = ((page - 1) * 20).to_i64 + end + + # Prevent censoring of self harm topics + # See https://github.com/iv-org/invidious/issues/4398 + object["30:varint"] = 1.to_i64 + + return object + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + end + + # Function to parse the `sp` URL parameter from Youtube + # search page. It's a base64-encoded protobuf object. + def self.from_yt_params(params : HTTP::Params) : Filters + # Initialize output variable + filters = Filters.new + + # Get parameter, and check emptyness + search_params = params["sp"]? + + if search_params.nil? || search_params.empty? + return filters + end + + # Decode protobuf object + object = search_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) } + + # Parse items from embedded object + if embedded = object["2:0:embedded"]? + # All the following fields (date, type, duration) are optional. + if date = embedded["1:0:varint"]? + filters.date = Date.from_value?(date.as_i) || Date::None + end + + if type = embedded["2:0:varint"]? + filters.type = Type.from_value?(type.as_i) || Type::All + end + + if duration = embedded["3:0:varint"]? + filters.duration = Duration.from_value?(duration.as_i) || Duration::None + end + + # All features should have a value of "1" when enabled, and + # the field should be omitted when the feature is no selected. + features = 0 + features += (embedded["4:0:varint"]?.try &.as_i == 1_i64) ? Features::HD.value : 0 + features += (embedded["5:0:varint"]?.try &.as_i == 1_i64) ? Features::Subtitles.value : 0 + features += (embedded["6:0:varint"]?.try &.as_i == 1_i64) ? Features::CCommons.value : 0 + features += (embedded["7:0:varint"]?.try &.as_i == 1_i64) ? Features::ThreeD.value : 0 + features += (embedded["8:0:varint"]?.try &.as_i == 1_i64) ? Features::Live.value : 0 + features += (embedded["9:0:varint"]?.try &.as_i == 1_i64) ? Features::Purchased.value : 0 + features += (embedded["14:0:varint"]?.try &.as_i == 1_i64) ? Features::FourK.value : 0 + features += (embedded["15:0:varint"]?.try &.as_i == 1_i64) ? Features::ThreeSixty.value : 0 + features += (embedded["23:0:varint"]?.try &.as_i == 1_i64) ? Features::Location.value : 0 + features += (embedded["25:0:varint"]?.try &.as_i == 1_i64) ? Features::HDR.value : 0 + features += (embedded["26:0:varint"]?.try &.as_i == 1_i64) ? Features::VR180.value : 0 + + filters.features = Features.from_value?(features) || Features::None + end + + if sort = object["1:0:varint"]? + filters.sort = Sort.from_value?(sort.as_i) || Sort::Relevance + end + + # Remove URL parameter and return result + params.delete("sp") + return filters + end + end +end diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr new file mode 100644 index 00000000..25edb936 --- /dev/null +++ b/src/invidious/search/processors.cr @@ -0,0 +1,56 @@ +module Invidious::Search + module Processors + extend self + + # Regular search (`/search` endpoint) + def regular(query : Query) : Array(SearchItem) + search_params = query.filters.to_yt_params(page: query.page) + + client_config = YoutubeAPI::ClientConfig.new(region: query.region) + initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config) + + items, _ = extract_items(initial_data) + return items.reject!(Category) + end + + # Search a youtube channel + # TODO: clean code, and rely more on YoutubeAPI + def channel(query : Query) : Array(SearchItem) + response = YT_POOL.client &.get("/channel/#{query.channel}") + + 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) + ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?) + raise ChannelSearchException.new(query.channel) if !ucid + else + ucid = query.channel + end + + continuation = produce_channel_search_continuation(ucid, query.text, query.page) + response_json = YoutubeAPI.browse(continuation) + + items, _ = extract_items(response_json, "", ucid) + return items.reject!(Category) + end + + # Search inside of user subscriptions + def subscriptions(query : Query, user : Invidious::User) : Array(ChannelVideo) + view_name = "subscriptions_#{sha256(user.email)}" + + return PG_DB.query_all(" + SELECT id,title,published,updated,ucid,author,length_seconds + FROM ( + SELECT *, + to_tsvector(#{view_name}.title) || + to_tsvector(#{view_name}.author) + as document + FROM #{view_name} + ) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", + query.text, (query.page - 1) * 20, + as: ChannelVideo + ) + end + end +end diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr new file mode 100644 index 00000000..c8e8cf7f --- /dev/null +++ b/src/invidious/search/query.cr @@ -0,0 +1,168 @@ +module Invidious::Search + class Query + enum Type + # Types related to YouTube + Regular # Youtube search page + Channel # Youtube channel search box + + # Types specific to Invidious + Subscriptions # Search user subscriptions + Playlist # "Add playlist item" search + end + + getter type : Type = Type::Regular + + @raw_query : String + @query : String = "" + + property filters : Filters = Filters.new + property page : Int32 + property region : String? + property channel : String = "" + + # Flag that indicates if the smart search features have been disabled. + @inhibit_ssf : Bool = false + + # Return true if @raw_query is either `nil` or empty + private def empty_raw_query? + return @raw_query.empty? + end + + # Same as `empty_raw_query?`, but named for external use + def empty? + return self.empty_raw_query? + end + + # Getter for the query string. + # It is named `text` to reduce confusion (`search_query.text` makes more + # sense than `search_query.query`) + def text + return @query + end + + # Initialize a new search query. + # Parameters are used to get the query string, the page number + # and the search filters (if any). Type tells this function + # where it is being called from (See `Type` above). + def initialize( + params : HTTP::Params, + @type : Type = Type::Regular, + @region : String? = nil + ) + # Get the raw search query string (common to all search types). In + # Regular search mode, also look for the `search_query` URL parameter + _raw_query = params["q"]? + _raw_query ||= params["search_query"]? if @type.regular? + _raw_query ||= "" + + # Remove surrounding whitespaces. Mostly useful for copy/pasted URLs. + @raw_query = _raw_query.strip + + # Check for smart features (ex: URL search) inhibitor (backslash). + # If inhibitor is present, remove it. + if @raw_query.starts_with?('\\') + @inhibit_ssf = true + @raw_query = @raw_query[1..] + end + + # Get the page number (also common to all search types) + @page = params["page"]?.try &.to_i? || 1 + + # Stop here if raw query is empty + # NOTE: maybe raise in the future? + return if self.empty_raw_query? + + # Specific handling + case @type + when .channel? + # In "channel search" mode, filters are ignored, but we still parse + # the query prevent transmission of legacy filters to youtube. + # + _, _, @query, _ = Filters.from_legacy_filters(@raw_query) + # + when .playlist? + # In "add playlist item" mode, filters are parsed from the query + # string itself (legacy), and the channel is ignored. + # + @filters, _, @query, _ = Filters.from_legacy_filters(@raw_query) + # + when .subscriptions?, .regular? + if params["sp"]? + # Parse the `sp` URL parameter (youtube compatibility) + @filters = Filters.from_yt_params(params) + @query = @raw_query || "" + else + # Parse invidious URL parameters (sort, date, etc...) + @filters = Filters.from_iv_params(params) + @channel = params["channel"]? || "" + + if @filters.default? && @raw_query.index(/\w:\w/) + # Parse legacy filters from query + @filters, @channel, @query, subs = Filters.from_legacy_filters(@raw_query) + else + @query = @raw_query || "" + end + + if !@channel.empty? + # Switch to channel search mode (filters will be ignored) + @type = Type::Channel + elsif subs + # Switch to subscriptions search mode + @type = Type::Subscriptions + end + end + end + end + + # Run the search query using the corresponding search processor. + # Returns either the results or an empty array of `SearchItem`. + def process(user : Invidious::User? = nil) : Array(SearchItem) | Array(ChannelVideo) + items = [] of SearchItem + + # Don't bother going further if search query is empty + return items if self.empty_raw_query? + + case @type + when .regular?, .playlist? + items = Processors.regular(self) + # + when .channel? + items = Processors.channel(self) + # + when .subscriptions? + if user + items = Processors.subscriptions(self, user.as(Invidious::User)) + end + end + + return items + end + + # Return the HTTP::Params corresponding to this Query (invidious format) + def to_http_params : HTTP::Params + params = @filters.to_iv_params + + params["q"] = @query + params["channel"] = @channel if !@channel.empty? + + return params + end + + # Checks if the query is a standalone URL + def url? : Bool + # If the smart features have been inhibited, don't go further. + return false if @inhibit_ssf + + # Only supported in regular search mode + return false if !@type.regular? + + # If filters are present, that's a regular search + return false if !@filters.default? + + # Simple heuristics: domain name + return @raw_query.starts_with?( + /(https?:\/\/)?(www\.)?(m\.)?youtu(\.be|be\.com)\// + ) + end + end +end diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index 910a99d8..107d148d 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -2,42 +2,41 @@ def fetch_trending(trending_type, region, locale) region ||= "US" region = region.upcase - trending = "" plid = nil - if trending_type && trending_type != "Default" - if trending_type == "Music" - trending_type = 1 - elsif trending_type == "Gaming" - trending_type = 2 - elsif trending_type == "Movies" - trending_type = 3 - end + case trending_type.try &.downcase + when "music" + params = "4gINGgt5dG1hX2NoYXJ0cw%3D%3D" + when "gaming" + params = "4gIcGhpnYW1pbmdfY29ycHVzX21vc3RfcG9wdWxhcg%3D%3D" + when "movies" + params = "4gIKGgh0cmFpbGVycw%3D%3D" + else # Default + params = "" + end - response = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en").body + client_config = YoutubeAPI::ClientConfig.new(region: region) + initial_data = YoutubeAPI.browse("FEtrending", params: params, client_config: client_config) - initial_data = extract_initial_data(response) - url = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][trending_type]["tabRenderer"]["endpoint"]["commandMetadata"]["webCommandMetadata"]["url"] - url = "#{url}&gl=#{region}&hl=en" + items, _ = extract_items(initial_data) - trending = YT_POOL.client &.get(url).body - plid = extract_plid(url) - else - trending = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en").body - end + extracted = [] of SearchItem - initial_data = extract_initial_data(trending) - trending = extract_videos(initial_data) + deduplicate = items.size > 1 - return {trending, plid} -end + items.each do |itm| + if itm.is_a?(Category) + # Ignore the smaller categories, as they generally contain a sponsored + # channel, which brings a lot of noise on the trending page. + # See: https://github.com/iv-org/invidious/issues/2989 + next if (itm.contents.size < 24 && deduplicate) + + extracted.concat extract_category(itm) + else + extracted << itm + end + end -def extract_plid(url) - return url.try { |i| URI.parse(i).query } - .try { |i| HTTP::Params.parse(i)["bp"] } - .try { |i| URI.decode_www_form(i) } - .try { |i| Base64.decode(i) } - .try { |i| IO::Memory.new(i) } - .try { |i| Protodec::Any.parse(i) } - .try &.["44:0:embedded"]?.try &.["2:1:string"]?.try &.as_s + # Deduplicate items before returning results + return extracted.select(SearchVideo).uniq!(&.id), plid end diff --git a/src/invidious/user/captcha.cr b/src/invidious/user/captcha.cr new file mode 100644 index 00000000..8a0f67e5 --- /dev/null +++ b/src/invidious/user/captcha.cr @@ -0,0 +1,78 @@ +require "openssl/hmac" + +struct Invidious::User + module Captcha + extend self + + private TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com") + + def generate_image(key) + second = Random::Secure.rand(12) + second_angle = second * 30 + second = second * 5 + + minute = Random::Secure.rand(12) + minute_angle = minute * 30 + minute = minute * 5 + + hour = Random::Secure.rand(12) + hour_angle = hour * 30 + minute_angle.to_f / 12 + if hour == 0 + hour = 12 + end + + clock_svg = <<-END_SVG + <svg viewBox="0 0 100 100" width="200px" height="200px"> + <circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle> + + <text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text> + <text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text> + <text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text> + <text x="82.909" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 4</text> + <text x="69" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 5</text> + <text x="50" y="91" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 6</text> + <text x="31" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 7</text> + <text x="17.091" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 8</text> + <text x="12" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 9</text> + <text x="17.091" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">10</text> + <text x="31" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">11</text> + <text x="50" y="15" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">12</text> + + <circle cx="50" cy="50" r="3" fill="black"></circle> + <line id="second" transform="rotate(#{second_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="12" fill="black" stroke="black" stroke-width="1"></line> + <line id="minute" transform="rotate(#{minute_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="16" fill="black" stroke="black" stroke-width="2"></line> + <line id="hour" transform="rotate(#{hour_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="24" fill="black" stroke="black" stroke-width="2"></line> + </svg> + END_SVG + + image = "data:image/png;base64," + image += Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true, + input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe + ) do |proc| + Base64.strict_encode(proc.output.gets_to_end) + end + + answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}" + answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer) + + return { + question: image, + tokens: {generate_response(answer, {":login"}, key, use_nonce: true)}, + } + end + + def generate_text(key) + response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body) + response = JSON.parse(response) + + tokens = response["a"].as_a.map do |answer| + generate_response(answer.as_s, {":login"}, key, use_nonce: true) + end + + return { + question: response["q"].as_s, + tokens: tokens, + } + end + end +end diff --git a/src/invidious/user/converters.cr b/src/invidious/user/converters.cr new file mode 100644 index 00000000..dcbf8c53 --- /dev/null +++ b/src/invidious/user/converters.cr @@ -0,0 +1,12 @@ +def convert_theme(theme) + case theme + when "true" + "dark" + when "false" + "light" + when "", nil + nil + else + theme + end +end diff --git a/src/invidious/user/cookies.cr b/src/invidious/user/cookies.cr new file mode 100644 index 00000000..654efc15 --- /dev/null +++ b/src/invidious/user/cookies.cr @@ -0,0 +1,39 @@ +require "http/cookie" + +struct Invidious::User + module Cookies + extend self + + # Note: we use ternary operator because the two variables + # used in here are not booleans. + SECURE = (Kemal.config.ssl || CONFIG.https_only) ? true : false + + # Session ID (SID) cookie + # Parameter "domain" comes from the global config + def sid(domain : String?, sid) : HTTP::Cookie + return HTTP::Cookie.new( + name: "SID", + domain: domain, + value: sid, + expires: Time.utc + 2.years, + secure: SECURE, + http_only: true, + samesite: HTTP::Cookie::SameSite::Lax + ) + end + + # Preferences (PREFS) cookie + # Parameter "domain" comes from the global config + def prefs(domain : String?, preferences : Preferences) : HTTP::Cookie + return HTTP::Cookie.new( + name: "PREFS", + domain: domain, + value: URI.encode_www_form(preferences.to_json), + expires: Time.utc + 2.years, + secure: SECURE, + http_only: false, + samesite: HTTP::Cookie::SameSite::Lax + ) + end + end +end diff --git a/src/invidious/user/exports.cr b/src/invidious/user/exports.cr new file mode 100644 index 00000000..b52503c9 --- /dev/null +++ b/src/invidious/user/exports.cr @@ -0,0 +1,35 @@ +struct Invidious::User + module Export + extend self + + def to_invidious(user : User) + playlists = Invidious::Database::Playlists.select_like_iv(user.email) + + return JSON.build do |json| + json.object do + json.field "subscriptions", user.subscriptions + json.field "watch_history", user.watched + json.field "preferences", user.preferences + json.field "playlists" do + json.array do + playlists.each do |playlist| + json.object do + json.field "title", playlist.title + json.field "description", html_to_content(playlist.description_html) + json.field "privacy", playlist.privacy.to_s + json.field "videos" do + json.array do + Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: CONFIG.playlist_length_limit).each do |video_id| + json.string video_id + end + end + end + end + end + end + end + end + end + end + end # module +end diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr new file mode 100644 index 00000000..533c18d9 --- /dev/null +++ b/src/invidious/user/imports.cr @@ -0,0 +1,337 @@ +require "csv" + +struct Invidious::User + module Import + extend self + + # Parse a youtube CSV subscription file + def parse_subscription_export_csv(csv_content : String) + rows = CSV.new(csv_content.strip('\n'), headers: true) + subscriptions = Array(String).new + + # Counter to limit the amount of imports. + # This is intended to prevent DoS. + row_counter = 0 + + rows.each do |row| + # Limit to 1200 + row_counter += 1 + break if row_counter > 1_200 + + # Channel ID is the first column in the csv export we can't use the header + # name, because the header name is localized depending on the + # language the user has set on their account + channel_id = row[0].strip + + next if channel_id.empty? + subscriptions << channel_id + end + + return subscriptions + end + + def parse_playlist_export_csv(user : User, 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) + + # 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] + + if visibility.compare("Public", case_insensitive: true) == 0 + privacy = PlaylistPrivacy::Public + else + privacy = PlaylistPrivacy::Private + end + + playlist = create_playlist(title, privacy, user) + Invidious::Database::Playlists.update_description(playlist.id, description) + + # Add each video to the playlist from the body content + csv_body = CSV.new(raw_body.strip('\n'), headers: true) + csv_body.each do |row| + video_id = row[0] + if playlist + next if !video_id + next if video_id == "Video Id" + + begin + video = get_video(video_id) + rescue ex + next + end + + playlist_video = PlaylistVideo.new({ + title: video.title, + id: video.id, + author: video.author, + ucid: video.ucid, + length_seconds: video.length_seconds, + published: video.published, + plid: playlist.id, + live_now: video.live_now, + index: Random::Secure.rand(0_i64..Int64::MAX), + }) + + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) + end + end + + return playlist + end + + # ------------------- + # Invidious + # ------------------- + + # Import from another invidious account + def from_invidious(user : User, body : String) + data = JSON.parse(body) + + if data["subscriptions"]? + user.subscriptions += data["subscriptions"].as_a.map(&.as_s) + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + end + + if data["watch_history"]? + user.watched += data["watch_history"].as_a.map(&.as_s) + user.watched.reverse!.uniq!.reverse! + Invidious::Database::Users.update_watch_history(user) + end + + if data["preferences"]? + user.preferences = Preferences.from_json(data["preferences"].to_json) + Invidious::Database::Users.update_preferences(user) + end + + if playlists = data["playlists"]?.try &.as_a? + playlists.each do |item| + title = item["title"]?.try &.as_s?.try &.delete("<>") + description = item["description"]?.try &.as_s?.try &.delete("\r") + privacy = item["privacy"]?.try &.as_s?.try { |raw_pl_privacy_state| PlaylistPrivacy.parse? raw_pl_privacy_state } + + next if !title + next if !description + next if !privacy + + playlist = create_playlist(title, privacy, user) + Invidious::Database::Playlists.update_description(playlist.id, description) + + item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| + if idx > CONFIG.playlist_length_limit + raise InfoException.new("Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") + end + + video_id = video_id.try &.as_s? + next if !video_id + + begin + video = get_video(video_id, false) + rescue ex + next + end + + playlist_video = PlaylistVideo.new({ + title: video.title, + id: video.id, + author: video.author, + ucid: video.ucid, + length_seconds: video.length_seconds, + published: video.published, + plid: playlist.id, + live_now: video.live_now, + index: Random::Secure.rand(0_i64..Int64::MAX), + }) + + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) + end + end + end + end + + # ------------------- + # Youtube + # ------------------- + + private def opml?(mimetype : String, extension : String) + opml_mimetypes = [ + "application/xml", + "text/xml", + "text/x-opml", + "text/x-opml+xml", + ] + + opml_extensions = ["xml", "opml"] + + return opml_mimetypes.any?(&.== mimetype) || opml_extensions.any?(&.== extension) + end + + # Import subscribed channels from Youtube + # Returns success status + def from_youtube(user : User, body : String, filename : String, type : String) : Bool + extension = filename.split(".").last + + if opml?(type, extension) + subscriptions = XML.parse(body) + user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| + channel["xmlUrl"].match!(/UC[a-zA-Z0-9_-]{22}/)[0] + end + elsif extension == "json" || type == "application/json" + subscriptions = JSON.parse(body) + user.subscriptions += subscriptions.as_a.compact_map do |entry| + entry["snippet"]["resourceId"]["channelId"].as_s + end + elsif extension == "csv" || type == "text/csv" + subscriptions = parse_subscription_export_csv(body) + user.subscriptions += subscriptions + else + return false + end + + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + return true + end + + def from_youtube_pl(user : User, body : String, filename : String, type : String) : Bool + extension = filename.split(".").last + + if extension == "csv" || type == "text/csv" + playlist = parse_playlist_export_csv(user, body) + if playlist + return true + else + return false + end + else + return false + end + end + + def from_youtube_wh(user : User, body : String, filename : String, type : String) : Bool + extension = filename.split(".").last + + if extension == "json" || type == "application/json" + data = JSON.parse(body) + watched = data.as_a.compact_map do |item| + next unless url = item["titleUrl"]? + next unless match = url.as_s.match(/\?v=(?<video_id>[a-zA-Z0-9_-]+)$/) + match["video_id"] + end + watched.reverse! # YouTube have newest first + user.watched += watched + user.watched.uniq! + Invidious::Database::Users.update_watch_history(user) + return true + else + return false + end + end + + # ------------------- + # Freetube + # ------------------- + + def from_freetube(user : User, body : String) + # Legacy import? + matches = body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/) + subs = matches.map(&.["channel_id"]) + + if subs.empty? + profiles = body.split('\n', remove_empty: true) + profiles.each do |profile| + if data = JSON.parse(profile)["subscriptions"]? + subs += data.as_a.map(&.["id"].as_s) + end + end + end + + user.subscriptions += subs + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + end + + # ------------------- + # Newpipe + # ------------------- + + def from_newpipe_subs(user : User, body : String) + data = JSON.parse(body) + + user.subscriptions += data["subscriptions"].as_a.compact_map do |channel| + if match = channel["url"].as_s.match(/\/channel\/(?<channel>UC[a-zA-Z0-9_-]{22})/) + next match["channel"] + elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/) + # Resolve URL using the API + resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com/user/#{match["user"]}") + ucid = resolved_url.dig?("endpoint", "browseEndpoint", "browseId") + next ucid.as_s if ucid + end + + nil + end + + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + end + + def from_newpipe(user : User, body : String) : Bool + io = IO::Memory.new(body) + + Compress::Zip::File.open(io) do |file| + file.entries.each do |entry| + entry.open do |file_io| + # Ensure max size of 4MB + io_sized = IO::Sized.new(file_io, 0x400000) + + next if entry.filename != "newpipe.db" + + tempfile = File.tempfile(".db") + + begin + File.write(tempfile.path, io_sized.gets_to_end) + rescue + return false + end + + db = DB.open("sqlite3://" + tempfile.path) + + user.watched += db.query_all("SELECT url FROM streams", as: String) + .map(&.lchop("https://www.youtube.com/watch?v=")) + + user.watched.uniq! + Invidious::Database::Users.update_watch_history(user) + + user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String) + .map(&.lchop("https://www.youtube.com/channel/")) + + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + + db.close + tempfile.delete + end + end + end + + # Success! + return true + end + end # module +end diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr new file mode 100644 index 00000000..0a8525f3 --- /dev/null +++ b/src/invidious/user/preferences.cr @@ -0,0 +1,275 @@ +struct Preferences + include JSON::Serializable + include YAML::Serializable + + property annotations : Bool = CONFIG.default_user_preferences.annotations + property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed + property preload : Bool = CONFIG.default_user_preferences.preload + property autoplay : Bool = CONFIG.default_user_preferences.autoplay + property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect + + @[JSON::Field(converter: Preferences::StringToArray)] + @[YAML::Field(converter: Preferences::StringToArray)] + property captions : Array(String) = CONFIG.default_user_preferences.captions + + @[JSON::Field(converter: Preferences::StringToArray)] + @[YAML::Field(converter: Preferences::StringToArray)] + property comments : Array(String) = CONFIG.default_user_preferences.comments + property continue : Bool = CONFIG.default_user_preferences.continue + property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay + + @[JSON::Field(converter: Preferences::BoolToString)] + @[YAML::Field(converter: Preferences::BoolToString)] + property dark_mode : String = CONFIG.default_user_preferences.dark_mode + property latest_only : Bool = CONFIG.default_user_preferences.latest_only + property listen : Bool = CONFIG.default_user_preferences.listen + property local : Bool = CONFIG.default_user_preferences.local + property watch_history : Bool = CONFIG.default_user_preferences.watch_history + property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode + property show_nick : Bool = CONFIG.default_user_preferences.show_nick + + @[JSON::Field(converter: Preferences::ProcessString)] + property locale : String = CONFIG.default_user_preferences.locale + property region : String? = CONFIG.default_user_preferences.region + + @[JSON::Field(converter: Preferences::ClampInt)] + property max_results : Int32 = CONFIG.default_user_preferences.max_results + property notifications_only : Bool = CONFIG.default_user_preferences.notifications_only + + @[JSON::Field(converter: Preferences::ProcessString)] + property player_style : String = CONFIG.default_user_preferences.player_style + + @[JSON::Field(converter: Preferences::ProcessString)] + property quality : String = CONFIG.default_user_preferences.quality + @[JSON::Field(converter: Preferences::ProcessString)] + property quality_dash : String = CONFIG.default_user_preferences.quality_dash + property default_home : String? = CONFIG.default_user_preferences.default_home + property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu + property related_videos : Bool = CONFIG.default_user_preferences.related_videos + + @[JSON::Field(converter: Preferences::ProcessString)] + property sort : String = CONFIG.default_user_preferences.sort + property speed : Float32 = CONFIG.default_user_preferences.speed + property thin_mode : Bool = CONFIG.default_user_preferences.thin_mode + property unseen_only : Bool = CONFIG.default_user_preferences.unseen_only + property video_loop : Bool = CONFIG.default_user_preferences.video_loop + property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc + property volume : Int32 = CONFIG.default_user_preferences.volume + property save_player_pos : Bool = CONFIG.default_user_preferences.save_player_pos + + module BoolToString + def self.to_json(value : String, json : JSON::Builder) + json.string value + end + + def self.from_json(value : JSON::PullParser) : String + begin + result = value.read_string + + if result.empty? + CONFIG.default_user_preferences.dark_mode + else + result + end + rescue ex + if value.read_bool + "dark" + else + "light" + end + end + end + + def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) + yaml.scalar value + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String + unless node.is_a?(YAML::Nodes::Scalar) + node.raise "Expected scalar, not #{node.class}" + end + + case node.value + when "true" + "dark" + when "false" + "light" + when "" + CONFIG.default_user_preferences.dark_mode + else + node.value + end + end + end + + module ClampInt + def self.to_json(value : Int32, json : JSON::Builder) + json.number value + end + + def self.from_json(value : JSON::PullParser) : Int32 + value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32 + end + + def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder) + yaml.scalar value + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32 + node.value.clamp(0, MAX_ITEMS_PER_PAGE) + end + end + + module FamilyConverter + def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder) + case value + when Socket::Family::UNSPEC + yaml.scalar nil + when Socket::Family::INET + yaml.scalar "ipv4" + when Socket::Family::INET6 + yaml.scalar "ipv6" + when Socket::Family::UNIX + raise "Invalid socket family #{value}" + end + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family + if node.is_a?(YAML::Nodes::Scalar) + case node.value.downcase + when "ipv4" + Socket::Family::INET + when "ipv6" + Socket::Family::INET6 + else + Socket::Family::UNSPEC + end + else + node.raise "Expected scalar, not #{node.class}" + end + end + end + + module URIConverter + def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder) + yaml.scalar value.normalize! + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI + if node.is_a?(YAML::Nodes::Scalar) + URI.parse node.value + else + node.raise "Expected scalar, not #{node.class}" + end + end + end + + module ProcessString + def self.to_json(value : String, json : JSON::Builder) + json.string value + end + + def self.from_json(value : JSON::PullParser) : String + HTML.escape(value.read_string[0, 100]) + end + + def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) + yaml.scalar value + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String + HTML.escape(node.value[0, 100]) + end + end + + module StringToArray + def self.to_json(value : Array(String), json : JSON::Builder) + json.array do + value.each do |element| + json.string element + end + end + end + + def self.from_json(value : JSON::PullParser) : Array(String) + begin + result = [] of String + value.read_array do + result << HTML.escape(value.read_string[0, 100]) + end + rescue ex + result = [HTML.escape(value.read_string[0, 100]), ""] + end + + result + end + + def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder) + yaml.sequence do + value.each do |element| + yaml.scalar element + end + end + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String) + begin + unless node.is_a?(YAML::Nodes::Sequence) + node.raise "Expected sequence, not #{node.class}" + end + + result = [] of String + node.nodes.each do |item| + unless item.is_a?(YAML::Nodes::Scalar) + node.raise "Expected scalar, not #{item.class}" + end + + result << HTML.escape(item.value[0, 100]) + end + rescue ex + if node.is_a?(YAML::Nodes::Scalar) + result = [HTML.escape(node.value[0, 100]), ""] + else + result = ["", ""] + end + end + + result + end + end + + module StringToCookies + def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder) + (value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml) + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies + unless node.is_a?(YAML::Nodes::Scalar) + node.raise "Expected scalar, not #{node.class}" + end + + cookies = HTTP::Cookies.new + node.value.split(";").each do |cookie| + next if cookie.strip.empty? + name, value = cookie.split("=", 2) + cookies << HTTP::Cookie.new(name.strip, value.strip) + end + + cookies + end + end + + module TimeSpanConverter + def self.to_yaml(value : Time::Span, yaml : YAML::Nodes::Builder) + return yaml.scalar value.total_minutes.to_i32 + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Time::Span + if node.is_a?(YAML::Nodes::Scalar) + return decode_interval(node.value) + else + node.raise "Expected scalar, not #{node.class}" + end + end + end +end diff --git a/src/invidious/user/user.cr b/src/invidious/user/user.cr new file mode 100644 index 00000000..a6d05fd1 --- /dev/null +++ b/src/invidious/user/user.cr @@ -0,0 +1,27 @@ +require "db" + +struct Invidious::User + include DB::Serializable + + property updated : Time + property notifications : Array(String) + property subscriptions : Array(String) + property email : String + + @[DB::Field(converter: Invidious::User::PreferencesConverter)] + property preferences : Preferences + property password : String? + property token : String + property watched : Array(String) + property feed_needs_update : Bool? + + module PreferencesConverter + def self.from_rs(rs) + begin + Preferences.from_json(rs.read(String)) + rescue ex + Preferences.from_json("{}") + end + end + end +end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index e4ebb4d1..65566d20 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -3,374 +3,11 @@ require "crypto/bcrypt/password" # Materialized views may not be defined using bound parameters (`$1` as used elsewhere) MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" } -struct User - include DB::Serializable - - property updated : Time - property notifications : Array(String) - property subscriptions : Array(String) - property email : String - - @[DB::Field(converter: User::PreferencesConverter)] - property preferences : Preferences - property password : String? - property token : String - property watched : Array(String) - property feed_needs_update : Bool? - - module PreferencesConverter - def self.from_rs(rs) - begin - Preferences.from_json(rs.read(String)) - rescue ex - Preferences.from_json("{}") - end - end - end -end - -struct Preferences - include JSON::Serializable - include YAML::Serializable - - property annotations : Bool = CONFIG.default_user_preferences.annotations - property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed - property autoplay : Bool = CONFIG.default_user_preferences.autoplay - - @[JSON::Field(converter: Preferences::StringToArray)] - @[YAML::Field(converter: Preferences::StringToArray)] - property captions : Array(String) = CONFIG.default_user_preferences.captions - - @[JSON::Field(converter: Preferences::StringToArray)] - @[YAML::Field(converter: Preferences::StringToArray)] - property comments : Array(String) = CONFIG.default_user_preferences.comments - property continue : Bool = CONFIG.default_user_preferences.continue - property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay - - @[JSON::Field(converter: Preferences::BoolToString)] - @[YAML::Field(converter: Preferences::BoolToString)] - property dark_mode : String = CONFIG.default_user_preferences.dark_mode - property latest_only : Bool = CONFIG.default_user_preferences.latest_only - property listen : Bool = CONFIG.default_user_preferences.listen - property local : Bool = CONFIG.default_user_preferences.local - - @[JSON::Field(converter: Preferences::ProcessString)] - property locale : String = CONFIG.default_user_preferences.locale - - @[JSON::Field(converter: Preferences::ClampInt)] - property max_results : Int32 = CONFIG.default_user_preferences.max_results - property notifications_only : Bool = CONFIG.default_user_preferences.notifications_only - - @[JSON::Field(converter: Preferences::ProcessString)] - property player_style : String = CONFIG.default_user_preferences.player_style - - @[JSON::Field(converter: Preferences::ProcessString)] - property quality : String = CONFIG.default_user_preferences.quality - @[JSON::Field(converter: Preferences::ProcessString)] - property quality_dash : String = CONFIG.default_user_preferences.quality_dash - property default_home : String? = CONFIG.default_user_preferences.default_home - property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu - property related_videos : Bool = CONFIG.default_user_preferences.related_videos - - @[JSON::Field(converter: Preferences::ProcessString)] - property sort : String = CONFIG.default_user_preferences.sort - property speed : Float32 = CONFIG.default_user_preferences.speed - property thin_mode : Bool = CONFIG.default_user_preferences.thin_mode - property unseen_only : Bool = CONFIG.default_user_preferences.unseen_only - property video_loop : Bool = CONFIG.default_user_preferences.video_loop - property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc - property volume : Int32 = CONFIG.default_user_preferences.volume - - module BoolToString - def self.to_json(value : String, json : JSON::Builder) - json.string value - end - - def self.from_json(value : JSON::PullParser) : String - begin - result = value.read_string - - if result.empty? - CONFIG.default_user_preferences.dark_mode - else - result - end - rescue ex - if value.read_bool - "dark" - else - "light" - end - end - end - - def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) - yaml.scalar value - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String - unless node.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{node.class}" - end - - case node.value - when "true" - "dark" - when "false" - "light" - when "" - CONFIG.default_user_preferences.dark_mode - else - node.value - end - end - end - - module ClampInt - def self.to_json(value : Int32, json : JSON::Builder) - json.number value - end - - def self.from_json(value : JSON::PullParser) : Int32 - value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32 - end - - def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder) - yaml.scalar value - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32 - node.value.clamp(0, MAX_ITEMS_PER_PAGE) - end - end - - module FamilyConverter - def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder) - case value - when Socket::Family::UNSPEC - yaml.scalar nil - when Socket::Family::INET - yaml.scalar "ipv4" - when Socket::Family::INET6 - yaml.scalar "ipv6" - when Socket::Family::UNIX - raise "Invalid socket family #{value}" - end - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family - if node.is_a?(YAML::Nodes::Scalar) - case node.value.downcase - when "ipv4" - Socket::Family::INET - when "ipv6" - Socket::Family::INET6 - else - Socket::Family::UNSPEC - end - else - node.raise "Expected scalar, not #{node.class}" - end - end - end - - module URIConverter - def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder) - yaml.scalar value.normalize! - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI - if node.is_a?(YAML::Nodes::Scalar) - URI.parse node.value - else - node.raise "Expected scalar, not #{node.class}" - end - end - end - - module ProcessString - def self.to_json(value : String, json : JSON::Builder) - json.string value - end - - def self.from_json(value : JSON::PullParser) : String - HTML.escape(value.read_string[0, 100]) - end - - def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) - yaml.scalar value - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String - HTML.escape(node.value[0, 100]) - end - end - - module StringToArray - def self.to_json(value : Array(String), json : JSON::Builder) - json.array do - value.each do |element| - json.string element - end - end - end - - def self.from_json(value : JSON::PullParser) : Array(String) - begin - result = [] of String - value.read_array do - result << HTML.escape(value.read_string[0, 100]) - end - rescue ex - result = [HTML.escape(value.read_string[0, 100]), ""] - end - - result - end - - def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder) - yaml.sequence do - value.each do |element| - yaml.scalar element - end - end - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String) - begin - unless node.is_a?(YAML::Nodes::Sequence) - node.raise "Expected sequence, not #{node.class}" - end - - result = [] of String - node.nodes.each do |item| - unless item.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{item.class}" - end - - result << HTML.escape(item.value[0, 100]) - end - rescue ex - if node.is_a?(YAML::Nodes::Scalar) - result = [HTML.escape(node.value[0, 100]), ""] - else - result = ["", ""] - end - end - - result - end - end - - module StringToCookies - def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder) - (value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml) - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies - unless node.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{node.class}" - end - - cookies = HTTP::Cookies.new - node.value.split(";").each do |cookie| - next if cookie.strip.empty? - name, value = cookie.split("=", 2) - cookies << HTTP::Cookie.new(name.strip, value.strip) - end - - cookies - end - end -end - -def get_user(sid, headers, db, refresh = true) - if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) - user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User) - - if refresh && Time.utc - user.updated > 1.minute - user, sid = fetch_user(sid, headers, db) - user_array = user.to_a - user_array[4] = user_array[4].to_json # User preferences - args = arg_array(user_array) - - db.exec("INSERT INTO users VALUES (#{args}) \ - ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array) - - db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ - ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc) - - begin - view_name = "subscriptions_#{sha256(user.email)}" - db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") - rescue ex - end - end - else - user, sid = fetch_user(sid, headers, db) - user_array = user.to_a - user_array[4] = user_array[4].to_json # User preferences - args = arg_array(user.to_a) - - db.exec("INSERT INTO users VALUES (#{args}) \ - ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array) - - db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ - ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc) - - begin - view_name = "subscriptions_#{sha256(user.email)}" - db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") - rescue ex - end - end - - return user, sid -end - -def fetch_user(sid, headers, db) - feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) - feed = XML.parse_html(feed.body) - - channels = [] of String - channels = feed.xpath_nodes(%q(//ul[@id="guide-channels"]/li/a)).compact_map do |channel| - if {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? channel["title"] - nil - else - channel["href"].lstrip("/channel/") - end - end - - channels = get_batch_channels(channels, db, false, false) - - email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"])) - if email - email = email.content.strip - else - email = "" - end - - token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - - user = User.new({ - updated: Time.utc, - notifications: [] of String, - subscriptions: channels, - email: email, - preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple), - password: nil, - token: token, - watched: [] of String, - feed_needs_update: true, - }) - return user, sid -end - def create_user(sid, email, password) password = Crypto::Bcrypt::Password.create(password, cost: 10) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - user = User.new({ + user = Invidious::User.new({ updated: Time.utc, notifications: [] of String, subscriptions: [] of String, @@ -385,135 +22,29 @@ def create_user(sid, email, password) return user, sid end -def generate_captcha(key, db) - second = Random::Secure.rand(12) - second_angle = second * 30 - second = second * 5 - - minute = Random::Secure.rand(12) - minute_angle = minute * 30 - minute = minute * 5 - - hour = Random::Secure.rand(12) - hour_angle = hour * 30 + minute_angle.to_f / 12 - if hour == 0 - hour = 12 - end - - clock_svg = <<-END_SVG - <svg viewBox="0 0 100 100" width="200px" height="200px"> - <circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle> - - <text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text> - <text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text> - <text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text> - <text x="82.909" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 4</text> - <text x="69" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 5</text> - <text x="50" y="91" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 6</text> - <text x="31" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 7</text> - <text x="17.091" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 8</text> - <text x="12" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 9</text> - <text x="17.091" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">10</text> - <text x="31" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">11</text> - <text x="50" y="15" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">12</text> - - <circle cx="50" cy="50" r="3" fill="black"></circle> - <line id="second" transform="rotate(#{second_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="12" fill="black" stroke="black" stroke-width="1"></line> - <line id="minute" transform="rotate(#{minute_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="16" fill="black" stroke="black" stroke-width="2"></line> - <line id="hour" transform="rotate(#{hour_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="24" fill="black" stroke="black" stroke-width="2"></line> - </svg> - END_SVG - - image = "" - convert = Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true, - input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe) do |proc| - image = proc.output.gets_to_end - image = Base64.strict_encode(image) - image = "data:image/png;base64,#{image}" - end - - answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}" - answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer) - - return { - question: image, - tokens: {generate_response(answer, {":login"}, key, db, use_nonce: true)}, - } -end - -def generate_text_captcha(key, db) - response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body) - response = JSON.parse(response) - - tokens = response["a"].as_a.map do |answer| - generate_response(answer.as_s, {":login"}, key, db, use_nonce: true) - end - - return { - question: response["q"].as_s, - tokens: tokens, - } -end - -def subscribe_ajax(channel_id, action, env_headers) - headers = HTTP::Headers.new - headers["Cookie"] = env_headers["Cookie"] - - html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) - - cookies = HTTP::Cookies.from_headers(headers) - html.cookies.each do |cookie| - if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name - if cookies[cookie.name]? - cookies[cookie.name] = cookie - else - cookies << cookie - end - end - end - headers = cookies.add_request_headers(headers) - - if match = html.body.match(/'XSRF_TOKEN': "(?<session_token>[^"]+)"/) - session_token = match["session_token"] - - headers["content-type"] = "application/x-www-form-urlencoded" - - post_req = { - session_token: session_token, - } - post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}" - - YT_POOL.client &.post(post_url, headers, form: post_req) - end -end - -def get_subscription_feed(db, user, max_results = 40, page = 1) +def get_subscription_feed(user, max_results = 40, page = 1) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) offset = (page - 1) * limit - notifications = db.query_one("SELECT notifications FROM users WHERE email = $1", user.email, - as: Array(String)) + notifications = Invidious::Database::Users.select_notifications(user) view_name = "subscriptions_#{sha256(user.email)}" if user.preferences.notifications_only && !notifications.empty? # Only show notifications - - args = arg_array(notifications) - - notifications = db.query_all("SELECT * FROM channel_videos WHERE id IN (#{args}) ORDER BY published DESC", args: notifications, as: ChannelVideo) + notifications = Invidious::Database::ChannelVideos.select(notifications) videos = [] of ChannelVideo - notifications.sort_by! { |video| video.published }.reverse! + notifications.sort_by!(&.published).reverse! case user.preferences.sort when "alphabetically" - notifications.sort_by! { |video| video.title } + notifications.sort_by!(&.title) when "alphabetically - reverse" - notifications.sort_by! { |video| video.title }.reverse! + notifications.sort_by!(&.title).reverse! when "channel name" - notifications.sort_by! { |video| video.author } + notifications.sort_by!(&.author) when "channel name - reverse" - notifications.sort_by! { |video| video.author }.reverse! + notifications.sort_by!(&.author).reverse! else nil # Ignore end else @@ -534,7 +65,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo) end - videos.sort_by! { |video| video.published }.reverse! + videos.sort_by!(&.published).reverse! else if user.preferences.unseen_only # Only show unwatched @@ -554,20 +85,19 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) case user.preferences.sort when "published - reverse" - videos.sort_by! { |video| video.published } + videos.sort_by!(&.published) when "alphabetically" - videos.sort_by! { |video| video.title } + videos.sort_by!(&.title) when "alphabetically - reverse" - videos.sort_by! { |video| video.title }.reverse! + videos.sort_by!(&.title).reverse! when "channel name" - videos.sort_by! { |video| video.author } + videos.sort_by!(&.author) when "channel name - reverse" - videos.sort_by! { |video| video.author }.reverse! + videos.sort_by!(&.author).reverse! else nil # Ignore end - notifications = PG_DB.query_one("SELECT notifications FROM users WHERE email = $1", user.email, as: Array(String)) - + notifications = Invidious::Database::Users.select_notifications(user) notifications = videos.select { |v| notifications.includes? v.id } videos = videos - notifications end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index bf281507..ae09e736 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1,255 +1,22 @@ -CAPTION_LANGUAGES = { - "", - "English", - "English (auto-generated)", - "Afrikaans", - "Albanian", - "Amharic", - "Arabic", - "Armenian", - "Azerbaijani", - "Bangla", - "Basque", - "Belarusian", - "Bosnian", - "Bulgarian", - "Burmese", - "Catalan", - "Cebuano", - "Chinese (Simplified)", - "Chinese (Traditional)", - "Corsican", - "Croatian", - "Czech", - "Danish", - "Dutch", - "Esperanto", - "Estonian", - "Filipino", - "Finnish", - "French", - "Galician", - "Georgian", - "German", - "Greek", - "Gujarati", - "Haitian Creole", - "Hausa", - "Hawaiian", - "Hebrew", - "Hindi", - "Hmong", - "Hungarian", - "Icelandic", - "Igbo", - "Indonesian", - "Irish", - "Italian", - "Japanese", - "Javanese", - "Kannada", - "Kazakh", - "Khmer", - "Korean", - "Kurdish", - "Kyrgyz", - "Lao", - "Latin", - "Latvian", - "Lithuanian", - "Luxembourgish", - "Macedonian", - "Malagasy", - "Malay", - "Malayalam", - "Maltese", - "Maori", - "Marathi", - "Mongolian", - "Nepali", - "Norwegian Bokmål", - "Nyanja", - "Pashto", - "Persian", - "Polish", - "Portuguese", - "Punjabi", - "Romanian", - "Russian", - "Samoan", - "Scottish Gaelic", - "Serbian", - "Shona", - "Sindhi", - "Sinhala", - "Slovak", - "Slovenian", - "Somali", - "Southern Sotho", - "Spanish", - "Spanish (Latin America)", - "Sundanese", - "Swahili", - "Swedish", - "Tajik", - "Tamil", - "Telugu", - "Thai", - "Turkish", - "Ukrainian", - "Urdu", - "Uzbek", - "Vietnamese", - "Welsh", - "Western Frisian", - "Xhosa", - "Yiddish", - "Yoruba", - "Zulu", -} - -REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"} - -# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 -VIDEO_FORMATS = { - "5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, - "6" => {"ext" => "flv", "width" => 450, "height" => 270, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, - "13" => {"ext" => "3gp", "acodec" => "aac", "vcodec" => "mp4v"}, - "17" => {"ext" => "3gp", "width" => 176, "height" => 144, "acodec" => "aac", "abr" => 24, "vcodec" => "mp4v"}, - "18" => {"ext" => "mp4", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 96, "vcodec" => "h264"}, - "22" => {"ext" => "mp4", "width" => 1280, "height" => 720, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "34" => {"ext" => "flv", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "35" => {"ext" => "flv", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - - "36" => {"ext" => "3gp", "width" => 320, "acodec" => "aac", "vcodec" => "mp4v"}, - "37" => {"ext" => "mp4", "width" => 1920, "height" => 1080, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "38" => {"ext" => "mp4", "width" => 4096, "height" => 3072, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "43" => {"ext" => "webm", "width" => 640, "height" => 360, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "44" => {"ext" => "webm", "width" => 854, "height" => 480, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "45" => {"ext" => "webm", "width" => 1280, "height" => 720, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "46" => {"ext" => "webm", "width" => 1920, "height" => 1080, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "59" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "78" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - - # 3D videos - "82" => {"ext" => "mp4", "height" => 360, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "83" => {"ext" => "mp4", "height" => 480, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "84" => {"ext" => "mp4", "height" => 720, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "85" => {"ext" => "mp4", "height" => 1080, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "100" => {"ext" => "webm", "height" => 360, "format" => "3D", "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "101" => {"ext" => "webm", "height" => 480, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "102" => {"ext" => "webm", "height" => 720, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - - # Apple HTTP Live Streaming - "91" => {"ext" => "mp4", "height" => 144, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "92" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "93" => {"ext" => "mp4", "height" => 360, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "94" => {"ext" => "mp4", "height" => 480, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "95" => {"ext" => "mp4", "height" => 720, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, - "96" => {"ext" => "mp4", "height" => 1080, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, - "132" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "151" => {"ext" => "mp4", "height" => 72, "format" => "HLS", "acodec" => "aac", "abr" => 24, "vcodec" => "h264"}, - - # DASH mp4 video - "133" => {"ext" => "mp4", "height" => 240, "format" => "DASH video", "vcodec" => "h264"}, - "134" => {"ext" => "mp4", "height" => 360, "format" => "DASH video", "vcodec" => "h264"}, - "135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, - "136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"}, - "137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"}, - "138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559) - "160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"}, - "212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, - "264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"}, - "298" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, - "299" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, - "266" => {"ext" => "mp4", "height" => 2160, "format" => "DASH video", "vcodec" => "h264"}, - - # Dash mp4 audio - "139" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 48, "container" => "m4a_dash"}, - "140" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 128, "container" => "m4a_dash"}, - "141" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 256, "container" => "m4a_dash"}, - "256" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, - "258" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, - "325" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "dtse", "container" => "m4a_dash"}, - "328" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "ec-3", "container" => "m4a_dash"}, - - # Dash webm - "167" => {"ext" => "webm", "height" => 360, "width" => 640, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "168" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "169" => {"ext" => "webm", "height" => 720, "width" => 1280, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "170" => {"ext" => "webm", "height" => 1080, "width" => 1920, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "218" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "219" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "278" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "container" => "webm", "vcodec" => "vp9"}, - "242" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9"}, - "243" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9"}, - "244" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "245" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "246" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "247" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9"}, - "248" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9"}, - "271" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9"}, - # itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug) - "272" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, - "302" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "303" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "308" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "313" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, - "315" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "330" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "331" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "332" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "333" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "334" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "335" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "336" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "337" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - - # Dash webm audio - "171" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 128}, - "172" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 256}, - - # Dash webm audio with opus inside - "249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50}, - "250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70}, - "251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160}, - - # av01 video only formats sometimes served with "unknown" codecs - "394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"}, - "395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"}, - "396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"}, - "397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"}, -} - -struct VideoPreferences - include JSON::Serializable - - property annotations : 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 +enum VideoType + Video + Livestream + Scheduled end struct Video include DB::Serializable + # Version of the JSON structure + # It prevents us from loading an incompatible version from cache + # (either newer or older, if instances with different versions run + # concurrently, e.g during a version upgrade rollout). + # + # NOTE: don't forget to bump this number if any change is made to + # the `params` structure in videos/parser.cr!!! + # + SCHEMA_VERSION = 2 + property id : String @[DB::Field(converter: Video::JSONConverter)] @@ -257,13 +24,7 @@ struct Video property updated : Time @[DB::Field(ignore: true)] - property captions : Array(Caption)? - - @[DB::Field(ignore: true)] - property adaptive_fmts : Array(Hash(String, JSON::Any))? - - @[DB::Field(ignore: true)] - property fmt_stream : Array(Hash(String, JSON::Any))? + @captions = [] of Invidious::Videos::Captions::Metadata @[DB::Field(ignore: true)] property description : String? @@ -274,345 +35,81 @@ struct Video end end - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "video" - - json.field "title", self.title - json.field "videoId", self.id - - json.field "error", info["reason"] if info["reason"]? - - json.field "videoThumbnails" do - generate_thumbnails(json, self.id) - end - json.field "storyboards" do - generate_storyboards(json, self.id, self.storyboards) - end - - json.field "description", self.description - json.field "descriptionHtml", self.description_html - json.field "published", self.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) - json.field "keywords", self.keywords - - json.field "viewCount", self.views - json.field "likeCount", self.likes - json.field "dislikeCount", self.dislikes - - json.field "paid", self.paid - json.field "premium", self.premium - json.field "isFamilyFriendly", self.is_family_friendly - json.field "allowedRegions", self.allowed_regions - json.field "genre", self.genre - json.field "genreUrl", self.genre_url - - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", self.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "subCountText", self.sub_count_text - - json.field "lengthSeconds", self.length_seconds - json.field "allowRatings", self.allow_ratings - json.field "rating", self.average_rating - json.field "isListed", self.is_listed - json.field "liveNow", self.live_now - json.field "isUpcoming", self.is_upcoming - - if self.premiere_timestamp - json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix - end - - if hlsvp = self.hls_manifest_url - hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL) - json.field "hlsUrl", hlsvp - end - - json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{id}" - - json.field "adaptiveFormats" do - json.array do - self.adaptive_fmts.each do |fmt| - json.object do - json.field "index", "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}" - json.field "bitrate", fmt["bitrate"].as_i.to_s - json.field "init", "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}" - json.field "url", fmt["url"] - json.field "itag", fmt["itag"].as_i.to_s - json.field "type", fmt["mimeType"] - json.field "clen", fmt["contentLength"] - json.field "lmt", fmt["lastModified"] - json.field "projectionType", fmt["projectionType"] - - fmt_info = itag_to_metadata?(fmt["itag"]) - if fmt_info - fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 - json.field "fps", fps - json.field "container", fmt_info["ext"] - json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] - - if fmt_info["height"]? - json.field "resolution", "#{fmt_info["height"]}p" - - quality_label = "#{fmt_info["height"]}p" - if fps > 30 - quality_label += "60" - end - json.field "qualityLabel", quality_label - - if fmt_info["width"]? - json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" - end - end - end - end - end - end - end + # Methods for API v1 JSON - json.field "formatStreams" do - json.array do - self.fmt_stream.each do |fmt| - json.object do - json.field "url", fmt["url"] - json.field "itag", fmt["itag"].as_i.to_s - json.field "type", fmt["mimeType"] - json.field "quality", fmt["quality"] - - fmt_info = itag_to_metadata?(fmt["itag"]) - if fmt_info - fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 - json.field "fps", fps - json.field "container", fmt_info["ext"] - json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] - - if fmt_info["height"]? - json.field "resolution", "#{fmt_info["height"]}p" - - quality_label = "#{fmt_info["height"]}p" - if fps > 30 - quality_label += "60" - end - json.field "qualityLabel", quality_label - - if fmt_info["width"]? - json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" - end - end - end - end - end - end - end - - json.field "captions" do - json.array do - self.captions.each do |caption| - json.object do - json.field "label", caption.name.simpleText - json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name.simpleText)}" - end - end - end - end - - json.field "recommendedVideos" do - json.array do - self.related_videos.each do |rv| - if rv["id"]? - json.object do - json.field "videoId", rv["id"] - json.field "title", rv["title"] - json.field "videoThumbnails" do - generate_thumbnails(json, rv["id"]) - end - - json.field "author", rv["author"] - json.field "authorUrl", rv["author_url"]? - json.field "authorId", rv["ucid"]? - if rv["author_thumbnail"]? - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", rv["author_thumbnail"]?.try &.gsub(/s\d+-/, "s#{quality}-") - json.field "width", quality - json.field "height", quality - end - end - end - end - end - - json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i - json.field "viewCountText", rv["short_view_count_text"]? - json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 - end - end - end - end - end - end + def to_json(locale : String?, json : JSON::Builder) + Invidious::JSONify::APIv1.video(self, json, locale: locale) end - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end + # TODO: remove the locale and follow the crystal convention + def to_json(locale : String?, _json : Nil) + JSON.build do |json| + Invidious::JSONify::APIv1.video(self, json, locale: locale) end end - def title - info["videoDetails"]["title"]?.try &.as_s || "" + def to_json(json : JSON::Builder | Nil = nil) + to_json(nil, json) end - def ucid - info["videoDetails"]["channelId"]?.try &.as_s || "" - end + # Misc methods - def author - info["videoDetails"]["author"]?.try &.as_s || "" + def video_type : VideoType + video_type = info["videoType"]?.try &.as_s || "video" + return VideoType.parse?(video_type) || VideoType::Video end - def length_seconds : Int32 - info["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["lengthSeconds"]?.try &.as_s.to_i || - info["videoDetails"]["lengthSeconds"]?.try &.as_s.to_i || 0 - end - - def views : Int64 - info["videoDetails"]["viewCount"]?.try &.as_s.to_i64 || 0_i64 - end - - def likes : Int64 - info["likes"]?.try &.as_i64 || 0_i64 - end - - def dislikes : Int64 - info["dislikes"]?.try &.as_i64 || 0_i64 - end - - def average_rating : Float64 - # (likes / (likes + dislikes) * 4 + 1) - info["videoDetails"]["averageRating"]?.try { |t| t.as_f? || t.as_i64?.try &.to_f64 }.try &.round(4) || 0.0 + def schema_version : Int + return info["version"]?.try &.as_i || 1 end def published : Time - info["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["publishDate"]?.try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc + return info["published"]? + .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc end def published=(other : Time) - info["microformat"].as_h["playerMicroformatRenderer"].as_h["publishDate"] = JSON::Any.new(other.to_s("%Y-%m-%d")) - end - - def cookie - info["cookie"]?.try &.as_h.map { |k, v| "#{k}=#{v}" }.join("; ") || "" - end - - def allow_ratings - r = info["videoDetails"]["allowRatings"]?.try &.as_bool - r.nil? ? false : r + info["published"] = JSON::Any.new(other.to_s("%Y-%m-%d")) end def live_now - info["microformat"]?.try &.["playerMicroformatRenderer"]? - .try &.["liveBroadcastDetails"]?.try &.["isLiveNow"]?.try &.as_bool || false + return (self.video_type == VideoType::Livestream) end - def is_listed - info["videoDetails"]["isCrawlable"]?.try &.as_bool || false - end - - def is_upcoming - info["videoDetails"]["isUpcoming"]?.try &.as_bool || false + def post_live_dvr + return info["isPostLiveDvr"].as_bool end def premiere_timestamp : Time? - info["microformat"]?.try &.["playerMicroformatRenderer"]? - .try &.["liveBroadcastDetails"]?.try &.["startTimestamp"]?.try { |t| Time.parse_rfc3339(t.as_s) } - end - - def keywords - info["videoDetails"]["keywords"]?.try &.as_a.map &.as_s || [] of String + info + .dig?("microformat", "playerMicroformatRenderer", "liveBroadcastDetails", "startTimestamp") + .try { |t| Time.parse_rfc3339(t.as_s) } end def related_videos info["relatedVideos"]?.try &.as_a.map { |h| h.as_h.transform_values &.as_s } || [] of Hash(String, String) end - def allowed_regions - info["microformat"]?.try &.["playerMicroformatRenderer"]? - .try &.["availableCountries"]?.try &.as_a.map &.as_s || [] of String - end - - def author_thumbnail : String - info["authorThumbnail"]?.try &.as_s || "" - end - - def sub_count_text : String - info["subCountText"]?.try &.as_s || "-" - end + # Methods for parsing streaming data - def fmt_stream - return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream - - fmt_stream = info["streamingData"]?.try &.["formats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) - fmt_stream.each do |fmt| - if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) } - s.each do |k, v| - fmt[k] = JSON::Any.new(v) - end - fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}") - end - - fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") - fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? + def fmt_stream : Array(Hash(String, JSON::Any)) + if formats = info.dig?("streamingData", "formats") + return formats + .as_a.map(&.as_h) + .sort_by! { |f| f["width"]?.try &.as_i || 0 } + else + return [] of Hash(String, JSON::Any) end - fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } - @fmt_stream = fmt_stream - return @fmt_stream.as(Array(Hash(String, JSON::Any))) end - def adaptive_fmts - return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) if @adaptive_fmts - fmt_stream = info["streamingData"]?.try &.["adaptiveFormats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) - fmt_stream.each do |fmt| - if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) } - s.each do |k, v| - fmt[k] = JSON::Any.new(v) - end - fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}") - end - - fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") - fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? + def adaptive_fmts : Array(Hash(String, JSON::Any)) + if formats = info.dig?("streamingData", "adaptiveFormats") + return formats + .as_a.map(&.as_h) + .sort_by! { |f| f["width"]?.try &.as_i || 0 } + else + return [] of Hash(String, JSON::Any) end - # See https://github.com/TeamNewPipe/NewPipe/issues/2415 - # Some streams are segmented by URL `sq/` rather than index, for now we just filter them out - fmt_stream.reject! { |f| !f["indexRange"]? } - fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } - @adaptive_fmts = fmt_stream - return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) end def video_streams @@ -623,391 +120,222 @@ struct Video adaptive_fmts.select &.["mimeType"]?.try &.as_s.starts_with?("audio") end - def storyboards - storyboards = info["storyboards"]? - .try &.as_h - .try &.["playerStoryboardSpecRenderer"]? - .try &.["spec"]? - .try &.as_s.split("|") - - if !storyboards - if storyboard = info["storyboards"]? - .try &.as_h - .try &.["playerLiveStoryboardSpecRenderer"]? - .try &.["spec"]? - .try &.as_s - return [{ - url: storyboard.split("#")[0], - width: 106, - height: 60, - count: -1, - interval: 5000, - storyboard_width: 3, - storyboard_height: 3, - storyboard_count: -1, - }] - end - end - - items = [] of NamedTuple( - url: String, - width: Int32, - height: Int32, - count: Int32, - interval: Int32, - storyboard_width: Int32, - storyboard_height: Int32, - storyboard_count: Int32) - - return items if !storyboards - - url = URI.parse(storyboards.shift) - params = HTTP::Params.parse(url.query || "") - - storyboards.each_with_index do |storyboard, i| - width, height, count, storyboard_width, storyboard_height, interval, _, sigh = storyboard.split("#") - params["sigh"] = sigh - url.query = params.to_s - - width = width.to_i - height = height.to_i - count = count.to_i - interval = interval.to_i - storyboard_width = storyboard_width.to_i - storyboard_height = storyboard_height.to_i - storyboard_count = (count / (storyboard_width * storyboard_height)).ceil.to_i - - items << { - url: url.to_s.sub("$L", i).sub("$N", "M$M"), - width: width, - height: height, - count: count, - interval: interval, - storyboard_width: storyboard_width, - storyboard_height: storyboard_height, - storyboard_count: storyboard_count, - } - end + # Misc. methods - items + def storyboards + container = info.dig?("storyboards") || JSON::Any.new("{}") + return IV::Videos::Storyboard.from_yt_json(container, self.length_seconds) end def paid - reason = info["playabilityStatus"]?.try &.["reason"]? - paid = reason == "This video requires payment to watch." ? true : false - paid + return (self.reason || "").includes? "requires payment" end def premium keywords.includes? "YouTube Red" end - def captions : Array(Caption) - return @captions.as(Array(Caption)) if @captions - captions = info["captions"]?.try &.["playerCaptionsTracklistRenderer"]?.try &.["captionTracks"]?.try &.as_a.map do |caption| - caption = Caption.from_json(caption.to_json) - caption.name.simpleText = caption.name.simpleText.split(" - ")[0] - caption + def captions : Array(Invidious::Videos::Captions::Metadata) + if @captions.empty? && @info.has_key?("captions") + @captions = Invidious::Videos::Captions::Metadata.from_yt_json(info["captions"]) end - captions ||= [] of Caption - @captions = captions - return @captions.as(Array(Caption)) - end - - def description - description = info["microformat"]?.try &.["playerMicroformatRenderer"]? - .try &.["description"]?.try &.["simpleText"]?.try &.as_s || "" - end - - # TODO - def description=(value : String) - @description = value - end - - def description_html - info["descriptionHtml"]?.try &.as_s || "<p></p>" - end - - def description_html=(value : String) - info["descriptionHtml"] = JSON::Any.new(value) - end - def short_description - info["shortDescription"]?.try &.as_s? || "" + return @captions end def hls_manifest_url : String? - info["streamingData"]?.try &.["hlsManifestUrl"]?.try &.as_s + info.dig?("streamingData", "hlsManifestUrl").try &.as_s end - def dash_manifest_url - info["streamingData"]?.try &.["dashManifestUrl"]?.try &.as_s - end + def dash_manifest_url : String? + raw_dash_url = info.dig?("streamingData", "dashManifestUrl").try &.as_s + return nil if raw_dash_url.nil? - def genre : String - info["genre"]?.try &.as_s || "" - end + # Use manifest v5 parameter to reduce file size + # See https://github.com/iv-org/invidious/issues/4186 + dash_url = URI.parse(raw_dash_url) + dash_query = dash_url.query || "" - def genre_url : String? - info["genreUcid"]? ? "/channel/#{info["genreUcid"]}" : nil - end + if dash_query.empty? + dash_url.path = "#{dash_url.path}/mpd_version/5" + else + dash_url.query = "#{dash_query}&mpd_version=5" + end - def license : String? - info["license"]?.try &.as_s + return dash_url.to_s end - def is_family_friendly : Bool - info["microformat"]?.try &.["playerMicroformatRenderer"]["isFamilySafe"]?.try &.as_bool || false + def genre_url : String? + info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil end - def wilson_score : Float64 - ci_lower_bound(likes, likes + dislikes).round(4) + def vr? : Bool? + return {"EQUIRECTANGULAR", "MESH"}.includes? self.projection_type end - def engagement : Float64 - (((likes + dislikes) / views) * 100).round(4) + def projection_type : String? + return info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s end def reason : String? info["reason"]?.try &.as_s end - def session_token : String? - info["sessionToken"]?.try &.as_s? + def music : Array(VideoMusic) + info["music"].as_a.map { |music_json| + VideoMusic.new( + music_json["song"].as_s, + music_json["album"].as_s, + music_json["artist"].as_s, + music_json["license"].as_s + ) + } end -end -struct CaptionName - include JSON::Serializable + # Macros defining getters/setters for various types of data - property simpleText : String -end + private macro getset_string(name) + # Return {{name.stringify}} from `info` + def {{name.id.underscore}} : String + return info[{{name.stringify}}]?.try &.as_s || "" + end -struct Caption - include JSON::Serializable + # Update {{name.stringify}} into `info` + def {{name.id.underscore}}=(value : String) + info[{{name.stringify}}] = JSON::Any.new(value) + end - property name : CaptionName - property baseUrl : String - property languageCode : String -end + {% if flag?(:debug_macros) %} {{debug}} {% end %} + end -class VideoRedirect < Exception - property video_id : String + private macro getset_string_array(name) + # Return {{name.stringify}} from `info` + def {{name.id.underscore}} : Array(String) + return info[{{name.stringify}}]?.try &.as_a.map &.as_s || [] of String + end + + # Update {{name.stringify}} into `info` + def {{name.id.underscore}}=(value : Array(String)) + info[{{name.stringify}}] = JSON::Any.new(value) + end - def initialize(@video_id) + {% if flag?(:debug_macros) %} {{debug}} {% end %} end -end -def parse_related(r : JSON::Any) : JSON::Any? - # TODO: r["endScreenPlaylistRenderer"], etc. - return if !r["endScreenVideoRenderer"]? - r = r["endScreenVideoRenderer"].as_h - - return if !r["lengthInSeconds"]? - - rv = {} of String => JSON::Any - rv["author"] = r["shortBylineText"]["runs"][0]?.try &.["text"] || JSON::Any.new("") - rv["ucid"] = r["shortBylineText"]["runs"][0]?.try &.["navigationEndpoint"]["browseEndpoint"]["browseId"] || JSON::Any.new("") - rv["author_url"] = JSON::Any.new("/channel/#{rv["ucid"]}") - rv["length_seconds"] = JSON::Any.new(r["lengthInSeconds"].as_i.to_s) - rv["title"] = r["title"]["simpleText"] - rv["short_view_count_text"] = JSON::Any.new(r["shortViewCountText"]?.try &.["simpleText"]?.try &.as_s || "") - rv["view_count"] = JSON::Any.new(r["title"]["accessibility"]?.try &.["accessibilityData"]["label"].as_s.match(/(?<views>[1-9](\d+,?)*) views/).try &.["views"].gsub(/\D/, "") || "") - rv["id"] = r["videoId"] - JSON::Any.new(rv) -end + {% for op, type in {i32: Int32, i64: Int64} %} + private macro getset_{{op}}(name) + def \{{name.id.underscore}} : {{type}} + return info[\{{name.stringify}}]?.try &.as_i64.to_{{op}} || 0_{{op}} + end -def extract_polymer_config(body) - params = {} of String => JSON::Any - player_response = body.match(/(window\["ytInitialPlayerResponse"\]|var\sytInitialPlayerResponse)\s*=\s*(?<info>{.*?});\s*var\s*meta/m) - .try { |r| JSON.parse(r["info"]).as_h } - - if body.includes?("To continue with your YouTube experience, please fill out the form below.") || - body.includes?("https://www.google.com/sorry/index") - params["reason"] = JSON::Any.new("Could not extract video info. Instance is likely blocked.") - elsif !player_response - params["reason"] = JSON::Any.new("Video unavailable.") - elsif player_response["playabilityStatus"]?.try &.["status"]?.try &.as_s != "OK" - reason = player_response["playabilityStatus"]["errorScreen"]?.try &.["playerErrorMessageRenderer"]?.try &.["subreason"]?.try { |s| s["simpleText"]?.try &.as_s || s["runs"].as_a.map { |r| r["text"] }.join("") } || - player_response["playabilityStatus"]["reason"].as_s - params["reason"] = JSON::Any.new(reason) - end + def \{{name.id.underscore}}=(value : Int) + info[\{{name.stringify}}] = JSON::Any.new(value.to_i64) + end - session_token_json_encoded = body.match(/"XSRF_TOKEN":"(?<session_token>[^"]+)"/).try &.["session_token"]? || "" - params["sessionToken"] = JSON.parse(%({"key": "#{session_token_json_encoded}"}))["key"] - params["shortDescription"] = JSON::Any.new(body.match(/"og:description" content="(?<description>[^"]+)"/).try &.["description"]?) + \{% if flag?(:debug_macros) %} \{{debug}} \{% end %} + end + {% end %} - return params if !player_response + private macro getset_bool(name) + # Return {{name.stringify}} from `info` + def {{name.id.underscore}} : Bool + return info[{{name.stringify}}]?.try &.as_bool || false + end + + # Update {{name.stringify}} into `info` + def {{name.id.underscore}}=(value : Bool) + info[{{name.stringify}}] = JSON::Any.new(value) + end - {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f| - params[f] = player_response[f] if player_response[f]? + {% if flag?(:debug_macros) %} {{debug}} {% end %} end - yt_initial_data = extract_initial_data(body) - - params["relatedVideos"] = yt_initial_data.try &.["playerOverlays"]?.try &.["playerOverlayRenderer"]? - .try &.["endScreen"]?.try &.["watchNextEndScreenRenderer"]?.try &.["results"]?.try &.as_a.compact_map { |r| - parse_related r - }.try { |a| JSON::Any.new(a) } || yt_initial_data.try &.["webWatchNextResponseExtensionData"]?.try &.["relatedVideoArgs"]? - .try &.as_s.split(",").map { |r| - r = HTTP::Params.parse(r).to_h - JSON::Any.new(Hash.zip(r.keys, r.values.map { |v| JSON::Any.new(v) })) - }.try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any) - - primary_results = yt_initial_data.try &.["contents"]?.try &.["twoColumnWatchNextResults"]?.try &.["results"]? - .try &.["results"]?.try &.["contents"]? - sentiment_bar = primary_results.try &.as_a.select { |object| object["videoPrimaryInfoRenderer"]? }[0]? - .try &.["videoPrimaryInfoRenderer"]? - .try &.["sentimentBar"]? - .try &.["sentimentBarRenderer"]? - .try &.["tooltip"]? - .try &.as_s - - likes, dislikes = sentiment_bar.try &.split(" / ", 2).map &.gsub(/\D/, "").to_i64 || {0_i64, 0_i64} - params["likes"] = JSON::Any.new(likes) - params["dislikes"] = JSON::Any.new(dislikes) - - params["descriptionHtml"] = JSON::Any.new(primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]? - .try &.["videoSecondaryInfoRenderer"]?.try &.["description"]?.try &.["runs"]? - .try &.as_a.try { |t| content_to_comment_html(t).gsub("\n", "<br/>") } || "<p></p>") - - metadata = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]? - .try &.["videoSecondaryInfoRenderer"]? - .try &.["metadataRowContainer"]? - .try &.["metadataRowContainerRenderer"]? - .try &.["rows"]? - .try &.as_a - - params["genre"] = params["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["category"]? || JSON::Any.new("") - params["genreUrl"] = JSON::Any.new(nil) - - metadata.try &.each do |row| - title = row["metadataRowRenderer"]?.try &.["title"]?.try &.["simpleText"]?.try &.as_s - contents = row["metadataRowRenderer"]? - .try &.["contents"]? - .try &.as_a[0]? - - if title.try &.== "Category" - contents = contents.try &.["runs"]? - .try &.as_a[0]? - - params["genre"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "") - params["genreUcid"] = JSON::Any.new(contents.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]? - .try &.["browseId"]?.try &.as_s || "") - elsif title.try &.== "License" - contents = contents.try &.["runs"]? - .try &.as_a[0]? - - params["license"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "") - elsif title.try &.== "Licensed to YouTube by" - params["license"] = JSON::Any.new(contents.try &.["simpleText"]?.try &.as_s || "") + # Macro to generate ? and = accessor methods for attributes in `info` + private macro predicate_bool(method_name, name) + # Return {{name.stringify}} from `info` + def {{method_name.id.underscore}}? : Bool + return info[{{name.stringify}}]?.try &.as_bool || false end - end - author_info = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]? - .try &.["videoSecondaryInfoRenderer"]?.try &.["owner"]?.try &.["videoOwnerRenderer"]? + # Update {{name.stringify}} into `info` + def {{method_name.id.underscore}}=(value : Bool) + info[{{name.stringify}}] = JSON::Any.new(value) + end - params["authorThumbnail"] = JSON::Any.new(author_info.try &.["thumbnail"]? - .try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"]? - .try &.as_s || "") + {% if flag?(:debug_macros) %} {{debug}} {% end %} + end - params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]? - .try { |t| t["simpleText"]? || t["runs"]?.try &.[0]?.try &.["text"]? }.try &.as_s.split(" ", 2)[0] || "-") + # Method definitions, using the macros above - initial_data = body.match(/ytplayer\.config\s*=\s*(?<info>.*?);ytplayer\.web_player_context_config/) - .try { |r| JSON.parse(r["info"]) }.try &.["args"]["player_response"]? - .try &.as_s?.try &.try { |r| JSON.parse(r).as_h } + getset_string author + getset_string authorThumbnail + getset_string description + getset_string descriptionHtml + getset_string genre + getset_string genreUcid + getset_string license + getset_string shortDescription + getset_string subCountText + getset_string title + getset_string ucid - if initial_data - {"playabilityStatus", "streamingData"}.each do |f| - params[f] = initial_data[f] if initial_data[f]? - end - else - {"playabilityStatus", "streamingData"}.each do |f| - params[f] = player_response[f] if player_response[f]? - end - end + getset_string_array allowedRegions + getset_string_array keywords + + getset_i32 lengthSeconds + getset_i64 likes + getset_i64 views - params + # TODO: Make predicate_bool the default as to adhere to Crystal conventions + getset_bool allowRatings + getset_bool authorVerified + getset_bool isFamilyFriendly + getset_bool isListed + predicate_bool upcoming, isUpcoming end -def get_video(id, db, refresh = true, region = nil, force_refresh = false) - if (video = db.query_one?("SELECT * FROM videos WHERE id = $1", id, as: Video)) && !region +def get_video(id, refresh = true, region = nil, force_refresh = false) + if (video = Invidious::Database::Videos.select(id)) && !region # If record was last updated over 10 minutes ago, or video has since premiered, # refresh (expire param in response lasts for 6 hours) if (refresh && (Time.utc - video.updated > 10.minutes) || (video.premiere_timestamp.try &.< Time.utc)) || - force_refresh + force_refresh || + video.schema_version != Video::SCHEMA_VERSION # cache control begin video = fetch_video(id, region) - db.exec("UPDATE videos SET (id, info, updated) = ($1, $2, $3) WHERE id = $1", video.id, video.info.to_json, video.updated) + Invidious::Database::Videos.update(video) rescue ex - db.exec("DELETE FROM videos * WHERE id = $1", id) + Invidious::Database::Videos.delete(id) raise ex end end else video = fetch_video(id, region) - if !region - db.exec("INSERT INTO videos VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", video.id, video.info.to_json, video.updated) - end + Invidious::Database::Videos.insert(video) if !region end return video +rescue DB::Error + # Avoid common `DB::PoolRetryAttemptsExceeded` error and friends + # Note: All DB errors inherit from `DB::Error` + return fetch_video(id, region) end def fetch_video(id, region) - response = YT_POOL.client(region, &.get("/watch?v=#{id}&gl=US&hl=en&has_verified=1&bpctr=9999999999")) - - if md = response.headers["location"]?.try &.match(/v=(?<id>[a-zA-Z0-9_-]{11})/) - raise VideoRedirect.new(video_id: md["id"]) - end - - info = extract_polymer_config(response.body) - info["cookie"] = JSON::Any.new(response.cookies.to_h.transform_values { |v| JSON::Any.new(v.value) }) - allowed_regions = info["microformat"]?.try &.["playerMicroformatRenderer"]["availableCountries"]?.try &.as_a.map &.as_s || [] of String - - # Check for region-blocks - if info["reason"]?.try &.as_s.includes?("your country") - bypass_regions = PROXY_LIST.keys & allowed_regions - if !bypass_regions.empty? - region = bypass_regions[rand(bypass_regions.size)] - response = YT_POOL.client(region, &.get("/watch?v=#{id}&gl=US&hl=en&has_verified=1&bpctr=9999999999")) - - region_info = extract_polymer_config(response.body) - region_info["region"] = JSON::Any.new(region) if region - region_info["cookie"] = JSON::Any.new(response.cookies.to_h.transform_values { |v| JSON::Any.new(v.value) }) - info = region_info if !region_info["reason"]? + info = extract_video_info(video_id: id) + + if reason = info["reason"]? + if reason == "Video unavailable" + raise NotFoundException.new(reason.as_s || "") + elsif !reason.as_s.starts_with? "Premieres" + # dont error when it's a premiere. + # we already parsed most of the data and display the premiere date + raise InfoException.new(reason.as_s || "") end end - # Try to pull streams from embed URL - if info["reason"]? - embed_page = YT_POOL.client &.get("/embed/#{id}").body - sts = embed_page.match(/"sts"\s*:\s*(?<sts>\d+)/).try &.["sts"]? || "" - embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?html5=1&video_id=#{id}&eurl=https://youtube.googleapis.com/v/#{id}&gl=US&hl=en&sts=#{sts}").body) - - if embed_info["player_response"]? - player_response = JSON.parse(embed_info["player_response"]) - {"captions", "microformat", "playabilityStatus", "streamingData", "videoDetails", "storyboards"}.each do |f| - info[f] = player_response[f] if player_response[f]? - end - end - - initial_data = JSON.parse(embed_info["watch_next_response"]) if embed_info["watch_next_response"]? - - info["relatedVideos"] = initial_data.try &.["playerOverlays"]?.try &.["playerOverlayRenderer"]? - .try &.["endScreen"]?.try &.["watchNextEndScreenRenderer"]?.try &.["results"]?.try &.as_a.compact_map { |r| - parse_related r - }.try { |a| JSON::Any.new(a) } || embed_info["rvs"]?.try &.split(",").map { |r| - r = HTTP::Params.parse(r).to_h - JSON::Any.new(Hash.zip(r.keys, r.values.map { |v| JSON::Any.new(v) })) - }.try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any) - end - - raise InfoException.new(info["reason"]?.try &.as_s || "") if !info["videoDetails"]? - video = Video.new({ id: id, info: info, @@ -1017,11 +345,7 @@ def fetch_video(id, region) return video end -def itag_to_metadata?(itag : JSON::Any) - return VIDEO_FORMATS[itag.to_s]? -end - -def process_continuation(db, query, plid, id) +def process_continuation(query, plid, id) continuation = nil if plid if index = query["index"]?.try &.to_i? @@ -1035,125 +359,6 @@ def process_continuation(db, query, plid, id) continuation end -def process_video_params(query, preferences) - annotations = query["iv_load_policy"]?.try &.to_i? - autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } - comments = query["comments"]?.try &.split(",").map { |a| a.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 { |a| a.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? - - if preferences - # region ||= preferences.region - annotations ||= preferences.annotations.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 - end - - annotations ||= CONFIG.default_user_preferences.annotations.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 - - annotations = annotations == 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 - - 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, - 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, - }) - - return params -end - def build_thumbnails(id) return { {host: HOST_URL, height: 720, width: 1280, name: "maxres", url: "maxres"}, @@ -1167,34 +372,3 @@ def build_thumbnails(id) {host: HOST_URL, height: 90, width: 120, name: "end", url: "3"}, } end - -def generate_thumbnails(json, id) - json.array do - build_thumbnails(id).each do |thumbnail| - json.object do - json.field "quality", thumbnail[:name] - json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" - json.field "width", thumbnail[:width] - json.field "height", thumbnail[:height] - end - end - end -end - -def generate_storyboards(json, id, storyboards) - json.array do - storyboards.each do |storyboard| - json.object do - json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}" - json.field "templateUrl", storyboard[:url] - json.field "width", storyboard[:width] - json.field "height", storyboard[:height] - json.field "count", storyboard[:count] - json.field "interval", storyboard[:interval] - json.field "storyboardWidth", storyboard[:storyboard_width] - json.field "storyboardHeight", storyboard[:storyboard_height] - json.field "storyboardCount", storyboard[:storyboard_count] - end - end - end -end diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr new file mode 100644 index 00000000..c811cfe1 --- /dev/null +++ b/src/invidious/videos/caption.cr @@ -0,0 +1,224 @@ +require "json" + +module Invidious::Videos + module Captions + struct Metadata + property name : String + property language_code : String + property base_url : String + + property auto_generated : Bool + + def initialize(@name, @language_code, @base_url, @auto_generated) + end + + # Parse the JSON structure from Youtube + def self.from_yt_json(container : JSON::Any) : Array(Captions::Metadata) + caption_tracks = container + .dig?("playerCaptionsTracklistRenderer", "captionTracks") + .try &.as_a + + captions_list = [] of Captions::Metadata + return captions_list if caption_tracks.nil? + + caption_tracks.each do |caption| + name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] + name = name.to_s.split(" - ")[0] + + language_code = caption["languageCode"].to_s + base_url = caption["baseUrl"].to_s + + auto_generated = (caption["kind"]? == "asr") + + captions_list << Captions::Metadata.new(name, language_code, base_url, auto_generated) + end + + return captions_list + end + + def timedtext_to_vtt(timedtext : String, tlang = nil) : String + # In the future, we could just directly work with the url. This is more of a POC + cues = [] of XML::Node + tree = XML.parse(timedtext) + tree = tree.children.first + + tree.children.each do |item| + if item.name == "body" + item.children.each do |cue| + if cue.name == "p" && !(cue.children.size == 1 && cue.children[0].content == "\n") + cues << cue + end + end + break + end + end + + settings_field = { + "Kind" => "captions", + "Language" => "#{tlang || @language_code}", + } + + result = WebVTT.build(settings_field) do |vtt| + cues.each_with_index do |node, i| + start_time = node["t"].to_f.milliseconds + + duration = node["d"]?.try &.to_f.milliseconds + + duration ||= start_time + + if cues.size > i + 1 + end_time = cues[i + 1]["t"].to_f.milliseconds + else + end_time = start_time + duration + end + + text = String.build do |io| + node.children.each do |s| + io << s.content + end + end + + vtt.cue(start_time, end_time, text) + end + end + + return result + end + end + + # List of all caption languages available on Youtube. + LANGUAGES = { + "", + "English", + "English (auto-generated)", + "English (United Kingdom)", + "English (United States)", + "Afrikaans", + "Albanian", + "Amharic", + "Arabic", + "Armenian", + "Azerbaijani", + "Bangla", + "Basque", + "Belarusian", + "Bosnian", + "Bulgarian", + "Burmese", + "Cantonese (Hong Kong)", + "Catalan", + "Cebuano", + "Chinese", + "Chinese (China)", + "Chinese (Hong Kong)", + "Chinese (Simplified)", + "Chinese (Taiwan)", + "Chinese (Traditional)", + "Corsican", + "Croatian", + "Czech", + "Danish", + "Dutch", + "Dutch (auto-generated)", + "Esperanto", + "Estonian", + "Filipino", + "Filipino (auto-generated)", + "Finnish", + "French", + "French (auto-generated)", + "Galician", + "Georgian", + "German", + "German (auto-generated)", + "Greek", + "Gujarati", + "Haitian Creole", + "Hausa", + "Hawaiian", + "Hebrew", + "Hindi", + "Hmong", + "Hungarian", + "Icelandic", + "Igbo", + "Indonesian", + "Indonesian (auto-generated)", + "Interlingue", + "Irish", + "Italian", + "Italian (auto-generated)", + "Japanese", + "Japanese (auto-generated)", + "Javanese", + "Kannada", + "Kazakh", + "Khmer", + "Korean", + "Korean (auto-generated)", + "Kurdish", + "Kyrgyz", + "Lao", + "Latin", + "Latvian", + "Lithuanian", + "Luxembourgish", + "Macedonian", + "Malagasy", + "Malay", + "Malayalam", + "Maltese", + "Maori", + "Marathi", + "Mongolian", + "Nepali", + "Norwegian Bokmål", + "Nyanja", + "Pashto", + "Persian", + "Polish", + "Portuguese", + "Portuguese (auto-generated)", + "Portuguese (Brazil)", + "Punjabi", + "Romanian", + "Russian", + "Russian (auto-generated)", + "Samoan", + "Scottish Gaelic", + "Serbian", + "Shona", + "Sindhi", + "Sinhala", + "Slovak", + "Slovenian", + "Somali", + "Southern Sotho", + "Spanish", + "Spanish (auto-generated)", + "Spanish (Latin America)", + "Spanish (Mexico)", + "Spanish (Spain)", + "Sundanese", + "Swahili", + "Swedish", + "Tajik", + "Tamil", + "Telugu", + "Thai", + "Turkish", + "Turkish (auto-generated)", + "Ukrainian", + "Urdu", + "Uzbek", + "Vietnamese", + "Vietnamese (auto-generated)", + "Welsh", + "Western Frisian", + "Xhosa", + "Yiddish", + "Yoruba", + "Zulu", + } + end +end diff --git a/src/invidious/videos/clip.cr b/src/invidious/videos/clip.cr new file mode 100644 index 00000000..29c57182 --- /dev/null +++ b/src/invidious/videos/clip.cr @@ -0,0 +1,22 @@ +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) } + + start_time = decoded_protobuf + .try(&.["50:0:embedded"]["2:1:varint"].as_i64) + .try { |i| i/1000 } + + end_time = decoded_protobuf + .try(&.["50:0:embedded"]["3:2:varint"].as_i64) + .try { |i| i/1000 } + + clip_title = decoded_protobuf + .try(&.["50:0:embedded"]["4:3:string"].as_s) + + return start_time, end_time, clip_title +end diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr new file mode 100644 index 00000000..1371bebb --- /dev/null +++ b/src/invidious/videos/description.cr @@ -0,0 +1,82 @@ +require "json" +require "uri" + +private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int + copied = 0 + while copied < count + cp = iter.next + break if cp.is_a?(Iterator::Stop) + + if cp == 0x26 # Ampersand (&) + str << "&" + elsif cp == 0x27 # Single quote (') + str << "'" + elsif cp == 0x22 # Double quote (") + str << """ + elsif cp == 0x3C # Less-than (<) + str << "<" + elsif cp == 0x3E # Greater than (>) + str << ">" + else + str << cp.chr + end + + # A codepoint from the SMP counts twice + copied += 1 if cp > 0xFFFF + copied += 1 + end + + return copied +end + +def parse_description(desc, video_id : String) : String? + return "" if desc.nil? + + content = desc["content"].as_s + return "" if content.empty? + + commands = desc["commandRuns"]?.try &.as_a + if commands.nil? + # Slightly faster than HTML.escape, as we're only doing one pass on + # the string instead of five for the standard library + return String.build do |str| + copy_string(str, content.each_codepoint, content.size) + 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 + + return String.build do |str| + commands.each do |command| + cmd_start = command["startIndex"].as_i + cmd_length = command["length"].as_i + + # Copy the text chunk between this command and the previous if needed. + length = cmd_start - index + index += copy_string(str, iter, length) + + # We need to copy the command's text using the iterator + # and the special function defined above. + cmd_content = String.build(cmd_length) do |str2| + copy_string(str2, iter, cmd_length) + end + + link = cmd_content + if on_tap = command.dig?("onTap", "innertubeCommand") + link = parse_link_endpoint(on_tap, cmd_content, video_id) + end + str << link + index += cmd_length + end + + # Copy the end of the string (past the last command). + remaining_length = content.size - index + copy_string(str, iter, remaining_length) if remaining_length > 0 + end +end diff --git a/src/invidious/videos/formats.cr b/src/invidious/videos/formats.cr new file mode 100644 index 00000000..e98e7257 --- /dev/null +++ b/src/invidious/videos/formats.cr @@ -0,0 +1,116 @@ +module Invidious::Videos::Formats + def self.itag_to_metadata?(itag : JSON::Any) + return FORMATS[itag.to_s]? + end + + # See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 + private FORMATS = { + "5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, + "6" => {"ext" => "flv", "width" => 450, "height" => 270, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, + "13" => {"ext" => "3gp", "acodec" => "aac", "vcodec" => "mp4v"}, + "17" => {"ext" => "3gp", "width" => 176, "height" => 144, "acodec" => "aac", "abr" => 24, "vcodec" => "mp4v"}, + "18" => {"ext" => "mp4", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 96, "vcodec" => "h264"}, + "22" => {"ext" => "mp4", "width" => 1280, "height" => 720, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "34" => {"ext" => "flv", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "35" => {"ext" => "flv", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + + "36" => {"ext" => "3gp", "width" => 320, "acodec" => "aac", "vcodec" => "mp4v"}, + "37" => {"ext" => "mp4", "width" => 1920, "height" => 1080, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "38" => {"ext" => "mp4", "width" => 4096, "height" => 3072, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "43" => {"ext" => "webm", "width" => 640, "height" => 360, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "44" => {"ext" => "webm", "width" => 854, "height" => 480, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "45" => {"ext" => "webm", "width" => 1280, "height" => 720, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "46" => {"ext" => "webm", "width" => 1920, "height" => 1080, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "59" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "78" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + + # 3D videos + "82" => {"ext" => "mp4", "height" => 360, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "83" => {"ext" => "mp4", "height" => 480, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "84" => {"ext" => "mp4", "height" => 720, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "85" => {"ext" => "mp4", "height" => 1080, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "100" => {"ext" => "webm", "height" => 360, "format" => "3D", "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "101" => {"ext" => "webm", "height" => 480, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "102" => {"ext" => "webm", "height" => 720, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + + # Apple HTTP Live Streaming + "91" => {"ext" => "mp4", "height" => 144, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "92" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "93" => {"ext" => "mp4", "height" => 360, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "94" => {"ext" => "mp4", "height" => 480, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "95" => {"ext" => "mp4", "height" => 720, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, + "96" => {"ext" => "mp4", "height" => 1080, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, + "132" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "151" => {"ext" => "mp4", "height" => 72, "format" => "HLS", "acodec" => "aac", "abr" => 24, "vcodec" => "h264"}, + + # DASH mp4 video + "133" => {"ext" => "mp4", "height" => 240, "format" => "DASH video", "vcodec" => "h264"}, + "134" => {"ext" => "mp4", "height" => 360, "format" => "DASH video", "vcodec" => "h264"}, + "135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, + "136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"}, + "137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"}, + "138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559) + "160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"}, + "212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, + "264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"}, + "298" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, + "299" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, + "266" => {"ext" => "mp4", "height" => 2160, "format" => "DASH video", "vcodec" => "h264"}, + + # Dash mp4 audio + "139" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 48, "container" => "m4a_dash"}, + "140" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 128, "container" => "m4a_dash"}, + "141" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 256, "container" => "m4a_dash"}, + "256" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, + "258" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, + "325" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "dtse", "container" => "m4a_dash"}, + "328" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "ec-3", "container" => "m4a_dash"}, + + # Dash webm + "167" => {"ext" => "webm", "height" => 360, "width" => 640, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "168" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "169" => {"ext" => "webm", "height" => 720, "width" => 1280, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "170" => {"ext" => "webm", "height" => 1080, "width" => 1920, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "218" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "219" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "278" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "container" => "webm", "vcodec" => "vp9"}, + "242" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9"}, + "243" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9"}, + "244" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "245" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "246" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "247" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9"}, + "248" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9"}, + "271" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9"}, + # itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug) + "272" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, + "302" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "303" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "308" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "313" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, + "315" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "330" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "331" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "332" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "333" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "334" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "335" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "336" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "337" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + + # Dash webm audio + "171" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 128}, + "172" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 256}, + + # Dash webm audio with opus inside + "249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50}, + "250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70}, + "251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160}, + + # av01 video only formats sometimes served with "unknown" codecs + "394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"}, + "395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"}, + "396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"}, + "397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"}, + } +end diff --git a/src/invidious/videos/music.cr b/src/invidious/videos/music.cr new file mode 100644 index 00000000..08d88a3e --- /dev/null +++ b/src/invidious/videos/music.cr @@ -0,0 +1,13 @@ +require "json" + +struct VideoMusic + include JSON::Serializable + + property song : String + property album : String + property artist : String + property license : String + + def initialize(@song : String, @album : String, @artist : String, @license : String) + end +end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr new file mode 100644 index 00000000..915c9baf --- /dev/null +++ b/src/invidious/videos/parser.cr @@ -0,0 +1,489 @@ +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"]? + + # 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) } + + # "4,088,033 views", only available on compact renderer + # and when video is not a livestream + view_count = related.dig?("viewCountText", "simpleText") + .try &.as_s.gsub(/\D/, "") + + short_view_count = related.try do |r| + HelperExtractors.get_short_view_count(r).to_s + end + + LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") + + # 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"), + "view_count" => JSON::Any.new(view_count || "0"), + "short_view_count" => JSON::Any.new(short_view_count || "0"), + "author_verified" => JSON::Any.new(author_verified), + } +end + +def extract_video_info(video_id : String) + # Init client config for the API + client_config = YoutubeAPI::ClientConfig.new + + # Fetch data from the player endpoint + player_response = YoutubeAPI.player(video_id: video_id, params: "2AMB", client_config: client_config) + + 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), + } + 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 + + 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 + + # 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": ""}) + player_response = player_response.merge(next_response) + end + + params = parse_video_info(video_id, player_response) + params["reason"] = JSON::Any.new(reason) if reason + + new_player_response = nil + + # Don't use Android test suite client if po_token is passed because po_token doesn't + # work for Android test suite client. + if reason.nil? && CONFIG.po_token.nil? + # Fetch the video streams using an Android client in order to get the + # decrypted URLs and maybe fix throttling issues (#2194). See the + # following issue for an explanation about decrypted URLs: + # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 + client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite + new_player_response = try_fetch_streaming_data(video_id, client_config) + end + + # Replace player response and reset reason + if !new_player_response.nil? + # Preserve captions & storyboard data before replacement + new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]? + new_player_response["captions"] = player_response["captions"] if player_response["captions"]? + + player_response = new_player_response + params.delete("reason") + end + + {"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.as_h["url"] = JSON::Any.new(convert_url(format)) + end + end + + params["streamingData"] = streaming_data + end + + # Data structure version, for cache control + params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64) + + return params +end + +def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)? + LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.") + response = YoutubeAPI.player(video_id: id, params: "2AMB", client_config: client_config) + + playability_status = response["playabilityStatus"]["status"] + LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") + + if id != response.dig("videoDetails", "videoId") + # YouTube may return a different video player response than expected. + # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 + raise InfoException.new( + "The video returned by YouTube isn't the requested one. (#{client_config.client_type} client)" + ) + elsif playability_status == "OK" + return response + else + return nil + end +end + +def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any) + # Top level elements + + main_results = player_response.dig?("contents", "twoColumnWatchNextResults") + + raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results + + # Primary results are not available on Music videos + # See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725 + if primary_results = main_results.dig?("results", "results", "contents") + video_primary_renderer = primary_results + .as_a.find(&.["videoPrimaryInfoRenderer"]?) + .try &.["videoPrimaryInfoRenderer"] + + video_secondary_renderer = primary_results + .as_a.find(&.["videoSecondaryInfoRenderer"]?) + .try &.["videoSecondaryInfoRenderer"] + + raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer + raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer + end + + video_details = player_response.dig?("videoDetails") + if !(microformat = player_response.dig?("microformat", "playerMicroformatRenderer")) + microformat = {} of String => JSON::Any + end + + raise BrokenTubeException.new("videoDetails") if !video_details + + # Basic video infos + + title = video_details["title"]?.try &.as_s + + # We have to try to extract viewCount from videoPrimaryInfoRenderer first, + # then from videoDetails, as the latter is "0" for livestreams (we want + # to get the amount of viewers watching). + views_txt = extract_text( + video_primary_renderer + .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount") + ) + views_txt ||= video_details["viewCount"]?.try &.as_s || "" + views = views_txt.gsub(/\D/, "").to_i64? + + length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) + .try &.as_s.to_i64 + + published = microformat["publishDate"]? + .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc + + premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") + .try { |t| Time.parse_rfc3339(t.as_s) } + + premiere_timestamp ||= player_response.dig?( + "playabilityStatus", "liveStreamability", + "liveStreamabilityRenderer", "offlineSlate", + "liveStreamOfflineSlateRenderer", "scheduledStartTime" + ) + .try &.as_s.to_i64 + .try { |t| Time.unix(t) } + + live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") + .try &.as_bool + live_now ||= video_details.dig?("isLive").try &.as_bool || false + + post_live_dvr = video_details.dig?("isPostLiveDvr") + .try &.as_bool || false + + # Extra video infos + + allowed_regions = microformat["availableCountries"]? + .try &.as_a.map &.as_s || [] of String + + allow_ratings = video_details["allowRatings"]?.try &.as_bool + family_friendly = microformat["isFamilySafe"]?.try &.as_bool + is_listed = video_details["isCrawlable"]?.try &.as_bool + is_upcoming = video_details["isUpcoming"]?.try &.as_bool + + keywords = video_details["keywords"]? + .try &.as_a.map &.as_s || [] of String + + # Related videos + + LOGGER.debug("extract_video_info: parsing related videos...") + + related = [] of JSON::Any + + # Parse "compactVideoRenderer" items (under secondary results) + secondary_results = main_results + .dig?("secondaryResults", "secondaryResults", "results") + secondary_results.try &.as_a.each do |element| + if item = element["compactVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end + end + + # 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" + ) + + player_overlays.try &.as_a.each do |element| + if item = element["endScreenVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end + end + end + + # Likes + + toplevel_buttons = video_primary_renderer + .try &.dig?("videoActions", "menuRenderer", "topLevelButtons") + + if toplevel_buttons + # New Format as of december 2023 + likes_button = toplevel_buttons.dig?(0, + "segmentedLikeDislikeButtonViewModel", + "likeButtonViewModel", + "likeButtonViewModel", + "toggleButtonViewModel", + "toggleButtonViewModel", + "defaultButtonViewModel", + "buttonViewModel" + ) + + likes_button ||= toplevel_buttons.try &.as_a + .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE") + .try &.["toggleButtonRenderer"] + + # New format as of september 2022 + likes_button ||= toplevel_buttons.try &.as_a + .find(&.["segmentedLikeDislikeButtonRenderer"]?) + .try &.dig?( + "segmentedLikeDislikeButtonRenderer", + "likeButton", "toggleButtonRenderer" + ) + + if likes_button + likes_txt = likes_button.dig?("accessibilityText") + # Note: The like count from `toggledText` is off by one, as it would + # represent the new like count in the event where the user clicks on "like". + likes_txt ||= (likes_button["defaultText"]? || likes_button["toggledText"]?) + .try &.dig?("accessibility", "accessibilityData", "label") + likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt + + LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"") + LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes + end + end + + # Description + + description = microformat.dig?("description", "simpleText").try &.as_s || "" + short_description = player_response.dig?("videoDetails", "shortDescription") + + # description_html = video_secondary_renderer.try &.dig?("description", "runs") + # .try &.as_a.try { |t| content_to_comment_html(t, video_id) } + + description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription"), video_id) + + # Video metadata + + metadata = video_secondary_renderer + .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows") + .try &.as_a + + genre = microformat["category"]? + genre_ucid = nil + license = nil + + metadata.try &.each do |row| + metadata_title = extract_text(row.dig?("metadataRowRenderer", "title")) + contents = row.dig?("metadataRowRenderer", "contents", 0) + + if metadata_title == "Category" + contents = contents.try &.dig?("runs", 0) + + genre = contents.try &.["text"]? + genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId") + elsif metadata_title == "License" + license = contents.try &.dig?("runs", 0, "text") + elsif metadata_title == "Licensed to YouTube by" + license = contents.try &.["simpleText"]? + end + end + + # Music section + + music_list = [] of VideoMusic + music_desclist = player_response.dig?( + "engagementPanels", 1, "engagementPanelSectionListRenderer", + "content", "structuredDescriptionContentRenderer", "items", 2, + "videoDescriptionMusicSectionRenderer", "carouselLockups" + ) + + music_desclist.try &.as_a.each do |music_desc| + artist = nil + album = nil + music_license = nil + + # Used when the video has multiple songs + if song_title = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title") + # "simpleText" for plain text / "runs" when song has a link + song = song_title["simpleText"]? || song_title.dig?("runs", 0, "text") + + # some videos can have empty tracks. See: https://www.youtube.com/watch?v=eBGIQ7ZuuiU + next if !song + end + + music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc| + desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) + if desc_title == "ARTIST" + artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "SONG" + song = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "ALBUM" + album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "LICENSES" + music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata")) + end + end + music_list << VideoMusic.new(song.to_s, album.to_s, artist.to_s, music_license.to_s) + end + + # Author infos + + author = video_details["author"]?.try &.as_s + ucid = video_details["channelId"]?.try &.as_s + + if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") + author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url") + author_verified = has_verified_badge?(author_info["badges"]?) + + subs_text = author_info["subscriberCountText"]? + .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") } + .try &.as_s.split(" ", 2)[0] + end + + # Return data + + if live_now + video_type = VideoType::Livestream + elsif !premiere_timestamp.nil? + video_type = VideoType::Scheduled + published = premiere_timestamp || Time.utc + else + video_type = VideoType::Video + end + + params = { + "videoType" => JSON::Any.new(video_type.to_s), + # Basic video infos + "title" => JSON::Any.new(title || ""), + "views" => JSON::Any.new(views || 0_i64), + "likes" => JSON::Any.new(likes || 0_i64), + "lengthSeconds" => JSON::Any.new(length_txt || 0_i64), + "published" => JSON::Any.new(published.to_rfc3339), + # Extra video infos + "allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }), + "allowRatings" => JSON::Any.new(allow_ratings || false), + "isFamilyFriendly" => JSON::Any.new(family_friendly || false), + "isListed" => JSON::Any.new(is_listed || false), + "isUpcoming" => JSON::Any.new(is_upcoming || false), + "keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }), + "isPostLiveDvr" => JSON::Any.new(post_live_dvr), + # Related videos + "relatedVideos" => JSON::Any.new(related), + # Description + "description" => JSON::Any.new(description || ""), + "descriptionHtml" => JSON::Any.new(description_html || "<p></p>"), + "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), + # Video metadata + "genre" => JSON::Any.new(genre.try &.as_s || ""), + "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?), + "license" => JSON::Any.new(license.try &.as_s || ""), + # Music section + "music" => JSON.parse(music_list.to_json), + # Author infos + "author" => JSON::Any.new(author || ""), + "ucid" => JSON::Any.new(ucid || ""), + "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""), + "authorVerified" => JSON::Any.new(author_verified || false), + "subCountText" => JSON::Any.new(subs_text || "-"), + } + + return params +end + +private def convert_url(fmt) + if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } + sp = cfr["sp"] + url = URI.parse(cfr["url"]) + params = url.query_params + + LOGGER.debug("convert_url: Decoding '#{cfr}'") + + unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"]) + params[sp] = unsig if unsig + else + url = URI.parse(fmt["url"].as_s) + params = url.query_params + end + + n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"]) + params["n"] = n if n + + if token = CONFIG.po_token + params["pot"] = token + 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 diff --git a/src/invidious/videos/regions.cr b/src/invidious/videos/regions.cr new file mode 100644 index 00000000..575f8c25 --- /dev/null +++ b/src/invidious/videos/regions.cr @@ -0,0 +1,27 @@ +# List of geographical regions that Youtube recognizes. +# This is used to determine if a video is either restricted to a list +# of allowed regions (= whitelisted) or if it can't be watched in +# a set of regions (= blacklisted). +REGIONS = { + "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", + "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", + "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", + "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", + "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", + "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", + "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", + "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", + "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", + "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", + "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", + "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", + "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", + "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", + "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", + "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", + "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", + "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", + "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", + "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", + "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW", +} diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr new file mode 100644 index 00000000..a72c2f55 --- /dev/null +++ b/src/invidious/videos/storyboard.cr @@ -0,0 +1,122 @@ +require "uri" +require "http/params" + +module Invidious::Videos + struct Storyboard + # Template URL + getter url : URI + getter proxied_url : URI + + # Thumbnail parameters + getter width : Int32 + getter height : Int32 + getter count : Int32 + getter interval : Int32 + + # Image (storyboard) parameters + getter rows : Int32 + getter columns : Int32 + getter images_count : Int32 + + def initialize( + *, @url, @width, @height, @count, @interval, + @rows, @columns, @images_count + ) + authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]? + + @proxied_url = URI.parse(HOST_URL) + @proxied_url.path = "/sb/#{authority}/#{@url.path.lchop("/sb/")}" + @proxied_url.query = @url.query + end + + # Parse the JSON structure from Youtube + def self.from_yt_json(container : JSON::Any, length_seconds : Int32) : Array(Storyboard) + # Livestream storyboards are a bit different + # TODO: document exactly how + if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s + return [Storyboard.new( + url: URI.parse(storyboard.split("#")[0]), + width: 106, + height: 60, + count: -1, + interval: 5000, + rows: 3, + columns: 3, + images_count: -1 + )] + end + + # Split the storyboard string into chunks + # + # General format (whitespaces added for legibility): + # https://i.ytimg.com/sb/<video_id>/storyboard3_L$L/$N.jpg?sqp=<sig0> + # | 48 # 27 # 100 # 10 # 10 # 0 # default # rs$<sig1> + # | 80 # 45 # 95 # 10 # 10 # 10000 # M$M # rs$<sig2> + # | 160 # 90 # 95 # 5 # 5 # 10000 # M$M # rs$<sig3> + # + storyboards = container.dig?("playerStoryboardSpecRenderer", "spec") + .try &.as_s.split("|") + + return [] of Storyboard if !storyboards + + # The base URL is the first chunk + base_url = URI.parse(storyboards.shift) + + return storyboards.map_with_index do |sb, i| + # Separate the different storyboard parameters: + # width/height: respective dimensions, in pixels, of a single thumbnail + # count: how many thumbnails are displayed across the full video + # columns/rows: maximum amount of thumbnails that can be stuffed in a + # single image, horizontally and vertically. + # interval: interval between two thumbnails, in milliseconds + # name: storyboard filename. Usually "M$M" or "default" + # sigh: URL cryptographic signature + width, height, count, columns, rows, interval, name, sigh = sb.split("#") + + width = width.to_i + height = height.to_i + count = count.to_i + interval = interval.to_i + columns = columns.to_i + rows = rows.to_i + + # Copy base URL object, so that we can modify it + url = base_url.dup + + # Add the signature to the URL + params = url.query_params + params["sigh"] = sigh + url.query_params = params + + # Replace the template parts with what we have + url.path = url.path.sub("$L", i).sub("$N", name) + + # This value represents the maximum amount of thumbnails that can fit + # in a single image. The last image (or the only one for short videos) + # will contain less thumbnails than that. + thumbnails_per_image = columns * rows + + # This value represents the total amount of storyboards required to + # hold all of the thumbnails. It can't be less than 1. + images_count = (count / thumbnails_per_image).ceil.to_i + + # Compute the interval when needed (in general, that's only required + # for the first "default" storyboard). + if interval == 0 + interval = ((length_seconds / count) * 1_000).to_i + end + + Storyboard.new( + url: url, + width: width, + height: height, + count: count, + interval: interval, + rows: rows, + columns: columns, + images_count: images_count, + ) + end + end + end +end diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr new file mode 100644 index 00000000..4bd9f820 --- /dev/null +++ b/src/invidious/videos/transcript.cr @@ -0,0 +1,126 @@ +module Invidious::Videos + # A `Transcripts` struct encapsulates a sequence of lines that together forms the whole transcript for a given YouTube video. + # These lines can be categorized into two types: section headings and regular lines representing content from the video. + struct Transcript + # Types + record HeadingLine, start_ms : Time::Span, end_ms : Time::Span, line : String + record RegularLine, start_ms : Time::Span, end_ms : Time::Span, line : String + alias TranscriptLine = HeadingLine | RegularLine + + property lines : Array(TranscriptLine) + + property language_code : String + property auto_generated : Bool + + # User friendly label for the current transcript. + # Example: "English (auto-generated)" + property label : String + + # Initializes a new Transcript struct with the contents and associated metadata describing it + def initialize(@lines : Array(TranscriptLine), @language_code : String, @auto_generated : Bool, @label : String) + end + + # Generates a protobuf string to fetch the requested transcript from YouTube + def self.generate_param(video_id : String, language_code : String, auto_generated : Bool) : String + kind = auto_generated ? "asr" : "" + + object = { + "1:0:string" => video_id, + + "2:base64" => { + "1:string" => kind, + "2:string" => language_code, + "3:string" => "", + }, + + "3:varint" => 1_i64, + "5:string" => "engagement-panel-searchable-transcript-search-panel", + "6:varint" => 1_i64, + "7:varint" => 1_i64, + "8:varint" => 1_i64, + } + + params = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return params + end + + # Constructs a Transcripts struct from the initial YouTube response + def self.from_raw(initial_data : Hash(String, JSON::Any), language_code : String, auto_generated : Bool) + transcript_panel = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer", + "content", "transcriptSearchPanelRenderer") + + segment_list = transcript_panel.dig("body", "transcriptSegmentListRenderer") + + if !segment_list["initialSegments"]? + raise NotFoundException.new("Requested transcript does not exist") + end + + # Extract user-friendly label for the current transcript + + footer_language_menu = transcript_panel.dig?( + "footer", "transcriptFooterRenderer", "languageMenu", "sortFilterSubMenuRenderer", "subMenuItems" + ) + + if footer_language_menu + label = footer_language_menu.as_a.select(&.["selected"].as_bool)[0]["title"].as_s + else + label = language_code + end + + # Extract transcript lines + + initial_segments = segment_list["initialSegments"].as_a + + lines = [] of TranscriptLine + + initial_segments.each do |line| + if unpacked_line = line["transcriptSectionHeaderRenderer"]? + line_type = HeadingLine + else + unpacked_line = line["transcriptSegmentRenderer"] + line_type = RegularLine + end + + start_ms = unpacked_line["startMs"].as_s.to_i.millisecond + end_ms = unpacked_line["endMs"].as_s.to_i.millisecond + text = extract_text(unpacked_line["snippet"]) || "" + + lines << line_type.new(start_ms, end_ms, text) + end + + return Transcript.new( + lines: lines, + language_code: language_code, + auto_generated: auto_generated, + label: label + ) + end + + # Converts transcript lines to a WebVTT file + # + # This is used within Invidious to replace subtitles + # as to workaround YouTube's rate-limited timedtext endpoint. + def to_vtt + settings_field = { + "Kind" => "captions", + "Language" => @language_code, + } + + vtt = WebVTT.build(settings_field) do |builder| + @lines.each do |line| + # Section headers are excluded from the VTT conversion as to + # match the regular captions returned from YouTube as much as possible + next if line.is_a? HeadingLine + + builder.cue(line.start_ms, line.end_ms, line.line) + end + end + + return vtt + end + end +end diff --git a/src/invidious/videos/video_preferences.cr b/src/invidious/videos/video_preferences.cr new file mode 100644 index 00000000..48177bd8 --- /dev/null +++ b/src/invidious/videos/video_preferences.cr @@ -0,0 +1,162 @@ +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 diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr index 09eacbc8..6aea82ae 100644 --- a/src/invidious/views/add_playlist_items.ecr +++ b/src/invidious/views/add_playlist_items.ecr @@ -11,7 +11,9 @@ <legend><a href="/playlist?list=<%= playlist.id %>"><%= translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %></a></legend> <fieldset> - <input class="pure-input-1" type="search" name="q" <% if query %>value="<%= HTML.escape(query) %>"<% else %>placeholder="<%= translate(locale, "Search for videos") %>"<% end %>> + <input class="pure-input-1" type="search" name="q" + <% if query %>value="<%= HTML.escape(query.text) %>"<% end %> + placeholder="<%= translate(locale, "Search for videos") %>"> <input type="hidden" name="list" value="<%= plid %>"> </fieldset> </form> @@ -29,30 +31,5 @@ </script> <script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script> -<div class="pure-g"> - <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -</div> -<% if query %> - <div class="pure-g h-box"> - <div class="pure-u-1 pure-u-lg-1-5"> - <% if page > 1 %> - <a href="/add_playlist_items?list=<%= plid %>&q=<%= HTML.escape(query.not_nil!) %>&page=<%= page - 1 %>"> - <%= translate(locale, "Previous page") %> - </a> - <% end %> - </div> - <div class="pure-u-1 pure-u-lg-3-5"></div> - <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> - <% if count >= 20 %> - <a href="/add_playlist_items?list=<%= plid %>&q=<%= HTML.escape(query.not_nil!) %>&page=<%= page + 1 %>"> - <%= translate(locale, "Next page") %> - </a> - <% end %> - </div> - </div> -<% end %> +<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 061d7eec..a84e44bc 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -1,108 +1,54 @@ -<% content_for "header" do %> -<title><%= channel.author %> - Invidious</title> -<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= channel.ucid %>" /> -<% end %> +<%- + ucid = channel.ucid + author = HTML.escape(channel.author) + channel_profile_pic = URI.parse(channel.author_thumbnail).request_target + + relative_url = + case selected_tab + when .shorts? then "/channel/#{ucid}/shorts" + when .streams? then "/channel/#{ucid}/streams" + when .playlists? then "/channel/#{ucid}/playlists" + when .channels? then "/channel/#{ucid}/channels" + when .podcasts? then "/channel/#{ucid}/podcasts" + when .releases? then "/channel/#{ucid}/releases" + else + "/channel/#{ucid}" + end + + youtube_url = "https://www.youtube.com#{relative_url}" + redirect_url = Invidious::Frontend::Misc.redirect_url(env) + + page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale, + base_url: relative_url, + ctoken: next_continuation + ) +%> -<% if channel.banner %> - <div class="h-box"> - <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>"> - </div> - - <div class="h-box"> - <hr> - </div> +<% content_for "header" do %> +<%- if selected_tab.videos? -%> +<meta name="description" content="<%= channel.description %>"> +<meta property="og:site_name" content="Invidious"> +<meta property="og:url" content="<%= HOST_URL %>/channel/<%= ucid %>"> +<meta property="og:title" content="<%= author %>"> +<meta property="og:image" content="<%= HOST_URL %>/ggpht<%= channel_profile_pic %>"> +<meta property="og:description" content="<%= channel.description %>"> +<meta name="twitter:card" content="summary"> +<meta name="twitter:url" content="<%= HOST_URL %>/channel/<%= ucid %>"> +<meta name="twitter:title" content="<%= author %>"> +<meta name="twitter:description" content="<%= channel.description %>"> +<meta name="twitter:image" content="<%= HOST_URL %>/ggpht<%= channel_profile_pic %>"> +<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/channel/<%= ucid %>" /> +<%- end -%> + +<link rel="alternate" href="<%= youtube_url %>"> +<title><%= author %> - Invidious</title> <% end %> -<div class="pure-g h-box"> - <div class="pure-u-2-3"> - <div class="channel-profile"> - <img src="/ggpht<%= URI.parse(channel.author_thumbnail).request_target %>"> - <span><%= channel.author %></span> - </div> - </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> - <a href="/feed/channel/<%= channel.ucid %>"><i class="icon ion-logo-rss"></i></a> - </h3> - </div> -</div> - -<div class="h-box"> - <p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p> -</div> - -<div class="h-box"> - <% ucid = channel.ucid %> - <% author = channel.author %> - <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> -</div> - -<div class="pure-g h-box"> - <div class="pure-u-1-3"> - <a href="https://www.youtube.com/channel/<%= channel.ucid %>"><%= translate(locale, "View channel on YouTube") %></a> - <% if !channel.auto_generated %> - <div class="pure-u-1 pure-md-1-3"> - <b><%= translate(locale, "Videos") %></b> - </div> - <% end %> - <div class="pure-u-1 pure-md-1-3"> - <% if channel.auto_generated %> - <b><%= translate(locale, "Playlists") %></b> - <% else %> - <a href="/channel/<%= channel.ucid %>/playlists"><%= translate(locale, "Playlists") %></a> - <% end %> - </div> - <div class="pure-u-1 pure-md-1-3"> - <% if channel.tabs.includes? "community" %> - <a href="/channel/<%= channel.ucid %>/community"><%= translate(locale, "Community") %></a> - <% end %> - </div> - </div> - <div class="pure-u-1-3"></div> - <div class="pure-u-1-3"> - <div class="pure-g" style="text-align:right"> - <% sort_options.each do |sort| %> - <div class="pure-u-1 pure-md-1-3"> - <% if sort_by == sort %> - <b><%= translate(locale, sort) %></b> - <% else %> - <a href="/channel/<%= channel.ucid %>?page=<%= page %>&sort_by=<%= sort %>"> - <%= translate(locale, sort) %> - </a> - <% end %> - </div> - <% end %> - </div> - </div> -</div> +<%= rendered "components/channel_info" %> <div class="h-box"> <hr> </div> -<div class="pure-g"> - <% items.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -</div> -<div class="pure-g h-box"> - <div class="pure-u-1 pure-u-lg-1-5"> - <% if page > 1 %> - <a href="/channel/<%= channel.ucid %>?page=<%= page - 1 %><% if sort_by != "newest" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>"> - <%= translate(locale, "Previous page") %> - </a> - <% end %> - </div> - <div class="pure-u-1 pure-u-lg-3-5"></div> - <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> - <% if count == 60 %> - <a href="/channel/<%= channel.ucid %>?page=<%= page + 1 %><% if sort_by != "newest" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>"> - <%= translate(locale, "Next page") %> - </a> - <% end %> - </div> -</div> +<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 3c4eaabb..d2a305d3 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -1,61 +1,21 @@ -<% content_for "header" do %> -<title><%= channel.author %> - Invidious</title> -<% end %> - -<% if channel.banner %> - <div class="h-box"> - <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>"> - </div> +<%- + ucid = channel.ucid + author = HTML.escape(channel.author) + channel_profile_pic = URI.parse(channel.author_thumbnail).request_target - <div class="h-box"> - <hr> - </div> -<% end %> - -<div class="pure-g h-box"> - <div class="pure-u-2-3"> - <div class="channel-profile"> - <img src="/ggpht<%= URI.parse(channel.author_thumbnail).request_target %>"> - <span><%= channel.author %></span> - </div> - </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> - <a href="/feed/channel/<%= channel.ucid %>"><i class="icon ion-logo-rss"></i></a> - </h3> - </div> -</div> + relative_url = "/channel/#{ucid}/community" + youtube_url = "https://www.youtube.com#{relative_url}" + redirect_url = Invidious::Frontend::Misc.redirect_url(env) -<div class="h-box"> - <p><span style="white-space:pre-wrap"><%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content %></span></p> -</div> + selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Community +-%> -<div class="h-box"> - <% ucid = channel.ucid %> - <% author = channel.author %> - <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> -</div> +<% content_for "header" do %> +<link rel="alternate" href="<%= youtube_url %>"> +<title><%= author %> - Invidious</title> +<% end %> -<div class="pure-g h-box"> - <div class="pure-u-1-3"> - <a href="https://www.youtube.com/channel/<%= channel.ucid %>/community"><%= translate(locale, "View channel on YouTube") %></a> - <% if !channel.auto_generated %> - <div class="pure-u-1 pure-md-1-3"> - <a href="/channel/<%= channel.ucid %>"><%= translate(locale, "Videos") %></a> - </div> - <% end %> - <div class="pure-u-1 pure-md-1-3"> - <a href="/channel/<%= channel.ucid %>/playlists"><%= translate(locale, "Playlists") %></a> - </div> - <div class="pure-u-1 pure-md-1-3"> - <% if channel.tabs.includes? "community" %> - <b><%= translate(locale, "Community") %></b> - <% end %> - </div> - </div> - <div class="pure-u-2-3"></div> -</div> +<%= rendered "components/channel_info" %> <div class="h-box"> <hr> @@ -66,15 +26,15 @@ <p><%= error_message %></p> </div> <% else %> - <div class="h-box pure-g" id="comments"> - <%= template_youtube_comments(items.not_nil!, locale, thin_mode) %> + <div class="h-box pure-g comments" id="comments"> + <%= IV::Frontend::Comments.template_youtube(items.not_nil!, locale, thin_mode) %> </div> <% end %> <script id="community_data" type="application/json"> <%= { - "ucid" => channel.ucid, + "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")), diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr new file mode 100644 index 00000000..f4164f31 --- /dev/null +++ b/src/invidious/views/components/channel_info.ecr @@ -0,0 +1,61 @@ +<% if channel.banner %> + <div class="h-box"> + <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>" alt="" /> + </div> + + <div class="h-box"> + <hr> + </div> +<% end %> + +<div class="pure-g h-box flexible title"> + <div class="pure-u-1-2 flex-left flexible"> + <div class="channel-profile"> + <img src="/ggpht<%= channel_profile_pic %>" alt="" /> + <span><%= author %></span><% if !channel.verified.nil? && channel.verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end %> + </div> + </div> + + <div class="pure-u-1-2 flex-right flexible button-container"> + <div class="pure-u"> + <% sub_count_text = number_to_short_text(channel.sub_count) %> + <%= rendered "components/subscribe_widget" %> + </div> + + <div class="pure-u"> + <a class="pure-button pure-button-secondary" dir="auto" href="/feed/channel/<%= ucid %>"> + <i class="icon ion-logo-rss"></i> <%= translate(locale, "generic_button_rss") %> + </a> + </div> + </div> +</div> + +<div class="h-box"> + <div id="descriptionWrapper"><p><span style="white-space:pre-wrap"><%= channel.description_html %></span></p></div> +</div> + +<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> + </div> + <div class="pure-u-1 pure-md-1-3"> + <a href="<%= redirect_url %>"><%= translate(locale, "Switch Invidious Instance") %></a> + </div> + + <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %> + </div> + <div class="pure-u-1-2"> + <div class="pure-g" style="text-align:end"> + <% sort_options.each do |sort| %> + <div class="pure-u-1 pure-md-1-3"> + <% if sort_by == sort %> + <b><%= translate(locale, sort) %></b> + <% else %> + <a href="<%= relative_url %>?sort_by=<%= sort %>"><%= translate(locale, sort) %></a> + <% end %> + </div> + <% end %> + </div> + </div> +</div> diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 9dfa047e..6d227cfc 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -1,167 +1,205 @@ +<%- + thin_mode = env.get("preferences").as(Preferences).thin_mode + item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil + author_verified = item.responds_to?(:author_verified) && item.author_verified +-%> + <div class="pure-u-1 pure-u-md-1-4"> <div class="h-box"> <% case item when %> <% when SearchChannel %> - <a style="width:100%" href="/channel/<%= item.ucid %>"> - <% if !env.get("preferences").as(Preferences).thin_mode %> + <% if !thin_mode %> + <a tabindex="-1" href="/channel/<%= item.ucid %>"> <center> - <img style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/> + <img loading="lazy" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>" alt="" /> </center> - <% end %> - <p><%= item.author %></p> - </a> - <p><%= translate(locale, "`x` subscribers", number_with_separator(item.subscriber_count)) %></p> - <% if !item.auto_generated %><p><%= translate(locale, "`x` videos", number_with_separator(item.video_count)) %></p><% end %> + </a> + <%- else -%> + <div class="thumbnail-placeholder" style="width:56.25%"></div> + <% end %> + + <div class="video-card-row flexible"> + <div class="flex-left"><a href="/channel/<%= item.ucid %>"> + <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %> + <%- if author_verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end -%> + </p> + </a></div> + </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 %> <h5><%= item.description_html %></h5> - <% when SearchPlaylist, InvidiousPlaylist %> - <% if item.id.starts_with? "RD" %> - <% url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").request_target.split("/")[2]}" %> - <% else %> - <% url = "/playlist?list=#{item.id}" %> + <% when SearchHashtag %> + <% if !thin_mode %> + <a tabindex="-1" href="<%= item.url %>"> + <center><img style="width:56.25%" src="/hashtag.svg" alt="" /></center> + </a> + <%- else -%> + <div class="thumbnail-placeholder" style="width:56.25%"></div> <% end %> - <a style="width:100%" href="<%= url %>"> - <% if !env.get("preferences").as(Preferences).thin_mode %> - <div class="thumbnail"> - <img class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/> - <p class="length"><%= number_with_separator(item.video_count) %> videos</p> - </div> - <% end %> - <p><%= item.title %></p> - </a> - <p> - <b> - <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a> - </b> - </p> - <% when MixVideo %> - <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.rdid %>"> - <% if !env.get("preferences").as(Preferences).thin_mode %> - <div class="thumbnail"> - <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> - <% if item.length_seconds != 0 %> - <p class="length"><%= recode_length_seconds(item.length_seconds) %></p> - <% end %> - </div> - <% end %> - <p><%= HTML.escape(item.title) %></p> - </a> - <p> - <b> - <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a> - </b> - </p> - <% when PlaylistVideo %> - <a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>"> - <% if !env.get("preferences").as(Preferences).thin_mode %> - <div class="thumbnail"> - <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> - <% if plid = env.get?("remove_playlist_items") %> - <form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post"> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> - <p class="watched"> - <a data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid %>" href="javascript:void(0)"> - <button type="submit" style="all:unset"> - <i class="icon ion-md-trash"></i> - </button> - </a> - </p> - </form> - <% end %> + <div class="video-card-row"> + <div class="flex-left"><a href="<%= item.url %>"><%= HTML.escape(item.title) %></a></div> + </div> - <% if item.responds_to?(:live_now) && item.live_now %> - <p class="length"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p> - <% elsif item.length_seconds != 0 %> - <p class="length"><%= recode_length_seconds(item.length_seconds) %></p> - <% end %> - </div> - <% end %> - <p><a href="/watch?v=<%= item.id %>"><%= HTML.escape(item.title) %></a></p> - </a> - <p> - <b> - <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a> - </b> - </p> - - <h5 class="pure-g"> - <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> - <div class="pure-u-2-3"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></div> - <% elsif Time.utc - item.published > 1.minute %> - <div class="pure-u-2-3"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></div> - <% else %> - <div class="pure-u-2-3"></div> - <% end %> + <div class="video-card-row"> + <%- if item.video_count != 0 -%> + <p><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p> + <%- end -%> + </div> + + <div class="video-card-row"> + <%- if item.channel_count != 0 -%> + <p><%= translate_count(locale, "generic_channels_count", item.channel_count, NumberFormatting::Separator) %></p> + <%- end -%> + </div> + <% when SearchPlaylist, InvidiousPlaylist %> + <%- + if item.id.starts_with? "RD" + link_url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").request_target.split("/")[2]}" + else + link_url = "/playlist?list=#{item.id}" + end + -%> + + <div class="thumbnail"> + <%- if !thin_mode %> + <a tabindex="-1" href="<%= link_url %>"> + <img loading="lazy" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>" alt="" /> + </a> + <%- else -%> + <div class="thumbnail-placeholder"></div> + <%- end -%> + + <div class="bottom-right-overlay"> + <p class="length"><%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %></p> + </div> + </div> - <div class="pure-u-1-3" style="text-align:right"> - <%= item.responds_to?(:views) && item.views ? translate(locale, "`x` views", number_to_short_text(item.views || 0)) : "" %> + <div class="video-card-row"> + <a href="<%= link_url %>"><p dir="auto"><%= HTML.escape(item.title) %></p></a> + </div> + + <div class="video-card-row flexible"> + <div class="flex-left"> + <% if !item.ucid.to_s.empty? %> + <a href="/channel/<%= item.ucid %>"> + <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %> + <%- if author_verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end -%> + </p> + </a> + <% else %> + <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %> + <%- if author_verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end -%> + </p> + <% end %> </div> - </h5> + </div> + <% when Category %> <% else %> - <% if !env.get("preferences").as(Preferences).thin_mode %> - <a style="width:100%" href="/watch?v=<%= item.id %>"> - <div class="thumbnail"> - <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> - <% if env.get? "show_watched" %> - <form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post"> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> - <p class="watched"> - <a data-onclick="mark_watched" data-id="<%= item.id %>" href="javascript:void(0)"> - <button type="submit" style="all:unset"> - <i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye" - class="icon ion-ios-eye"> - </i> - </button> - </a> - </p> - </form> - <% elsif plid = env.get? "add_playlist_items" %> - <form data-onsubmit="return_false" action="/playlist_ajax?action_add_video=1&video_id=<%= item.id %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post"> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> - <p class="watched"> - <a data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid %>" href="javascript:void(0)"> - <button type="submit" style="all:unset"> - <i class="icon ion-md-add"></i> - </button> - </a> - </p> - </form> - <% end %> + <%- + # `endpoint_params` is used for the "video-context-buttons" component + if item.is_a?(PlaylistVideo) + link_url = "/watch?v=#{item.id}&list=#{item.plid}&index=#{item.index}" + endpoint_params = "?v=#{item.id}&list=#{item.plid}" + elsif item.is_a?(MixVideo) + link_url = "/watch?v=#{item.id}&list=#{item.rdid}" + endpoint_params = "?v=#{item.id}&list=#{item.rdid}" + else + link_url = "/watch?v=#{item.id}" + endpoint_params = "?v=#{item.id}" + end + -%> + + <div class="thumbnail"> + <%- if !thin_mode -%> + <a tabindex="-1" href="<%= link_url %>"> + <img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg" alt="" /> - <% if item.responds_to?(:live_now) && item.live_now %> - <p class="length"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p> - <% elsif item.length_seconds != 0 %> - <p class="length"><%= recode_length_seconds(item.length_seconds) %></p> + <% if item_watched %> + <div class="watched-overlay"></div> + <div class="watched-indicator" data-length="<%= item.length_seconds %>" data-id="<%= item.id %>"></div> <% end %> - </div> - </a> - <% end %> - <p><a href="/watch?v=<%= item.id %>"><%= HTML.escape(item.title) %></a></p> - <p style="display: flex;"> - <b style="flex: 1;"> - <a style="width:100%" href="/channel/<%= item.ucid %>"><%= item.author %></a> - </b> - <a title="<%=translate(locale, "Watch on YouTube")%>" href="https://www.youtube.com/watch?v=<%= item.id %>" style="margin-right: 5px;"> - <i class="icon ion-logo-youtube"></i> - </a> - <a title="<%=translate(locale, "Audio mode")%>" href="/watch?v=<%= item.id %>&listen=1"> - <i class="icon ion-md-headset"></i> - </a> - </p> - - <h5 class="pure-g"> - <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> - <div class="pure-u-2-3"><%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %></div> - <% elsif Time.utc - item.published > 1.minute %> - <div class="pure-u-2-3"><%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %></div> - <% else %> - <div class="pure-u-2-3"></div> - <% end %> + </a> + <%- else -%> + <div class="thumbnail-placeholder"></div> + <%- end -%> + + <div class="top-left-overlay"> + <%- if env.get? "show_watched" -%> + <form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> + <button type="submit" class="pure-button pure-button-secondary low-profile" + data-onclick="mark_watched" data-id="<%= item.id %>"> + <i data-mouse="switch_classes" data-switch-classes="ion-ios-eye-off,ion-ios-eye" class="icon ion-ios-eye"></i> + </button> + </form> + <%- end -%> + + <%- if plid_form = env.get?("add_playlist_items") -%> + <%- form_parameters = "action_add_video=1&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%> + <form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> + <button type="submit" class="pure-button pure-button-secondary low-profile" + data-onclick="add_playlist_item" data-id="<%= item.id %>" data-plid="<%= plid_form %>"><i class="icon ion-md-add"></i></button> + </form> + <%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%> + <%- form_parameters = "action_remove_video=1&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%> + <form data-onsubmit="return_false" action="/playlist_ajax?<%= form_parameters %>" method="post"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> + <button type="submit" class="pure-button pure-button-secondary low-profile" + data-onclick="remove_playlist_item" data-index="<%= item.index %>" data-plid="<%= plid_form %>"><i class="icon ion-md-trash"></i></button> + </form> + <%- end -%> + </div> + + <div class="bottom-right-overlay"> + <%- if item.responds_to?(:live_now) && item.live_now -%> + <p class="length" dir="auto"><i class="icon ion-ios-play-circle"></i> <%= translate(locale, "LIVE") %></p> + <%- elsif item.length_seconds != 0 -%> + <p class="length"><%= recode_length_seconds(item.length_seconds) %></p> + <%- end -%> + </div> + </div> + + <div class="video-card-row"> + <a href="<%= link_url %>"><p dir="auto"><%= HTML.escape(item.title) %></p></a> + </div> + + <div class="video-card-row flexible"> + <div class="flex-left"> + <% if !item.ucid.to_s.empty? %> + <a href="/channel/<%= item.ucid %>"> + <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %> + <%- if author_verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end -%> + </p> + </a> + <% else %> + <p class="channel-name" dir="auto"><%= HTML.escape(item.author) %> + <%- if author_verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end -%> + </p> + <% end %> + </div> + + <%= rendered "components/video-context-buttons" %> + </div> - <div class="pure-u-1-3" style="text-align:right"> - <%= item.responds_to?(:views) && item.views ? translate(locale, "`x` views", number_to_short_text(item.views || 0)) : "" %> + <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> + <% 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> + <% end %> </div> - </h5> + + <% 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> + </div> + <% end %> + </div> <% end %> </div> </div> diff --git a/src/invidious/views/components/items_paginated.ecr b/src/invidious/views/components/items_paginated.ecr new file mode 100644 index 00000000..4534a0a3 --- /dev/null +++ b/src/invidious/views/components/items_paginated.ecr @@ -0,0 +1,11 @@ +<%= page_nav_html %> + +<div class="pure-g"> + <%- items.each do |item| -%> + <%= rendered "components/item" %> + <%- end -%> +</div> + +<%= page_nav_html %> + +<script src="/js/watched_indicator.js"></script> diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index cff3e60a..5c28358b 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -1,5 +1,6 @@ <video style="outline:none;width:100%;background-color:#000" playsinline poster="<%= thumbnail %>" id="player" class="on-video_player video-js player-style-<%= params.player_style %>" + preload="<% if params.preload %>auto<% else %>none<% end %>" <% if params.autoplay %>autoplay<% end %> <% if params.video_loop %>loop<% end %> <% if params.controls %>controls<% end %>> @@ -7,31 +8,61 @@ <source src="<%= URI.parse(hlsvp).request_target %><% if params.local %>?local=true<% end %>" type="application/x-mpegURL" label="livestream"> <% else %> <% if params.listen %> - <% audio_streams.each_with_index do |fmt, i| %> - <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% if params.local %>&local=true<% end %>" type='<%= fmt["mimeType"] %>' label="<%= fmt["bitrate"] %>k" selected="<%= i == 0 ? true : false %>"> + <% # default to 128k m4a stream + best_m4a_stream_index = 0 + best_m4a_stream_bitrate = 0 + audio_streams.each_with_index do |fmt, i| + bandwidth = fmt["bitrate"].as_i + if (fmt["mimeType"].as_s.starts_with?("audio/mp4") && bandwidth > best_m4a_stream_bitrate) + best_m4a_stream_bitrate = bandwidth + best_m4a_stream_index = i + end + end + + 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 + + bitrate = fmt["bitrate"] + mimetype = HTML.escape(fmt["mimeType"].as_s) + + selected = (i == best_m4a_stream_index) + %> + <source src="<%= src_url %>" type='<%= mimetype %>' label="<%= bitrate %>k" selected="<%= selected %>"> + <% if !params.local && !CONFIG.disabled?("local") %> + <source src="<%= src_url %>&local=true" type='<%= mimetype %>' hidequalityoption="true"> + <% end %> <% end %> - <% else %> + <% else %> <% if params.quality == "dash" %> <source src="/api/manifest/dash/id/<%= video.id %>?local=true&unique_res=1" type='application/dash+xml' label="dash"> <% end %> - <% fmt_stream.each_with_index do |fmt, i| %> - <% if params.quality %> - <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% if params.local %>&local=true<% end %>" type='<%= fmt["mimeType"] %>' label="<%= fmt["quality"] %>" selected="<%= params.quality == fmt["quality"] %>"> - <% else %> - <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% if params.local %>&local=true<% end %>" type='<%= fmt["mimeType"] %>' label="<%= fmt["quality"] %>" selected="<%= i == 0 ? true : false %>"> + <% + fmt_stream.reject! { |f| f["itag"] == 17 } + fmt_stream.sort_by! {|f| params.quality == f["quality"] ? 0 : 1 } + fmt_stream.each_with_index do |fmt, i| + src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" + src_url += "&local=true" if params.local + + quality = fmt["quality"] + mimetype = HTML.escape(fmt["mimeType"].as_s) + + selected = params.quality ? (params.quality == quality) : (i == 0) + %> + <source src="<%= src_url %>" type="<%= mimetype %>" label="<%= quality %>" selected="<%= selected %>"> + <% if !params.local && !CONFIG.disabled?("local") %> + <source src="<%= src_url %>&local=true" type="<%= mimetype %>" hidequalityoption="true"> <% end %> <% end %> <% end %> <% preferred_captions.each do |caption| %> - <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name.simpleText %>&hl=<%= env.get("preferences").as(Preferences).locale %>" - label="<%= caption.name.simpleText %>"> + <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name %>" label="<%= caption.name %>"> <% end %> <% captions.each do |caption| %> - <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name.simpleText %>&hl=<%= env.get("preferences").as(Preferences).locale %>" - label="<%= caption.name.simpleText %>"> + <track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name %>" label="<%= caption.name %>"> <% end %> <% end %> </video> diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index a99fdbca..9af3899c 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -1,17 +1,19 @@ -<link rel="stylesheet" href="/css/video-js.min.css?v=<%= ASSET_COMMIT %>"> -<link rel="stylesheet" href="/css/videojs-http-source-selector.css?v=<%= ASSET_COMMIT %>"> -<link rel="stylesheet" href="/css/videojs.markers.min.css?v=<%= ASSET_COMMIT %>"> -<link rel="stylesheet" href="/css/videojs-share.css?v=<%= ASSET_COMMIT %>"> -<link rel="stylesheet" href="/css/videojs-vtt-thumbnails.css?v=<%= ASSET_COMMIT %>"> -<link rel="stylesheet" href="/css/videojs-mobile-ui.css?v=<%= ASSET_COMMIT %>"> +<link rel="stylesheet" href="/videojs/video.js/video-js.css?v=<%= ASSET_COMMIT %>"> +<link rel="stylesheet" href="/videojs/videojs-http-source-selector/videojs-http-source-selector.css?v=<%= ASSET_COMMIT %>"> +<link rel="stylesheet" href="/videojs/videojs-markers/videojs.markers.css?v=<%= ASSET_COMMIT %>"> +<link rel="stylesheet" href="/videojs/videojs-share/videojs-share.css?v=<%= ASSET_COMMIT %>"> +<link rel="stylesheet" href="/videojs/videojs-vtt-thumbnails/videojs-vtt-thumbnails.css?v=<%= ASSET_COMMIT %>"> +<link rel="stylesheet" href="/videojs/videojs-mobile-ui/videojs-mobile-ui.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/player.css?v=<%= ASSET_COMMIT %>"> -<script src="/js/video.min.js?v=<%= ASSET_COMMIT %>"></script> -<script src="/js/videojs-mobile-ui.min.js?v=<%= ASSET_COMMIT %>"></script> -<script src="/js/videojs-contrib-quality-levels.min.js?v=<%= ASSET_COMMIT %>"></script> -<script src="/js/videojs-http-source-selector.min.js?v=<%= ASSET_COMMIT %>"></script> -<script src="/js/videojs-markers.min.js?v=<%= ASSET_COMMIT %>"></script> -<script src="/js/videojs-share.min.js?v=<%= ASSET_COMMIT %>"></script> -<script src="/js/videojs-vtt-thumbnails.min.js?v=<%= ASSET_COMMIT %>"></script> + +<script src="/videojs/video.js/video.js?v=<%= ASSET_COMMIT %>"></script> +<script src="/videojs/videojs-mobile-ui/videojs-mobile-ui.js?v=<%= ASSET_COMMIT %>"></script> +<script src="/videojs/videojs-contrib-quality-levels/videojs-contrib-quality-levels.js?v=<%= ASSET_COMMIT %>"></script> +<script src="/videojs/videojs-http-source-selector/videojs-http-source-selector.js?v=<%= ASSET_COMMIT %>"></script> +<script src="/videojs/videojs-markers/videojs-markers.js?v=<%= ASSET_COMMIT %>"></script> +<script src="/videojs/videojs-share/videojs-share.js?v=<%= ASSET_COMMIT %>"></script> +<script src="/videojs/videojs-vtt-thumbnails/videojs-vtt-thumbnails.js?v=<%= ASSET_COMMIT %>"></script> + <% if params.annotations %> <link rel="stylesheet" href="/css/videojs-youtube-annotations.min.css?v=<%= ASSET_COMMIT %>"> @@ -22,3 +24,8 @@ <link rel="stylesheet" href="/css/quality-selector.css?v=<%= ASSET_COMMIT %>"> <script src="/js/silvermine-videojs-quality-selector.min.js?v=<%= ASSET_COMMIT %>"></script> <% end %> + +<% if !params.listen && params.vr_mode %> + <link rel="stylesheet" href="/videojs/videojs-vr/videojs-vr.css?v=<%= ASSET_COMMIT %>"> + <script src="/videojs/videojs-vr/videojs-vr.js?v=<%= ASSET_COMMIT %>"></script> +<% end %> diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr new file mode 100644 index 00000000..29da2c52 --- /dev/null +++ b/src/invidious/views/components/search_box.ecr @@ -0,0 +1,12 @@ +<form class="pure-form" action="/search" method="get"> + <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") %>" + value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> + </fieldset> + <button type="submit" id="searchbutton" aria-label="<%= translate(locale, "search") %>"> + <i class="icon ion-ios-search"></i> + </button> +</form> diff --git a/src/invidious/views/components/subscribe_widget.ecr b/src/invidious/views/components/subscribe_widget.ecr index ac2fbf1d..05e4e253 100644 --- a/src/invidious/views/components/subscribe_widget.ecr +++ b/src/invidious/views/components/subscribe_widget.ecr @@ -1,22 +1,18 @@ <% if user %> <% if subscriptions.includes? ucid %> - <p> <form action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post"> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <button data-type="unsubscribe" id="subscribe" class="pure-button pure-button-primary"> <b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b> </button> </form> - </p> <% else %> - <p> <form action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post"> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <button data-type="subscribe" id="subscribe" class="pure-button pure-button-primary"> <b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b> </button> </form> - </p> <% end %> <script id="subscribe_data" type="application/json"> @@ -33,10 +29,8 @@ </script> <script src="/js/subscribe_widget.js?v=<%= ASSET_COMMIT %>"></script> <% else %> - <p> <a id="subscribe" class="pure-button pure-button-primary" href="/login?referer=<%= env.get("current_page") %>"> <b><%= translate(locale, "Subscribe") %> | <%= sub_count_text %></b> </a> - </p> <% end %> diff --git a/src/invidious/views/components/video-context-buttons.ecr b/src/invidious/views/components/video-context-buttons.ecr new file mode 100644 index 00000000..22458a03 --- /dev/null +++ b/src/invidious/views/components/video-context-buttons.ecr @@ -0,0 +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%>"> + <i class="icon ion-logo-youtube"></i> + </a> + <a title="<%=translate(locale, "Audio mode")%>" href="/watch<%=endpoint_params%>&listen=1"> + <i class="icon ion-md-headset"></i> + </a> + + <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> + <a title="<%=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%>"> + <i class="icon ion-md-jet"></i> + </a> + <% end %> + + </div> +</div>
\ No newline at end of file diff --git a/src/invidious/views/create_playlist.ecr b/src/invidious/views/create_playlist.ecr index 14f3673e..807244e6 100644 --- a/src/invidious/views/create_playlist.ecr +++ b/src/invidious/views/create_playlist.ecr @@ -30,7 +30,7 @@ </button> </div> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>"> </fieldset> </form> </div> diff --git a/src/invidious/views/delete_playlist.ecr b/src/invidious/views/delete_playlist.ecr index 480e36f4..cd66b963 100644 --- a/src/invidious/views/delete_playlist.ecr +++ b/src/invidious/views/delete_playlist.ecr @@ -19,6 +19,6 @@ </div> </div> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>"> </form> </div> diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr index bd8d6207..34157c67 100644 --- a/src/invidious/views/edit_playlist.ecr +++ b/src/invidious/views/edit_playlist.ecr @@ -1,81 +1,60 @@ +<% title = HTML.escape(playlist.title) %> + <% content_for "header" do %> -<title><%= playlist.title %> - Invidious</title> +<title><%= title %> - Invidious</title> <link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/playlist/<%= plid %>" /> <% end %> <form class="pure-form" action="/edit_playlist?list=<%= plid %>" method="post"> - <div class="pure-g h-box"> - <div class="pure-u-2-3"> - <h3><input class="pure-input-1" maxlength="150" name="title" type="text" value="<%= playlist.title %>"></h3> + <div class="h-box flexible"> + <div class="flex-right button-container"> + <div class="pure-u"> + <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/playlist?list=<%= plid %>"> + <i class="icon ion-md-close"></i> <%= translate(locale, "generic_button_cancel") %> + </a> + </div> + <div class="pure-u"> + <button class="pure-button pure-button-secondary low-profile" dir="auto" type="submit"> + <i class="icon ion-md-save"></i> <%= translate(locale, "generic_button_save") %> + </button> + </div> + <div class="pure-u"> + <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>"> + <i class="icon ion-md-trash"></i> <%= translate(locale, "generic_button_delete") %> + </a> + </div> + </div> + </div> + + <div class="h-box flexible title"> + <div> + <h3><input class="pure-input-1" maxlength="150" name="title" type="text" value="<%= title %>"></h3> + </div> + </div> + + <div class="h-box"> + <div class="pure-u-1-1"> <b> - <%= playlist.author %> | - <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | - <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | - <i class="icon <%= {"ion-md-globe", "ion-ios-unlock", "ion-ios-lock"}[playlist.privacy.value] %>"></i> - <select name="privacy"> - <% {"Public", "Unlisted", "Private"}.each do |option| %> - <option value="<%= option %>" <% if option == playlist.privacy.to_s %>selected<% end %>><%= translate(locale, option) %></option> - <% end %> - </select> + <%= HTML.escape(playlist.author) %> | + <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> | </b> - </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> - <div class="pure-g user-field"> - <div class="pure-u-1-3"> - <a href="javascript:void(0)"> - <button type="submit" style="all:unset"> - <i class="icon ion-md-save"></i> - </button> - </a> - </div> - <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div> - <div class="pure-u-1-3"><a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a></div> - </div> - </h3> + <select name="privacy"> + <%- {"Public", "Unlisted", "Private"}.each do |option| -%> + <option value="<%= option %>" <% if option == playlist.privacy.to_s %>selected<% end %>><%= translate(locale, option) %></option> + <%- end -%> + </select> </div> </div> <div class="h-box"> <textarea maxlength="5000" name="description" style="margin-top:10px;max-width:100%;height:20vh" class="pure-input-1"><%= playlist.description %></textarea> </div> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>"> </form> -<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> -<div class="h-box" style="text-align:right"> - <h3> - <a href="/add_playlist_items?list=<%= plid %>"><i class="icon ion-md-add"></i></a> - </h3> -</div> -<% end %> - <div class="h-box"> <hr> </div> -<div class="pure-g"> - <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -</div> -<div class="pure-g h-box"> - <div class="pure-u-1 pure-u-lg-1-5"> - <% if page > 1 %> - <a href="/playlist?list=<%= playlist.id %>&page=<%= page - 1 %>"> - <%= translate(locale, "Previous page") %> - </a> - <% end %> - </div> - <div class="pure-u-1 pure-u-lg-3-5"></div> - <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> - <% if videos.size == 100 %> - <a href="/playlist?list=<%= playlist.id %>&page=<%= page + 1 %>"> - <%= translate(locale, "Next page") %> - </a> - <% end %> - </div> -</div> +<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index dbb86009..1bf5cc3e 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -6,11 +6,12 @@ <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="thumbnail" content="<%= thumbnail %>"> <%= rendered "components/player_sources" %> - <link rel="stylesheet" href="/css/videojs-overlay.css?v=<%= ASSET_COMMIT %>"> - <script src="/js/videojs-overlay.min.js?v=<%= ASSET_COMMIT %>"></script> + <link rel="stylesheet" href="/videojs/videojs-overlay/videojs-overlay.css?v=<%= ASSET_COMMIT %>"> + <script src="/videojs/videojs-overlay/videojs-overlay.js?v=<%= ASSET_COMMIT %>"></script> <link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/embed.css?v=<%= ASSET_COMMIT %>"> <title><%= HTML.escape(video.title) %> - Invidious</title> + <script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script> </head> <body class="dark-theme"> @@ -24,7 +25,8 @@ "video_series" => video_series, "params" => params, "preferences" => preferences, - "premiere_timestamp" => video.premiere_timestamp.try &.to_unix + "premiere_timestamp" => video.premiere_timestamp.try &.to_unix, + "local_disabled" => CONFIG.disabled?("local") }.to_pretty_json %> </script> diff --git a/src/invidious/views/error.ecr b/src/invidious/views/error.ecr index d0752e5b..04eb74d5 100644 --- a/src/invidious/views/error.ecr +++ b/src/invidious/views/error.ecr @@ -4,4 +4,5 @@ <div class="h-box"> <%= error_message %> + <%= next_steps %> </div> diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr new file mode 100644 index 00000000..bda4e1f3 --- /dev/null +++ b/src/invidious/views/feeds/history.ecr @@ -0,0 +1,59 @@ +<% content_for "header" do %> +<title><%= 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> + </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> + </h3> + </div> + <div class="pure-u-1-3"> + <h3 style="text-align:right"> + <a href="/clear_watch_history"><%= translate(locale, "Clear watch history") %></a> + </h3> + </div> +</div> + +<script id="watched_data" type="application/json"> +<%= +{ + "csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") +}.to_pretty_json +%> +</script> +<script src="/js/watched_widget.js"></script> + +<div class="pure-g"> + <% watched.each do |item| %> + <div class="pure-u-1 pure-u-md-1-4"> + <div class="h-box"> + <div class="thumbnail"> + <a style="width:100%" href="/watch?v=<%= item %>"> + <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg" alt="" /> + </a> + + <div class="top-left-overlay"><div class="watched"> + <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post"> + <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> + <button type="submit" class="pure-button pure-button-secondary low-profile" + data-onclick="mark_unwatched" data-id="<%= item %>"><i class="icon ion-md-trash"></i></button> + </form> + </div></div> + </div> + <p></p> + </div> + </div> + <% end %> +</div> + +<%= + IV::Frontend::Pagination.nav_numeric(locale, + base_url: base_url, + current_page: page, + show_next: (watched.size >= max_results) + ) +%> diff --git a/src/invidious/views/feeds/playlists.ecr b/src/invidious/views/feeds/playlists.ecr new file mode 100644 index 00000000..2a4b6edd --- /dev/null +++ b/src/invidious/views/feeds/playlists.ecr @@ -0,0 +1,43 @@ +<% content_for "header" do %> +<title><%= 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> + </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> + </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") %> + </a> + </h3> + </div> +</div> + +<div class="pure-g"> +<% items_created.each do |item| %> + <%= rendered "components/item" %> +<% end %> +</div> + +<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> + </div> +</div> + +<div class="pure-g"> +<% items_saved.each do |item| %> + <%= rendered "components/item" %> +<% end %> +</div> + +<script src="/js/watched_indicator.js"></script> diff --git a/src/invidious/views/popular.ecr b/src/invidious/views/feeds/popular.ecr index 62abb12a..5fbe539c 100644 --- a/src/invidious/views/popular.ecr +++ b/src/invidious/views/feeds/popular.ecr @@ -12,9 +12,9 @@ <%= rendered "components/feed_menu" %> <div class="pure-g"> - <% popular_videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% popular_videos.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> + +<script src="/js/watched_indicator.js"></script> diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr new file mode 100644 index 00000000..c36bd00f --- /dev/null +++ b/src/invidious/views/feeds/subscriptions.ecr @@ -0,0 +1,74 @@ +<% content_for "header" do %> +<title><%= translate(locale, "Subscriptions") %> - Invidious</title> +<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/private?token=<%= token %>" /> +<% end %> + +<%= rendered "components/feed_menu" %> + +<div class="pure-g h-box"> + <div class="pure-u-1-3"> + <h3> + <a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a> + </h3> + </div> + <div class="pure-u-1-3"> + <h3 style="text-align:center"> + <a href="/feed/history"><%= translate(locale, "Watch history") %></a> + </h3> + </div> + <div class="pure-u-1-3"> + <h3 style="text-align:right"> + <a href="/feed/private?token=<%= token %>"><i class="icon ion-logo-rss"></i></a> + </h3> + </div> +</div> + +<% if CONFIG.enable_user_notifications %> + +<center> + <%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %> +</center> + +<% if !notifications.empty? %> + <div class="h-box"> + <hr> + </div> +<% end %> + +<div class="pure-g"> +<% notifications.each do |item| %> + <%= rendered "components/item" %> +<% end %> +</div> + +<% end %> + +<div class="h-box"> + <hr> +</div> + +<script id="watched_data" type="application/json"> +<%= +{ + "csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") +}.to_pretty_json +%> +</script> +<script src="/js/watched_widget.js"></script> + + +<div class="pure-g"> +<% videos.each do |item| %> + <%= rendered "components/item" %> +<% end %> +</div> + +<script src="/js/watched_indicator.js"></script> + +<%= + IV::Frontend::Pagination.nav_numeric(locale, + base_url: base_url, + current_page: page, + show_next: ((videos.size + notifications.size) == max_results) + ) +%> diff --git a/src/invidious/views/trending.ecr b/src/invidious/views/feeds/trending.ecr index 3ec62555..7dc416c6 100644 --- a/src/invidious/views/trending.ecr +++ b/src/invidious/views/feeds/trending.ecr @@ -41,9 +41,9 @@ </div> <div class="pure-g"> - <% trending.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% trending.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> + +<script src="/js/watched_indicator.js"></script> diff --git a/src/invidious/views/hashtag.ecr b/src/invidious/views/hashtag.ecr new file mode 100644 index 00000000..2000337e --- /dev/null +++ b/src/invidious/views/hashtag.ecr @@ -0,0 +1,8 @@ +<% content_for "header" do %> +<title><%= HTML.escape(hashtag) %> - Invidious</title> +<% end %> + +<hr/> + + +<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/history.ecr b/src/invidious/views/history.ecr deleted file mode 100644 index fe8c70b9..00000000 --- a/src/invidious/views/history.ecr +++ /dev/null @@ -1,75 +0,0 @@ -<% content_for "header" do %> -<title><%= translate(locale, "History") %> - Invidious</title> -<% end %> - -<div class="pure-g h-box"> - <div class="pure-u-1-3"> - <h3><%= translate(locale, "`x` videos", %(<span id="count">#{user.watched.size}</span>)) %></h3> - </div> - <div class="pure-u-1-3" style="text-align:center"> - <h3> - <a href="/feed/subscriptions"><%= translate(locale, "`x` subscriptions", %(<span id="count">#{user.subscriptions.size}</span>)) %></a> - </h3> - </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> - <a href="/clear_watch_history"><%= translate(locale, "Clear watch history") %></a> - </h3> - </div> -</div> - -<script id="watched_data" type="application/json"> -<%= -{ - "csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") -}.to_pretty_json -%> -</script> -<script src="/js/watched_widget.js"></script> - -<div class="pure-g"> - <% watched.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <div class="pure-u-1 pure-u-md-1-4"> - <div class="h-box"> - <a style="width:100%" href="/watch?v=<%= item %>"> - <% if !env.get("preferences").as(Preferences).thin_mode %> - <div class="thumbnail"> - <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg"/> - <form data-onsubmit="return_false" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post"> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> - <p class="watched"> - <a data-onclick="mark_unwatched" data-id="<%= item %>" href="javascript:void(0)"> - <button type="submit" style="all:unset"> - <i class="icon ion-md-trash"></i> - </button> - </a> - </p> - </form> - </div> - <p></p> - <% end %> - </a> - </div> - </div> - <% end %> - <% end %> -</div> - -<div class="pure-g h-box"> - <div class="pure-u-1 pure-u-lg-1-5"> - <% if page > 1 %> - <a href="/feed/history?page=<%= page - 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>"> - <%= translate(locale, "Previous page") %> - </a> - <% end %> - </div> - <div class="pure-u-1 pure-u-lg-3-5"></div> - <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> - <% if watched.size >= max_results %> - <a href="/feed/history?page=<%= page + 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>"> - <%= translate(locale, "Next page") %> - </a> - <% end %> - </div> -</div> diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index c2ada992..667cfa37 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -11,6 +11,34 @@ <table id="jslicense-labels1"> <tr> <td> + <a href="/js/_helpers.js?v=<%= ASSET_COMMIT %>">_helpers.js</a> + </td> + + <td> + <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a> + </td> + + <td> + <a href="/js/_helpers.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a> + </td> + </tr> + + <tr> + <td> + <a href="/js/handlers.js?v=<%= ASSET_COMMIT %>">handlers.js</a> + </td> + + <td> + <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a> + </td> + + <td> + <a href="/js/handlers.js?v=<%= ASSET_COMMIT %>"><%= translate(locale, "source") %></a> + </td> + </tr> + + <tr> + <td> <a href="/js/community.js?v=<%= ASSET_COMMIT %>">community.js</a> </td> @@ -75,7 +103,7 @@ </td> <td> - <a href="https://github.com/omarroth/videojs-quality-selector"><%= translate(locale, "source") %></a> + <a href="https://github.com/iv-org/videojs-quality-selector"><%= translate(locale, "source") %></a> </td> </tr> @@ -123,7 +151,7 @@ <tr> <td> - <a href="/js/videojs-contrib-quality-levels.min.js?v=<%= ASSET_COMMIT %>">videojs-contrib-quality-levels.min.js</a> + <a href="/videojs/videojs-contrib-quality-levels/videojs-contrib-quality-levels.js?v=<%= ASSET_COMMIT %>">videojs-contrib-quality-levels.js</a> </td> <td> @@ -137,7 +165,7 @@ <tr> <td> - <a href="/js/videojs-http-source-selector.min.js?v=<%= ASSET_COMMIT %>">videojs-http-source-selector.min.js</a> + <a href="/videojs/videojs-http-source-selector/videojs-http-source-selector.js?v=<%= ASSET_COMMIT %>">videojs-http-source-selector.js</a> </td> <td> @@ -151,11 +179,11 @@ <tr> <td> - <a href="/js/videojs-mobile-ui.min.js?v=<%= ASSET_COMMIT %>">videojs-mobile-ui.min.js</a> + <a href="/videojs/videojs-mobile-ui/videojs-mobile-ui.js?v=<%= ASSET_COMMIT %>">videojs-mobile-ui.js</a> </td> <td> - <a href="https://choosealicense.com/licenses/mit/">MIT</a> + <a href="https://choosealicense.com/licenses/mit/">Expat</a> </td> <td> @@ -165,7 +193,7 @@ <tr> <td> - <a href="/js/videojs-markers.min.js?v=<%= ASSET_COMMIT %>">videojs-markers.min.js</a> + <a href="/videojs/videojs-markers/videojs-markers.js?v=<%= ASSET_COMMIT %>">videojs-markers.js</a> </td> <td> @@ -179,7 +207,7 @@ <tr> <td> - <a href="/js/videojs-overlay.min.js?v=<%= ASSET_COMMIT %>">videojs-overlay.min.js</a> + <a href="/videojs/videojs-overlay/videojs-overlay.js?v=<%= ASSET_COMMIT %>">videojs-overlay.js</a> </td> <td> @@ -193,7 +221,7 @@ <tr> <td> - <a href="/js/videojs-share.min.js?v=<%= ASSET_COMMIT %>">videojs-share.min.js</a> + <a href="/videojs/videojs-share/videojs-share.js?v=<%= ASSET_COMMIT %>">videojs-share.js</a> </td> <td> @@ -207,7 +235,7 @@ <tr> <td> - <a href="/js/videojs-vtt-thumbnails.min.js?v=<%= ASSET_COMMIT %>">videojs-vtt-thumbnails.min.js</a> + <a href="/videojs/videojs-vtt-thumbnails/videojs-vtt-thumbnails.js?v=<%= ASSET_COMMIT %>">videojs-vtt-thumbnails.js</a> </td> <td> @@ -215,7 +243,7 @@ </td> <td> - <a href="https://github.com/omarroth/videojs-vtt-thumbnails"><%= translate(locale, "source") %></a> + <a href="https://github.com/chrisboustead/videojs-vtt-thumbnails"><%= translate(locale, "source") %></a> </td> </tr> @@ -235,7 +263,21 @@ <tr> <td> - <a href="/js/video.min.js?v=<%= ASSET_COMMIT %>">video.min.js</a> + <a href="/videojs/videojs-vr/videojs-vr.js?v=<%= ASSET_COMMIT %>">videojs-vr.js</a> + </td> + + <td> + <a href="https://choosealicense.com/licenses/mit">Expat</a> + </td> + + <td> + <a href="https://github.com/videojs/videojs-vr"><%= translate(locale, "source") %></a> + </td> + </tr> + + <tr> + <td> + <a href="/videojs/video.js/video.js?v=<%= ASSET_COMMIT %>">video.js</a> </td> <td> diff --git a/src/invidious/views/mix.ecr b/src/invidious/views/mix.ecr index e9c0dcbc..e55b00f8 100644 --- a/src/invidious/views/mix.ecr +++ b/src/invidious/views/mix.ecr @@ -1,22 +1,20 @@ <% content_for "header" do %> -<title><%= mix.title %> - Invidious</title> +<title><%= HTML.escape(mix.title) %> - Invidious</title> <% end %> <div class="pure-g h-box"> <div class="pure-u-2-3"> - <h3><%= mix.title %></h3> + <h3><%= HTML.escape(mix.title) %></h3> </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:right"> <a href="/feed/playlist/<%= mix.id %>"><i class="icon ion-logo-rss"></i></a> </h3> </div> </div> <div class="pure-g"> - <% mix.videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% mix.videos.each do |item| %> + <%= rendered "components/item" %> +<% end %> </div> diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 91156028..c27ddba6 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -1,19 +1,63 @@ +<% title = HTML.escape(playlist.title) %> +<% author = HTML.escape(playlist.author) %> + <% content_for "header" do %> -<title><%= playlist.title %> - Invidious</title> +<title><%= title %> - Invidious</title> <link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/playlist/<%= plid %>" /> <% end %> -<div class="pure-g h-box"> - <div class="pure-u-2-3"> - <h3><%= playlist.title %></h3> +<div class="h-box flexible title"> + <div class="flex-left"><h3><%= title %></h3></div> + + <div class="flex-right button-container"> + <%- if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email -%> + <div class="pure-u"> + <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/add_playlist_items?list=<%= plid %>"> + <i class="icon ion-md-add"></i> <%= translate(locale, "playlist_button_add_items") %> + </a> + </div> + <div class="pure-u"> + <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/edit_playlist?list=<%= plid %>"> + <i class="icon ion-md-create"></i> <%= translate(locale, "generic_button_edit") %> + </a> + </div> + <div class="pure-u"> + <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>"> + <i class="icon ion-md-trash"></i> <%= translate(locale, "generic_button_delete") %> + </a> + </div> + <%- else -%> + <div class="pure-u"> + <%- if IV::Database::Playlists.exists?(playlist.id) -%> + <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/subscribe_playlist?list=<%= plid %>"> + <i class="icon ion-md-add"></i> <%= translate(locale, "Subscribe") %> + </a> + <%- else -%> + <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/delete_playlist?list=<%= plid %>"> + <i class="icon ion-md-trash"></i> <%= translate(locale, "Unsubscribe") %> + </a> + <%- end -%> + </div> + <%- end -%> + + <div class="pure-u"> + <a class="pure-button pure-button-secondary low-profile" dir="auto" href="/feed/playlist/<%= plid %>"> + <i class="icon ion-logo-rss"></i> <%= translate(locale, "generic_button_rss") %> + </a> + </div> + </div> +</div> + +<div class="h-box"> + <div class="pure-u-1-1"> <% if playlist.is_a? InvidiousPlaylist %> <b> <% if playlist.author == user.try &.email %> - <a href="/view_all_playlists"><%= playlist.author %></a> | + <a href="/feed/playlists"><%= author %></a> | <% else %> - <%= playlist.author %> | + <%= author %> | <% end %> - <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | + <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> | <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | <% case playlist.as(InvidiousPlaylist).privacy when %> <% when PlaylistPrivacy::Public %> @@ -26,50 +70,42 @@ </b> <% else %> <b> - <a href="/channel/<%= playlist.ucid %>"><%= playlist.author %></a> | - <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | + <% if !author.empty? %> + <a href="/channel/<%= playlist.ucid %>"><%= author %></a> | + <% elsif !playlist.subtitle.nil? %> + <% 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)) %> </b> <% end %> + <% if !playlist.is_a? InvidiousPlaylist %> <div class="pure-u-2-3"> - <a href="https://www.youtube.com/playlist?list=<%= playlist.id %>"> + <a rel="noreferrer noopener" href="https://www.youtube.com/playlist?list=<%= playlist.id %>"> <%= translate(locale, "View playlist on YouTube") %> </a> - </div> - <% end %> - </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> - <div class="pure-g user-field"> - <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> - <div class="pure-u-1-3"><a href="/edit_playlist?list=<%= plid %>"><i class="icon ion-md-create"></i></a></div> - <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div> - <% else %> - <% if PG_DB.query_one?("SELECT id FROM playlists WHERE id = $1", playlist.id, as: String).nil? %> - <div class="pure-u-1-3"><a href="/subscribe_playlist?list=<%= plid %>"><i class="icon ion-md-add"></i></a></div> + <span> | </span> + + <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> + <a href="/redirect?referer=<%= env.get?("current_page") %>"> + <%= translate(locale, "Switch Invidious Instance") %> + </a> <% else %> - <div class="pure-u-1-3"><a href="/delete_playlist?list=<%= plid %>"><i class="icon ion-md-trash"></i></a></div> + <a href="https://redirect.invidious.io/playlist?list=<%= playlist.id %>"> + <%= translate(locale, "Switch Invidious Instance") %> + </a> <% end %> - <% end %> - <div class="pure-u-1-3"><a href="/feed/playlist/<%= plid %>"><i class="icon ion-logo-rss"></i></a></div> </div> - </h3> + <% end %> </div> </div> <div class="h-box"> - <p><%= playlist.description_html %></p> + <div id="descriptionWrapper"><%= playlist.description_html %></div> </div> -<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> -<div class="h-box" style="text-align:right"> - <h3> - <a href="/add_playlist_items?list=<%= plid %>"><i class="icon ion-md-add"></i></a> - </h3> -</div> -<% end %> - <div class="h-box"> <hr> </div> @@ -85,28 +121,5 @@ <script src="/js/playlist_widget.js?v=<%= ASSET_COMMIT %>"></script> <% end %> -<div class="pure-g"> - <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -</div> -<div class="pure-g h-box"> - <div class="pure-u-1 pure-u-lg-1-5"> - <% if page > 1 %> - <a href="/playlist?list=<%= playlist.id %>&page=<%= page - 1 %>"> - <%= translate(locale, "Previous page") %> - </a> - <% end %> - </div> - <div class="pure-u-1 pure-u-lg-3-5"></div> - <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> - <% if page_count != 1 && page < page_count %> - <a href="/playlist?list=<%= playlist.id %>&page=<%= page + 1 %>"> - <%= translate(locale, "Next page") %> - </a> - <% end %> - </div> -</div> +<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr deleted file mode 100644 index 44bdb94d..00000000 --- a/src/invidious/views/playlists.ecr +++ /dev/null @@ -1,98 +0,0 @@ -<% content_for "header" do %> -<title><%= channel.author %> - Invidious</title> -<% end %> - -<% if channel.banner %> - <div class="h-box"> - <img style="width:100%" src="/ggpht<%= URI.parse(channel.banner.not_nil!.gsub("=w1060-", "=w1280-")).request_target %>"> - </div> - - <div class="h-box"> - <hr> - </div> -<% end %> - -<div class="pure-g h-box"> - <div class="pure-u-2-3"> - <div class="channel-profile"> - <img src="/ggpht<%= URI.parse(channel.author_thumbnail).request_target %>"> - <span><%= channel.author %></span> - </div> - </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> - <a href="/feed/channel/<%= channel.ucid %>"><i class="icon ion-logo-rss"></i></a> - </h3> - </div> -</div> - -<div class="h-box"> - <p><span style="white-space:pre-wrap"><%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content if !channel.description_html.empty? %></span></p> -</div> - -<div class="h-box"> - <% ucid = channel.ucid %> - <% author = channel.author %> - <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> -</div> - -<div class="pure-g h-box"> - <div class="pure-g pure-u-1-3"> - <div class="pure-u-1 pure-md-1-3"> - <a href="https://www.youtube.com/channel/<%= channel.ucid %>/playlists"><%= translate(locale, "View channel on YouTube") %></a> - </div> - <div class="pure-u-1 pure-md-1-3"> - <a href="/channel/<%= channel.ucid %>"><%= translate(locale, "Videos") %></a> - </div> - <div class="pure-u-1 pure-md-1-3"> - <% if !channel.auto_generated %> - <b><%= translate(locale, "Playlists") %></b> - <% end %> - </div> - <div class="pure-u-1 pure-md-1-3"> - <% if channel.tabs.includes? "community" %> - <a href="/channel/<%= channel.ucid %>/community"><%= translate(locale, "Community") %></a> - <% end %> - </div> - </div> - <div class="pure-u-1-3"></div> - <div class="pure-u-1-3"> - <div class="pure-g" style="text-align:right"> - <% {"last", "oldest", "newest"}.each do |sort| %> - <div class="pure-u-1 pure-md-1-3"> - <% if sort_by == sort %> - <b><%= translate(locale, sort) %></b> - <% else %> - <a href="/channel/<%= channel.ucid %>/playlists?sort_by=<%= sort %>"> - <%= translate(locale, sort) %> - </a> - <% end %> - </div> - <% end %> - </div> - </div> -</div> - -<div class="h-box"> - <hr> -</div> - -<div class="pure-g"> - <% items.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -</div> - -<div class="pure-g h-box"> - <div class="pure-u-1 pure-u-md-4-5"></div> - <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> - <% if continuation %> - <a href="/channel/<%= channel.ucid %>/playlists?continuation=<%= continuation %><% if sort_by != "last" %>&sort_by=<%= HTML.escape(sort_by) %><% end %>"> - <%= translate(locale, "Next page") %> - </a> - <% end %> - </div> -</div> diff --git a/src/invidious/views/post.ecr b/src/invidious/views/post.ecr new file mode 100644 index 00000000..fb03a44c --- /dev/null +++ b/src/invidious/views/post.ecr @@ -0,0 +1,48 @@ +<% content_for "header" do %> +<title>Invidious</title> +<% end %> + +<div> + <div id="post" class="comments post-comments"> + <%= IV::Frontend::Comments.template_youtube(post_response.not_nil!, locale, thin_mode) %> + </div> + + <% if nojs %> + <hr> + <% end %> + <br /> + + <div id="comments" class="comments post-comments"> + <% if nojs %> + <%= comment_html %> + <% 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.") %> + </a> + </noscript> + <% end %> + </div> +</div> + +<script id="video_data" type="application/json"> +<%= +{ + "id" => id, + "youtube_comments_text" => HTML.escape(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")), + "params" => { + "comments": ["youtube"] + }, + "preferences" => prefs, + "base_url" => "/api/v1/post/#{URI.encode_www_form(id)}/comments", + "ucid" => ucid +}.to_pretty_json +%> +</script> +<script src="/js/comments.js?v=<%= ASSET_COMMIT %>"></script> +<script src="/js/post.js?v=<%= ASSET_COMMIT %>"></script>
\ No newline at end of file diff --git a/src/invidious/views/privacy.ecr b/src/invidious/views/privacy.ecr index 643f880b..bc5ff40b 100644 --- a/src/invidious/views/privacy.ecr +++ b/src/invidious/views/privacy.ecr @@ -16,12 +16,11 @@ <li>a list of channel UCIDs the user is subscribed to</li> <li>a user ID (for persistent storage of subscriptions and preferences)</li> <li>a json object containing user preferences</li> - <li>a hashed password if applicable (not present on google accounts)</li> + <li>a hashed password</li> <li>a randomly generated token for providing an RSS feed of a user's subscriptions</li> <li>a list of video IDs identifying watched videos</li> </ul> <p>Users can clear their watch history using the <a href="/clear_watch_history">clear watch history</a> page.</p> - <p>If a user is logged in with a Google account, no password will ever be stored. This website uses the session token provided by Google to identify a user, but does not store the information required to make requests on a user's behalf without their knowledge or consent.</p> <h3>Data you passively provide</h3> <p>When you request any resource from this website (for example: a page, a font, an image, or an API endpoint) information about the request may be logged.</p> diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index fefc9fbb..b1300214 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -1,136 +1,21 @@ <% content_for "header" do %> -<title><%= search_query.not_nil!.size > 30 ? HTML.escape(query.not_nil![0,30].rstrip(".") + "...") : HTML.escape(query.not_nil!) %> - Invidious</title> +<title><%= query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "…" : HTML.escape(query.text) %> - Invidious</title> +<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>"> <% end %> -<details id="filters"> - <summary> - <h3 style="display:inline"> <%= translate(locale, "filter") %> </h3> - </summary> - <div id="filters" class="pure-g h-box"> - <div class="pure-u-1-3 pure-u-md-1-5"> - <b><%= translate(locale, "date") %></b> - <hr/> - <% ["hour", "today", "week", "month", "year"].each do |date| %> - <div class="pure-u-1 pure-md-1-5"> - <% if operator_hash.fetch("date", "all") == date %> - <b><%= translate(locale, date) %></b> - <% else %> - <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?date:[a-z]+/, "") + " date:" + date) %>&page=<%= page %>"> - <%= translate(locale, date) %> - </a> - <% end %> - </div> - <% end %> - </div> - <div class="pure-u-1-3 pure-u-md-1-5"> - <b><%= translate(locale, "content_type") %></b> - <hr/> - <% ["video", "channel", "playlist", "movie", "show"].each do |content_type| %> - <div class="pure-u-1 pure-md-1-5"> - <% if operator_hash.fetch("content_type", "all") == content_type %> - <b><%= translate(locale, content_type) %></b> - <% else %> - <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?content_type:[a-z]+/, "") + " content_type:" + content_type) %>&page=<%= page %>"> - <%= translate(locale, content_type) %> - </a> - <% end %> - </div> - <% end %> - </div> - <div class="pure-u-1-3 pure-u-md-1-5"> - <b><%= translate(locale, "duration") %></b> - <hr/> - <% ["short", "long"].each do |duration| %> - <div class="pure-u-1 pure-md-1-5"> - <% if operator_hash.fetch("duration", "all") == duration %> - <b><%= translate(locale, duration) %></b> - <% else %> - <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?duration:[a-z]+/, "") + " duration:" + duration) %>&page=<%= page %>"> - <%= translate(locale, duration) %> - </a> - <% end %> - </div> - <% end %> - </div> - <div class="pure-u-1-3 pure-u-md-1-5"> - <b><%= translate(locale, "features") %></b> - <hr/> - <% ["hd", "subtitles", "creative_commons", "3d", "live", "purchased", "4k", "360", "location", "hdr"].each do |feature| %> - <div class="pure-u-1 pure-md-1-5"> - <% if operator_hash.fetch("features", "all").includes?(feature) %> - <b><%= translate(locale, feature) %></b> - <% elsif operator_hash.has_key?("features") %> - <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/features:/, "features:" + feature + ",")) %>&page=<%= page %>"> - <%= translate(locale, feature) %> - </a> - <% else %> - <a href="/search?q=<%= HTML.escape(query.not_nil! + " features:" + feature) %>&page=<%= page %>"> - <%= translate(locale, feature) %> - </a> - <% end %> - </div> - <% end %> - </div> - <div class="pure-u-1-3 pure-u-md-1-5"> - <b><%= translate(locale, "sort") %></b> - <hr/> - <% ["relevance", "rating", "date", "views"].each do |sort| %> - <div class="pure-u-1 pure-md-1-5"> - <% if operator_hash.fetch("sort", "relevance") == sort %> - <b><%= translate(locale, sort) %></b> - <% else %> - <a href="/search?q=<%= HTML.escape(query.not_nil!.gsub(/ ?sort:[a-z]+/, "") + " sort:" + sort) %>&page=<%= page %>"> - <%= translate(locale, sort) %> - </a> - <% end %> - </div> - <% end %> - </div> - </div> -</details> - +<!-- Search redirection and filtering UI --> +<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %> <hr/> -<div class="pure-g h-box v-box"> - <div class="pure-u-1 pure-u-lg-1-5"> - <% if page > 1 %> - <a href="/search?q=<%= HTML.escape(query.not_nil!) %>&page=<%= page - 1 %>"> - <%= translate(locale, "Previous page") %> - </a> - <% end %> - </div> - <div class="pure-u-1 pure-u-lg-3-5"></div> - <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> - <% if count >= 20 %> - <a href="/search?q=<%= HTML.escape(query.not_nil!) %>&page=<%= page + 1 %>"> - <%= translate(locale, "Next page") %> - </a> - <% end %> - </div> -</div> - -<div class="pure-g"> - <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -</div> -<div class="pure-g h-box"> - <div class="pure-u-1 pure-u-lg-1-5"> - <% if page > 1 %> - <a href="/search?q=<%= HTML.escape(query.not_nil!) %>&page=<%= page - 1 %>"> - <%= translate(locale, "Previous page") %> - </a> - <% end %> - </div> - <div class="pure-u-1 pure-u-lg-3-5"></div> - <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> - <% if count >= 20 %> - <a href="/search?q=<%= HTML.escape(query.not_nil!) %>&page=<%= page + 1 %>"> - <%= translate(locale, "Next page") %> - </a> - <% end %> +<%- 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) %> </div> </div> +<%- else -%> + <%= rendered "components/items_paginated" %> +<%- end -%> diff --git a/src/invidious/views/search_homepage.ecr b/src/invidious/views/search_homepage.ecr index 8927c3f1..2424a1cf 100644 --- a/src/invidious/views/search_homepage.ecr +++ b/src/invidious/views/search_homepage.ecr @@ -1,7 +1,7 @@ <% content_for "header" do %> <meta name="description" content="<%= translate(locale, "An alternative front-end to YouTube") %>"> <title> - Invidious + Invidious - <%= translate(locale, "search") %> </title> <link rel="stylesheet" href="/css/empty.css?v=<%= ASSET_COMMIT %>"> <% end %> @@ -14,11 +14,7 @@ </div> <div class="pure-u-1-4"></div> <div class="pure-u-1 pure-u-md-12-24 searchbar"> - <form class="pure-form" action="/search" method="get"> - <fieldset> - <input autofocus type="search" style="width:100%" name="q" placeholder="<%= translate(locale, "search") %>" value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> - </fieldset> - </form> + <% autofocus = true %><%= rendered "components/search_box" %> </div> <div class="pure-u-1-4"></div> </div> diff --git a/src/invidious/views/subscriptions.ecr b/src/invidious/views/subscriptions.ecr deleted file mode 100644 index af1d4fbc..00000000 --- a/src/invidious/views/subscriptions.ecr +++ /dev/null @@ -1,81 +0,0 @@ -<% content_for "header" do %> -<title><%= translate(locale, "Subscriptions") %> - Invidious</title> -<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed/private?token=<%= token %>" /> -<% end %> - -<%= rendered "components/feed_menu" %> - -<div class="pure-g h-box"> - <div class="pure-u-1-3"> - <h3> - <a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a> - </h3> - </div> - <div class="pure-u-1-3" style="text-align:center"> - <h3> - <a href="/feed/history"><%= translate(locale, "Watch history") %></a> - </h3> - </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> - <a href="/feed/private?token=<%= token %>"><i class="icon ion-logo-rss"></i></a> - </h3> - </div> -</div> - -<center> - <%= translate(locale, "`x` unseen notifications", "#{notifications.size}") %> -</center> - -<% if !notifications.empty? %> - <div class="h-box"> - <hr> - </div> -<% end %> - -<div class="pure-g"> - <% notifications.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -</div> - -<div class="h-box"> - <hr> -</div> - -<script id="watched_data" type="application/json"> -<%= -{ - "csrf_token" => URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") -}.to_pretty_json -%> -</script> -<script src="/js/watched_widget.js"></script> - -<div class="pure-g"> - <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -</div> - -<div class="pure-g h-box"> - <div class="pure-u-1 pure-u-lg-1-5"> - <% if page > 1 %> - <a href="/feed/subscriptions?page=<%= page - 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>"> - <%= translate(locale, "Previous page") %> - </a> - <% end %> - </div> - <div class="pure-u-1 pure-u-lg-3-5"></div> - <div class="pure-u-1 pure-u-lg-1-5" style="text-align:right"> - <% if (videos.size + notifications.size) == max_results %> - <a href="/feed/subscriptions?page=<%= page + 1 %><% if env.params.query["max_results"]? %>&max_results=<%= max_results %><% end %>"> - <%= translate(locale, "Next page") %> - </a> - <% end %> - </div> -</div> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 5b63bf1f..9904b4fc 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -1,5 +1,9 @@ +<% + locale = env.get("preferences").as(Preferences).locale + dark_mode = env.get("preferences").as(Preferences).dark_mode +%> <!DOCTYPE html> -<html lang="<%= env.get("preferences").as(Preferences).locale %>"> +<html lang="<%= locale %>"> <head> <meta charset="utf-8"> @@ -17,35 +21,29 @@ <link rel="stylesheet" href="/css/grids-responsive-min.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/ionicons.min.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>"> + <link rel="stylesheet" href="/css/carousel.css?v=<%= ASSET_COMMIT %>"> + <script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script> </head> -<% locale = LOCALES[env.get("preferences").as(Preferences).locale]? %> -<% dark_mode = env.get("preferences").as(Preferences).dark_mode %> - <body class="<%= dark_mode.blank? ? "no" : dark_mode %>-theme"> - <span style="display:none" id="dark_mode_pref"><%= env.get("preferences").as(Preferences).dark_mode %></span> + <span style="display:none" id="dark_mode_pref"><%= dark_mode %></span> <div class="pure-g"> - <div class="pure-u-1 pure-u-md-2-24"></div> - <div class="pure-u-1 pure-u-md-20-24", id="contents"> + <div class="pure-u-1 pure-u-xl-20-24" id="contents"> <div class="pure-g navbar h-box"> <% if navbar_search %> <div class="pure-u-1 pure-u-md-4-24"> <a href="/" class="index-link pure-menu-heading">Invidious</a> </div> <div class="pure-u-1 pure-u-md-12-24 searchbar"> - <form class="pure-form" action="/search" method="get"> - <fieldset> - <input type="search" style="width:100%" name="q" placeholder="<%= translate(locale, "search") %>" value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> - </fieldset> - </form> + <% autofocus = false %><%= rendered "components/search_box" %> </div> <% end %> <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"> - <% if env.get("preferences").as(Preferences).dark_mode == "dark" %> + <a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= translate(locale, "toggle_theme") %>"> + <% if dark_mode == "dark" %> <i class="icon ion-ios-sunny"></i> <% else %> <i class="icon ion-ios-moon"></i> @@ -54,8 +52,8 @@ </div> <div class="pure-u-1-4"> <a id="notification_ticker" title="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading"> - <% notification_count = env.get("user").as(User).notifications.size %> - <% if notification_count > 0 %> + <% 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> <% else %> <i class="icon ion-ios-notifications-outline"></i> @@ -67,9 +65,14 @@ <i class="icon ion-ios-cog"></i> </a> </div> + <% if env.get("preferences").as(Preferences).show_nick %> + <div class="pure-u-1-4" style="overflow: hidden; white-space: nowrap;"> + <span id="user_name"><%= HTML.escape(env.get("user").as(Invidious::User).email) %></span> + </div> + <% end %> <div class="pure-u-1-4"> <form action="/signout?referer=<%= env.get?("current_page") %>" method="post"> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>"> <a class="pure-menu-heading" href="#"> <input style="all:unset" type="submit" value="<%= translate(locale, "Log out") %>"> </a> @@ -77,8 +80,8 @@ </div> <% else %> <div class="pure-u-1-3"> - <a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading"> - <% if env.get("preferences").as(Preferences).dark_mode == "dark" %> + <a id="toggle_theme" href="/toggle_theme?referer=<%= env.get?("current_page") %>" class="pure-menu-heading" title="<%= translate(locale, "toggle_theme") %>"> + <% if dark_mode == "dark" %> <i class="icon ion-ios-sunny"></i> <% else %> <i class="icon ion-ios-moon"></i> @@ -112,40 +115,46 @@ <footer> <div class="pure-g"> <div class="pure-u-1 pure-u-md-1-3"> - <a href="https://github.com/iv-org/invidious"> - <%= translate(locale, "Released under the AGPLv3 by Omar Roth.") %> - </a> - </div> - <div class="pure-u-1 pure-u-md-1-3"> - <i class="icon ion-ios-wallet"></i> - BTC: <a href="bitcoin:bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr">bc1qfhe7rq3lqzuayzjxzyt9waz9ytrs09kla3tsgr</a> - </div> - <div class="pure-u-1 pure-u-md-1-3"> - <i class="icon ion-ios-wallet"></i> - XMR: <a href="monero:41nMCtek197boJtiUvGnTFYMatrLEpnpkQDmUECqx5Es2uX3sTKKWVhSL76suXsG3LXqkEJBrCZBgPTwJrDp1FrZJfycGPR">Click here</a> - </div> - <div class="pure-u-1 pure-u-md-1-3"> - <a href="https://github.com/iv-org/documentation">Documentation</a> + <span> + <i class="icon ion-logo-github"></i> + <% if CONFIG.modified_source_code_url %> + <a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_original_source_code") %></a> / + <a href="<%= CONFIG.modified_source_code_url %>"><%= translate(locale, "footer_modfied_source_code") %></a> + <% else %> + <a href="https://github.com/iv-org/invidious"><%= translate(locale, "footer_source_code") %></a> + <% end %> + </span> + <span> + <i class="icon ion-ios-paper"></i> + <a href="https://github.com/iv-org/documentation"><%= translate(locale, "footer_documentation") %></a> + </span> </div> + <div class="pure-u-1 pure-u-md-1-3"> - <i class="icon ion-logo-javascript"></i> - <a rel="jslicense" href="/licenses"> - <%= translate(locale, "View JavaScript license information.") %> - </a> - / - <i class="icon ion-ios-paper"></i> - <a href="/privacy"> - <%= translate(locale, "View privacy policy.") %> - </a> + <span> + <a href="https://github.com/iv-org/invidious/blob/master/LICENSE"><%= translate(locale, "Released under the AGPLv3 on Github.") %></a> + </span> + <span> + <i class="icon ion-logo-javascript"></i> + <a rel="jslicense" href="/licenses"><%= 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> + </span> </div> + <div class="pure-u-1 pure-u-md-1-3"> - <i class="icon ion-logo-github"></i> - <%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %> + <span> + <i class="icon ion-ios-wallet"></i> + <a href="https://invidious.io/donate/"><%= translate(locale, "footer_donate_page") %></a> + </span> + <span><%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %></span> </div> </div> </footer> + </div> - <div class="pure-u-1 pure-u-md-2-24"></div> </div> <script src="/js/handlers.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/themes.js?v=<%= ASSET_COMMIT %>"></script> @@ -159,7 +168,9 @@ }.to_pretty_json %> </script> + <% if CONFIG.enable_user_notifications %> <script src="/js/notifications.js?v=<%= ASSET_COMMIT %>"></script> + <% end %> <% end %> </body> diff --git a/src/invidious/views/authorize_token.ecr b/src/invidious/views/user/authorize_token.ecr index 8ea99010..725f392e 100644 --- a/src/invidious/views/authorize_token.ecr +++ b/src/invidious/views/user/authorize_token.ecr @@ -9,13 +9,13 @@ <%= translate(locale, "Token") %> </h3> </div> - <div class="pure-u-1-3" style="text-align:center"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:center"> <a href="/token_manager"><%= translate(locale, "Token manager") %></a> </h3> </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:right"> <a href="/preferences"><%= translate(locale, "Preferences") %></a> </h3> </div> @@ -72,7 +72,7 @@ <input type="hidden" name="expire" value="<%= expire %>"> <% end %> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>"> </form> </div> <% end %> diff --git a/src/invidious/views/change_password.ecr b/src/invidious/views/user/change_password.ecr index fb558f1d..1b9eb82e 100644 --- a/src/invidious/views/change_password.ecr +++ b/src/invidious/views/user/change_password.ecr @@ -23,7 +23,7 @@ <%= translate(locale, "Change password") %> </button> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>"> </fieldset> </form> </div> diff --git a/src/invidious/views/clear_watch_history.ecr b/src/invidious/views/user/clear_watch_history.ecr index 5f9d1032..c9acbe44 100644 --- a/src/invidious/views/clear_watch_history.ecr +++ b/src/invidious/views/user/clear_watch_history.ecr @@ -19,6 +19,6 @@ </div> </div> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>"> </form> </div> diff --git a/src/invidious/views/data_control.ecr b/src/invidious/views/user/data_control.ecr index 74ccc06c..9ce42c99 100644 --- a/src/invidious/views/data_control.ecr +++ b/src/invidious/views/user/data_control.ecr @@ -8,13 +8,13 @@ <legend><%= translate(locale, "Import") %></legend> <div class="pure-control-group"> - <label for="import_youtube"><%= translate(locale, "Import Invidious data") %></label> + <label for="import_invidious"><%= translate(locale, "Import Invidious data") %></label> <input type="file" id="import_invidious" name="import_invidious"> </div> <div class="pure-control-group"> <label for="import_youtube"> - <a rel="noopener" target="_blank" href="https://github.com/iv-org/documentation/blob/master/Export-YouTube-subscriptions.md"> + <a rel="noopener" target="_blank" href="https://github.com/iv-org/documentation/blob/master/docs/export-youtube-subscriptions.md"> <%= translate(locale, "Import YouTube subscriptions") %> </a> </label> @@ -22,6 +22,16 @@ </div> <div class="pure-control-group"> + <label for="import_youtube_pl"><%= translate(locale, "Import YouTube playlist (.csv)") %></label> + <input type="file" id="import_youtube_pl" name="import_youtube_pl"> + </div> + + <div class="pure-control-group"> + <label for="import_youtube_wh"><%= 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> <input type="file" id="import_freetube" name="import_freetube"> </div> diff --git a/src/invidious/views/delete_account.ecr b/src/invidious/views/user/delete_account.ecr index 9103d5b7..67351bbf 100644 --- a/src/invidious/views/delete_account.ecr +++ b/src/invidious/views/user/delete_account.ecr @@ -19,6 +19,6 @@ </div> </div> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(csrf_token) %>"> + <input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>"> </form> </div> diff --git a/src/invidious/views/login.ecr b/src/invidious/views/user/login.ecr index b6e8117b..2b03d280 100644 --- a/src/invidious/views/login.ecr +++ b/src/invidious/views/user/login.ecr @@ -6,63 +6,12 @@ <div class="pure-u-1 pure-u-lg-1-5"></div> <div class="pure-u-1 pure-u-lg-3-5"> <div class="h-box"> - <div class="pure-g"> - <div class="pure-u-1-2"> - <a class="pure-button <% if account_type == "invidious" %>pure-button-disabled<% end %>" href="/login?type=invidious"> - <%= translate(locale, "Log in/register") %> - </a> - </div> - <div class="pure-u-1-2"> - <a class="pure-button <% if account_type == "google" %>pure-button-disabled<% end %>" href="/login?type=google"> - <%= translate(locale, "Log in with Google") %> - </a> - </div> - </div> - - <hr> - <% case account_type when %> - <% when "google" %> - <form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=google" method="post"> - <fieldset> - <% if email %> - <input name="email" type="hidden" value="<%= email %>"> - <% else %> - <label for="email"><%= translate(locale, "E-mail") %> :</label> - <input required class="pure-input-1" name="email" type="email" placeholder="<%= translate(locale, "E-mail") %>"> - <% 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") %>"> - <% end %> - - <% if prompt %> - <label for="tfa"><%= translate(locale, prompt) %> :</label> - <input required class="pure-input-1" name="tfa" type="text" placeholder="<%= translate(locale, prompt) %>"> - <% end %> - - <% if tfa %> - <input type="hidden" name="tfa" value="<%= tfa %>"> - <% end %> - - <% if captcha %> - <img style="width:50%" src="/Captcha?v=2&ctoken=<%= captcha[:tokens][0] %>"/> - <input type="hidden" name="token" value="<%= captcha[:tokens][0] %>"> - <label for="answer"><%= translate(locale, "Answer") %> :</label> - <input type="text" name="answer" type="text" placeholder="<%= translate(locale, "Answer") %>"> - <% end %> - - <button type="submit" class="pure-button pure-button-primary"><%= translate(locale, "Sign In") %></button> - </fieldset> - </form> <% else # "invidious" %> <form class="pure-form pure-form-stacked" action="/login?referer=<%= URI.encode_www_form(referer) %>&type=invidious" method="post"> <fieldset> <% if email %> - <input name="email" type="hidden" value="<%= 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") %>"> @@ -81,7 +30,7 @@ <% captcha = captcha.not_nil! %> <img style="width:50%" src='<%= captcha[:question] %>'/> <% captcha[:tokens].each_with_index do |token, i| %> - <input type="hidden" name="token[<%= i %>]" value="<%= URI.encode_www_form(token) %>"> + <input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>"> <% end %> <input type="hidden" name="captcha_type" value="image"> <label for="answer"><%= translate(locale, "Time (h:mm:ss):") %></label> @@ -89,7 +38,7 @@ <% else # "text" %> <% captcha = captcha.not_nil! %> <% captcha[:tokens].each_with_index do |token, i| %> - <input type="hidden" name="token[<%= i %>]" value="<%= URI.encode_www_form(token) %>"> + <input type="hidden" name="token[<%= i %>]" value="<%= HTML.escape(token) %>"> <% end %> <input type="hidden" name="captcha_type" value="text"> <label for="answer"><%= captcha[:question] %></label> diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/user/preferences.ecr index 602340a4..cf8b5593 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -5,40 +5,45 @@ <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, "Player preferences") %></legend> + <legend><%= translate(locale, "preferences_category_player") %></legend> <div class="pure-control-group"> - <label for="video_loop"><%= translate(locale, "Always loop: ") %></label> + <label for="video_loop"><%= translate(locale, "preferences_video_loop_label") %></label> <input name="video_loop" id="video_loop" type="checkbox" <% if preferences.video_loop %>checked<% end %>> </div> <div class="pure-control-group"> - <label for="autoplay"><%= translate(locale, "Autoplay: ") %></label> + <label for="preload"><%= translate(locale, "preferences_preload_label") %></label> + <input name="preload" id="preload" type="checkbox" <% if preferences.preload %>checked<% end %>> + </div> + + <div class="pure-control-group"> + <label for="autoplay"><%= 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, "Play next by default: ") %></label> + <label for="continue"><%= translate(locale, "preferences_continue_label") %></label> <input name="continue" id="continue" type="checkbox" <% if preferences.continue %>checked<% end %>> </div> <div class="pure-control-group"> - <label for="continue_autoplay"><%= translate(locale, "Autoplay next video: ") %></label> + <label for="continue_autoplay"><%= translate(locale, "preferences_continue_autoplay_label") %></label> <input name="continue_autoplay" id="continue_autoplay" type="checkbox" <% if preferences.continue_autoplay %>checked<% end %>> </div> <div class="pure-control-group"> - <label for="local"><%= translate(locale, "Proxy videos: ") %></label> + <label for="local"><%= translate(locale, "preferences_local_label") %></label> <input name="local" id="local" type="checkbox" <% if preferences.local && !CONFIG.disabled?("local") %>checked<% end %> <% if CONFIG.disabled?("local") %>disabled<% end %>> </div> <div class="pure-control-group"> - <label for="listen"><%= translate(locale, "Listen by default: ") %></label> + <label for="listen"><%= translate(locale, "preferences_listen_label") %></label> <input name="listen" id="listen" type="checkbox" <% if preferences.listen %>checked<% end %>> </div> <div class="pure-control-group"> - <label for="speed"><%= translate(locale, "Default speed: ") %></label> + <label for="speed"><%= translate(locale, "preferences_speed_label") %></label> <select name="speed" id="speed"> <% {2.0, 1.75, 1.5, 1.25, 1.0, 0.75, 0.5, 0.25}.each do |option| %> <option <% if preferences.speed == option %> selected <% end %>><%= option %></option> @@ -47,11 +52,11 @@ </div> <div class="pure-control-group"> - <label for="quality"><%= translate(locale, "Preferred video quality: ") %></label> + <label for="quality"><%= translate(locale, "preferences_quality_label") %></label> <select name="quality" id="quality"> <% {"dash", "hd720", "medium", "small"}.each do |option| %> <% if !(option == "dash" && CONFIG.disabled?("dash")) %> - <option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= translate(locale, option) %></option> + <option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= translate(locale, "preferences_quality_option_" + option) %></option> <% end %> <% end %> </select> @@ -59,23 +64,23 @@ <% if !CONFIG.disabled?("dash") %> <div class="pure-control-group"> - <label for="quality_dash"><%= translate(locale, "Preferred dash video quality: ") %></label> + <label for="quality_dash"><%= translate(locale, "preferences_quality_dash_label") %></label> <select name="quality_dash" id="quality_dash"> <% {"auto", "best", "4320p", "2160p", "1440p", "1080p", "720p", "480p", "360p", "240p", "144p", "worst"}.each do |option| %> - <option value="<%= option %>" <% if preferences.quality_dash == option %> selected <% end %>><%= translate(locale, option) %></option> + <option value="<%= option %>" <% if preferences.quality_dash == option %> selected <% end %>><%= translate(locale, "preferences_quality_dash_option_" + option) %></option> <% end %> </select> </div> <% end %> <div class="pure-control-group"> - <label for="volume"><%= translate(locale, "Player volume: ") %></label> + <label for="volume"><%= translate(locale, "preferences_volume_label") %></label> <input name="volume" id="volume" data-onrange="update_volume_value" type="range" min="0" max="100" step="5" value="<%= preferences.volume %>"> <span class="pure-form-message-inline" id="volume-value"><%= preferences.volume %></span> </div> <div class="pure-control-group"> - <label for="comments[0]"><%= translate(locale, "Default comments: ") %></label> + <label for="comments[0]"><%= translate(locale, "preferences_comments_label") %></label> <% preferences.comments.each_with_index do |comments, index| %> <select name="comments[<%= index %>]" id="comments[<%= index %>]"> <% {"", "youtube", "reddit"}.each do |option| %> @@ -86,10 +91,10 @@ </div> <div class="pure-control-group"> - <label for="captions[0]"><%= translate(locale, "Default captions: ") %></label> + <label for="captions[0]"><%= translate(locale, "preferences_captions_label") %></label> <% preferences.captions.each_with_index do |caption, index| %> <select class="pure-u-1-6" name="captions[<%= index %>]" id="captions[<%= index %>]"> - <% CAPTION_LANGUAGES.each do |option| %> + <% Invidious::Videos::Captions::LANGUAGES.each do |option| %> <option value="<%= option %>" <% if preferences.captions[index] == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option> <% end %> </select> @@ -97,34 +102,52 @@ </div> <div class="pure-control-group"> - <label for="related_videos"><%= translate(locale, "Show related videos: ") %></label> + <label for="related_videos"><%= translate(locale, "preferences_related_videos_label") %></label> <input name="related_videos" id="related_videos" type="checkbox" <% if preferences.related_videos %>checked<% end %>> </div> <div class="pure-control-group"> - <label for="annotations"><%= translate(locale, "Show annotations by default: ") %></label> + <label for="annotations"><%= translate(locale, "preferences_annotations_label") %></label> <input name="annotations" id="annotations" type="checkbox" <% if preferences.annotations %>checked<% end %>> </div> <div class="pure-control-group"> - <label for="extend_desc"><%= translate(locale, "Automatically extend video description: ") %></label> + <label for="extend_desc"><%= translate(locale, "preferences_extend_desc_label") %></label> <input name="extend_desc" id="extend_desc" type="checkbox" <% if preferences.extend_desc %>checked<% end %>> </div> + <div class="pure-control-group"> + <label for="vr_mode"><%= 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> + <input name="save_player_pos" id="save_player_pos" type="checkbox" <% if preferences.save_player_pos %>checked<% end %>> + </div> - <legend><%= translate(locale, "Visual preferences") %></legend> + <legend><%= translate(locale, "preferences_category_visual") %></legend> <div class="pure-control-group"> - <label for="locale"><%= translate(locale, "Language: ") %></label> + <label for="locale"><%= translate(locale, "preferences_locale_label") %></label> <select name="locale" id="locale"> - <% LOCALES.each_key do |option| %> - <option value="<%= option %>" <% if preferences.locale == option %> selected <% end %>><%= option %></option> + <% 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="player_style"><%= translate(locale, "Player style: ") %></label> + <label for="region"><%= translate(locale, "preferences_region_label") %></label> + <select name="region" id="region"> + <% 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> <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> @@ -133,7 +156,7 @@ </div> <div class="pure-control-group"> - <label for="dark_mode"><%= translate(locale, "Theme: ") %></label> + <label for="dark_mode"><%= translate(locale, "preferences_dark_mode_label") %></label> <select name="dark_mode" id="dark_mode"> <% {"", "light", "dark"}.each do |option| %> <option value="<%= option %>" <% if preferences.dark_mode == option %> selected <% end %>><%= translate(locale, option.blank? ? "auto" : option) %></option> @@ -142,7 +165,7 @@ </div> <div class="pure-control-group"> - <label for="thin_mode"><%= translate(locale, "Thin mode: ") %></label> + <label for="thin_mode"><%= translate(locale, "preferences_thin_mode_label") %></label> <input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>> </div> @@ -153,7 +176,7 @@ <% end %> <div class="pure-control-group"> - <label for="default_home"><%= translate(locale, "Default homepage: ") %></label> + <label for="default_home"><%= translate(locale, "preferences_default_home_label") %></label> <select name="default_home" id="default_home"> <% feed_options.each do |option| %> <option value="<%= option %>" <% if preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "Search" : option) %></option> @@ -162,7 +185,7 @@ </div> <div class="pure-control-group"> - <label for="feed_menu"><%= translate(locale, "Feed menu: ") %></label> + <label for="feed_menu"><%= translate(locale, "preferences_feed_menu_label") %></label> <% (feed_options.size - 1).times do |index| %> <select name="feed_menu[<%= index %>]" id="feed_menu[<%= index %>]"> <% feed_options.each do |option| %> @@ -171,22 +194,40 @@ </select> <% end %> </div> + <% if env.get? "user" %> + <div class="pure-control-group"> + <label for="show_nick"><%= translate(locale, "preferences_show_nick_label") %></label> + <input name="show_nick" id="show_nick" type="checkbox" <% if preferences.show_nick %>checked<% end %>> + </div> + <% end %> + + <legend><%= translate(locale, "preferences_category_misc") %></legend> + + <div class="pure-control-group"> + <label for="automatic_instance_redirect"><%= translate(locale, "preferences_automatic_instance_redirect_label") %></label> + <input name="automatic_instance_redirect" id="automatic_instance_redirect" type="checkbox" <% if preferences.automatic_instance_redirect %>checked<% end %>> + </div> <% if env.get? "user" %> - <legend><%= translate(locale, "Subscription preferences") %></legend> + <legend><%= translate(locale, "preferences_category_subscription") %></legend> <div class="pure-control-group"> - <label for="annotations_subscribed"><%= translate(locale, "Show annotations by default for subscribed channels: ") %></label> + <label for="watch_history"><%= translate(locale, "preferences_watch_history_label") %></label> + <input name="watch_history" id="watch_history" type="checkbox" <% if preferences.watch_history %>checked<% end %>> + </div> + + <div class="pure-control-group"> + <label for="annotations_subscribed"><%= 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, "Number of videos shown in feed: ") %></label> + <label for="max_results"><%= translate(locale, "preferences_max_results_label") %></label> <input name="max_results" id="max_results" type="number" value="<%= preferences.max_results %>"> </div> <div class="pure-control-group"> - <label for="sort"><%= translate(locale, "Sort videos by: ") %></label> + <label for="sort"><%= translate(locale, "preferences_sort_label") %></label> <select name="sort" id="sort"> <% {"published", "published - reverse", "alphabetically", "alphabetically - reverse", "channel name", "channel name - reverse"}.each do |option| %> <option value="<%= option %>" <% if preferences.sort == option %> selected <% end %>><%= translate(locale, option) %></option> @@ -204,12 +245,13 @@ </div> <div class="pure-control-group"> - <label for="unseen_only"><%= translate(locale, "Only show unwatched: ") %></label> + <label for="unseen_only"><%= translate(locale, "preferences_unseen_only_label") %></label> <input name="unseen_only" id="unseen_only" type="checkbox" <% if preferences.unseen_only %>checked<% end %>> </div> + <% if CONFIG.enable_user_notifications %> <div class="pure-control-group"> - <label for="notifications_only"><%= translate(locale, "Only show notifications (if there are any): ") %></label> + <label for="notifications_only"><%= translate(locale, "preferences_notifications_only_label") %></label> <input name="notifications_only" id="notifications_only" type="checkbox" <% if preferences.notifications_only %>checked<% end %>> </div> @@ -219,13 +261,14 @@ <a href="#" data-onclick="notification_requestPermission"><%= translate(locale, "Enable web notifications") %></a> </div> <% end %> + <% end %> <% end %> - <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(User).email %> - <legend><%= translate(locale, "Administrator preferences") %></legend> + <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %> + <legend><%= translate(locale, "preferences_category_admin") %></legend> <div class="pure-control-group"> - <label for="admin_default_home"><%= translate(locale, "Default homepage: ") %></label> + <label for="admin_default_home"><%= translate(locale, "preferences_default_home_label") %></label> <select name="admin_default_home" id="admin_default_home"> <% feed_options.each do |option| %> <option value="<%= option %>" <% if CONFIG.default_user_preferences.default_home == option %> selected <% end %>><%= translate(locale, option.blank? ? "none" : option) %></option> @@ -234,7 +277,7 @@ </div> <div class="pure-control-group"> - <label for="admin_feed_menu"><%= translate(locale, "Feed menu: ") %></label> + <label for="admin_feed_menu"><%= translate(locale, "preferences_feed_menu_label") %></label> <% (feed_options.size - 1).times do |index| %> <select name="admin_feed_menu[<%= index %>]" id="admin_feed_menu[<%= index %>]"> <% feed_options.each do |option| %> @@ -269,10 +312,15 @@ <label for="statistics_enabled"><%= translate(locale, "Report statistics: ") %></label> <input name="statistics_enabled" id="statistics_enabled" type="checkbox" <% if CONFIG.statistics_enabled %>checked<% end %>> </div> + + <div class="pure-control-group"> + <label for="modified_source_code_url"><%= 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, "Data preferences") %></legend> + <legend><%= 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> @@ -295,7 +343,7 @@ </div> <div class="pure-control-group"> - <a href="/view_all_playlists"><%= translate(locale, "View all playlists") %></a> + <a href="/feed/playlists"><%= translate(locale, "View all playlists") %></a> </div> <div class="pure-control-group"> diff --git a/src/invidious/views/subscription_manager.ecr b/src/invidious/views/user/subscription_manager.ecr index 6cddcd6c..c9801f09 100644 --- a/src/invidious/views/subscription_manager.ecr +++ b/src/invidious/views/user/subscription_manager.ecr @@ -6,19 +6,19 @@ <div class="pure-u-1-3"> <h3> <a href="/feed/subscriptions"> - <%= translate(locale, "`x` subscriptions", %(<span id="count">#{subscriptions.size}</span>)) %> + <%= translate_count(locale, "generic_subscriptions_count", subscriptions.size, NumberFormatting::HtmlSpan) %> </a> </h3> </div> - <div class="pure-u-1-3" style="text-align:center"> - <h3> + <div class="pure-u-1-3"> + <h3 style="text-align:center"> <a href="/feed/history"> <%= translate(locale, "Watch history") %> </a> </h3> </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> + <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") %> </a> @@ -31,17 +31,15 @@ <div class="pure-g<% if channel.deleted %> deleted <% end %>"> <div class="pure-u-2-5"> <h3 style="padding-left:0.5em"> - <a href="/channel/<%= channel.id %>"><%= channel.author %></a> + <a href="/channel/<%= channel.id %>"><%= HTML.escape(channel.author) %></a> </h3> </div> <div class="pure-u-2-5"></div> <div class="pure-u-1-5" style="text-align:right"> <h3 style="padding-right:0.5em"> <form data-onsubmit="return_false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post"> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> - <a data-onclick="remove_subscription" data-ucid="<%= channel.id %>" href="#"> - <input style="all:unset" type="submit" value="<%= translate(locale, "unsubscribe") %>"> - </a> + <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") %>"> </form> </h3> </div> diff --git a/src/invidious/views/token_manager.ecr b/src/invidious/views/user/token_manager.ecr index e48aec2f..a73fa048 100644 --- a/src/invidious/views/token_manager.ecr +++ b/src/invidious/views/user/token_manager.ecr @@ -5,7 +5,7 @@ <div class="pure-g h-box"> <div class="pure-u-1-3"> <h3> - <%= translate(locale, "`x` tokens", %(<span id="count">#{tokens.size}</span>)) %> + <%= translate_count(locale, "tokens_count", tokens.size, NumberFormatting::HtmlSpan) %> </h3> </div> <div class="pure-u-1-3"></div> @@ -30,10 +30,8 @@ <div class="pure-u-1-5" style="text-align:right"> <h3 style="padding-right:0.5em"> <form data-onsubmit="return_false" action="/token_ajax?action_revoke_token=1&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post"> - <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> - <a data-onclick="revoke_token" data-session="<%= token[:session] %>" href="#"> - <input style="all:unset" type="submit" value="<%= translate(locale, "revoke") %>"> - </a> + <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") %>"> </form> </h3> </div> diff --git a/src/invidious/views/view_all_playlists.ecr b/src/invidious/views/view_all_playlists.ecr deleted file mode 100644 index 5ec6aa31..00000000 --- a/src/invidious/views/view_all_playlists.ecr +++ /dev/null @@ -1,38 +0,0 @@ -<% content_for "header" do %> -<title><%= translate(locale, "Playlists") %> - Invidious</title> -<% end %> - -<%= rendered "components/feed_menu" %> - -<div class="pure-g h-box"> - <div class="pure-u-2-3"> - <h3><%= translate(locale, "`x` created playlists", %(<span id="count">#{items_created.size}</span>)) %></h3> - </div> - <div class="pure-u-1-3" style="text-align:right"> - <h3> - <a href="/create_playlist?referer=<%= URI.encode_www_form(referer) %>"><%= translate(locale, "Create playlist") %></a> - </h3> - </div> -</div> - -<div class="pure-g"> - <% items_created.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -</div> - -<div class="pure-g h-box"> - <div class="pure-u-1"> - <h3><%= translate(locale, "`x` saved playlists", %(<span id="count">#{items_saved.size}</span>)) %></h3> - </div> -</div> - -<div class="pure-g"> - <% items_saved.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -</div> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 8b587eb3..45c58a16 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -1,12 +1,17 @@ +<% ucid = video.ucid %> +<% title = HTML.escape(video.title) %> +<% author = HTML.escape(video.author) %> + + <% content_for "header" do %> <meta name="thumbnail" content="<%= thumbnail %>"> <meta name="description" content="<%= HTML.escape(video.short_description) %>"> <meta name="keywords" content="<%= video.keywords.join(",") %>"> -<meta property="og:site_name" content="Invidious"> +<meta property="og:site_name" content="<%= author %> | Invidious"> <meta property="og:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>"> -<meta property="og:title" content="<%= HTML.escape(video.title) %>"> -<meta property="og:image" content="/vi/<%= video.id %>/maxres.jpg"> -<meta property="og:description" content="<%= video.short_description %>"> +<meta property="og:title" content="<%= title %>"> +<meta property="og:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg"> +<meta property="og:description" content="<%= HTML.escape(video.short_description) %>"> <meta property="og:type" content="video.other"> <meta property="og:video:url" content="<%= HOST_URL %>/embed/<%= video.id %>"> <meta property="og:video:secure_url" content="<%= HOST_URL %>/embed/<%= video.id %>"> @@ -14,27 +19,26 @@ <meta property="og:video:width" content="1280"> <meta property="og:video:height" content="720"> <meta name="twitter:card" content="player"> -<meta name="twitter:site" content="@omarroth1"> <meta name="twitter:url" content="<%= HOST_URL %>/watch?v=<%= video.id %>"> -<meta name="twitter:title" content="<%= HTML.escape(video.title) %>"> -<meta name="twitter:description" content="<%= video.short_description %>"> +<meta name="twitter:title" content="<%= title %>"> +<meta name="twitter:description" content="<%= HTML.escape(video.short_description) %>"> <meta name="twitter:image" content="<%= HOST_URL %>/vi/<%= video.id %>/maxres.jpg"> <meta name="twitter:player" content="<%= HOST_URL %>/embed/<%= video.id %>"> <meta name="twitter:player:width" content="1280"> <meta name="twitter:player:height" content="720"> <link rel="alternate" href="https://www.youtube.com/watch?v=<%= video.id %>"> <%= rendered "components/player_sources" %> -<title><%= HTML.escape(video.title) %> - Invidious</title> +<title><%= title %> - Invidious</title> <!-- Description expansion also updates the 'Show more' button to 'Show less' so we're going to need to do it here in order to allow for translations. --> <style> -#descexpansionbutton + label > a::after { +#descexpansionbutton ~ label > a::after { content: "<%= translate(locale, "Show more") %>" } -#descexpansionbutton:checked + label > a::after { +#descexpansionbutton:checked ~ label > a::after { content: "<%= translate(locale, "Show less") %>" } </style> @@ -57,7 +61,11 @@ we're going to need to do it here in order to allow for translations. "show_replies_text" => HTML.escape(translate(locale, "Show replies")), "params" => params, "preferences" => preferences, - "premiere_timestamp" => video.premiere_timestamp.try &.to_unix + "premiere_timestamp" => video.premiere_timestamp.try &.to_unix, + "vr" => video.vr?, + "projection_type" => video.projection_type, + "local_disabled" => CONFIG.disabled?("local"), + "support_reddit" => true }.to_pretty_json %> </script> @@ -68,7 +76,7 @@ we're going to need to do it here in order to allow for translations. <div class="h-box"> <h1> - <%= HTML.escape(video.title) %> + <%= title %> <% if params.listen %> <a title="<%=translate(locale, "Video mode")%>" href="/watch?<%= env.params.query %>&listen=0"> <i class="icon ion-ios-videocam"></i> @@ -96,7 +104,7 @@ we're going to need to do it here in order to allow for translations. </h3> <% elsif video.live_now %> <h3> - <%= video.premiere_timestamp.try { |t| translate(locale, "Started streaming `x` ago", recode_date((Time.utc - t).ago, locale)) } %> + <%= video.premiere_timestamp.try { |t| translate(locale, "videoinfo_started_streaming_x_ago", recode_date((Time.utc - t).ago, locale)) } %> </h3> <% end %> </div> @@ -105,12 +113,36 @@ we're going to need to do it here in order to allow for translations. <div class="pure-u-1 pure-u-lg-1-5"> <div class="h-box"> <span id="watch-on-youtube"> - <a href="https://www.youtube.com/watch?v=<%= video.id %>"><%= translate(locale, "Watch on YouTube") %></a> - (<a href="https://www.youtube.com/embed/<%= video.id %>"><%= translate(locale, "Embed") %></a>) + <%- + link_yt_watch = URI.new(scheme: "https", host: "www.youtube.com", path: "/watch", query: "v=#{video.id}") + link_yt_embed = URI.new(scheme: "https", host: "www.youtube.com", path: "/embed/#{video.id}") + + if !plid.nil? && !continuation.nil? + link_yt_param = URI::Params{"list" => [plid], "index" => [continuation.to_s]} + link_yt_watch = IV::HttpServer::Utils.add_params_to_url(link_yt_watch, link_yt_param) + 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>) </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> + </p> + <p id="embed-link"> - <a href="<%= embed_link %>"><%= translate(locale, "Embed Link") %></a> + <%- + params_iv_embed = env.params.query.dup + params_iv_embed.delete_all("v") + + 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> </p> + <p id="annotations"> <% if params.annotations %> <a href="/watch?<%= env.params.query %>&iv_load_policy=3"> @@ -124,18 +156,21 @@ we're going to need to do it here in order to allow for translations. </p> <% if user %> - <% playlists = PG_DB.query_all("SELECT id,title FROM playlists WHERE author = $1 AND id LIKE 'IV%'", user.email, as: {String, String}) %> + <% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %> <% if !playlists.empty? %> - <form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax" method="post"> + <form data-onsubmit="return_false" class="pure-form pure-form-stacked" action="/playlist_ajax" method="post" target="_blank"> <div class="pure-control-group"> <label for="playlist_id"><%= translate(locale, "Add to playlist: ") %></label> <select style="width:100%" name="playlist_id" id="playlist_id"> - <% playlists.each do |plid, title| %> - <option data-plid="<%= plid %>" value="<%= plid %>"><%= title %></option> + <% playlists.each do |plid, playlist_title| %> + <option data-plid="<%= plid %>" value="<%= plid %>"><%= HTML.escape(playlist_title) %></option> <% end %> </select> </div> + <input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>"> + <input type="hidden" name="action_add_video" value="1"> + <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> </button> @@ -151,45 +186,11 @@ we're going to need to do it here in order to allow for translations. <% end %> <% end %> - <% if CONFIG.dmca_content.includes?(video.id) || CONFIG.disabled?("downloads") %> - <p id="download"><%= translate(locale, "Download is disabled.") %></p> - <% else %> - <form class="pure-form pure-form-stacked" action="/latest_version" method="get" rel="noopener" target="_blank"> - <div class="pure-control-group"> - <label for="download_widget"><%= translate(locale, "Download as: ") %></label> - <select style="width:100%" name="download_widget" id="download_widget"> - <% fmt_stream.each do |option| %> - <option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'> - <%= itag_to_metadata?(option["itag"]).try &.["height"]? || "~240" %>p - <%= option["mimeType"].as_s.split(";")[0] %> - </option> - <% end %> - <% video_streams.each do |option| %> - <option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'> - <%= option["qualityLabel"] %> - <%= option["mimeType"].as_s.split(";")[0] %> @ <%= option["fps"] %>fps - video only - </option> - <% end %> - <% audio_streams.each do |option| %> - <option value='{"id":"<%= video.id %>","itag":"<%= option["itag"] %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= option["mimeType"].as_s.split(";")[0].split("/")[1] %>"}'> - <%= option["mimeType"].as_s.split(";")[0] %> @ <%= option["bitrate"]?.try &.as_i./ 1000 %>k - audio only - </option> - <% end %> - <% captions.each do |caption| %> - <option value='{"id":"<%= video.id %>","label":"<%= caption.name.simpleText %>","title":"<%= URI.encode_www_form(video.title) %>-<%= video.id %>.<%= caption.languageCode %>.vtt"}'> - <%= translate(locale, "Subtitles - `x` (.vtt)", caption.name.simpleText) %> - </option> - <% end %> - </select> - </div> - - <button type="submit" class="pure-button pure-button-primary"> - <b><%= translate(locale, "Download") %></b> - </button> - </form> - <% end %> + <%= Invidious::Frontend::WatchPage.download_widget(locale, video, video_assets) %> <p id="views"><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p> <p id="likes"><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p> - <p id="dislikes"><i class="icon ion-ios-thumbs-down"></i> <%= number_with_separator(video.dislikes) %></p> + <p id="dislikes" style="display: none; visibility: hidden;"></p> <p id="genre"><%= translate(locale, "Genre: ") %> <% if !video.genre_url %> <%= video.genre %> @@ -198,12 +199,16 @@ we're going to need to do it here in order to allow for translations. <% end %> </p> <% if video.license %> - <p id="license"><%= translate(locale, "License: ") %><%= video.license %></p> + <% if video.license.empty? %> + <p id="license"><%= translate(locale, "License: ") %><%= translate(locale, "Standard YouTube license") %></p> + <% else %> + <p id="license"><%= 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="wilson"><%= translate(locale, "Wilson score: ") %><%= video.wilson_score %></p> - <p id="rating"><%= translate(locale, "Rating: ") %><%= video.average_rating %> / 5</p> - <p id="engagement"><%= translate(locale, "Engagement: ") %><%= video.engagement %>%</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 %> @@ -217,21 +222,28 @@ we're going to need to do it here in order to allow for translations. </div> <div class="pure-u-1 <% if params.related_videos || plid %>pure-u-lg-3-5<% else %>pure-u-md-4-5<% end %>"> - <div class="h-box"> - <a href="/channel/<%= video.ucid %>" style="display:block;width:fit-content;width:-moz-fit-content"> - <div class="channel-profile"> - <% if !video.author_thumbnail.empty? %> - <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>"> - <% end %> - <span id="channel-name"><%= video.author %></span> - </div> - </a> - <% ucid = video.ucid %> - <% author = video.author %> - <% sub_count_text = video.sub_count_text %> - <%= rendered "components/subscribe_widget" %> + <div class="pure-g h-box flexible title"> + <div class="pure-u-1-2 flex-left flexible"> + <a href="/channel/<%= video.ucid %>"> + <div class="channel-profile"> + <% if !video.author_thumbnail.empty? %> + <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" /> + <% end %> + <span id="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></span> + </div> + </a> + </div> + <div class="pure-u-1-2 flex-right flexible button-container"> + <div class="pure-u"> + <% sub_count_text = video.sub_count_text %> + <%= rendered "components/subscribe_widget" %> + </div> + </div> + </div> + + <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> @@ -242,21 +254,41 @@ we're going to need to do it here in order to allow for translations. <div id="description-box"> <!-- Description --> <% if video.description.size < 200 || params.extend_desc %> - <%= video.description_html %> + <div id="descriptionWrapper"><%= video.description_html %></div> <% else %> <input id="descexpansionbutton" type="checkbox"/> - <label for="descexpansionbutton" style="order: 1;"> + <div id="descriptionWrapper"><%= video.description_html %></div> + <label for="descexpansionbutton"> <a></a> </label> - <div id="descriptionWrapper"> - <%= video.description_html %> - </div> <% end %> </div> <hr> - <div id="comments"> + <% if !video.music.empty? %> + <input id="music-desc-expansion" type="checkbox"/> + <label for="music-desc-expansion"> + <h3 id="music-description-title"> + <%= translate(locale, "Music in this video") %> + <span class="icon ion-ios-arrow-up"></span> + <span class="icon ion-ios-arrow-down"></span> + </h3> + </label> + + <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> + </div> + <% end %> + </div> + <hr> + + <% end %> + <div id="comments" class="comments"> <% if nojs %> <%= comment_html %> <% else %> @@ -281,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, "Play next by default: ") %></label> + <label for="continue"><%= translate(locale, "preferences_continue_label") %></label> <input name="continue" id="continue" type="checkbox" <% if params.continue %>checked<% end %>> </div> <hr> @@ -290,32 +322,47 @@ we're going to need to do it here in order to allow for translations. <% video.related_videos.each do |rv| %> <% if rv["id"]? %> - <a href="/watch?v=<%= rv["id"] %>"> - <% if !env.get("preferences").as(Preferences).thin_mode %> - <div class="thumbnail"> - <img class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg"> - <p class="length"><%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %></p> - </div> - <% end %> - <p style="width:100%"><%= rv["title"] %></p> - <h5 class="pure-g"> - <div class="pure-u-14-24"> - <% if rv["ucid"]? %> - <b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %></a></b> - <% else %> - <b style="width:100%"><%= rv["author"]? %></b> - <% end %> - </div> - - <div class="pure-u-10-24" style="text-align:right"> - <% if views = rv["short_view_count_text"]?.try &.delete(", views watching") %> - <% if !views.empty? %> - <b class="width:100%"><%= translate(locale, "`x` views", views) %></b> - <% end %> - <% end %> - </div> - </h5> - </a> + <div class="pure-u-1"> + + <div class="thumbnail"> + <%- if !env.get("preferences").as(Preferences).thin_mode -%> + <a tabindex="-1" href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>"> + <img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg" alt="" /> + </a> + <%- else -%> + <div class="thumbnail-placeholder"></div> + <%- end -%> + + <div class="bottom-right-overlay"> + <%- if (length_seconds = rv["length_seconds"]?.try &.to_i?) && length_seconds != 0 -%> + <p class="length"><%= recode_length_seconds(length_seconds) %></p> + <%- end -%> + </div> + </div> + + <div class="video-card-row"> + <a href="/watch?v=<%= rv["id"] %>&listen=<%= params.listen %>"><p dir="auto"><%= HTML.escape(rv["title"]) %></p></a> + </div> + + <h5 class="pure-g"> + <div class="pure-u-14-24"> + <% if !rv["ucid"].empty? %> + <b style="width:100%"><a href="/channel/<%= rv["ucid"] %>"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></a></b> + <% else %> + <b style="width:100%"><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <i class="icon ion ion-md-checkmark-circle"></i><% end %></b> + <% end %> + </div> + + <div class="pure-u-10-24" style="text-align:right"> + <b class="width:100%"><%= + views = rv["view_count"]?.try &.to_i? + views ||= rv["view_count_short"]?.try { |x| short_text_to_number(x) } + translate_count(locale, "generic_views_count", views || 0, NumberFormatting::Short) + %></b> + </div> + </h5> + + </div> <% end %> <% end %> </div> @@ -323,4 +370,5 @@ we're going to need to do it here in order to allow for translations. </div> <% end %> </div> +<script src="/js/comments.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/watch.js?v=<%= ASSET_COMMIT %>"></script> diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr new file mode 100644 index 00000000..c4a73aa7 --- /dev/null +++ b/src/invidious/yt_backend/connection_pool.cr @@ -0,0 +1,116 @@ +# Mapping of subdomain => YoutubeConnectionPool +# This is needed as we may need to access arbitrary subdomains of ytimg +private YTIMG_POOLS = {} of String => YoutubeConnectionPool + +struct YoutubeConnectionPool + property! url : URI + property! capacity : Int32 + property! timeout : Float64 + property pool : DB::Pool(HTTP::Client) + + def initialize(url : URI, @capacity = 5, @timeout = 5.0) + @url = url + @pool = build_pool() + end + + def client(&) + conn = pool.checkout + # Proxy needs to be reinstated every time we get a client from the pool + conn.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy + + begin + response = yield conn + rescue ex + conn.close + conn = make_client(url, force_resolve: true) + + response = yield conn + ensure + pool.release(conn) + end + + response + end + + private def build_pool + options = DB::Pool::Options.new( + initial_pool_size: 0, + max_pool_size: capacity, + max_idle_pool_size: capacity, + checkout_timeout: timeout + ) + + DB::Pool(HTTP::Client).new(options) do + next make_client(url, force_resolve: true) + end + end +end + +def add_yt_headers(request) + request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal" + request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + + request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" + request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + request.headers["Accept-Language"] ||= "en-us,en;q=0.5" + + # Preserve original cookies and add new YT consent cookie for EU servers + request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}" + if !CONFIG.cookies.empty? + request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" + end +end + +def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false) + client = HTTP::Client.new(url) + client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy + + # Force the usage of a specific configured IP Family + if force_resolve + client.family = CONFIG.force_resolve + client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC + end + + client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers + client.read_timeout = 10.seconds + client.connect_timeout = 10.seconds + + return client +end + +def make_client(url : URI, region = nil, force_resolve : Bool = false, &) + client = make_client(url, region, force_resolve: force_resolve) + begin + yield client + ensure + client.close + end +end + +def make_configured_http_proxy_client + # This method is only called when configuration for an HTTP proxy are set + config_proxy = CONFIG.http_proxy.not_nil! + + return HTTP::Proxy::Client.new( + config_proxy.host, + config_proxy.port, + + username: config_proxy.user, + password: config_proxy.password, + ) +end + +# Fetches a HTTP pool for the specified subdomain of ytimg.com +# +# Creates a new one when the specified pool for the subdomain does not exist +def get_ytimg_pool(subdomain) + if pool = YTIMG_POOLS[subdomain]? + return pool + else + LOGGER.info("ytimg_pool: Creating a new HTTP pool for \"https://#{subdomain}.ytimg.com\"") + pool = YoutubeConnectionPool.new(URI.parse("https://#{subdomain}.ytimg.com"), capacity: CONFIG.pool_size) + YTIMG_POOLS[subdomain] = pool + + return pool + end +end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr new file mode 100644 index 00000000..2631b62a --- /dev/null +++ b/src/invidious/yt_backend/extractors.cr @@ -0,0 +1,1042 @@ +require "../helpers/serialized_yt_data" + +# This file contains helper methods to parse the Youtube API json data into +# neat little packages we can use + +# Tuple of Parsers/Extractors so we can easily cycle through them. +private ITEM_CONTAINER_EXTRACTOR = { + Extractors::YouTubeTabs, + Extractors::SearchResults, + Extractors::ContinuationContent, +} + +private ITEM_PARSERS = { + Parsers::RichItemRendererParser, + Parsers::VideoRendererParser, + Parsers::ChannelRendererParser, + Parsers::GridPlaylistRendererParser, + Parsers::PlaylistRendererParser, + Parsers::CategoryRendererParser, + Parsers::ReelItemRendererParser, + Parsers::ItemSectionRendererParser, + Parsers::ContinuationItemRendererParser, + Parsers::HashtagRendererParser, + Parsers::LockupViewModelParser, +} + +private alias InitialData = Hash(String, JSON::Any) + +record AuthorFallback, name : String, id : String + +# Namespace for logic relating to parsing InnerTube data into various datastructs. +# +# Each of the parsers in this namespace are accessed through the #process() method +# which validates the given data as applicable to itself. If it is applicable the given +# data is passed to the private `#parse()` method which returns a datastruct of the given +# type. Otherwise, nil is returned. +private module Parsers + # Parses a InnerTube videoRenderer into a SearchVideo. Returns nil when the given object isn't a videoRenderer + # + # A videoRenderer renders a video to click on within the YouTube and Invidious UI. It is **not** + # the watchable video itself. + # + # See specs for example. + # + # `videoRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. + # + module VideoRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?) + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + video_id = item_contents["videoId"].as_s + title = extract_text(item_contents["title"]?) || "" + + # Extract author information + if author_info = item_contents.dig?("ownerText", "runs", 0) + author = author_info["text"].as_s + author_id = HelperExtractors.get_browse_id(author_info) + elsif author_info = item_contents.dig?("shortBylineText", "runs", 0) + author = author_info["text"].as_s + author_id = HelperExtractors.get_browse_id(author_info) + else + author = author_fallback.name + author_id = author_fallback.id + end + + author_verified = has_verified_badge?(item_contents["ownerBadges"]?) + + # For live videos (and possibly recently premiered videos) there is no published information. + # Instead, in its place is the amount of people currently watching. This behavior should be replicated + # on Invidious once all features of livestreams are supported. On an unrelated note, defaulting to the current + # time for publishing isn't a good idea. + published = item_contents.dig?("publishedTimeText", "simpleText").try { |t| decode_date(t.as_s) } || Time.local + + # Typically views are stored under a "simpleText" in the "viewCountText". However, for + # livestreams and premiered it is stored under a "runs" array: [{"text":123}, {"text": "watching"}] + # When view count is disabled the "viewCountText" is not present on InnerTube data. + # TODO change default value to nil and typical encoding type to tuple storing type (watchers, views, etc) + # and count + view_count = item_contents.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 + description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t, video_id) } || "" + + # The length information generally exist in "lengthText". However, the info can sometimes + # be retrieved from "thumbnailOverlays" (e.g when the video is a "shorts" one). + if length_container = item_contents["lengthText"]? + length_seconds = decode_length_seconds(length_container["simpleText"].as_s) + elsif length_container = item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?) + # This needs to only go down the `simpleText` path (if possible). If more situations came up that requires + # a specific pathway then we should add an argument to extract_text that'll make this possible + length_text = length_container.dig?("thumbnailOverlayTimeStatusRenderer", "text", "simpleText") + + if length_text + length_text = length_text.as_s + + if length_text == "SHORTS" + # Approximate length to one minute, as "shorts" generally don't exceed that length. + # TODO: Add some sort of metadata for the type of video (normal, live, premiere, shorts) + length_seconds = 60_i32 + else + length_seconds = decode_length_seconds(length_text) + end + else + length_seconds = 0 + end + else + length_seconds = 0 + end + + premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } + badges = VideoBadges::None + item_contents["badges"]?.try &.as_a.each do |badge| + b = badge["metadataBadgeRenderer"] + case b["label"].as_s + when "LIVE" + badges |= VideoBadges::LiveNow + when "New" + badges |= VideoBadges::New + when "4K" + badges |= VideoBadges::FourK + when "8K" + badges |= VideoBadges::EightK + when "VR180" + badges |= VideoBadges::VR180 + when "360°" + badges |= VideoBadges::VR360 + when "3D" + badges |= VideoBadges::ThreeD + when "CC" + badges |= VideoBadges::ClosedCaptions + when "Premium" + # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"] + badges |= VideoBadges::Premium + else nil # Ignore + end + end + + SearchVideo.new({ + title: title, + id: video_id, + author: author, + ucid: author_id, + published: published, + views: view_count, + description_html: description_html, + length_seconds: length_seconds, + premiere_timestamp: premiere_timestamp, + author_verified: author_verified, + badges: badges, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses a InnerTube channelRenderer into a SearchChannel. Returns nil when the given object isn't a channelRenderer + # + # A channelRenderer renders a channel to click on within the YouTube and Invidious UI. It is **not** + # the channel page itself. + # + # See specs for example. + # + # `channelRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. + # + module ChannelRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?) + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + author = extract_text(item_contents["title"]) || author_fallback.name + author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id + author_verified = has_verified_badge?(item_contents["ownerBadges"]?) + author_thumbnail = HelperExtractors.get_thumbnails(item_contents) + + # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. + # Always simpleText + # TODO change default value to nil + + subscriber_count = item_contents.dig?("subscriberCountText", "simpleText").try &.as_s + channel_handle = subscriber_count if (subscriber_count.try &.starts_with? "@") + + # Since youtube added channel handles, `VideoCountText` holds the number of + # subscribers and `subscriberCountText` holds the handle, except when the + # channel doesn't have a handle (e.g: some topic music channels). + # See https://github.com/iv-org/invidious/issues/3394#issuecomment-1321261688 + if !subscriber_count || !subscriber_count.includes? " subscriber" + subscriber_count = item_contents.dig?("videoCountText", "simpleText").try &.as_s + end + subscriber_count = subscriber_count + .try { |s| short_text_to_number(s.split(" ")[0]).to_i32 } || 0 + + # Auto-generated channels doesn't have videoCountText + # Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922 + auto_generated = item_contents["videoCountText"]?.nil? + + video_count = HelperExtractors.get_video_count(item_contents) + description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" + + SearchChannel.new({ + author: author, + ucid: author_id, + author_thumbnail: author_thumbnail, + subscriber_count: subscriber_count, + video_count: video_count, + channel_handle: channel_handle, + description_html: description_html, + auto_generated: auto_generated, + author_verified: author_verified, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses an Innertube `hashtagTileRenderer` into a `SearchHashtag`. + # Returns `nil` when the given object is not a `hashtagTileRenderer`. + # + # A `hashtagTileRenderer` is a kind of search result. + # It can be found when searching for any hashtag (e.g "#hi" or "#shorts") + module HashtagRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["hashtagTileRenderer"]? + return self.parse(item_contents) + end + end + + private def self.parse(item_contents) + title = extract_text(item_contents["hashtag"]).not_nil! # E.g "#hi" + + # E.g "/hashtag/hi" + url = item_contents.dig?("onTapCommand", "commandMetadata", "webCommandMetadata", "url").try &.as_s + url ||= URI.encode_path("/hashtag/#{title.lchop('#')}") + + video_count_txt = extract_text(item_contents["hashtagVideoCount"]?) # E.g "203K videos" + channel_count_txt = extract_text(item_contents["hashtagChannelCount"]?) # E.g "81K channels" + + # Fallback for video/channel counts + if channel_count_txt.nil? || video_count_txt.nil? + # E.g: "203K videos • 81K channels" + info_text = extract_text(item_contents["hashtagInfoText"]?).try &.split(" • ") + + if info_text && info_text.size == 2 + video_count_txt ||= info_text[0] + channel_count_txt ||= info_text[1] + end + end + + return SearchHashtag.new({ + title: title, + url: url, + video_count: short_text_to_number(video_count_txt || ""), + channel_count: short_text_to_number(channel_count_txt || ""), + }) + rescue ex + LOGGER.debug("HashtagRendererParser: Failed to extract renderer.") + LOGGER.debug("HashtagRendererParser: Got exception: #{ex.message}") + return nil + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses a InnerTube gridPlaylistRenderer into a SearchPlaylist. Returns nil when the given object isn't a gridPlaylistRenderer + # + # A gridPlaylistRenderer renders a playlist, that is located in a grid, to click on within the YouTube and Invidious UI. + # It is **not** the playlist itself. + # + # See specs for example. + # + # `gridPlaylistRenderer`s can be found on the playlist-tabs of channels and expanded categories. + # + module GridPlaylistRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["gridPlaylistRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + title = extract_text(item_contents["title"]) || "" + plid = item_contents["playlistId"]?.try &.as_s || "" + + author_verified = has_verified_badge?(item_contents["ownerBadges"]?) + + video_count = HelperExtractors.get_video_count(item_contents) + playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents) + + SearchPlaylist.new({ + title: title, + id: plid, + author: author_fallback.name, + ucid: author_fallback.id, + video_count: video_count, + videos: [] of SearchPlaylistVideo, + thumbnail: playlist_thumbnail, + author_verified: author_verified, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses a InnerTube playlistRenderer into a SearchPlaylist. Returns nil when the given object isn't a playlistRenderer + # + # A playlistRenderer renders a playlist to click on within the YouTube and Invidious UI. It is **not** the playlist itself. + # + # See specs for example. + # + # `playlistRenderer`s can be found almost everywhere on YouTube. In categories, search results, recommended, etc. + # + module PlaylistRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["playlistRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + title = extract_text(item_contents["title"]) || "" + plid = item_contents["playlistId"]?.try &.as_s || "" + + video_count = HelperExtractors.get_video_count(item_contents) + playlist_thumbnail = HelperExtractors.get_thumbnails_plural(item_contents) + + author_info = item_contents.dig?("shortBylineText", "runs", 0) + author = author_info.try &.["text"].as_s || author_fallback.name + author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id + author_verified = has_verified_badge?(item_contents["ownerBadges"]?) + + videos = item_contents["videos"]?.try &.as_a.map do |v| + v = v["childVideoRenderer"] + v_title = v.dig?("title", "simpleText").try &.as_s || "" + v_id = v["videoId"]?.try &.as_s || "" + v_length_seconds = v.dig?("lengthText", "simpleText").try { |t| decode_length_seconds(t.as_s) } || 0 + SearchPlaylistVideo.new({ + title: v_title, + id: v_id, + length_seconds: v_length_seconds, + }) + end || [] of SearchPlaylistVideo + + # TODO: item_contents["publishedTimeText"]? + + SearchPlaylist.new({ + title: title, + id: plid, + author: author, + ucid: author_id, + video_count: video_count, + videos: videos, + thumbnail: playlist_thumbnail, + author_verified: author_verified, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses a InnerTube shelfRenderer into a Category. Returns nil when the given object isn't a shelfRenderer + # + # A shelfRenderer renders divided sections on YouTube. IE "People also watched" in search results and + # the various organizational sections in the channel home page. A separate one (richShelfRenderer) is used + # for YouTube home. A shelfRenderer can also sometimes be expanded to show more content within it. + # + # See specs for example. + # + # `shelfRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. + # + module CategoryRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["shelfRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + title = extract_text(item_contents["title"]?) || "" + url = item_contents.dig?("endpoint", "commandMetadata", "webCommandMetadata", "url") + .try &.as_s + + # Sometimes a category can have badges. + badges = [] of Tuple(String, String) # (Badge style, label) + item_contents["badges"]?.try &.as_a.each do |badge| + badge = badge["metadataBadgeRenderer"] + badges << {badge["style"].as_s, badge["label"].as_s} + end + + # Category description + description_html = item_contents["subtitle"]?.try { |desc| parse_content(desc) } || "" + + # Content parsing + contents = [] of SearchItem + + # InnerTube recognizes some "special" categories, which are organized differently. + if special_category_container = item_contents["content"]? + if content_container = special_category_container["horizontalListRenderer"]? + elsif content_container = special_category_container["expandedShelfContentsRenderer"]? + elsif content_container = special_category_container["verticalListRenderer"]? + else + # Anything else, such as `horizontalMovieListRenderer` is currently unsupported. + return + end + else + # "Normal" category. + content_container = item_contents["contents"] + end + + content_container["items"]?.try &.as_a.each do |item| + result = parse_item(item, author_fallback.name, author_fallback.id) + contents << result if result.is_a?(SearchItem) + end + + Category.new({ + title: title, + contents: contents, + description_html: description_html, + url: url, + badges: badges, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses an InnerTube itemSectionRenderer into a SearchVideo. + # Returns nil when the given object isn't a ItemSectionRenderer + # + # A itemSectionRenderer seems to be a simple wrapper for a videoRenderer or a playlistRenderer, used + # by the result page for channel searches. It is located inside a continuationItems + # container.It is very similar to RichItemRendererParser + # + module ItemSectionRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item.dig?("itemSectionRenderer", "contents", 0) + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + child = VideoRendererParser.process(item_contents, author_fallback) + child ||= PlaylistRendererParser.process(item_contents, author_fallback) + + return child + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses an InnerTube richItemRenderer into a SearchVideo. + # Returns nil when the given object isn't a RichItemRenderer + # + # A richItemRenderer seems to be a simple wrapper for a various other types, + # used on the hashtags result page and the channel podcast tab. It is located + # itself inside a richGridRenderer container. + # + module RichItemRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item.dig?("richItemRenderer", "content") + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + child = VideoRendererParser.process(item_contents, author_fallback) + child ||= ReelItemRendererParser.process(item_contents, author_fallback) + child ||= PlaylistRendererParser.process(item_contents, author_fallback) + child ||= LockupViewModelParser.process(item_contents, author_fallback) + child ||= ShortsLockupViewModelParser.process(item_contents, author_fallback) + return child + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses an InnerTube reelItemRenderer into a SearchVideo. + # Returns nil when the given object isn't a reelItemRenderer + # + # reelItemRenderer items are used in the new (2022) channel layout, + # in the "shorts" tab. + # + # NOTE: As of 10/2024, it might have been fully replaced by shortsLockupViewModel + # TODO: Confirm that hypothesis + # + module ReelItemRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["reelItemRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + video_id = item_contents["videoId"].as_s + + reel_player_overlay = item_contents.dig( + "navigationEndpoint", "reelWatchEndpoint", + "overlay", "reelPlayerOverlayRenderer" + ) + + if video_details_container = reel_player_overlay.dig?( + "reelPlayerHeaderSupportedRenderers", + "reelPlayerHeaderRenderer" + ) + # Author infos + + author = video_details_container + .dig?("channelTitleText", "runs", 0, "text") + .try &.as_s || author_fallback.name + + ucid = video_details_container + .dig?("channelNavigationEndpoint", "browseEndpoint", "browseId") + .try &.as_s || author_fallback.id + + # Title & publication date + + title = video_details_container.dig?("reelTitleText") + .try { |t| extract_text(t) } || "" + + published = video_details_container + .dig?("timestampText", "simpleText") + .try { |t| decode_date(t.as_s) } || Time.utc + + # View count + view_count_text = video_details_container.dig?("viewCountText", "simpleText") + else + author = author_fallback.name + ucid = author_fallback.id + published = Time.utc + title = item_contents.dig?("headline", "simpleText").try &.as_s || "" + end + # View count + + # View count used to be in the reelWatchEndpoint, but that changed? + view_count_text ||= item_contents.dig?("viewCountText", "simpleText") + + view_count = short_text_to_number(view_count_text.try &.as_s || "0") + + # Duration + + a11y_data = item_contents + .dig?("accessibility", "accessibilityData", "label") + .try &.as_s || "" + + regex_match = /- (?<min>\d+ minutes? )?(?<sec>\d+ seconds?)+ -/.match(a11y_data) + + minutes = regex_match.try &.["min"]?.try &.to_i(strict: false) || 0 + seconds = regex_match.try &.["sec"]?.try &.to_i(strict: false) || 0 + + duration = (minutes*60 + seconds) + + SearchVideo.new({ + title: title, + id: video_id, + author: author, + ucid: ucid, + published: published, + views: view_count, + description_html: "", + length_seconds: duration, + premiere_timestamp: Time.unix(0), + author_verified: false, + badges: VideoBadges::None, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses an InnerTube lockupViewModel into a SearchPlaylist. + # Returns nil when the given object is not a lockupViewModel. + # + # This structure is present since November 2024 on the "podcasts" and + # "playlists" tabs of the channel page. It is usually encapsulated in either + # a richItemRenderer or a richGridRenderer. + # + module LockupViewModelParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["lockupViewModel"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + playlist_id = item_contents["contentId"].as_s + + thumbnail_view_model = item_contents.dig( + "contentImage", "collectionThumbnailViewModel", + "primaryThumbnail", "thumbnailViewModel" + ) + + thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s + + # This complicated sequences tries to extract the following data structure: + # "overlays": [{ + # "thumbnailOverlayBadgeViewModel": { + # "thumbnailBadges": [{ + # "thumbnailBadgeViewModel": { + # "text": "430 episodes", + # "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT" + # } + # }] + # } + # }] + # + # NOTE: this simplistic `.to_i` conversion might not work on larger + # playlists and hasn't been tested. + video_count = thumbnail_view_model.dig("overlays").as_a + .compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a) + .flatten + .find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node| + {"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) } + }) + .try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false) + + metadata = item_contents.dig("metadata", "lockupMetadataViewModel") + title = metadata.dig("title", "content").as_s + + # TODO: Retrieve "updated" info from metadata parts + # rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a + # parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s) + # One of these parts should contain a string like: "Updated 2 days ago" + + # TODO: Maybe add a button to access the first video of the playlist? + # item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint") + # Available fields: "videoId", "playlistId", "params" + + return SearchPlaylist.new({ + title: title, + id: playlist_id, + author: author_fallback.name, + ucid: author_fallback.id, + video_count: video_count || -1, + videos: [] of SearchPlaylistVideo, + thumbnail: thumbnail, + author_verified: false, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses an InnerTube shortsLockupViewModel into a SearchVideo. + # Returns nil when the given object is not a shortsLockupViewModel. + # + # This structure is present since around October 2024 on the "shorts" tab of + # the channel page and likely replaces the reelItemRenderer structure. It is + # usually (always?) encapsulated in a richItemRenderer. + # + module ShortsLockupViewModelParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["shortsLockupViewModel"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + # TODO: Maybe add support for "oardefault.jpg" thumbnails? + # thumbnail = item_contents.dig("thumbnail", "sources", 0, "url").as_s + # Gives: https://i.ytimg.com/vi/{video_id}/oardefault.jpg?... + + video_id = item_contents.dig( + "onTap", "innertubeCommand", "reelWatchEndpoint", "videoId" + ).as_s + + title = item_contents.dig("overlayMetadata", "primaryText", "content").as_s + + view_count = short_text_to_number( + item_contents.dig("overlayMetadata", "secondaryText", "content").as_s + ) + + # Approximate to one minute, as "shorts" generally don't exceed that. + # NOTE: The actual duration is not provided by Youtube anymore. + # TODO: Maybe use -1 as an error value and handle that on the frontend? + duration = 60_i32 + + SearchVideo.new({ + title: title, + id: video_id, + author: author_fallback.name, + ucid: author_fallback.id, + published: Time.unix(0), + views: view_count, + description_html: "", + length_seconds: duration, + premiere_timestamp: Time.unix(0), + author_verified: false, + badges: VideoBadges::None, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses an InnerTube continuationItemRenderer into a Continuation. + # Returns nil when the given object isn't a continuationItemRenderer. + # + # continuationItemRenderer contains various metadata ued to load more + # content (i.e when the user scrolls down). The interesting bit is the + # protobuf object known as the "continutation token". Previously, those + # were generated from sratch, but recent (as of 11/2022) Youtube changes + # are forcing us to extract them from replies. + # + module ContinuationItemRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["continuationItemRenderer"]? + return self.parse(item_contents) + end + end + + private def self.parse(item_contents) + token = item_contents + .dig?("continuationEndpoint", "continuationCommand", "token") + .try &.as_s + + return Continuation.new(token) if token + end + + def self.parser_name + return {{@type.name}} + end + end +end + +# The following are the extractors for extracting an array of items from +# the internal Youtube API's JSON response. The result is then packaged into +# a structure we can more easily use via the parsers above. Their internals are +# identical to the item parsers. + +# Namespace for logic relating to extracting InnerTube's initial response to items we can parse. +# +# Each of the extractors in this namespace are accessed through the #process() method +# which validates the given data as applicable to itself. If it is applicable the given +# data is passed to the private `#extract()` method which returns an array of +# parsable items. Otherwise, nil is returned. +# +# NOTE perhaps the result from here should be abstracted into a struct in order to +# get additional metadata regarding the container of the item(s). +private module Extractors + # Extracts items from the selected YouTube tab. + # + # YouTube tabs are typically stored under "twoColumnBrowseResultsRenderer" + # and is structured like this: + # + # "twoColumnBrowseResultsRenderer": { + # {"tabs": [ + # {"tabRenderer": { + # "endpoint": {...} + # "title": "Playlists", + # "selected": true, # Is nil unless tab is selected + # "content": {...}, + # ... + # }} + # ]} + # }] + # + module YouTubeTabs + def self.process(initial_data : InitialData) + if target = initial_data["twoColumnBrowseResultsRenderer"]? + self.extract(target) + end + end + + private def self.extract(target) + raw_items = [] of JSON::Any + content = extract_selected_tab(target["tabs"])["content"] + + if section_list_contents = content.dig?("sectionListRenderer", "contents") + raw_items = unpack_section_list(section_list_contents) + elsif rich_grid_contents = content.dig?("richGridRenderer", "contents") + raw_items = rich_grid_contents.as_a + end + + return raw_items + end + + private def self.unpack_section_list(contents) + raw_items = [] of JSON::Any + + contents.as_a.each do |item| + if item_section_content = item.dig?("itemSectionRenderer", "contents") + raw_items += self.unpack_item_section(item_section_content) + else + raw_items << item + end + end + + return raw_items + end + + private def self.unpack_item_section(contents) + raw_items = [] of JSON::Any + + contents.as_a.each do |item| + # Category extraction + if container = item.dig?("gridRenderer", "items") || item.dig?("items") + raw_items += container.as_a + else + raw_items << item + end + end + + return raw_items + end + + def self.extractor_name + return {{@type.name}} + end + end + + # Extracts items from the InnerTube response for search results + # + # Search results are typically stored under "twoColumnSearchResultsRenderer" + # and is structured like this: + # + # "twoColumnSearchResultsRenderer": { + # {"primaryContents": { + # {"sectionListRenderer": { + # "contents": [...], + # ..., + # "subMenu": {...}, + # "hideBottomSeparator": true, + # "targetId": "search-feed" + # }} + # }} + # } + # + module SearchResults + def self.process(initial_data : InitialData) + if target = initial_data["twoColumnSearchResultsRenderer"]? + self.extract(target) + end + end + + private def self.extract(target) + raw_items = [] of Array(JSON::Any) + + target.dig("primaryContents", "sectionListRenderer", "contents").as_a.each do |node| + if node = node["itemSectionRenderer"]? + raw_items << node["contents"].as_a + end + end + + return raw_items.flatten + end + + def self.extractor_name + return {{@type.name}} + end + end + + # Extracts continuation items from a InnerTube response + # + # Continuation items (on YouTube) are items which are appended to the + # end of the page for continuous scrolling. As such, in many cases, + # the items are lacking information such as author or category title, + # since the original results has already rendered them on the top of the page. + # + # The way they are structured is too varied to be accurately written down here. + # However, they all eventually lead to an array of parsable items after traversing + # through the JSON structure. + module ContinuationContent + def self.process(initial_data : InitialData) + if target = initial_data["continuationContents"]? + self.extract(target) + elsif target = initial_data["appendContinuationItemsAction"]? + self.extract(target) + elsif target = initial_data["reloadContinuationItemsCommand"]? + self.extract(target) + end + end + + private def self.extract(target) + content = target["continuationItems"]? + content ||= target.dig?("gridContinuation", "items") + content ||= target.dig?("richGridContinuation", "contents") + + return content.nil? ? [] of JSON::Any : content.as_a + end + + def self.extractor_name + return {{@type.name}} + end + end +end + +# Helper methods to aid in the parsing of InnerTube to data structs. +# +# Mostly used to extract out repeated structures to deal with code +# repetition. +module HelperExtractors + # Retrieves the amount of videos present within the given InnerTube data. + # + # Returns a 0 when it's unable to do so + def self.get_video_count(container : JSON::Any) : Int32 + if box = container["videoCountText"]? + if (extracted_text = extract_text(box)) && !extracted_text.includes? " subscriber" + return extracted_text.gsub(/\D/, "").to_i + else + return 0 + end + elsif box = container["videoCount"]? + return box.as_s.to_i + else + return 0 + end + end + + # Retrieves the amount of views/viewers a video has. + # Seems to be used on related videos only + # + # Returns "0" when unable to parse + def self.get_short_view_count(container : JSON::Any) : String + box = container["shortViewCountText"]? + return "0" if !box + + # Simpletext: "4M views" + # runs: {"text": "1.1K"},{"text":" watching"} + return box["simpleText"]?.try &.as_s.sub(" views", "") || + box.dig?("runs", 0, "text").try &.as_s || "0" + end + + # Retrieve lowest quality thumbnail from InnerTube data + # + # TODO allow configuration of image quality (-1 is highest) + # + # Raises when it's unable to parse from the given JSON data. + def self.get_thumbnails(container : JSON::Any) : String + return container.dig("thumbnail", "thumbnails", 0, "url").as_s + end + + # ditto + # + # YouTube sometimes sends the thumbnail as: + # {"thumbnails": [{"thumbnails": [{"url": "example.com"}, ...]}]} + def self.get_thumbnails_plural(container : JSON::Any) : String + return container.dig("thumbnails", 0, "thumbnails", 0, "url").as_s + end + + # Retrieves the ID required for querying the InnerTube browse endpoint. + # Returns an empty string when it's unable to do so + def self.get_browse_id(container) + return container.dig?("navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || "" + end +end + +# Parses an item from Youtube's JSON response into a more usable structure. +# The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. +def parse_item(item : JSON::Any, author_fallback : String? = "", author_id_fallback : String? = "") + # We "allow" nil values but secretly use empty strings instead. This is to save us the + # hassle of modifying every author_fallback and author_id_fallback arg usage + # which is more often than not nil. + author_fallback = AuthorFallback.new(author_fallback || "", author_id_fallback || "") + + # Cycles through all of the item parsers and attempt to parse the raw YT JSON data. + # Each parser automatically validates the data given to see if the data is + # applicable to itself. If not nil is returned and the next parser is attempted. + ITEM_PARSERS.each do |parser| + LOGGER.trace("parse_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") + + if result = parser.process(item, author_fallback) + LOGGER.debug("parse_item: Successfully parsed via #{parser.parser_name}") + return result + else + LOGGER.trace("parse_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...") + end + end +end + +# Parses multiple items from YouTube's initial JSON response into a more usable structure. +# The end result is an array of SearchItem. +# +# This function yields the container so that items can be parsed separately. +# +def extract_items(initial_data : InitialData, &) + if unpackaged_data = initial_data["contents"]?.try &.as_h + elsif unpackaged_data = initial_data["response"]?.try &.as_h + elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 1).try &.as_h + elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h + else + unpackaged_data = initial_data + end + + # This is identical to the parser cycling of parse_item(). + ITEM_CONTAINER_EXTRACTOR.each do |extractor| + LOGGER.trace("extract_items: Attempting to extract item container using \"#{extractor.extractor_name}\" (cycling...)") + + if container = extractor.process(unpackaged_data) + LOGGER.debug("extract_items: Successfully unpacked container with \"#{extractor.extractor_name}\"") + # Extract items in container + container.each { |item| yield item } + else + LOGGER.trace("extract_items: Extractor \"#{extractor.extractor_name}\" does not apply. Cycling to the next one...") + end + end +end + +# Wrapper using the block function above +def extract_items( + initial_data : InitialData, + author_fallback : String? = nil, + author_id_fallback : String? = nil +) : {Array(SearchItem), String?} + items = [] of SearchItem + continuation = nil + + extract_items(initial_data) do |item| + parsed = parse_item(item, author_fallback, author_id_fallback) + + case parsed + when .is_a?(Continuation) then continuation = parsed.token + when .is_a?(SearchItem) then items << parsed + end + end + + return items, continuation +end diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr new file mode 100644 index 00000000..c83a2de5 --- /dev/null +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -0,0 +1,87 @@ +# Extracts text from InnerTube response +# +# InnerTube can package text in three different formats +# "runs": [ +# {"text": "something"}, +# {"text": "cont"}, +# ... +# ] +# +# "SimpleText": "something" +# +# Or sometimes just none at all as with the data returned from +# category continuations. +# +# In order to facilitate calling this function with `#[]?`: +# A nil will be accepted. Of course, since nil cannot be parsed, +# another nil will be returned. +def extract_text(item : JSON::Any?) : String? + if item.nil? + return nil + end + + if text_container = item["simpleText"]? + return text_container.as_s + elsif text_container = item["runs"]? + return text_container.as_a.map(&.["text"].as_s).join("") + else + nil + end +end + +# Check if an "ownerBadges" or a "badges" element contains a verified badge. +# There is currently two known types of verified badges: +# +# "ownerBadges": [{ +# "metadataBadgeRenderer": { +# "icon": { "iconType": "CHECK_CIRCLE_THICK" }, +# "style": "BADGE_STYLE_TYPE_VERIFIED", +# "tooltip": "Verified", +# "accessibilityData": { "label": "Verified" } +# } +# }], +# +# "ownerBadges": [{ +# "metadataBadgeRenderer": { +# "icon": { "iconType": "OFFICIAL_ARTIST_BADGE" }, +# "style": "BADGE_STYLE_TYPE_VERIFIED_ARTIST", +# "tooltip": "Official Artist Channel", +# "accessibilityData": { "label": "Official Artist Channel" } +# } +# }], +# +def has_verified_badge?(badges : JSON::Any?) + return false if badges.nil? + + badges.as_a.each do |badge| + style = badge.dig("metadataBadgeRenderer", "style").as_s + + return true if style == "BADGE_STYLE_TYPE_VERIFIED" + return true if style == "BADGE_STYLE_TYPE_VERIFIED_ARTIST" + end + + return false +rescue ex + LOGGER.debug("Unable to parse owner badges. Got exception: #{ex.message}") + LOGGER.trace("Owner badges data: #{badges.to_json}") + + return false +end + +# This function extracts SearchVideo items from a Category. +# Categories are commonly returned in search results and trending pages. +def extract_category(category : Category) : Array(SearchVideo) + return category.contents.select(SearchVideo) +end + +# :ditto: +def extract_category(category : Category, &) + category.contents.select(SearchVideo).each do |item| + yield item + end +end + +def extract_selected_tab(tabs) + # Extract the selected tab from the array of tabs Youtube returns + return tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"] +end diff --git a/src/invidious/yt_backend/url_sanitizer.cr b/src/invidious/yt_backend/url_sanitizer.cr new file mode 100644 index 00000000..d539dadb --- /dev/null +++ b/src/invidious/yt_backend/url_sanitizer.cr @@ -0,0 +1,121 @@ +require "uri" + +module UrlSanitizer + extend self + + ALLOWED_QUERY_PARAMS = { + channel: ["u", "user", "lb"], + playlist: ["list"], + search: ["q", "search_query", "sp"], + watch: [ + "v", # Video ID + "list", "index", # Playlist-related + "playlist", # Unnamed playlist (id,id,id,...) (embed-only?) + "t", "time_continue", "start", "end", # Timestamp + "lc", # Highlighted comment (watch page only) + ], + } + + # Returns whether the given string is an ASCII word. This is the same as + # running the following regex in US-ASCII locale: /^[\w-]+$/ + private def ascii_word?(str : String) : Bool + return false if str.bytesize != str.size + + str.each_byte do |byte| + next if 'a'.ord <= byte <= 'z'.ord + next if 'A'.ord <= byte <= 'Z'.ord + next if '0'.ord <= byte <= '9'.ord + next if byte == '-'.ord || byte == '_'.ord + + return false + end + + return true + end + + # Return which kind of parameters are allowed based on the + # first path component (breadcrumb 0). + private def determine_allowed(path_root : String) + case path_root + when "watch", "w", "v", "embed", "e", "shorts", "clip" + return :watch + when .starts_with?("@"), "c", "channel", "user", "profile", "attribution_link" + return :channel + when "playlist", "mix" + return :playlist + when "results", "search" + return :search + else # hashtag, post, trending, brand URLs, etc.. + return nil + end + end + + # Create a new URI::Param containing only the allowed parameters + private def copy_params(unsafe_params : URI::Params, allowed_type) : URI::Params + new_params = URI::Params.new + + ALLOWED_QUERY_PARAMS[allowed_type].each do |name| + if unsafe_params[name]? + # Only copy the last parameter, in case there is more than one + new_params[name] = unsafe_params.fetch_all(name)[-1] + end + end + + return new_params + end + + # Transform any user-supplied youtube URL into something we can trust + # and use across the code. + def process(str : String) : URI + # Because URI follows RFC3986 specifications, URL without a scheme + # will be parsed as a relative path. So we have to add a scheme ourselves. + str = "https://#{str}" if !str.starts_with?(/https?:\/\//) + + unsafe_uri = URI.parse(str) + unsafe_host = unsafe_uri.host + unsafe_path = unsafe_uri.path + + new_uri = URI.new(path: "/") + + # Redirect to homepage for bogus URLs + return new_uri if (unsafe_host.nil? || unsafe_path.nil?) + + breadcrumbs = unsafe_path + .split('/', remove_empty: true) + .compact_map do |bc| + # Exclude attempts at path trasversal + next if bc == "." || bc == ".." + + # Non-alnum characters are unlikely in a genuine URL + next if !ascii_word?(bc) + + bc + end + + # If nothing remains, it's either a legit URL to the homepage + # (who does that!?) or because we filtered some junk earlier. + return new_uri if breadcrumbs.empty? + + # Replace the original query parameters with the sanitized ones + case unsafe_host + when .ends_with?("youtube.com") + # Use our sanitized path (not forgetting the leading '/') + new_uri.path = "/#{breadcrumbs.join('/')}" + + # Then determine which params are allowed, and copy them over + if allowed = determine_allowed(breadcrumbs[0]) + new_uri.query_params = copy_params(unsafe_uri.query_params, allowed) + end + when "youtu.be" + # Always redirect to the watch page + new_uri.path = "/watch" + + new_params = copy_params(unsafe_uri.query_params, :watch) + new_params["v"] = breadcrumbs[0] + + new_uri.query_params = new_params + end + + return new_uri + end +end diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr new file mode 100644 index 00000000..8f5aa61d --- /dev/null +++ b/src/invidious/yt_backend/youtube_api.cr @@ -0,0 +1,699 @@ +# +# This file contains youtube API wrappers +# + +module YoutubeAPI + extend self + + # For Android versions, see https://en.wikipedia.org/wiki/Android_version_history + private ANDROID_APP_VERSION = "19.32.34" + private ANDROID_VERSION = "12" + private ANDROID_USER_AGENT = "com.google.android.youtube/#{ANDROID_APP_VERSION} (Linux; U; Android #{ANDROID_VERSION}; US) gzip" + private ANDROID_SDK_VERSION = 31_i64 + + private ANDROID_TS_APP_VERSION = "1.9" + private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip" + + # For Apple device names, see https://gist.github.com/adamawolf/3048717 + # For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases, + # then go to the dedicated article of the major version you want. + private IOS_APP_VERSION = "19.32.8" + private IOS_USER_AGENT = "com.google.ios.youtube/#{IOS_APP_VERSION} (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)" + private IOS_VERSION = "17.6.1.21G93" # Major.Minor.Patch.Build + + private WINDOWS_VERSION = "10.0" + + # Enumerate used to select one of the clients supported by the API + enum ClientType + Web + WebEmbeddedPlayer + WebMobile + WebScreenEmbed + WebCreator + + Android + AndroidEmbeddedPlayer + AndroidScreenEmbed + AndroidTestSuite + + IOS + IOSEmbedded + IOSMusic + + TvHtml5 + TvHtml5ScreenEmbed + end + + # List of hard-coded values used by the different clients + HARDCODED_CLIENTS = { + ClientType::Web => { + name: "WEB", + name_proto: "1", + version: "2.20240814.00.00", + screen: "WATCH_FULL_SCREEN", + os_name: "Windows", + os_version: WINDOWS_VERSION, + platform: "DESKTOP", + }, + ClientType::WebEmbeddedPlayer => { + name: "WEB_EMBEDDED_PLAYER", + name_proto: "56", + version: "1.20240812.01.00", + screen: "EMBED", + os_name: "Windows", + os_version: WINDOWS_VERSION, + platform: "DESKTOP", + }, + ClientType::WebMobile => { + name: "MWEB", + name_proto: "2", + version: "2.20240813.02.00", + os_name: "Android", + os_version: ANDROID_VERSION, + platform: "MOBILE", + }, + ClientType::WebScreenEmbed => { + name: "WEB", + name_proto: "1", + version: "2.20240814.00.00", + screen: "EMBED", + os_name: "Windows", + os_version: WINDOWS_VERSION, + platform: "DESKTOP", + }, + ClientType::WebCreator => { + name: "WEB_CREATOR", + name_proto: "62", + version: "1.20240918.03.00", + os_name: "Windows", + os_version: WINDOWS_VERSION, + platform: "DESKTOP", + }, + + # Android + + ClientType::Android => { + name: "ANDROID", + name_proto: "3", + version: ANDROID_APP_VERSION, + android_sdk_version: ANDROID_SDK_VERSION, + user_agent: ANDROID_USER_AGENT, + os_name: "Android", + os_version: ANDROID_VERSION, + platform: "MOBILE", + }, + ClientType::AndroidEmbeddedPlayer => { + name: "ANDROID_EMBEDDED_PLAYER", + name_proto: "55", + version: ANDROID_APP_VERSION, + }, + ClientType::AndroidScreenEmbed => { + name: "ANDROID", + name_proto: "3", + version: ANDROID_APP_VERSION, + screen: "EMBED", + android_sdk_version: ANDROID_SDK_VERSION, + user_agent: ANDROID_USER_AGENT, + os_name: "Android", + os_version: ANDROID_VERSION, + platform: "MOBILE", + }, + ClientType::AndroidTestSuite => { + name: "ANDROID_TESTSUITE", + name_proto: "30", + version: ANDROID_TS_APP_VERSION, + android_sdk_version: ANDROID_SDK_VERSION, + user_agent: ANDROID_TS_USER_AGENT, + os_name: "Android", + os_version: ANDROID_VERSION, + platform: "MOBILE", + }, + + # IOS + + ClientType::IOS => { + name: "IOS", + name_proto: "5", + version: IOS_APP_VERSION, + user_agent: IOS_USER_AGENT, + device_make: "Apple", + device_model: "iPhone14,5", + os_name: "iPhone", + os_version: IOS_VERSION, + platform: "MOBILE", + }, + ClientType::IOSEmbedded => { + name: "IOS_MESSAGES_EXTENSION", + name_proto: "66", + version: IOS_APP_VERSION, + user_agent: IOS_USER_AGENT, + device_make: "Apple", + device_model: "iPhone14,5", + os_name: "iPhone", + os_version: IOS_VERSION, + platform: "MOBILE", + }, + ClientType::IOSMusic => { + name: "IOS_MUSIC", + name_proto: "26", + version: "7.14", + user_agent: "com.google.ios.youtubemusic/7.14 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)", + device_make: "Apple", + device_model: "iPhone14,5", + os_name: "iPhone", + os_version: IOS_VERSION, + platform: "MOBILE", + }, + + # TV app + + ClientType::TvHtml5 => { + name: "TVHTML5", + name_proto: "7", + version: "7.20240813.07.00", + }, + ClientType::TvHtml5ScreenEmbed => { + name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", + name_proto: "85", + version: "2.0", + screen: "EMBED", + }, + } + + #################################################################### + # struct ClientConfig + # + # Data structure used to pass a client configuration to the different + # API endpoints handlers. + # + # Use case examples: + # + # ``` + # # Get Norwegian search results + # conf_1 = ClientConfig.new(region: "NO") + # YoutubeAPI::search("Kollektivet", params: "", client_config: conf_1) + # + # # Use the Android client to request video streams URLs + # conf_2 = ClientConfig.new(client_type: ClientType::Android) + # YoutubeAPI::player(video_id: "dQw4w9WgXcQ", client_config: conf_2) + # + # + struct ClientConfig + # Type of client to emulate. + # See `enum ClientType` and `HARDCODED_CLIENTS`. + property client_type : ClientType + + # Region to provide to youtube, e.g to alter search results + # (this is passed as the `gl` parameter). + property region : String | Nil + + # Initialization function + def initialize( + *, + @client_type = ClientType::Web, + @region = "US" + ) + end + + # Getter functions that provides easy access to hardcoded clients + # parameters (name/version strings and related API key) + def name : String + HARDCODED_CLIENTS[@client_type][:name] + end + + def name_proto : String + HARDCODED_CLIENTS[@client_type][:name_proto] + end + + # :ditto: + def version : String + HARDCODED_CLIENTS[@client_type][:version] + end + + # :ditto: + def screen : String + HARDCODED_CLIENTS[@client_type][:screen]? || "" + end + + def android_sdk_version : Int64? + HARDCODED_CLIENTS[@client_type][:android_sdk_version]? + end + + def user_agent : String? + HARDCODED_CLIENTS[@client_type][:user_agent]? + end + + def os_name : String? + HARDCODED_CLIENTS[@client_type][:os_name]? + end + + def device_make : String? + HARDCODED_CLIENTS[@client_type][:device_make]? + end + + def device_model : String? + HARDCODED_CLIENTS[@client_type][:device_model]? + end + + def os_version : String? + HARDCODED_CLIENTS[@client_type][:os_version]? + end + + def platform : String? + HARDCODED_CLIENTS[@client_type][:platform]? + end + + # Convert to string, for logging purposes + def to_s + return { + client_type: self.name, + region: @region, + }.to_s + end + end + + # Default client config, used if nothing is passed + DEFAULT_CLIENT_CONFIG = ClientConfig.new + + #################################################################### + # make_context(client_config) + # + # Return, as a Hash, the "context" data required to request the + # youtube API endpoints. + # + private def make_context(client_config : ClientConfig | Nil, video_id = "dQw4w9WgXcQ") : Hash + # Use the default client config if nil is passed + client_config ||= DEFAULT_CLIENT_CONFIG + + client_context = { + "client" => { + "hl" => "en", + "gl" => client_config.region || "US", # Can't be empty! + "clientName" => client_config.name, + "clientVersion" => client_config.version, + } of String => String | Int64, + } + + # Add some more context if it exists in the client definitions + if !client_config.screen.empty? + client_context["client"]["clientScreen"] = client_config.screen + end + + if client_config.screen == "EMBED" + client_context["thirdParty"] = { + "embedUrl" => "https://www.youtube.com/embed/#{video_id}", + } of String => String | Int64 + end + + if android_sdk_version = client_config.android_sdk_version + client_context["client"]["androidSdkVersion"] = android_sdk_version + end + + if device_make = client_config.device_make + client_context["client"]["deviceMake"] = device_make + end + + if device_model = client_config.device_model + client_context["client"]["deviceModel"] = device_model + end + + if os_name = client_config.os_name + client_context["client"]["osName"] = os_name + end + + if os_version = client_config.os_version + client_context["client"]["osVersion"] = os_version + end + + if platform = client_config.platform + client_context["client"]["platform"] = platform + end + + if CONFIG.visitor_data.is_a?(String) + client_context["client"]["visitorData"] = CONFIG.visitor_data.as(String) + end + + return client_context + end + + #################################################################### + # browse(continuation, client_config?) + # browse(browse_id, params, client_config?) + # + # Requests the youtubei/v1/browse endpoint with the required headers + # and POST data in order to get a JSON reply in english that can + # be easily parsed. + # + # Both forms can take an optional ClientConfig parameter (see + # `struct ClientConfig` above for more details). + # + # The requested data can either be: + # + # - A continuation token (ctoken). Depending on this token's + # contents, the returned data can be playlist videos, channel + # community tab content, channel info, ... + # + # - A playlist ID (parameters MUST be an empty string) + # + def browse(continuation : String, client_config : ClientConfig | Nil = nil) + # JSON Request data, required by the API + data = { + "context" => self.make_context(client_config), + "continuation" => continuation, + } + + return self._post_json("/youtubei/v1/browse", data, client_config) + end + + # :ditto: + def browse( + browse_id : String, + *, # Force the following parameters to be passed by name + params : String, + client_config : ClientConfig | Nil = nil + ) + # JSON Request data, required by the API + data = { + "browseId" => browse_id, + "context" => self.make_context(client_config), + } + + # Append the additional parameters if those were provided + # (this is required for channel info, playlist and community, e.g) + if params != "" + data["params"] = params + end + + return self._post_json("/youtubei/v1/browse", data, client_config) + end + + #################################################################### + # next(continuation, client_config?) + # next(data, client_config?) + # + # Requests the youtubei/v1/next endpoint with the required headers + # and POST data in order to get a JSON reply in english that can + # be easily parsed. + # + # Both forms can take an optional ClientConfig parameter (see + # `struct ClientConfig` above for more details). + # + # The requested data can be: + # + # - A continuation token (ctoken). Depending on this token's + # contents, the returned data can be videos comments, + # their replies, ... In this case, the string must be passed + # directly to the function. E.g: + # + # ``` + # YoutubeAPI::next("ABCDEFGH_abcdefgh==") + # ``` + # + # - Arbitrary parameters, in Hash form. See examples below for + # known examples of arbitrary data that can be passed to YouTube: + # + # ``` + # # Get the videos related to a specific video ID + # YoutubeAPI::next({"videoId" => "dQw4w9WgXcQ"}) + # + # # Get a playlist video's details + # YoutubeAPI::next({ + # "videoId" => "9bZkp7q19f0", + # "playlistId" => "PL_oFlvgqkrjUVQwiiE3F3k3voF4tjXeP0", + # }) + # ``` + # + def next(continuation : String, *, client_config : ClientConfig | Nil = nil) + # JSON Request data, required by the API + data = { + "context" => self.make_context(client_config), + "continuation" => continuation, + } + + return self._post_json("/youtubei/v1/next", data, client_config) + end + + # :ditto: + def next(data : Hash, *, client_config : ClientConfig | Nil = nil) + # JSON Request data, required by the API + data2 = data.merge({ + "context" => self.make_context(client_config), + }) + + return self._post_json("/youtubei/v1/next", data2, client_config) + end + + # Allow a NamedTuple to be passed, too. + def next(data : NamedTuple, *, client_config : ClientConfig | Nil = nil) + return self.next(data.to_h, client_config: client_config) + end + + #################################################################### + # player(video_id, params, client_config?) + # + # Requests the youtubei/v1/player endpoint with the required headers + # and POST data in order to get a JSON reply. + # + # The requested data is a video ID (`v=` parameter), with some + # additional parameters, formatted as a base64 string. + # + # An optional ClientConfig parameter can be passed, too (see + # `struct ClientConfig` above for more details). + # + def player( + video_id : String, + *, # Force the following parameters to be passed by name + params : String, + client_config : ClientConfig | Nil = nil + ) + # Playback context, separate because it can be different between clients + playback_ctx = { + "html5Preference" => "HTML5_PREF_WANTS", + "referer" => "https://www.youtube.com/watch?v=#{video_id}", + } of String => String | Int64 + + if {"WEB", "TVHTML5"}.any? { |s| client_config.name.starts_with? s } + if sts = DECRYPT_FUNCTION.try &.get_sts + playback_ctx["signatureTimestamp"] = sts.to_i64 + end + end + + # JSON Request data, required by the API + data = { + "contentCheckOk" => true, + "videoId" => video_id, + "context" => self.make_context(client_config, video_id), + "racyCheckOk" => true, + "user" => { + "lockedSafetyMode" => false, + }, + "playbackContext" => { + "contentPlaybackContext" => playback_ctx, + }, + "serviceIntegrityDimensions" => { + "poToken" => CONFIG.po_token, + }, + } + + # Append the additional parameters if those were provided + if params != "" + data["params"] = params + end + + return self._post_json("/youtubei/v1/player", data, client_config) + end + + #################################################################### + # resolve_url(url, client_config?) + # + # Requests the youtubei/v1/navigation/resolve_url endpoint with the + # required headers and POST data in order to get a JSON reply. + # + # An optional ClientConfig parameter can be passed, too (see + # `struct ClientConfig` above for more details). + # + # Output: + # + # ``` + # # Valid channel "brand URL" gives the related UCID and browse ID + # channel_a = YoutubeAPI.resolve_url("https://youtube.com/c/google") + # channel_a # => { + # "endpoint": { + # "browseEndpoint": { + # "params": "EgC4AQA%3D", + # "browseId":"UCK8sQmJBp8GCxrOtXWBpyEA" + # }, + # ... + # } + # } + # + # # Invalid URL returns throws an InfoException + # channel_b = YoutubeAPI.resolve_url("https://youtube.com/c/invalid") + # ``` + # + def resolve_url(url : String, client_config : ClientConfig | Nil = nil) + data = { + "context" => self.make_context(nil), + "url" => url, + } + + return self._post_json("/youtubei/v1/navigation/resolve_url", data, client_config) + end + + #################################################################### + # search(search_query, params, client_config?) + # + # Requests the youtubei/v1/search endpoint with the required headers + # and POST data in order to get a JSON reply. As the search results + # vary depending on the region, a region code can be specified in + # order to get non-US results. + # + # The requested data is a search string, with some additional + # parameters, formatted as a base64 string. + # + # An optional ClientConfig parameter can be passed, too (see + # `struct ClientConfig` above for more details). + # + def search( + search_query : String, + params : String, + client_config : ClientConfig | Nil = nil + ) + # JSON Request data, required by the API + data = { + "query" => search_query, + "context" => self.make_context(client_config), + "params" => params, + } + + return self._post_json("/youtubei/v1/search", data, client_config) + end + + #################################################################### + # get_transcript(params, client_config?) + # + # Requests the youtubei/v1/get_transcript endpoint with the required headers + # and POST data in order to get a JSON reply. + # + # The requested data is a specially encoded protobuf string that denotes the specific language requested. + # + # An optional ClientConfig parameter can be passed, too (see + # `struct ClientConfig` above for more details). + # + + def get_transcript( + params : String, + client_config : ClientConfig | Nil = nil + ) : Hash(String, JSON::Any) + data = { + "context" => self.make_context(client_config), + "params" => params, + } + + return self._post_json("/youtubei/v1/get_transcript", data, client_config) + end + + #################################################################### + # _post_json(endpoint, data, client_config?) + # + # Internal function that does the actual request to youtube servers + # and handles errors. + # + # The requested data is an endpoint (URL without the domain part) + # and the data as a Hash object. + # + def _post_json( + endpoint : String, + data : Hash, + client_config : ClientConfig | Nil + ) : Hash(String, JSON::Any) + # Use the default client config if nil is passed + client_config ||= DEFAULT_CLIENT_CONFIG + + # Query parameters + url = "#{endpoint}?prettyPrint=false" + + headers = HTTP::Headers{ + "Content-Type" => "application/json; charset=UTF-8", + "Accept-Encoding" => "gzip, deflate", + "x-goog-api-format-version" => "2", + "x-youtube-client-name" => client_config.name_proto, + "x-youtube-client-version" => client_config.version, + } + + if user_agent = client_config.user_agent + headers["User-Agent"] = user_agent + end + + if CONFIG.visitor_data.is_a?(String) + headers["X-Goog-Visitor-Id"] = CONFIG.visitor_data.as(String) + end + + # Logging + LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") + LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}") + LOGGER.trace("YoutubeAPI: POST data: #{data}") + + # Send the POST request + body = YT_POOL.client() do |client| + client.post(url, headers: headers, body: data.to_json) do |response| + if response.status_code != 200 + raise InfoException.new("Error: non 200 status code. Youtube API returned \ + status code #{response.status_code}. See <a href=\"https://docs.invidious.io/youtube-errors-explained/\"> \ + https://docs.invidious.io/youtube-errors-explained/</a> for troubleshooting.") + end + self._decompress(response.body_io, response.headers["Content-Encoding"]?) + end + end + + # Convert result to Hash + initial_data = JSON.parse(body).as_h + + # Error handling + if initial_data.has_key?("error") + code = initial_data["error"]["code"] + message = initial_data["error"]["message"].to_s.sub(/(\\n)+\^$/, "") + + # Logging + LOGGER.error("YoutubeAPI: Got error #{code} when requesting #{endpoint}") + LOGGER.error("YoutubeAPI: #{message}") + LOGGER.info("YoutubeAPI: POST data was: #{data}") + + raise InfoException.new("Could not extract JSON. Youtube API returned \ + error #{code} with message:<br>\"#{message}\"") + end + + return initial_data + end + + #################################################################### + # _decompress(body_io, headers) + # + # Internal function that reads the Content-Encoding headers and + # decompresses the content accordingly. + # + # We decompress the body ourselves (when using HTTP::Client) because + # the auto-decompress feature is broken in the Crystal stdlib. + # + # Read more: + # - https://github.com/iv-org/invidious/issues/2612 + # - https://github.com/crystal-lang/crystal/issues/11354 + # + def _decompress(body_io : IO, encodings : String?) : String + if encodings + # Multiple encodings can be combined, and are listed in the order + # in which they were applied. E.g: "deflate, gzip" means that the + # content must be first "gunzipped", then "defated". + encodings.split(',').reverse.each do |enc| + case enc.strip(' ') + when "gzip" + body_io = Compress::Gzip::Reader.new(body_io, sync_close: true) + when "deflate" + body_io = Compress::Deflate::Reader.new(body_io, sync_close: true) + end + end + end + + return body_io.gets_to_end + end +end # End of module diff --git a/videojs-dependencies.yml b/videojs-dependencies.yml new file mode 100644 index 00000000..e9ccc9dd --- /dev/null +++ b/videojs-dependencies.yml @@ -0,0 +1,54 @@ +# Due to a 'video append of' error (see #3011), we're stuck on 7.12.1. +video.js: + version: 7.12.1 + shasum: 1d12eeb1f52e3679e8e4c987d9b9eb37e2247fa2 + +videojs-contrib-quality-levels: + version: 2.1.0 + shasum: 046e9e21ed01043f512b83a1916001d552457083 + +videojs-http-source-selector: + version: 1.1.6 + shasum: 073aadbea0106ba6c98d6b611094dbf8554ffa1f + +videojs-markers: + version: 1.0.1 + shasum: d7f8d804253fd587813271f8db308a22b9f7df34 + +videojs-mobile-ui: + version: 0.6.1 + shasum: 0e146c4c481cbee0729cb5e162e558b455562cd0 + +videojs-overlay: + version: 2.1.4 + shasum: 5a103b25374dbb753eb87960d8360c2e8f39cc05 + +videojs-share: + version: 3.2.1 + shasum: 0a3024b981387b9d21c058c829760a72c14b8ceb + +videojs-vr: + version: 1.8.0 + shasum: 7f2f07f760d8a329c615acd316e49da6ee8edd34 + +videojs-vtt-thumbnails: + version: 0.0.13 + shasum: d1e7d47f4ed80bb52f5fc4f4bad4bfc871f5970f + +# We're using iv-org's fork of videojs-quality-selector, +# which isn't published on NPM, and doesn't have any +# easy way of fetching the compiled variant. +# +# silvermine-videojs-quality-selector: +# version: 1.1.2 +# shasum: 94033ff9ee52ba6da1263b97c9a74d5b3dfdf711 + + +# Ditto. Although this extension contains the complied variant in its git repo, +# it lacks any sort of versioning. As such, the script will ignore it. +# +# videojs-youtube-annotations: +# github: https://github.com/afrmtbl/videojs-youtube-annotations + + + |
