├── .ameba.yml ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── enhancement.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── auto-close-duplicate.yaml │ ├── build-nightly-container.yml │ ├── build-stable-container.yml │ ├── ci.yml │ └── stale.yml ├── .gitignore ├── .gitmodules ├── CHANGELOG.md ├── CHANGELOG_legacy.md ├── LICENSE ├── Makefile ├── README.md ├── TRANSLATION ├── assets ├── .well-known │ └── dnt-policy.txt ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── browserconfig.xml ├── css │ ├── carousel.css │ ├── default.css │ ├── embed.css │ ├── empty.css │ ├── grids-responsive-min.css │ ├── ionicons.min.css │ ├── player.css │ ├── pure-min.css │ ├── quality-selector.css │ ├── search.css │ └── videojs-youtube-annotations.min.css ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── fonts │ ├── ionicons.eot │ ├── ionicons.svg │ ├── ionicons.ttf │ ├── ionicons.woff │ └── ionicons.woff2 ├── hashtag.svg ├── invidious-colored-vector.svg ├── js │ ├── _helpers.js │ ├── comments.js │ ├── community.js │ ├── embed.js │ ├── handlers.js │ ├── notifications.js │ ├── pagination.js │ ├── player.js │ ├── playlist_widget.js │ ├── post.js │ ├── silvermine-videojs-quality-selector.min.js │ ├── sse.js │ ├── subscribe_widget.js │ ├── themes.js │ ├── videojs-youtube-annotations.min.js │ ├── watch.js │ ├── watched_indicator.js │ └── watched_widget.js ├── mstile-150x150.png ├── robots.txt ├── safari-pinned-tab.svg ├── site.webmanifest └── videojs │ └── .gitignore ├── config ├── config.example.yml ├── migrate-scripts │ ├── migrate-db-17cf077.sh │ ├── migrate-db-1c8075c.sh │ ├── migrate-db-1eca969.sh │ ├── migrate-db-30e6d29.sh │ ├── migrate-db-3646395.sh │ ├── migrate-db-3bcb98e.sh │ ├── migrate-db-52cb239.sh │ ├── migrate-db-6e51189.sh │ ├── migrate-db-701b5ea.sh │ ├── migrate-db-88b7097.sh │ └── migrate-db-8e884fe.sh └── sql │ ├── annotations.sql │ ├── channel_videos.sql │ ├── channels.sql │ ├── nonces.sql │ ├── playlist_videos.sql │ ├── playlists.sql │ ├── session_ids.sql │ ├── users.sql │ └── videos.sql ├── docker-compose.yml ├── docker ├── Dockerfile ├── Dockerfile.arm64 └── init-invidious-db.sh ├── invidious.service ├── kubernetes └── README.md ├── locales ├── af.json ├── ar.json ├── az.json ├── be.json ├── bg.json ├── bn.json ├── bn_BD.json ├── ca.json ├── cs.json ├── cy.json ├── da.json ├── de.json ├── el.json ├── en-US.json ├── eo.json ├── es.json ├── et.json ├── eu.json ├── fa.json ├── fi.json ├── fr.json ├── he.json ├── hi.json ├── hr.json ├── hu-HU.json ├── ia.json ├── id.json ├── is.json ├── it.json ├── ja.json ├── ko.json ├── lmo.json ├── lt.json ├── lv.json ├── nb-NO.json ├── nl.json ├── or.json ├── pl.json ├── pt-BR.json ├── pt-PT.json ├── pt.json ├── ro.json ├── ru.json ├── si.json ├── sk.json ├── sl.json ├── sq.json ├── sr.json ├── sr_Cyrl.json ├── sv-SE.json ├── ta.json ├── tk.json ├── tok.json ├── tr.json ├── uk.json ├── vi.json ├── zh-CN.json └── zh-TW.json ├── screenshots ├── 01_player.png ├── 02_preferences.png ├── 03_subscriptions.png ├── 04_description.png ├── 05_preferences.png ├── 06_subscriptions.png └── native_notification.png ├── scripts ├── deploy-database.sh ├── fetch-player-dependencies.cr ├── generate_js_licenses.cr ├── git │ └── pre-commit └── install-dependencies.sh ├── shard.lock ├── shard.yml ├── spec ├── helpers │ └── vtt │ │ └── builder_spec.cr ├── i18next_plurals_spec.cr ├── invidious │ ├── hashtag_spec.cr │ ├── helpers_spec.cr │ ├── search │ │ ├── iv_filters_spec.cr │ │ ├── query_spec.cr │ │ └── yt_filters_spec.cr │ ├── user │ │ └── imports_spec.cr │ ├── utils_spec.cr │ └── videos │ │ ├── regular_videos_extract_spec.cr │ │ └── scheduled_live_extract_spec.cr ├── parsers_helper.cr └── spec_helper.cr ├── src ├── ext │ └── kemal_static_file_handler.cr ├── invidious.cr └── invidious │ ├── channels │ ├── about.cr │ ├── channels.cr │ ├── community.cr │ ├── playlists.cr │ └── videos.cr │ ├── comments │ ├── content.cr │ ├── links_util.cr │ ├── reddit.cr │ ├── reddit_types.cr │ └── youtube.cr │ ├── config.cr │ ├── database │ ├── annotations.cr │ ├── base.cr │ ├── channels.cr │ ├── migration.cr │ ├── migrations │ │ ├── 0001_create_channels_table.cr │ │ ├── 0002_create_videos_table.cr │ │ ├── 0003_create_channel_videos_table.cr │ │ ├── 0004_create_users_table.cr │ │ ├── 0005_create_session_ids_table.cr │ │ ├── 0006_create_nonces_table.cr │ │ ├── 0007_create_annotations_table.cr │ │ ├── 0008_create_playlists_table.cr │ │ ├── 0009_create_playlist_videos_table.cr │ │ └── 0010_make_videos_unlogged.cr │ ├── migrator.cr │ ├── nonces.cr │ ├── playlists.cr │ ├── sessions.cr │ ├── statistics.cr │ ├── users.cr │ └── videos.cr │ ├── exceptions.cr │ ├── frontend │ ├── channel_page.cr │ ├── comments_reddit.cr │ ├── comments_youtube.cr │ ├── misc.cr │ ├── pagination.cr │ ├── search_filters.cr │ └── watch_page.cr │ ├── hashtag.cr │ ├── helpers │ ├── crystal_class_overrides.cr │ ├── errors.cr │ ├── handlers.cr │ ├── helpers.cr │ ├── i18n.cr │ ├── i18next.cr │ ├── logger.cr │ ├── macros.cr │ ├── serialized_yt_data.cr │ ├── sig_helper.cr │ ├── signatures.cr │ ├── tokens.cr │ ├── utils.cr │ └── webvtt.cr │ ├── http_server │ └── utils.cr │ ├── jobs.cr │ ├── jobs │ ├── base_job.cr │ ├── clear_expired_items_job.cr │ ├── instance_refresh_job.cr │ ├── notification_job.cr │ ├── pull_popular_videos_job.cr │ ├── refresh_channels_job.cr │ ├── refresh_feeds_job.cr │ ├── statistics_refresh_job.cr │ └── subscribe_to_feeds_job.cr │ ├── jsonify │ └── api_v1 │ │ ├── common.cr │ │ └── video_json.cr │ ├── mixes.cr │ ├── playlists.cr │ ├── routes │ ├── account.cr │ ├── api │ │ ├── manifest.cr │ │ └── v1 │ │ │ ├── authenticated.cr │ │ │ ├── channels.cr │ │ │ ├── feeds.cr │ │ │ ├── misc.cr │ │ │ ├── search.cr │ │ │ └── videos.cr │ ├── before_all.cr │ ├── channels.cr │ ├── embed.cr │ ├── errors.cr │ ├── feeds.cr │ ├── images.cr │ ├── login.cr │ ├── misc.cr │ ├── notifications.cr │ ├── playlists.cr │ ├── preferences.cr │ ├── search.cr │ ├── subscriptions.cr │ ├── video_playback.cr │ └── watch.cr │ ├── routing.cr │ ├── search │ ├── ctoken.cr │ ├── filters.cr │ ├── processors.cr │ └── query.cr │ ├── trending.cr │ ├── user │ ├── captcha.cr │ ├── converters.cr │ ├── cookies.cr │ ├── exports.cr │ ├── imports.cr │ ├── preferences.cr │ └── user.cr │ ├── users.cr │ ├── videos.cr │ ├── videos │ ├── caption.cr │ ├── clip.cr │ ├── description.cr │ ├── formats.cr │ ├── music.cr │ ├── parser.cr │ ├── regions.cr │ ├── storyboard.cr │ ├── transcript.cr │ └── video_preferences.cr │ ├── views │ ├── add_playlist_items.ecr │ ├── channel.ecr │ ├── community.ecr │ ├── components │ │ ├── channel_info.ecr │ │ ├── feed_menu.ecr │ │ ├── item.ecr │ │ ├── items_paginated.ecr │ │ ├── player.ecr │ │ ├── player_sources.ecr │ │ ├── search_box.ecr │ │ ├── subscribe_widget.ecr │ │ └── video-context-buttons.ecr │ ├── create_playlist.ecr │ ├── delete_playlist.ecr │ ├── edit_playlist.ecr │ ├── embed.ecr │ ├── error.ecr │ ├── feeds │ │ ├── history.ecr │ │ ├── playlists.ecr │ │ ├── popular.ecr │ │ ├── subscriptions.ecr │ │ └── trending.ecr │ ├── hashtag.ecr │ ├── licenses.ecr │ ├── message.ecr │ ├── mix.ecr │ ├── playlist.ecr │ ├── post.ecr │ ├── privacy.ecr │ ├── search.ecr │ ├── search_homepage.ecr │ ├── template.ecr │ ├── user │ │ ├── authorize_token.ecr │ │ ├── change_password.ecr │ │ ├── clear_watch_history.ecr │ │ ├── data_control.ecr │ │ ├── delete_account.ecr │ │ ├── login.ecr │ │ ├── preferences.ecr │ │ ├── subscription_manager.ecr │ │ └── token_manager.ecr │ └── watch.ecr │ └── yt_backend │ ├── connection_pool.cr │ ├── extractors.cr │ ├── extractors_utils.cr │ ├── url_sanitizer.cr │ └── youtube_api.cr └── videojs-dependencies.yml /.ameba.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Lint 3 | # 4 | 5 | # Exclude assigns for ECR files 6 | Lint/UselessAssign: 7 | Excluded: 8 | - src/invidious.cr 9 | - src/invidious/helpers/errors.cr 10 | - src/invidious/routes/**/*.cr 11 | 12 | # Ignore false negative (if !db.query_one?...) 13 | Lint/UnreachableCode: 14 | Excluded: 15 | - src/invidious/database/base.cr 16 | 17 | # Ignore shadowed variable `key` (it works for now, and that's 18 | # a sensitive part of the code) 19 | Lint/ShadowingOuterLocalVar: 20 | Excluded: 21 | - src/invidious/helpers/tokens.cr 22 | 23 | Lint/NotNil: 24 | Enabled: false 25 | 26 | Lint/SpecFilename: 27 | Excluded: 28 | - spec/parsers_helper.cr 29 | 30 | 31 | # 32 | # Style 33 | # 34 | 35 | Style/RedundantBegin: 36 | Enabled: false 37 | 38 | Style/RedundantReturn: 39 | Enabled: false 40 | 41 | Style/RedundantNext: 42 | Enabled: false 43 | 44 | Style/ParenthesesAroundCondition: 45 | Enabled: false 46 | 47 | # This requires a rewrite of most data structs (and their usage) in Invidious. 48 | Naming/QueryBoolMethods: 49 | Enabled: false 50 | 51 | Naming/AccessorMethodName: 52 | Enabled: false 53 | 54 | Naming/BlockParameterName: 55 | Enabled: false 56 | 57 | # Hides TODO comment warnings. 58 | # 59 | # Call `bin/ameba --only Documentation/DocumentationAdmonition` to 60 | # list them 61 | Documentation/DocumentationAdmonition: 62 | Enabled: false 63 | 64 | 65 | # 66 | # Metrics 67 | # 68 | 69 | # Ignore function complexity (number of if/else & case/when branches) 70 | # For some functions that can hardly be simplified for now 71 | Metrics/CyclomaticComplexity: 72 | Enabled: false 73 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cr] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # https://github.community/t/how-to-change-the-category/2261/3 2 | videojs-*.js linguist-detectable=false 3 | video.min.js linguist-detectable=false 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | docker-compose.yml @unixfox 2 | docker/ @unixfox 3 | kubernetes/ @unixfox 4 | 5 | README.md @thefrenchghosty 6 | config/config.example.yml @SamantazFox @unixfox 7 | 8 | scripts/ @syeopite 9 | shards.lock @syeopite 10 | shards.yml @syeopite 11 | 12 | locales/ @SamantazFox 13 | src/invidious/helpers/i18n.cr @SamantazFox 14 | 15 | src/invidious/helpers/youtube_api.cr @SamantazFox 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://invidious.io/donate/ 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve Invidious 4 | title: '[Bug] ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 21 | 22 | 23 | **Describe the bug** 24 | 25 | 26 | **Steps to Reproduce** 27 | 33 | 34 | **Logs** 35 | 36 | 37 | **Screenshots** 38 | 39 | 40 | **Additional context** 41 | 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement 3 | about: Suggest an enhancement for an existing feature 4 | title: '[Enhancement] ' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | 13 | 14 | **Is your enhancement request related to a problem? Please describe.** 15 | 16 | 17 | **Describe the solution you'd like** 18 | 19 | 20 | **Describe alternatives you've considered** 21 | 22 | 23 | **Additional context** 24 | 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[Feature request] ' 5 | labels: feature-request 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | 13 | 14 | **Is your feature request related to a problem? Please describe.** 15 | 16 | 17 | **Describe the solution you'd like** 18 | 19 | 20 | **Describe alternatives you've considered** 21 | 22 | 23 | **Additional context** 24 | 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "docker" 4 | directory: "/docker" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/auto-close-duplicate.yaml: -------------------------------------------------------------------------------- 1 | name: Close duplicates 2 | on: 3 | issues: 4 | types: [opened] 5 | jobs: 6 | run: 7 | runs-on: ubuntu-latest 8 | permissions: write-all 9 | steps: 10 | - uses: iv-org/close-potential-duplicates@v1 11 | with: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | # Issue title filter work with anymatch https://www.npmjs.com/package/anymatch. 14 | # Any matched issue will stop detection immediately. 15 | # You can specify multi filters in each line. 16 | filter: '' 17 | # Exclude keywords in title before detecting. 18 | exclude: '' 19 | # Label to set, when potential duplicates are detected. 20 | label: duplicate 21 | # Get issues with state to compare. Supported state: 'all', 'closed', 'open'. 22 | state: open 23 | # If similarity is higher than this threshold([0,1]), issue will be marked as duplicate. 24 | threshold: 0.9 25 | # Reactions to be add to comment when potential duplicates are detected. 26 | # Available reactions: "-1", "+1", "confused", "laugh", "heart", "hooray", "rocket", "eyes" 27 | reactions: '' 28 | close: true 29 | # Comment to post when potential duplicates are detected. 30 | comment: | 31 | Hello, your issue is a duplicate of this/these issue(s): {{#issues}} 32 | - #{{ number }} [accuracy: {{ accuracy }}%] 33 | {{/issues}} 34 | 35 | If this is a mistake please explain why and ping @\unixfox, @\SamantazFox and @\TheFrenchGhosty. 36 | 37 | Please refrain from opening new issues, it won't help in solving your problem. 38 | -------------------------------------------------------------------------------- /.github/workflows/build-nightly-container.yml: -------------------------------------------------------------------------------- 1 | name: Build and release container directly from master 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | paths-ignore: 8 | - "*.md" 9 | - LICENCE 10 | - TRANSLATION 11 | - invidious.service 12 | - .git* 13 | - .editorconfig 14 | - screenshots/* 15 | - .github/ISSUE_TEMPLATE/* 16 | - kubernetes/** 17 | 18 | jobs: 19 | release: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | 26 | - name: Set up QEMU 27 | uses: docker/setup-qemu-action@v3 28 | with: 29 | platforms: arm64 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Login to registry 35 | uses: docker/login-action@v3 36 | with: 37 | registry: quay.io 38 | username: ${{ secrets.QUAY_USERNAME }} 39 | password: ${{ secrets.QUAY_PASSWORD }} 40 | 41 | - name: Docker meta 42 | id: meta 43 | uses: docker/metadata-action@v5 44 | with: 45 | images: quay.io/invidious/invidious 46 | tags: | 47 | type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} 48 | type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} 49 | labels: | 50 | quay.expires-after=12w 51 | 52 | - name: Build and push Docker AMD64 image for Push Event 53 | uses: docker/build-push-action@v6 54 | with: 55 | context: . 56 | file: docker/Dockerfile 57 | platforms: linux/amd64 58 | labels: ${{ steps.meta.outputs.labels }} 59 | push: true 60 | tags: ${{ steps.meta.outputs.tags }} 61 | build-args: | 62 | "release=1" 63 | 64 | - name: Docker meta 65 | id: meta-arm64 66 | uses: docker/metadata-action@v5 67 | with: 68 | images: quay.io/invidious/invidious 69 | flavor: | 70 | suffix=-arm64 71 | tags: | 72 | type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} 73 | type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} 74 | labels: | 75 | quay.expires-after=12w 76 | 77 | - name: Build and push Docker ARM64 image for Push Event 78 | uses: docker/build-push-action@v6 79 | with: 80 | context: . 81 | file: docker/Dockerfile.arm64 82 | platforms: linux/arm64/v8 83 | labels: ${{ steps.meta-arm64.outputs.labels }} 84 | push: true 85 | tags: ${{ steps.meta-arm64.outputs.tags }} 86 | build-args: | 87 | "release=1" 88 | -------------------------------------------------------------------------------- /.github/workflows/build-stable-container.yml: -------------------------------------------------------------------------------- 1 | name: Build and release container 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up QEMU 18 | uses: docker/setup-qemu-action@v3 19 | with: 20 | platforms: arm64 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Login to registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: quay.io 29 | username: ${{ secrets.QUAY_USERNAME }} 30 | password: ${{ secrets.QUAY_PASSWORD }} 31 | 32 | - name: Docker meta 33 | id: meta 34 | uses: docker/metadata-action@v5 35 | with: 36 | images: quay.io/invidious/invidious 37 | flavor: | 38 | latest=false 39 | tags: | 40 | type=semver,pattern={{version}} 41 | type=raw,value=latest 42 | labels: | 43 | quay.expires-after=12w 44 | 45 | - name: Build and push Docker AMD64 image for Push Event 46 | uses: docker/build-push-action@v6 47 | with: 48 | context: . 49 | file: docker/Dockerfile 50 | platforms: linux/amd64 51 | labels: ${{ steps.meta.outputs.labels }} 52 | push: true 53 | tags: ${{ steps.meta.outputs.tags }} 54 | build-args: | 55 | "release=1" 56 | 57 | - name: Docker meta 58 | id: meta-arm64 59 | uses: docker/metadata-action@v5 60 | with: 61 | images: quay.io/invidious/invidious 62 | flavor: | 63 | latest=false 64 | suffix=-arm64 65 | tags: | 66 | type=semver,pattern={{version}} 67 | type=raw,value=latest 68 | labels: | 69 | quay.expires-after=12w 70 | 71 | - name: Build and push Docker ARM64 image for Push Event 72 | uses: docker/build-push-action@v6 73 | with: 74 | context: . 75 | file: docker/Dockerfile.arm64 76 | platforms: linux/arm64/v8 77 | labels: ${{ steps.meta-arm64.outputs.labels }} 78 | push: true 79 | tags: ${{ steps.meta-arm64.outputs.tags }} 80 | build-args: | 81 | "release=1" 82 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # Documentation: https://github.com/marketplace/actions/close-stale-issues 2 | 3 | name: "Stale issue handler" 4 | on: 5 | workflow_dispatch: 6 | schedule: 7 | - cron: "0 */12 * * *" 8 | 9 | jobs: 10 | stale: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/stale@v9 14 | with: 15 | repo-token: ${{ secrets.GITHUB_TOKEN }} 16 | days-before-stale: 730 17 | days-before-pr-stale: -1 18 | days-before-close: 60 19 | 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.' 20 | stale-issue-label: "stale" 21 | ascending: true 22 | # Exempt the following types of issues from being staled 23 | exempt-issue-labels: "feature-request,enhancement,discussion,exempt-stale" 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /dev/ 3 | /lib/ 4 | /bin/ 5 | /.shards/ 6 | /.vscode/ 7 | /invidious 8 | /sentry 9 | /config/config.yml 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "mocks"] 2 | path = mocks 3 | url = ../mocks 4 | -------------------------------------------------------------------------------- /TRANSLATION: -------------------------------------------------------------------------------- 1 | https://hosted.weblate.org/projects/invidious/ 2 | -------------------------------------------------------------------------------- /assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2b5797 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /assets/css/embed.css: -------------------------------------------------------------------------------- 1 | #player { 2 | position: fixed; 3 | right: 0; 4 | bottom: 0; 5 | min-width: 100%; 6 | min-height: 100%; 7 | width: auto; 8 | height: auto; 9 | z-index: -100; 10 | } 11 | 12 | .watch-on-invidious { 13 | font-size: 1.3em !important; 14 | font-weight: bold; 15 | white-space: nowrap; 16 | margin: 0 1em 0 1em !important; 17 | order: 3; 18 | } 19 | 20 | .watch-on-invidious > a { 21 | color: white; 22 | } 23 | 24 | .watch-on-invidious > a:hover, 25 | .watch-on-invidious > a:focus { 26 | color: rgba(0, 182, 240, 1);; 27 | } 28 | -------------------------------------------------------------------------------- /assets/css/empty.css: -------------------------------------------------------------------------------- 1 | #search-widget { 2 | text-align: center; 3 | margin: 20vh 0 50px 0; 4 | } 5 | 6 | #logo > h1 { 7 | font-size: 3.5em; 8 | margin: 0; 9 | padding: 0; 10 | } 11 | 12 | @media screen and (max-width: 1500px) and (max-height: 1000px) { 13 | #logo > h1 { 14 | font-size: 10vmin; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /assets/css/quality-selector.css: -------------------------------------------------------------------------------- 1 | .vjs-quality-selector .vjs-menu-button{margin:0;padding:0;height:100%;width:100%}.vjs-quality-selector .vjs-icon-placeholder{font-family:'VideoJS';font-weight:normal;font-style:normal}.vjs-quality-selector .vjs-icon-placeholder:before{content:'\f110'}.vjs-quality-changing .vjs-big-play-button{display:none}.vjs-quality-changing .vjs-control-bar{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;visibility:visible;opacity:1} 2 | -------------------------------------------------------------------------------- /assets/css/search.css: -------------------------------------------------------------------------------- 1 | #filters-collapse summary { 2 | /* This should hide the marker */ 3 | display: block; 4 | 5 | font-size: 1.17em; 6 | font-weight: bold; 7 | margin: 0 auto 10px auto; 8 | cursor: pointer; 9 | } 10 | 11 | #filters-collapse summary::-webkit-details-marker, 12 | #filters-collapse summary::marker { display: none; } 13 | 14 | #filters-collapse summary:before { 15 | border-radius: 5px; 16 | content: "[ + ]"; 17 | margin: -2px 10px 0 10px; 18 | padding: 1px 0 3px 0; 19 | text-align: center; 20 | width: 40px; 21 | } 22 | 23 | #filters-collapse details[open] > summary:before { content: "[ − ]"; } 24 | 25 | 26 | #filters-box { 27 | padding: 10px 20px 20px 10px; 28 | margin: 10px 15px; 29 | } 30 | #filters-flex { 31 | display: flex; 32 | flex-wrap: wrap; 33 | flex-direction: row; 34 | align-items: flex-start; 35 | align-content: flex-start; 36 | justify-content: flex-start; 37 | } 38 | 39 | 40 | fieldset, legend { 41 | display: contents !important; 42 | border: none !important; 43 | margin: 0 !important; 44 | padding: 0 !important; 45 | } 46 | 47 | 48 | .filter-column { 49 | display: inline-block; 50 | display: inline-flex; 51 | width: max-content; 52 | min-width: max-content; 53 | max-width: 16em; 54 | margin: 15px; 55 | flex-grow: 2; 56 | flex-basis: auto; 57 | flex-direction: column; 58 | } 59 | .filter-name, .filter-options { 60 | display: block; 61 | padding: 5px 10px; 62 | margin: 0; 63 | text-align: start; 64 | } 65 | 66 | .filter-options div { margin: 6px 0; } 67 | .filter-options div * { vertical-align: middle; } 68 | .filter-options label { margin: 0 10px; } 69 | 70 | 71 | #filters-apply { 72 | text-align: right; /* IE11 only */ 73 | text-align: end; /* Override for compatible browsers */ 74 | } 75 | 76 | /* Error message */ 77 | 78 | .no-results-error { 79 | text-align: center; 80 | line-height: 180%; 81 | font-size: 110%; 82 | padding: 15px 15px 125px 15px; 83 | } 84 | 85 | /* Responsive rules */ 86 | 87 | @media only screen and (max-width: 800px) { 88 | summary { font-size: 1.30em; } 89 | #filters-box { 90 | margin: 10px 0 0 0; 91 | padding: 0; 92 | } 93 | #filters-apply { 94 | text-align: center; 95 | padding: 15px; 96 | } 97 | } 98 | 99 | /* Light theme */ 100 | 101 | .light-theme #filters-box { 102 | background: #dfdfdf; 103 | } 104 | 105 | @media (prefers-color-scheme: light) { 106 | .no-theme #filters-box { 107 | background: #dfdfdf; 108 | } 109 | } 110 | 111 | /* Dark theme */ 112 | 113 | .dark-theme #filters-box { 114 | background: #373737; 115 | } 116 | 117 | @media (prefers-color-scheme: dark) { 118 | .no-theme #filters-box { 119 | background: #373737; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /assets/css/videojs-youtube-annotations.min.css: -------------------------------------------------------------------------------- 1 | .__cxt-ar-annotations-container__{--annotation-close-size: 20px;position:absolute;width:100%;height:100%;top:0;left:0;pointer-events:none;overflow:hidden}.__cxt-ar-annotation__{position:absolute;box-sizing:border-box;font-family:Arial,sans-serif;color:#fff;z-index:20;pointer-events:auto}.__cxt-ar-annotation__ span{position:absolute;left:0;top:0;overflow:hidden;word-wrap:break-word;white-space:pre-wrap;pointer-events:none;box-sizing:border-box;padding:2%;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}.__cxt-ar-annotation-close__{display:none;position:absolute;width:var(--annotation-close-size);height:var(--annotation-close-size);cursor:pointer;right:calc(var(--annotation-close-size)/-1.8);top:calc(var(--annotation-close-size)/-1.8);z-index:1}.__cxt-ar-annotation__:hover:not([hidden]):not([data-ar-closed]) .__cxt-ar-annotation-close__{display:block}.__cxt-ar-annotation__[hidden]{display:none!important}.__cxt-ar-annotation__[data-ar-type=highlight]{border:1px solid rgba(255,255,255,.1);background-color:transparent}.__cxt-ar-annotation__[data-ar-type=highlight]:hover{border:1px solid rgba(255,255,255,.5);background-color:transparent}.__cxt-ar-annotation__ svg{pointer-events:all} 2 | -------------------------------------------------------------------------------- /assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/assets/favicon-16x16.png -------------------------------------------------------------------------------- /assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/assets/favicon-32x32.png -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/assets/favicon.ico -------------------------------------------------------------------------------- /assets/fonts/ionicons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/assets/fonts/ionicons.eot -------------------------------------------------------------------------------- /assets/fonts/ionicons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/assets/fonts/ionicons.ttf -------------------------------------------------------------------------------- /assets/fonts/ionicons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/assets/fonts/ionicons.woff -------------------------------------------------------------------------------- /assets/fonts/ionicons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/assets/fonts/ionicons.woff2 -------------------------------------------------------------------------------- /assets/hashtag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /assets/js/embed.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var video_data = JSON.parse(document.getElementById('video_data').textContent); 3 | 4 | function get_playlist(plid) { 5 | var plid_url; 6 | if (plid.startsWith('RD')) { 7 | plid_url = '/api/v1/mixes/' + plid + 8 | '?continuation=' + video_data.id + 9 | '&format=html&hl=' + video_data.preferences.locale; 10 | } else { 11 | plid_url = '/api/v1/playlists/' + plid + 12 | '?index=' + video_data.index + 13 | '&continuation' + video_data.id + 14 | '&format=html&hl=' + video_data.preferences.locale; 15 | } 16 | 17 | helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, { 18 | on200: function (response) { 19 | if (!response.nextVideo) 20 | return; 21 | 22 | player.on('ended', function () { 23 | var url = new URL('https://example.com/embed/' + response.nextVideo); 24 | 25 | url.searchParams.set('list', plid); 26 | if (!plid.startsWith('RD')) 27 | url.searchParams.set('index', response.index); 28 | if (video_data.params.autoplay || video_data.params.continue_autoplay) 29 | url.searchParams.set('autoplay', '1'); 30 | if (video_data.params.listen !== video_data.preferences.listen) 31 | url.searchParams.set('listen', video_data.params.listen); 32 | if (video_data.params.speed !== video_data.preferences.speed) 33 | url.searchParams.set('speed', video_data.params.speed); 34 | if (video_data.params.local !== video_data.preferences.local) 35 | url.searchParams.set('local', video_data.params.local); 36 | 37 | location.assign(url.pathname + url.search); 38 | }); 39 | } 40 | }); 41 | } 42 | 43 | addEventListener('load', function (e) { 44 | if (video_data.plid) { 45 | get_playlist(video_data.plid); 46 | } else if (video_data.video_series) { 47 | player.on('ended', function () { 48 | var url = new URL('https://example.com/embed/' + video_data.video_series.shift()); 49 | 50 | if (video_data.params.autoplay || video_data.params.continue_autoplay) 51 | url.searchParams.set('autoplay', '1'); 52 | if (video_data.params.listen !== video_data.preferences.listen) 53 | url.searchParams.set('listen', video_data.params.listen); 54 | if (video_data.params.speed !== video_data.preferences.speed) 55 | url.searchParams.set('speed', video_data.params.speed); 56 | if (video_data.params.local !== video_data.preferences.local) 57 | url.searchParams.set('local', video_data.params.local); 58 | if (video_data.video_series.length !== 0) 59 | url.searchParams.set('playlist', video_data.video_series.join(',')); 60 | 61 | location.assign(url.pathname + url.search); 62 | }); 63 | } 64 | }); 65 | -------------------------------------------------------------------------------- /assets/js/playlist_widget.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var playlist_data = JSON.parse(document.getElementById('playlist_data').textContent); 3 | var payload = 'csrf_token=' + playlist_data.csrf_token; 4 | 5 | function add_playlist_video(target) { 6 | var select = target.parentNode.children[0].children[1]; 7 | var option = select.children[select.selectedIndex]; 8 | 9 | var url = '/playlist_ajax?action=add_video&redirect=false' + 10 | '&video_id=' + target.getAttribute('data-id') + 11 | '&playlist_id=' + option.getAttribute('data-plid'); 12 | 13 | helpers.xhr('POST', url, {payload: payload}, { 14 | on200: function (response) { 15 | option.textContent = '✓' + option.textContent; 16 | } 17 | }); 18 | } 19 | 20 | function add_playlist_item(target) { 21 | var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; 22 | tile.style.display = 'none'; 23 | 24 | var url = '/playlist_ajax?action=add_video&redirect=false' + 25 | '&video_id=' + target.getAttribute('data-id') + 26 | '&playlist_id=' + target.getAttribute('data-plid'); 27 | 28 | helpers.xhr('POST', url, {payload: payload}, { 29 | onNon200: function (xhr) { 30 | tile.style.display = ''; 31 | } 32 | }); 33 | } 34 | 35 | function remove_playlist_item(target) { 36 | var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; 37 | tile.style.display = 'none'; 38 | 39 | var url = '/playlist_ajax?action=remove_video&redirect=false' + 40 | '&set_video_id=' + target.getAttribute('data-index') + 41 | '&playlist_id=' + target.getAttribute('data-plid'); 42 | 43 | helpers.xhr('POST', url, {payload: payload}, { 44 | onNon200: function (xhr) { 45 | tile.style.display = ''; 46 | } 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /assets/js/post.js: -------------------------------------------------------------------------------- 1 | addEventListener('load', function (e) { 2 | get_youtube_comments(); 3 | }); 4 | -------------------------------------------------------------------------------- /assets/js/subscribe_widget.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var subscribe_data = JSON.parse(document.getElementById('subscribe_data').textContent); 3 | var payload = 'csrf_token=' + subscribe_data.csrf_token; 4 | 5 | var subscribe_button = document.getElementById('subscribe'); 6 | subscribe_button.parentNode.action = 'javascript:void(0)'; 7 | 8 | if (subscribe_button.getAttribute('data-type') === 'subscribe') { 9 | subscribe_button.onclick = subscribe; 10 | } else { 11 | subscribe_button.onclick = unsubscribe; 12 | } 13 | 14 | function subscribe() { 15 | var fallback = subscribe_button.innerHTML; 16 | subscribe_button.onclick = unsubscribe; 17 | subscribe_button.innerHTML = '' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + ''; 18 | 19 | var url = '/subscription_ajax?action=create_subscription_to_channel&redirect=false' + 20 | '&c=' + subscribe_data.ucid; 21 | 22 | helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, { 23 | onNon200: function (xhr) { 24 | subscribe_button.onclick = subscribe; 25 | subscribe_button.innerHTML = fallback; 26 | } 27 | }); 28 | } 29 | 30 | function unsubscribe() { 31 | var fallback = subscribe_button.innerHTML; 32 | subscribe_button.onclick = subscribe; 33 | subscribe_button.innerHTML = '' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + ''; 34 | 35 | var url = '/subscription_ajax?action=remove_subscriptions&redirect=false' + 36 | '&c=' + subscribe_data.ucid; 37 | 38 | helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, { 39 | onNon200: function (xhr) { 40 | subscribe_button.onclick = unsubscribe; 41 | subscribe_button.innerHTML = fallback; 42 | } 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /assets/js/themes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var toggle_theme = document.getElementById('toggle_theme'); 3 | toggle_theme.href = 'javascript:void(0)'; 4 | 5 | const STORAGE_KEY_THEME = 'dark_mode'; 6 | const THEME_DARK = 'dark'; 7 | const THEME_LIGHT = 'light'; 8 | 9 | // TODO: theme state controlled by system 10 | toggle_theme.addEventListener('click', function () { 11 | const isDarkTheme = helpers.storage.get(STORAGE_KEY_THEME) === THEME_DARK; 12 | const newTheme = isDarkTheme ? THEME_LIGHT : THEME_DARK; 13 | setTheme(newTheme); 14 | helpers.storage.set(STORAGE_KEY_THEME, newTheme); 15 | helpers.xhr('GET', '/toggle_theme?redirect=false', {}, {}); 16 | }); 17 | 18 | /** @param {THEME_DARK|THEME_LIGHT} theme */ 19 | function setTheme(theme) { 20 | // By default body element has .no-theme class that uses OS theme via CSS @media rules 21 | // It rewrites using hard className below 22 | if (theme === THEME_DARK) { 23 | toggle_theme.children[0].className = 'icon ion-ios-sunny'; 24 | document.body.className = 'dark-theme'; 25 | } else if (theme === THEME_LIGHT) { 26 | toggle_theme.children[0].className = 'icon ion-ios-moon'; 27 | document.body.className = 'light-theme'; 28 | } else { 29 | document.body.className = 'no-theme'; 30 | } 31 | } 32 | 33 | // Handles theme change event caused by other tab 34 | addEventListener('storage', function (e) { 35 | if (e.key === STORAGE_KEY_THEME) 36 | setTheme(helpers.storage.get(STORAGE_KEY_THEME)); 37 | }); 38 | 39 | // Set theme from preferences on page load 40 | addEventListener('DOMContentLoaded', function () { 41 | const prefTheme = document.getElementById('dark_mode_pref').textContent; 42 | if (prefTheme) { 43 | setTheme(prefTheme); 44 | helpers.storage.set(STORAGE_KEY_THEME, prefTheme); 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /assets/js/watched_indicator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var save_player_pos_key = 'save_player_pos'; 3 | 4 | function get_all_video_times() { 5 | return helpers.storage.get(save_player_pos_key) || {}; 6 | } 7 | 8 | document.querySelectorAll('.watched-indicator').forEach(function (indicator) { 9 | var watched_part = get_all_video_times()[indicator.dataset.id]; 10 | var total = parseInt(indicator.dataset.length, 10); 11 | if (watched_part === undefined) { 12 | watched_part = total; 13 | } 14 | var percentage = Math.round((watched_part / total) * 100); 15 | 16 | if (percentage < 5) { 17 | percentage = 5; 18 | } 19 | if (percentage > 90) { 20 | percentage = 100; 21 | } 22 | 23 | indicator.style.width = percentage + '%'; 24 | }); 25 | -------------------------------------------------------------------------------- /assets/js/watched_widget.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var watched_data = JSON.parse(document.getElementById('watched_data').textContent); 3 | var payload = 'csrf_token=' + watched_data.csrf_token; 4 | 5 | function mark_watched(target) { 6 | var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; 7 | tile.style.display = 'none'; 8 | 9 | var url = '/watch_ajax?action=mark_watched&redirect=false' + 10 | '&id=' + target.getAttribute('data-id'); 11 | 12 | helpers.xhr('POST', url, {payload: payload}, { 13 | onNon200: function (xhr) { 14 | tile.style.display = ''; 15 | } 16 | }); 17 | } 18 | 19 | function mark_unwatched(target) { 20 | var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; 21 | tile.style.display = 'none'; 22 | var count = document.getElementById('count'); 23 | count.textContent--; 24 | 25 | var url = '/watch_ajax?action=mark_unwatched&redirect=false' + 26 | '&id=' + target.getAttribute('data-id'); 27 | 28 | helpers.xhr('POST', url, {payload: payload}, { 29 | onNon200: function (xhr) { 30 | count.textContent++; 31 | tile.style.display = ''; 32 | } 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /assets/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/assets/mstile-150x150.png -------------------------------------------------------------------------------- /assets/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /assets/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 22 | 24 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /assets/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Invidious", 3 | "short_name": "Invidious", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#575757", 17 | "background_color": "#575757", 18 | "display": "standalone", 19 | "description": "An alternative front-end to YouTube", 20 | "start_url": "/" 21 | } 22 | -------------------------------------------------------------------------------- /assets/videojs/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /config/migrate-scripts/migrate-db-17cf077.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal 4 | [ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious 5 | 6 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN subscribed bool;" 7 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET subscribed = false;" 8 | -------------------------------------------------------------------------------- /config/migrate-scripts/migrate-db-1c8075c.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal 4 | [ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious 5 | 6 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos DROP COLUMN live_now CASCADE" 7 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos DROP COLUMN premiere_timestamp CASCADE" 8 | 9 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN live_now bool" 10 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz" 11 | -------------------------------------------------------------------------------- /config/migrate-scripts/migrate-db-1eca969.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal 4 | [ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious 5 | 6 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN title CASCADE" 7 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN views CASCADE" 8 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN likes CASCADE" 9 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN dislikes CASCADE" 10 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN wilson_score CASCADE" 11 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN published CASCADE" 12 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN description CASCADE" 13 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN language CASCADE" 14 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN author CASCADE" 15 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN ucid CASCADE" 16 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN allowed_regions CASCADE" 17 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN is_family_friendly CASCADE" 18 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN genre CASCADE" 19 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN genre_url CASCADE" 20 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN license CASCADE" 21 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN sub_count_text CASCADE" 22 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE videos DROP COLUMN author_thumbnail CASCADE" 23 | -------------------------------------------------------------------------------- /config/migrate-scripts/migrate-db-30e6d29.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal 4 | [ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious 5 | 6 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN deleted bool;" 7 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET deleted = false;" 8 | -------------------------------------------------------------------------------- /config/migrate-scripts/migrate-db-3646395.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal 4 | [ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious 5 | 6 | psql "$POSTGRES_DB" "$POSTGRES_USER" < config/sql/session_ids.sql 7 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "INSERT INTO session_ids (SELECT unnest(id), email, CURRENT_TIMESTAMP FROM users) ON CONFLICT (id) DO NOTHING" 8 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE users DROP COLUMN id" 9 | -------------------------------------------------------------------------------- /config/migrate-scripts/migrate-db-3bcb98e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal 4 | [ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious 5 | 6 | psql "$POSTGRES_DB" "$POSTGRES_USER" < config/sql/annotations.sql 7 | -------------------------------------------------------------------------------- /config/migrate-scripts/migrate-db-52cb239.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal 4 | [ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious 5 | 6 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN views bigint;" 7 | -------------------------------------------------------------------------------- /config/migrate-scripts/migrate-db-6e51189.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal 4 | [ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious 5 | 6 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN live_now bool;" 7 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channel_videos SET live_now = false;" 8 | -------------------------------------------------------------------------------- /config/migrate-scripts/migrate-db-701b5ea.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal 4 | [ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious 5 | 6 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE users ADD COLUMN feed_needs_update boolean" 7 | -------------------------------------------------------------------------------- /config/migrate-scripts/migrate-db-88b7097.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal 4 | [ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious 5 | 6 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channel_videos ADD COLUMN premiere_timestamp timestamptz;" 7 | -------------------------------------------------------------------------------- /config/migrate-scripts/migrate-db-8e884fe.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -z "$POSTGRES_USER" ] && POSTGRES_USER=kemal 4 | [ -z "$POSTGRES_DB" ] && POSTGRES_DB=invidious 5 | 6 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels DROP COLUMN subscribed" 7 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "ALTER TABLE channels ADD COLUMN subscribed timestamptz" 8 | psql "$POSTGRES_DB" "$POSTGRES_USER" -c "UPDATE channels SET subscribed = '2019-01-01 00:00:00+00'" 9 | -------------------------------------------------------------------------------- /config/sql/annotations.sql: -------------------------------------------------------------------------------- 1 | -- Table: public.annotations 2 | 3 | -- DROP TABLE public.annotations; 4 | 5 | CREATE TABLE IF NOT EXISTS public.annotations 6 | ( 7 | id text NOT NULL, 8 | annotations xml, 9 | CONSTRAINT annotations_id_key UNIQUE (id) 10 | ); 11 | 12 | GRANT ALL ON TABLE public.annotations TO current_user; 13 | -------------------------------------------------------------------------------- /config/sql/channel_videos.sql: -------------------------------------------------------------------------------- 1 | -- Table: public.channel_videos 2 | 3 | -- DROP TABLE public.channel_videos; 4 | 5 | CREATE TABLE IF NOT EXISTS public.channel_videos 6 | ( 7 | id text NOT NULL, 8 | title text, 9 | published timestamp with time zone, 10 | updated timestamp with time zone, 11 | ucid text, 12 | author text, 13 | length_seconds integer, 14 | live_now boolean, 15 | premiere_timestamp timestamp with time zone, 16 | views bigint, 17 | CONSTRAINT channel_videos_id_key UNIQUE (id) 18 | ); 19 | 20 | GRANT ALL ON TABLE public.channel_videos TO current_user; 21 | 22 | -- Index: public.channel_videos_ucid_idx 23 | 24 | -- DROP INDEX public.channel_videos_ucid_idx; 25 | 26 | CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx 27 | ON public.channel_videos 28 | USING btree 29 | (ucid COLLATE pg_catalog."default"); 30 | 31 | -------------------------------------------------------------------------------- /config/sql/channels.sql: -------------------------------------------------------------------------------- 1 | -- Table: public.channels 2 | 3 | -- DROP TABLE public.channels; 4 | 5 | CREATE TABLE IF NOT EXISTS public.channels 6 | ( 7 | id text NOT NULL, 8 | author text, 9 | updated timestamp with time zone, 10 | deleted boolean, 11 | subscribed timestamp with time zone, 12 | CONSTRAINT channels_id_key UNIQUE (id) 13 | ); 14 | 15 | GRANT ALL ON TABLE public.channels TO current_user; 16 | 17 | -- Index: public.channels_id_idx 18 | 19 | -- DROP INDEX public.channels_id_idx; 20 | 21 | CREATE INDEX IF NOT EXISTS channels_id_idx 22 | ON public.channels 23 | USING btree 24 | (id COLLATE pg_catalog."default"); 25 | 26 | -------------------------------------------------------------------------------- /config/sql/nonces.sql: -------------------------------------------------------------------------------- 1 | -- Table: public.nonces 2 | 3 | -- DROP TABLE public.nonces; 4 | 5 | CREATE TABLE IF NOT EXISTS public.nonces 6 | ( 7 | nonce text, 8 | expire timestamp with time zone, 9 | CONSTRAINT nonces_id_key UNIQUE (nonce) 10 | ); 11 | 12 | GRANT ALL ON TABLE public.nonces TO current_user; 13 | 14 | -- Index: public.nonces_nonce_idx 15 | 16 | -- DROP INDEX public.nonces_nonce_idx; 17 | 18 | CREATE INDEX IF NOT EXISTS nonces_nonce_idx 19 | ON public.nonces 20 | USING btree 21 | (nonce COLLATE pg_catalog."default"); 22 | 23 | -------------------------------------------------------------------------------- /config/sql/playlist_videos.sql: -------------------------------------------------------------------------------- 1 | -- Table: public.playlist_videos 2 | 3 | -- DROP TABLE public.playlist_videos; 4 | 5 | CREATE TABLE IF NOT EXISTS public.playlist_videos 6 | ( 7 | title text, 8 | id text, 9 | author text, 10 | ucid text, 11 | length_seconds integer, 12 | published timestamptz, 13 | plid text references playlists(id), 14 | index int8, 15 | live_now boolean, 16 | PRIMARY KEY (index,plid) 17 | ); 18 | 19 | GRANT ALL ON TABLE public.playlist_videos TO current_user; 20 | -------------------------------------------------------------------------------- /config/sql/playlists.sql: -------------------------------------------------------------------------------- 1 | -- Type: public.privacy 2 | 3 | -- DROP TYPE public.privacy; 4 | 5 | CREATE TYPE public.privacy AS ENUM 6 | ( 7 | 'Public', 8 | 'Unlisted', 9 | 'Private' 10 | ); 11 | 12 | -- Table: public.playlists 13 | 14 | -- DROP TABLE public.playlists; 15 | 16 | CREATE TABLE IF NOT EXISTS public.playlists 17 | ( 18 | title text, 19 | id text primary key, 20 | author text, 21 | description text, 22 | video_count integer, 23 | created timestamptz, 24 | updated timestamptz, 25 | privacy privacy, 26 | index int8[] 27 | ); 28 | 29 | GRANT ALL ON public.playlists TO current_user; 30 | -------------------------------------------------------------------------------- /config/sql/session_ids.sql: -------------------------------------------------------------------------------- 1 | -- Table: public.session_ids 2 | 3 | -- DROP TABLE public.session_ids; 4 | 5 | CREATE TABLE IF NOT EXISTS public.session_ids 6 | ( 7 | id text NOT NULL, 8 | email text, 9 | issued timestamp with time zone, 10 | CONSTRAINT session_ids_pkey PRIMARY KEY (id) 11 | ); 12 | 13 | GRANT ALL ON TABLE public.session_ids TO current_user; 14 | 15 | -- Index: public.session_ids_id_idx 16 | 17 | -- DROP INDEX public.session_ids_id_idx; 18 | 19 | CREATE INDEX IF NOT EXISTS session_ids_id_idx 20 | ON public.session_ids 21 | USING btree 22 | (id COLLATE pg_catalog."default"); 23 | 24 | -------------------------------------------------------------------------------- /config/sql/users.sql: -------------------------------------------------------------------------------- 1 | -- Table: public.users 2 | 3 | -- DROP TABLE public.users; 4 | 5 | CREATE TABLE IF NOT EXISTS public.users 6 | ( 7 | updated timestamp with time zone, 8 | notifications text[], 9 | subscriptions text[], 10 | email text NOT NULL, 11 | preferences text, 12 | password text, 13 | token text, 14 | watched text[], 15 | feed_needs_update boolean, 16 | CONSTRAINT users_email_key UNIQUE (email) 17 | ); 18 | 19 | GRANT ALL ON TABLE public.users TO current_user; 20 | 21 | -- Index: public.email_unique_idx 22 | 23 | -- DROP INDEX public.email_unique_idx; 24 | 25 | CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx 26 | ON public.users 27 | USING btree 28 | (lower(email) COLLATE pg_catalog."default"); 29 | 30 | -------------------------------------------------------------------------------- /config/sql/videos.sql: -------------------------------------------------------------------------------- 1 | -- Table: public.videos 2 | 3 | -- DROP TABLE public.videos; 4 | 5 | CREATE UNLOGGED TABLE IF NOT EXISTS public.videos 6 | ( 7 | id text NOT NULL, 8 | info text, 9 | updated timestamp with time zone, 10 | CONSTRAINT videos_pkey PRIMARY KEY (id) 11 | ); 12 | 13 | GRANT ALL ON TABLE public.videos TO current_user; 14 | 15 | -- Index: public.id_idx 16 | 17 | -- DROP INDEX public.id_idx; 18 | 19 | CREATE UNIQUE INDEX IF NOT EXISTS id_idx 20 | ON public.videos 21 | USING btree 22 | (id COLLATE pg_catalog."default"); 23 | 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Warning: This docker-compose file is made for development purposes. 2 | # Using it will build an image from the locally cloned repository. 3 | # 4 | # If you want to use Invidious in production, see the docker-compose.yml file provided 5 | # in the installation documentation: https://docs.invidious.io/installation/ 6 | 7 | version: "3" 8 | services: 9 | 10 | invidious: 11 | build: 12 | context: . 13 | dockerfile: docker/Dockerfile 14 | restart: unless-stopped 15 | ports: 16 | - "127.0.0.1:3000:3000" 17 | environment: 18 | # Please read the following file for a comprehensive list of all available 19 | # configuration options and their associated syntax: 20 | # https://github.com/iv-org/invidious/blob/master/config/config.example.yml 21 | INVIDIOUS_CONFIG: | 22 | db: 23 | dbname: invidious 24 | user: kemal 25 | password: kemal 26 | host: invidious-db 27 | port: 5432 28 | check_tables: true 29 | # external_port: 30 | # domain: 31 | # https_only: false 32 | # statistics_enabled: false 33 | hmac_key: "CHANGE_ME!!" 34 | healthcheck: 35 | test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/trending || exit 1 36 | interval: 30s 37 | timeout: 5s 38 | retries: 2 39 | 40 | invidious-db: 41 | image: docker.io/library/postgres:14 42 | restart: unless-stopped 43 | volumes: 44 | - postgresdata:/var/lib/postgresql/data 45 | - ./config/sql:/config/sql 46 | - ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh 47 | environment: 48 | POSTGRES_DB: invidious 49 | POSTGRES_USER: kemal 50 | POSTGRES_PASSWORD: kemal 51 | healthcheck: 52 | test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] 53 | 54 | volumes: 55 | postgresdata: 56 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM crystallang/crystal:1.16.3-alpine AS builder 2 | 3 | RUN apk add --no-cache sqlite-static yaml-static 4 | 5 | ARG release 6 | 7 | WORKDIR /invidious 8 | COPY ./shard.yml ./shard.yml 9 | COPY ./shard.lock ./shard.lock 10 | RUN shards install --production 11 | 12 | COPY ./src/ ./src/ 13 | # TODO: .git folder is required for building – this is destructive. 14 | # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. 15 | COPY ./.git/ ./.git/ 16 | 17 | # Required for fetching player dependencies 18 | COPY ./scripts/ ./scripts/ 19 | COPY ./assets/ ./assets/ 20 | COPY ./videojs-dependencies.yml ./videojs-dependencies.yml 21 | 22 | RUN crystal spec --warnings all \ 23 | --link-flags "-lxml2 -llzma" 24 | RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \ 25 | crystal build ./src/invidious.cr \ 26 | --release \ 27 | --static --warnings all \ 28 | --link-flags "-lxml2 -llzma"; \ 29 | else \ 30 | crystal build ./src/invidious.cr \ 31 | --static --warnings all \ 32 | --link-flags "-lxml2 -llzma"; \ 33 | fi 34 | 35 | FROM alpine:3.21 36 | RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata 37 | WORKDIR /invidious 38 | RUN addgroup -g 1000 -S invidious && \ 39 | adduser -u 1000 -S invidious -G invidious 40 | COPY --chown=invidious ./config/config.* ./config/ 41 | RUN mv -n config/config.example.yml config/config.yml 42 | RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: invidious-db/' config/config.yml 43 | COPY ./config/sql/ ./config/sql/ 44 | COPY ./locales/ ./locales/ 45 | COPY --from=builder /invidious/assets ./assets/ 46 | COPY --from=builder /invidious/invidious . 47 | RUN chmod o+rX -R ./assets ./config ./locales 48 | 49 | EXPOSE 3000 50 | USER invidious 51 | ENTRYPOINT ["/sbin/tini", "--"] 52 | CMD [ "/invidious/invidious" ] 53 | -------------------------------------------------------------------------------- /docker/Dockerfile.arm64: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 AS builder 2 | RUN apk add --no-cache 'crystal=1.14.0-r0' shards sqlite-static yaml-static yaml-dev libxml2-static \ 3 | zlib-static openssl-libs-static openssl-dev musl-dev xz-static 4 | 5 | ARG release 6 | 7 | WORKDIR /invidious 8 | COPY ./shard.yml ./shard.yml 9 | COPY ./shard.lock ./shard.lock 10 | RUN shards install --production 11 | 12 | COPY ./src/ ./src/ 13 | # TODO: .git folder is required for building – this is destructive. 14 | # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. 15 | COPY ./.git/ ./.git/ 16 | 17 | # Required for fetching player dependencies 18 | COPY ./scripts/ ./scripts/ 19 | COPY ./assets/ ./assets/ 20 | COPY ./videojs-dependencies.yml ./videojs-dependencies.yml 21 | 22 | RUN crystal spec --warnings all \ 23 | --link-flags "-lxml2 -llzma" 24 | 25 | RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \ 26 | crystal build ./src/invidious.cr \ 27 | --release \ 28 | --static --warnings all \ 29 | --link-flags "-lxml2 -llzma"; \ 30 | else \ 31 | crystal build ./src/invidious.cr \ 32 | --static --warnings all \ 33 | --link-flags "-lxml2 -llzma"; \ 34 | fi 35 | 36 | FROM alpine:3.21 37 | RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata 38 | WORKDIR /invidious 39 | RUN addgroup -g 1000 -S invidious && \ 40 | adduser -u 1000 -S invidious -G invidious 41 | COPY --chown=invidious ./config/config.* ./config/ 42 | RUN mv -n config/config.example.yml config/config.yml 43 | RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: invidious-db/' config/config.yml 44 | COPY ./config/sql/ ./config/sql/ 45 | COPY ./locales/ ./locales/ 46 | COPY --from=builder /invidious/assets ./assets/ 47 | COPY --from=builder /invidious/invidious . 48 | RUN chmod o+rX -R ./assets ./config ./locales 49 | 50 | EXPOSE 3000 51 | USER invidious 52 | ENTRYPOINT ["/sbin/tini", "--"] 53 | CMD [ "/invidious/invidious" ] 54 | -------------------------------------------------------------------------------- /docker/init-invidious-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eou pipefail 3 | 4 | psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channels.sql 5 | psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/videos.sql 6 | psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/channel_videos.sql 7 | psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/users.sql 8 | psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/session_ids.sql 9 | psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/nonces.sql 10 | psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/annotations.sql 11 | psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlists.sql 12 | psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < config/sql/playlist_videos.sql 13 | -------------------------------------------------------------------------------- /invidious.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Invidious (An alternative YouTube front-end) 3 | After=syslog.target 4 | After=network.target 5 | 6 | [Service] 7 | RestartSec=2s 8 | Type=simple 9 | 10 | User=invidious 11 | Group=invidious 12 | 13 | WorkingDirectory=/home/invidious/invidious 14 | ExecStart=/home/invidious/invidious/invidious -o invidious.log 15 | 16 | Restart=always 17 | 18 | [Install] 19 | WantedBy=multi-user.target 20 | -------------------------------------------------------------------------------- /kubernetes/README.md: -------------------------------------------------------------------------------- 1 | The Helm chart has moved to a dedicated GitHub repository: https://github.com/iv-org/invidious-helm-chart/tree/master/invidious -------------------------------------------------------------------------------- /locales/af.json: -------------------------------------------------------------------------------- 1 | { 2 | "generic_views_count": "{{count}} kyk", 3 | "generic_views_count_plural": "{{count}} kyke", 4 | "generic_videos_count": "{{count}} video", 5 | "generic_videos_count_plural": "{{count}} videos", 6 | "generic_playlists_count": "{{count}} snitlys", 7 | "generic_playlists_count_plural": "{{count}} snitlyste", 8 | "generic_subscriptions_count": "{{count}} intekening", 9 | "generic_subscriptions_count_plural": "{{count}} intekeninge", 10 | "LIVE": "LEWENDIG", 11 | "generic_subscribers_count": "{{count}} intekenaar", 12 | "generic_subscribers_count_plural": "{{count}} intekenare", 13 | "Shared `x` ago": "`x` gelede gedeel", 14 | "New passwords must match": "Nuwe wagwoord moet ooreenstem" 15 | } 16 | -------------------------------------------------------------------------------- /locales/az.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /locales/be.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /locales/bn_BD.json: -------------------------------------------------------------------------------- 1 | { 2 | "LIVE": "লাইভ", 3 | "Shared `x` ago": "`x` আগে শেয়ার করা হয়েছে", 4 | "Unsubscribe": "আনসাবস্ক্রাইব", 5 | "Subscribe": "সাবস্ক্রাইব", 6 | "View channel on YouTube": "ইউটিউবে চ্যানেল দেখুন", 7 | "View playlist on YouTube": "ইউটিউবে প্লেলিস্ট দেখুন", 8 | "newest": "সর্ব-নতুন", 9 | "oldest": "পুরানতম", 10 | "popular": "জনপ্রিয়", 11 | "last": "শেষটা", 12 | "Next page": "পরের পৃষ্ঠা", 13 | "Previous page": "আগের পৃষ্ঠা", 14 | "Clear watch history?": "দেখার ইতিহাস সাফ করবেন?", 15 | "New password": "নতুন পাসওয়ার্ড", 16 | "New passwords must match": "নতুন পাসওয়ার্ড অবশ্যই মিলতে হবে", 17 | "Authorize token?": "টোকেন অনুমোদন করবেন?", 18 | "Authorize token for `x`?": "`x` -এর জন্য টোকেন অনুমোদন?", 19 | "Yes": "হ্যাঁ", 20 | "No": "না", 21 | "Import and Export Data": "তথ্য আমদানি ও রপ্তানি", 22 | "Import": "আমদানি", 23 | "Import Invidious data": "ইনভিডিয়াস তথ্য আমদানি", 24 | "Import YouTube subscriptions": "ইউটিউব সাবস্ক্রিপশন আনুন", 25 | "Import FreeTube subscriptions (.db)": "ফ্রিটিউব সাবস্ক্রিপশন (.db) আনুন", 26 | "Import NewPipe subscriptions (.json)": "নতুন পাইপ সাবস্ক্রিপশন আনুন (.json)", 27 | "Import NewPipe data (.zip)": "নিউপাইপ তথ্য আনুন (.zip)", 28 | "Export": "তথ্য বের করুন", 29 | "Export subscriptions as OPML": "সাবস্ক্রিপশন OPML হিসাবে আনুন", 30 | "Export subscriptions as OPML (for NewPipe & FreeTube)": "OPML-এ সাবস্ক্রিপশন বের করুন(নিউ পাইপ এবং ফ্রিউটিউব এর জন্য)", 31 | "Export data as JSON": "JSON হিসাবে তথ্য বের করুন", 32 | "Delete account?": "অ্যাকাউন্ট মুছে ফেলবেন?", 33 | "History": "ইতিহাস", 34 | "An alternative front-end to YouTube": "ইউটিউবের একটি বিকল্পস্বরূপ সম্মুখ-প্রান্ত", 35 | "JavaScript license information": "জাভাস্ক্রিপ্ট লাইসেন্সের তথ্য", 36 | "source": "সূত্র", 37 | "Log in": "লগ ইন", 38 | "Log in/register": "লগ ইন/রেজিস্টার", 39 | "User ID": "ইউজার আইডি", 40 | "Password": "পাসওয়ার্ড", 41 | "Time (h:mm:ss):": "সময় (ঘণ্টা:মিনিট:সেকেন্ড):", 42 | "Text CAPTCHA": "টেক্সট ক্যাপচা", 43 | "Image CAPTCHA": "চিত্র ক্যাপচা", 44 | "Sign In": "সাইন ইন", 45 | "Register": "নিবন্ধন", 46 | "E-mail": "ই-মেইল", 47 | "Preferences": "পছন্দসমূহ", 48 | "preferences_category_player": "প্লেয়ারের পছন্দসমূহ", 49 | "preferences_video_loop_label": "সর্বদা লুপ: ", 50 | "preferences_autoplay_label": "স্বয়ংক্রিয় চালু: ", 51 | "preferences_continue_label": "ডিফল্টভাবে পরবর্তী চালাও: ", 52 | "preferences_continue_autoplay_label": "পরবর্তী ভিডিও স্বয়ংক্রিয়ভাবে চালাও: ", 53 | "preferences_listen_label": "সহজাতভাবে শোনো: ", 54 | "preferences_local_label": "ভিডিও প্রক্সি করো: ", 55 | "preferences_speed_label": "সহজাত গতি: ", 56 | "preferences_quality_label": "পছন্দের ভিডিও মান: ", 57 | "preferences_volume_label": "প্লেয়ার শব্দের মাত্রা: " 58 | } 59 | -------------------------------------------------------------------------------- /locales/ia.json: -------------------------------------------------------------------------------- 1 | { 2 | "New password": "Nove contrasigno", 3 | "preferences_player_style_label": "Stylo de reproductor: ", 4 | "preferences_region_label": "Pais de contento: ", 5 | "oldest": "plus ancian", 6 | "published": "data de publication", 7 | "invidious": "Invidious", 8 | "Image CAPTCHA": "Imagine CAPTCHA", 9 | "newest": "plus nove", 10 | "generic_button_save": "Salveguardar", 11 | "Dark mode: ": "Modo obscur: ", 12 | "preferences_dark_mode_label": "Thema: ", 13 | "preferences_category_subscription": "Preferentias de subscription", 14 | "last": "ultime", 15 | "generic_button_cancel": "Cancellar", 16 | "popular": "popular", 17 | "Time (h:mm:ss):": "Tempore (h:mm:ss):", 18 | "preferences_autoplay_label": "Reproduction automatic: ", 19 | "Sign In": "Aperir le session", 20 | "Log in": "Initiar le session", 21 | "preferences_speed_label": "Velocitate per predefinition: ", 22 | "preferences_comments_label": "Commentos predefinite: ", 23 | "light": "clar", 24 | "No": "Non", 25 | "youtube": "YouTube", 26 | "LIVE": "IN DIRECTO", 27 | "reddit": "Reddit", 28 | "preferences_category_player": "Preferentias de reproductor", 29 | "Preferences": "Preferentias", 30 | "preferences_quality_dash_option_auto": "Automatic", 31 | "dark": "obscur", 32 | "generic_button_rss": "RSS", 33 | "Export": "Exportar", 34 | "History": "Chronologia", 35 | "Password": "Contrasigno", 36 | "User ID": "ID de usator", 37 | "E-mail": "E-mail", 38 | "Delete account?": "Deler conto?", 39 | "preferences_volume_label": "Volumine del reproductor: ", 40 | "preferences_sort_label": "Ordinar le videos per: ", 41 | "Next page": "Pagina sequente", 42 | "Previous page": "Pagina previe", 43 | "Yes": "Si", 44 | "Import": "Importar" 45 | } 46 | -------------------------------------------------------------------------------- /locales/or.json: -------------------------------------------------------------------------------- 1 | { 2 | "preferences_quality_dash_option_720p": "୭୨୦ପି", 3 | "preferences_quality_dash_option_4320p": "୪୩୨୦ପି", 4 | "preferences_quality_dash_option_240p": "୨୪୦ପି", 5 | "preferences_quality_dash_option_2160p": "୨୧୬୦ପି", 6 | "preferences_quality_dash_option_144p": "୧୪୪ପି", 7 | "reddit": "Reddit", 8 | "preferences_quality_dash_option_480p": "୪୮୦ପି", 9 | "preferences_dark_mode_label": "ଥିମ୍: ", 10 | "dark": "ଗାଢ଼", 11 | "published": "ପ୍ରକାଶିତ", 12 | "generic_videos_count": "{{count}}ଟିଏ ଵିଡ଼ିଓ", 13 | "generic_videos_count_plural": "{{count}}ଟି ଵିଡ଼ିଓ", 14 | "generic_button_edit": "ସମ୍ପାଦନା", 15 | "light": "ହାଲୁକା", 16 | "last": "ଗତ", 17 | "New password": "ନୂଆ ପାସ୍‌ୱର୍ଡ଼", 18 | "preferences_quality_dash_option_1440p": "୧୪୪୦ପି", 19 | "preferences_quality_dash_option_360p": "୩୬୦ପି", 20 | "preferences_quality_option_medium": "ମଧ୍ୟମ", 21 | "preferences_quality_dash_option_1080p": "୧୦୮୦ପି", 22 | "youtube": "YouTube", 23 | "preferences_quality_option_hd720": "HD୭୨୦", 24 | "invidious": "Invidious", 25 | "generic_playlists_count": "{{count}}ଟିଏ ଚାଳନାତାଲିକା", 26 | "generic_playlists_count_plural": "{{count}}ଟି ଚାଳନାତାଲିକା", 27 | "Yes": "ହଁ", 28 | "No": "ନାହିଁ" 29 | } 30 | -------------------------------------------------------------------------------- /locales/tk.json: -------------------------------------------------------------------------------- 1 | { 2 | "Add to playlist": "Aýdym sanawyna goş", 3 | "Add to playlist: ": "Pleýliste goş: ", 4 | "Answer": "Jogap", 5 | "Search for videos": "Wideo gözläň", 6 | "The Popular feed has been disabled by the administrator.": "Trende bolan administrator tarapyndan ýapyldy." 7 | } 8 | -------------------------------------------------------------------------------- /locales/tok.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /screenshots/01_player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/screenshots/01_player.png -------------------------------------------------------------------------------- /screenshots/02_preferences.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/screenshots/02_preferences.png -------------------------------------------------------------------------------- /screenshots/03_subscriptions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/screenshots/03_subscriptions.png -------------------------------------------------------------------------------- /screenshots/04_description.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/screenshots/04_description.png -------------------------------------------------------------------------------- /screenshots/05_preferences.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/screenshots/05_preferences.png -------------------------------------------------------------------------------- /screenshots/06_subscriptions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/screenshots/06_subscriptions.png -------------------------------------------------------------------------------- /screenshots/native_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iv-org/invidious/df8839d1f018644afecb15e144f228d811708f8f/screenshots/native_notification.png -------------------------------------------------------------------------------- /scripts/deploy-database.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Parameters 5 | # 6 | 7 | interactive=true 8 | 9 | if [ "$1" = "--no-interactive" ]; then 10 | interactive=false 11 | fi 12 | 13 | # 14 | # Enable and start Postgres 15 | # 16 | 17 | sudo systemctl start postgresql.service 18 | sudo systemctl enable postgresql.service 19 | 20 | # 21 | # Create databse and user 22 | # 23 | 24 | if [ "$interactive" = "true" ]; then 25 | sudo -u postgres -- createuser -P kemal 26 | sudo -u postgres -- createdb -O kemal invidious 27 | else 28 | # Generate a DB password 29 | if [ -z "$POSTGRES_PASS" ]; then 30 | echo "Generating database password" 31 | POSTGRES_PASS=$(tr -dc 'A-Za-z0-9.;!?{[()]}\\/' < /dev/urandom | head -c16) 32 | fi 33 | 34 | # hostname:port:database:username:password 35 | echo "Writing .pgpass" 36 | echo "127.0.0.1:*:invidious:kemal:${POSTGRES_PASS}" > "$HOME/.pgpass" 37 | 38 | sudo -u postgres -- psql -c "CREATE USER kemal WITH PASSWORD '$POSTGRES_PASS';" 39 | sudo -u postgres -- psql -c "CREATE DATABASE invidious WITH OWNER kemal;" 40 | sudo -u postgres -- psql -c "GRANT ALL ON DATABASE invidious TO kemal;" 41 | fi 42 | 43 | 44 | # 45 | # Instructions for modification of pg_hba.conf 46 | # 47 | 48 | if [ "$interactive" = "true" ]; then 49 | echo 50 | echo "-------------" 51 | echo " NOTICE " 52 | echo "-------------" 53 | echo 54 | echo "Make sure that your postgreSQL's pg_hba.conf file contains the follwong" 55 | echo "lines before previous 'host' configurations:" 56 | echo 57 | echo "host invidious kemal 127.0.0.1/32 md5" 58 | echo "host invidious kemal ::1/128 md5" 59 | echo 60 | fi 61 | -------------------------------------------------------------------------------- /scripts/generate_js_licenses.cr: -------------------------------------------------------------------------------- 1 | # This file automatically generates Crystal strings of rows within an HTML Javascript licenses table 2 | # 3 | # These strings will then be placed within a `<%= %>` statement in licenses.ecr at compile time which 4 | # will be interpolated at run-time. This interpolation is only for the translation of the "source" string 5 | # so maybe we can just switch to a non-translated string to simplify the logic here. 6 | # 7 | # The Javascript Web Labels table defined at https://www.gnu.org/software/librejs/free-your-javascript.html#step3 8 | # for example just reiterates the name of the source file rather than use a "source" string. 9 | all_javascript_files = Dir.glob("assets/**/*.js") 10 | 11 | videojs_js = [] of String 12 | invidious_js = [] of String 13 | 14 | all_javascript_files.each do |js_path| 15 | if js_path.starts_with?("assets/videojs/") 16 | videojs_js << js_path[7..] 17 | else 18 | invidious_js << js_path[7..] 19 | end 20 | end 21 | 22 | def create_licence_tr(path, file_name, licence_name, licence_link, source_location) 23 | tr = <<-HTML 24 | " 25 | #{file_name} 26 | #{licence_name} 27 | \#{translate(locale, "source")} 28 | " 29 | HTML 30 | 31 | # New lines are removed as to allow for using String.join and StringLiteral.split 32 | # to get a clean list of each table row. 33 | tr.gsub('\n', "") 34 | end 35 | 36 | # TODO Use videojs-dependencies.yml to generate license info for videojs javascript 37 | jslicence_table_rows = [] of String 38 | 39 | invidious_js.each do |path| 40 | file_name = path.split('/')[-1] 41 | 42 | # A couple non Invidious JS files are also shipped alongside Invidious due to various reasons 43 | next if { 44 | "sse.js", "silvermine-videojs-quality-selector.min.js", "videojs-youtube-annotations.min.js", 45 | }.includes?(file_name) 46 | 47 | jslicence_table_rows << create_licence_tr( 48 | path: path, 49 | file_name: file_name, 50 | licence_name: "AGPL-3.0", 51 | licence_link: "https://www.gnu.org/licenses/agpl-3.0.html", 52 | source_location: path 53 | ) 54 | end 55 | 56 | puts jslicence_table_rows.join("\n") 57 | -------------------------------------------------------------------------------- /scripts/git/pre-commit: -------------------------------------------------------------------------------- 1 | # Useful precomit hooks 2 | # Please see https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks for instructions on installation. 3 | 4 | # Crystal linter 5 | # 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 6 | # Please refer to that if you'd like an version that doesn't automatically format staged files. 7 | changed_cr_files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.cr$') 8 | if [ ! -z "$changed_cr_files" ]; then 9 | if [ -x bin/crystal ]; then 10 | # use bin/crystal wrapper when available to run local compiler build 11 | bin/crystal tool format $changed_cr_files >&2 12 | else 13 | crystal tool format $changed_cr_files >&2 14 | fi 15 | 16 | git add $changed_cr_files 17 | fi 18 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | ameba: 4 | git: https://github.com/crystal-ameba/ameba.git 5 | version: 1.6.1 6 | 7 | athena-negotiation: 8 | git: https://github.com/athena-framework/negotiation.git 9 | version: 0.1.1 10 | 11 | backtracer: 12 | git: https://github.com/sija/backtracer.cr.git 13 | version: 1.2.2 14 | 15 | db: 16 | git: https://github.com/crystal-lang/crystal-db.git 17 | version: 0.13.1 18 | 19 | exception_page: 20 | git: https://github.com/crystal-loot/exception_page.git 21 | version: 0.4.1 22 | 23 | http_proxy: 24 | git: https://github.com/mamantoha/http_proxy.git 25 | version: 0.10.3 26 | 27 | kemal: 28 | git: https://github.com/kemalcr/kemal.git 29 | version: 1.6.0 30 | 31 | pg: 32 | git: https://github.com/will/crystal-pg.git 33 | version: 0.28.0 34 | 35 | protodec: 36 | git: https://github.com/iv-org/protodec.git 37 | version: 0.1.5 38 | 39 | radix: 40 | git: https://github.com/luislavena/radix.git 41 | version: 0.4.1 42 | 43 | spectator: 44 | git: https://github.com/icy-arctic-fox/spectator.git 45 | version: 0.10.6 46 | 47 | sqlite3: 48 | git: https://github.com/crystal-lang/crystal-sqlite3.git 49 | version: 0.21.0 50 | 51 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: invidious 2 | version: 2.20250517.0-dev 3 | 4 | authors: 5 | - Invidious team 6 | - Contributors! 7 | 8 | description: | 9 | Invidious is an alternative front-end to YouTube 10 | 11 | dependencies: 12 | pg: 13 | github: will/crystal-pg 14 | version: ~> 0.28.0 15 | sqlite3: 16 | github: crystal-lang/crystal-sqlite3 17 | version: ~> 0.21.0 18 | kemal: 19 | github: kemalcr/kemal 20 | version: ~> 1.6.0 21 | protodec: 22 | github: iv-org/protodec 23 | version: ~> 0.1.5 24 | athena-negotiation: 25 | github: athena-framework/negotiation 26 | version: ~> 0.1.1 27 | http_proxy: 28 | github: mamantoha/http_proxy 29 | version: ~> 0.10.3 30 | 31 | development_dependencies: 32 | spectator: 33 | github: icy-arctic-fox/spectator 34 | version: ~> 0.10.4 35 | ameba: 36 | github: crystal-ameba/ameba 37 | version: ~> 1.6.1 38 | 39 | crystal: ">= 1.10.0, < 2.0.0" 40 | 41 | license: AGPLv3 42 | 43 | repository: https://github.com/iv-org/invidious 44 | homepage: https://invidious.io 45 | documentation: https://docs.invidious.io 46 | -------------------------------------------------------------------------------- /spec/helpers/vtt/builder_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper.cr" 2 | 3 | MockLines = ["Line 1", "Line 2"] 4 | MockLinesWithEscapableCharacter = ["", "&Line 2>", '\u200E' + "Line\u200F 3", "\u00A0Line 4"] 5 | 6 | Spectator.describe "WebVTT::Builder" do 7 | it "correctly builds a vtt file" do 8 | result = WebVTT.build do |vtt| 9 | 2.times do |i| 10 | vtt.cue( 11 | Time::Span.new(seconds: i), 12 | Time::Span.new(seconds: i + 1), 13 | MockLines[i] 14 | ) 15 | end 16 | end 17 | 18 | expect(result).to eq([ 19 | "WEBVTT", 20 | "", 21 | "00:00:00.000 --> 00:00:01.000", 22 | "Line 1", 23 | "", 24 | "00:00:01.000 --> 00:00:02.000", 25 | "Line 2", 26 | "", 27 | "", 28 | ].join('\n')) 29 | end 30 | 31 | it "correctly builds a vtt file with setting fields" do 32 | setting_fields = { 33 | "Kind" => "captions", 34 | "Language" => "en", 35 | } 36 | 37 | result = WebVTT.build(setting_fields) do |vtt| 38 | 2.times do |i| 39 | vtt.cue( 40 | Time::Span.new(seconds: i), 41 | Time::Span.new(seconds: i + 1), 42 | MockLines[i] 43 | ) 44 | end 45 | end 46 | 47 | expect(result).to eq([ 48 | "WEBVTT", 49 | "Kind: captions", 50 | "Language: en", 51 | "", 52 | "00:00:00.000 --> 00:00:01.000", 53 | "Line 1", 54 | "", 55 | "00:00:01.000 --> 00:00:02.000", 56 | "Line 2", 57 | "", 58 | "", 59 | ].join('\n')) 60 | end 61 | 62 | it "properly escapes characters" do 63 | result = WebVTT.build do |vtt| 64 | 4.times do |i| 65 | vtt.cue(Time::Span.new(seconds: i), Time::Span.new(seconds: i + 1), MockLinesWithEscapableCharacter[i]) 66 | end 67 | end 68 | 69 | expect(result).to eq([ 70 | "WEBVTT", 71 | "", 72 | "00:00:00.000 --> 00:00:01.000", 73 | "<Line 1>", 74 | "", 75 | "00:00:01.000 --> 00:00:02.000", 76 | "&Line 2>", 77 | "", 78 | "00:00:02.000 --> 00:00:03.000", 79 | "‎Line‏ 3", 80 | "", 81 | "00:00:03.000 --> 00:00:04.000", 82 | " Line 4", 83 | "", 84 | "", 85 | ].join('\n')) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/invidious/user/imports_spec.cr: -------------------------------------------------------------------------------- 1 | require "spectator" 2 | require "../../../src/invidious/user/imports" 3 | 4 | Spectator.configure do |config| 5 | config.fail_blank 6 | config.randomize 7 | end 8 | 9 | def csv_sample 10 | return <<-CSV 11 | Kanal-ID,Kanal-URL,Kanaltitel 12 | UC0hHW5Y08ggq-9kbrGgWj0A,http://www.youtube.com/channel/UC0hHW5Y08ggq-9kbrGgWj0A,Matias Marolla 13 | UC0vBXGSyV14uvJ4hECDOl0Q,http://www.youtube.com/channel/UC0vBXGSyV14uvJ4hECDOl0Q,Techquickie 14 | UC1sELGmy5jp5fQUugmuYlXQ,http://www.youtube.com/channel/UC1sELGmy5jp5fQUugmuYlXQ,Minecraft 15 | UC9kFnwdCRrX7oTjqKd6-tiQ,http://www.youtube.com/channel/UC9kFnwdCRrX7oTjqKd6-tiQ,LUMOX - Topic 16 | UCBa659QWEk1AI4Tg--mrJ2A,http://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A,Tom Scott 17 | UCGu6_XQ64rXPR6nuitMQE_A,http://www.youtube.com/channel/UCGu6_XQ64rXPR6nuitMQE_A,Callcenter Fun 18 | UCGwu0nbY2wSkW8N-cghnLpA,http://www.youtube.com/channel/UCGwu0nbY2wSkW8N-cghnLpA,Jaiden Animations 19 | UCQ0OvZ54pCFZwsKxbltg_tg,http://www.youtube.com/channel/UCQ0OvZ54pCFZwsKxbltg_tg,Methos 20 | UCRE6itj4Jte4manQEu3Y7OA,http://www.youtube.com/channel/UCRE6itj4Jte4manQEu3Y7OA,Chipflake 21 | UCRLc6zsv_d0OEBO8OOkz-DA,http://www.youtube.com/channel/UCRLc6zsv_d0OEBO8OOkz-DA,Kegy 22 | UCSl5Uxu2LyaoAoMMGp6oTJA,http://www.youtube.com/channel/UCSl5Uxu2LyaoAoMMGp6oTJA,Atomic Shrimp 23 | UCXuqSBlHAE6Xw-yeJA0Tunw,http://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw,Linus Tech Tips 24 | UCZ5XnGb-3t7jCkXdawN2tkA,http://www.youtube.com/channel/UCZ5XnGb-3t7jCkXdawN2tkA,Discord 25 | CSV 26 | end 27 | 28 | Spectator.describe Invidious::User::Import do 29 | it "imports CSV" do 30 | subscriptions = Invidious::User::Import.parse_subscription_export_csv(csv_sample) 31 | 32 | expect(subscriptions).to be_an(Array(String)) 33 | expect(subscriptions.size).to eq(13) 34 | 35 | expect(subscriptions).to contain_exactly( 36 | "UC0hHW5Y08ggq-9kbrGgWj0A", 37 | "UC0vBXGSyV14uvJ4hECDOl0Q", 38 | "UC1sELGmy5jp5fQUugmuYlXQ", 39 | "UC9kFnwdCRrX7oTjqKd6-tiQ", 40 | "UCBa659QWEk1AI4Tg--mrJ2A", 41 | "UCGu6_XQ64rXPR6nuitMQE_A", 42 | "UCGwu0nbY2wSkW8N-cghnLpA", 43 | "UCQ0OvZ54pCFZwsKxbltg_tg", 44 | "UCRE6itj4Jte4manQEu3Y7OA", 45 | "UCRLc6zsv_d0OEBO8OOkz-DA", 46 | "UCSl5Uxu2LyaoAoMMGp6oTJA", 47 | "UCXuqSBlHAE6Xw-yeJA0Tunw", 48 | "UCZ5XnGb-3t7jCkXdawN2tkA", 49 | ).in_order 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/invidious/utils_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | Spectator.describe "Utils" do 4 | describe "decode_date" do 5 | it "parses short dates (en-US)" do 6 | expect(decode_date("1s ago")).to be_close(Time.utc - 1.second, 500.milliseconds) 7 | expect(decode_date("2min ago")).to be_close(Time.utc - 2.minutes, 500.milliseconds) 8 | expect(decode_date("3h ago")).to be_close(Time.utc - 3.hours, 500.milliseconds) 9 | expect(decode_date("4d ago")).to be_close(Time.utc - 4.days, 500.milliseconds) 10 | expect(decode_date("5w ago")).to be_close(Time.utc - 5.weeks, 500.milliseconds) 11 | expect(decode_date("6mo ago")).to be_close(Time.utc - 6.months, 500.milliseconds) 12 | expect(decode_date("7y ago")).to be_close(Time.utc - 7.years, 500.milliseconds) 13 | end 14 | 15 | it "parses short dates (en-GB)" do 16 | expect(decode_date("55s ago")).to be_close(Time.utc - 55.seconds, 500.milliseconds) 17 | expect(decode_date("44min ago")).to be_close(Time.utc - 44.minutes, 500.milliseconds) 18 | expect(decode_date("22hr ago")).to be_close(Time.utc - 22.hours, 500.milliseconds) 19 | expect(decode_date("1day ago")).to be_close(Time.utc - 1.day, 500.milliseconds) 20 | expect(decode_date("2days ago")).to be_close(Time.utc - 2.days, 500.milliseconds) 21 | expect(decode_date("3wk ago")).to be_close(Time.utc - 3.weeks, 500.milliseconds) 22 | expect(decode_date("11mo ago")).to be_close(Time.utc - 11.months, 500.milliseconds) 23 | expect(decode_date("11yr ago")).to be_close(Time.utc - 11.years, 500.milliseconds) 24 | end 25 | 26 | it "parses long forms (singular)" do 27 | expect(decode_date("1 second ago")).to be_close(Time.utc - 1.second, 500.milliseconds) 28 | expect(decode_date("1 minute ago")).to be_close(Time.utc - 1.minute, 500.milliseconds) 29 | expect(decode_date("1 hour ago")).to be_close(Time.utc - 1.hour, 500.milliseconds) 30 | expect(decode_date("1 day ago")).to be_close(Time.utc - 1.day, 500.milliseconds) 31 | expect(decode_date("1 week ago")).to be_close(Time.utc - 1.week, 500.milliseconds) 32 | expect(decode_date("1 month ago")).to be_close(Time.utc - 1.month, 500.milliseconds) 33 | expect(decode_date("1 year ago")).to be_close(Time.utc - 1.year, 500.milliseconds) 34 | end 35 | 36 | it "parses long forms (plural)" do 37 | expect(decode_date("5 seconds ago")).to be_close(Time.utc - 5.seconds, 500.milliseconds) 38 | expect(decode_date("17 minutes ago")).to be_close(Time.utc - 17.minutes, 500.milliseconds) 39 | expect(decode_date("23 hours ago")).to be_close(Time.utc - 23.hours, 500.milliseconds) 40 | expect(decode_date("3 days ago")).to be_close(Time.utc - 3.days, 500.milliseconds) 41 | expect(decode_date("2 weeks ago")).to be_close(Time.utc - 2.weeks, 500.milliseconds) 42 | expect(decode_date("9 months ago")).to be_close(Time.utc - 9.months, 500.milliseconds) 43 | expect(decode_date("8 years ago")).to be_close(Time.utc - 8.years, 500.milliseconds) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/parsers_helper.cr: -------------------------------------------------------------------------------- 1 | require "db" 2 | require "json" 3 | require "kemal" 4 | 5 | require "protodec/utils" 6 | 7 | require "spectator" 8 | 9 | require "../src/invidious/exceptions" 10 | require "../src/invidious/helpers/macros" 11 | require "../src/invidious/helpers/logger" 12 | require "../src/invidious/helpers/utils" 13 | 14 | require "../src/invidious/videos" 15 | require "../src/invidious/videos/*" 16 | require "../src/invidious/comments/content" 17 | 18 | require "../src/invidious/helpers/serialized_yt_data" 19 | require "../src/invidious/yt_backend/extractors" 20 | require "../src/invidious/yt_backend/extractors_utils" 21 | 22 | OUTPUT = File.open(File::NULL, "w") 23 | LOGGER = Invidious::LogHandler.new(OUTPUT, LogLevel::Off) 24 | 25 | def load_mock(file) : Hash(String, JSON::Any) 26 | file = File.join(__DIR__, "..", "mocks", file + ".json") 27 | content = File.read(file) 28 | 29 | return JSON.parse(content).as_h 30 | end 31 | 32 | Spectator.configure do |config| 33 | config.fail_blank 34 | config.randomize 35 | end 36 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "kemal" 2 | require "openssl/hmac" 3 | require "pg" 4 | require "protodec/utils" 5 | require "yaml" 6 | require "../src/invidious/helpers/*" 7 | require "../src/invidious/channels/*" 8 | require "../src/invidious/videos/caption" 9 | require "../src/invidious/videos" 10 | require "../src/invidious/playlists" 11 | require "../src/invidious/search/ctoken" 12 | require "../src/invidious/trending" 13 | require "spectator" 14 | 15 | Spectator.configure do |config| 16 | config.fail_blank 17 | config.randomize 18 | end 19 | -------------------------------------------------------------------------------- /src/invidious/channels/playlists.cr: -------------------------------------------------------------------------------- 1 | def fetch_channel_playlists(ucid, author, continuation, sort_by) 2 | if continuation 3 | initial_data = YoutubeAPI.browse(continuation) 4 | else 5 | params = 6 | case sort_by 7 | when "last", "last_added" 8 | # Equivalent to "&sort=lad" 9 | # {"2:string": "playlists", "3:varint": 4, "4:varint": 1, "6:varint": 1} 10 | "EglwbGF5bGlzdHMYBCABMAE%3D" 11 | when "oldest", "oldest_created" 12 | # formerly "&sort=da" 13 | # Not available anymore :c or maybe ?? 14 | # {"2:string": "playlists", "3:varint": 2, "4:varint": 1, "6:varint": 1} 15 | "EglwbGF5bGlzdHMYAiABMAE%3D" 16 | # {"2:string": "playlists", "3:varint": 1, "4:varint": 1, "6:varint": 1} 17 | # "EglwbGF5bGlzdHMYASABMAE%3D" 18 | when "newest", "newest_created" 19 | # Formerly "&sort=dd" 20 | # {"2:string": "playlists", "3:varint": 3, "4:varint": 1, "6:varint": 1} 21 | "EglwbGF5bGlzdHMYAyABMAE%3D" 22 | end 23 | 24 | initial_data = YoutubeAPI.browse(ucid, params: params || "") 25 | end 26 | 27 | return extract_items(initial_data, author, ucid) 28 | end 29 | 30 | def fetch_channel_podcasts(ucid, author, continuation) 31 | if continuation 32 | initial_data = YoutubeAPI.browse(continuation) 33 | else 34 | initial_data = YoutubeAPI.browse(ucid, params: "Eghwb2RjYXN0c_IGBQoDugEA") 35 | end 36 | return extract_items(initial_data, author, ucid) 37 | end 38 | 39 | def fetch_channel_releases(ucid, author, continuation) 40 | if continuation 41 | initial_data = YoutubeAPI.browse(continuation) 42 | else 43 | initial_data = YoutubeAPI.browse(ucid, params: "EghyZWxlYXNlc_IGBQoDsgEA") 44 | end 45 | return extract_items(initial_data, author, ucid) 46 | end 47 | 48 | def fetch_channel_courses(ucid, author, continuation) 49 | if continuation 50 | initial_data = YoutubeAPI.browse(continuation) 51 | else 52 | initial_data = YoutubeAPI.browse(ucid, params: "Egdjb3Vyc2Vz8gYFCgPCAQA%3D") 53 | end 54 | return extract_items(initial_data, author, ucid) 55 | end 56 | -------------------------------------------------------------------------------- /src/invidious/comments/links_util.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Comments 2 | extend self 3 | 4 | def replace_links(html) 5 | # Check if the document is empty 6 | # Prevents edge-case bug with Reddit comments, see issue #3115 7 | if html.nil? || html.empty? 8 | return html 9 | end 10 | 11 | html = XML.parse_html(html) 12 | 13 | html.xpath_nodes(%q(//a)).each do |anchor| 14 | url = URI.parse(anchor["href"]) 15 | 16 | if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be") 17 | if url.host.try &.ends_with? "youtu.be" 18 | url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}" 19 | else 20 | if url.path == "/redirect" 21 | params = HTTP::Params.parse(url.query.not_nil!) 22 | anchor["href"] = params["q"]? 23 | else 24 | anchor["href"] = url.request_target 25 | end 26 | end 27 | elsif url.to_s == "#" 28 | begin 29 | length_seconds = decode_length_seconds(anchor.content) 30 | rescue ex 31 | length_seconds = decode_time(anchor.content) 32 | end 33 | 34 | if length_seconds > 0 35 | anchor["href"] = "javascript:void(0)" 36 | anchor["onclick"] = "player.currentTime(#{length_seconds})" 37 | else 38 | anchor["href"] = url.request_target 39 | end 40 | end 41 | end 42 | 43 | html = html.xpath_node(%q(//body)).not_nil! 44 | if node = html.xpath_node(%q(./p)) 45 | html = node 46 | end 47 | 48 | return html.to_xml(options: XML::SaveOptions::NO_DECL) 49 | end 50 | 51 | def fill_links(html, scheme, host) 52 | # Check if the document is empty 53 | # Prevents edge-case bug with Reddit comments, see issue #3115 54 | if html.nil? || html.empty? 55 | return html 56 | end 57 | 58 | html = XML.parse_html(html) 59 | 60 | html.xpath_nodes("//a").each do |match| 61 | url = URI.parse(match["href"]) 62 | # Reddit links don't have host 63 | if !url.host && !match["href"].starts_with?("javascript") && !url.to_s.ends_with? "#" 64 | url.scheme = scheme 65 | url.host = host 66 | match["href"] = url 67 | end 68 | end 69 | 70 | if host == "www.youtube.com" 71 | html = html.xpath_node(%q(//body/p)).not_nil! 72 | end 73 | 74 | return html.to_xml(options: XML::SaveOptions::NO_DECL) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /src/invidious/comments/reddit.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Comments 2 | extend self 3 | 4 | def fetch_reddit(id, sort_by = "confidence") 5 | client = make_client(REDDIT_URL) 6 | headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} 7 | 8 | # TODO: Use something like #479 for a static list of instances to use here 9 | query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"}) 10 | search_results = client.get("/search.json?#{query}", headers) 11 | 12 | if search_results.status_code == 200 13 | search_results = RedditThing.from_json(search_results.body) 14 | 15 | # For videos that have more than one thread, choose the one with the highest score 16 | threads = search_results.data.as(RedditListing).children 17 | thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink)) 18 | result = thread.try do |t| 19 | body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body 20 | Array(RedditThing).from_json(body) 21 | end 22 | result ||= [] of RedditThing 23 | elsif search_results.status_code == 302 24 | # Previously, if there was only one result then the API would redirect to that result. 25 | # Now, it appears it will still return a listing so this section is likely unnecessary. 26 | 27 | result = client.get(search_results.headers["Location"], headers).body 28 | result = Array(RedditThing).from_json(result) 29 | 30 | thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink) 31 | else 32 | raise NotFoundException.new("Comments not found.") 33 | end 34 | 35 | client.close 36 | 37 | comments = result[1]?.try(&.data.as(RedditListing).children) 38 | comments ||= [] of RedditThing 39 | return comments, thread 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /src/invidious/comments/reddit_types.cr: -------------------------------------------------------------------------------- 1 | class RedditThing 2 | include JSON::Serializable 3 | 4 | property kind : String 5 | property data : RedditComment | RedditLink | RedditMore | RedditListing 6 | end 7 | 8 | class RedditComment 9 | include JSON::Serializable 10 | 11 | property author : String 12 | property body_html : String 13 | property replies : RedditThing | String 14 | property score : Int32 15 | property depth : Int32 16 | property permalink : String 17 | 18 | @[JSON::Field(converter: RedditComment::TimeConverter)] 19 | property created_utc : Time 20 | 21 | module TimeConverter 22 | def self.from_json(value : JSON::PullParser) : Time 23 | Time.unix(value.read_float.to_i) 24 | end 25 | 26 | def self.to_json(value : Time, json : JSON::Builder) 27 | json.number(value.to_unix) 28 | end 29 | end 30 | end 31 | 32 | struct RedditLink 33 | include JSON::Serializable 34 | 35 | property author : String 36 | property score : Int32 37 | property subreddit : String 38 | property num_comments : Int32 39 | property id : String 40 | property permalink : String 41 | property title : String 42 | end 43 | 44 | struct RedditMore 45 | include JSON::Serializable 46 | 47 | property children : Array(String) 48 | property count : Int32 49 | property depth : Int32 50 | end 51 | 52 | class RedditListing 53 | include JSON::Serializable 54 | 55 | property children : Array(RedditThing) 56 | property modhash : String 57 | end 58 | -------------------------------------------------------------------------------- /src/invidious/database/annotations.cr: -------------------------------------------------------------------------------- 1 | require "./base.cr" 2 | 3 | module Invidious::Database::Annotations 4 | extend self 5 | 6 | def insert(id : String, annotations : String) 7 | request = <<-SQL 8 | INSERT INTO annotations 9 | VALUES ($1, $2) 10 | ON CONFLICT DO NOTHING 11 | SQL 12 | 13 | PG_DB.exec(request, id, annotations) 14 | end 15 | 16 | def select(id : String) : Annotation? 17 | request = <<-SQL 18 | SELECT * FROM annotations 19 | WHERE id = $1 20 | SQL 21 | 22 | return PG_DB.query_one?(request, id, as: Annotation) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /src/invidious/database/migration.cr: -------------------------------------------------------------------------------- 1 | abstract class Invidious::Database::Migration 2 | macro inherited 3 | Migrator.migrations << self 4 | end 5 | 6 | @@version : Int64? 7 | 8 | def self.version(version : Int32 | Int64) 9 | @@version = version.to_i64 10 | end 11 | 12 | getter? completed = false 13 | 14 | def initialize(@db : DB::Database) 15 | end 16 | 17 | abstract def up(conn : DB::Connection) 18 | 19 | def migrate 20 | # migrator already ignores completed migrations 21 | # but this is an extra check to make sure a migration doesn't run twice 22 | return if completed? 23 | 24 | @db.transaction do |txn| 25 | up(txn.connection) 26 | track(txn.connection) 27 | @completed = true 28 | end 29 | end 30 | 31 | def version : Int64 32 | @@version.not_nil! 33 | end 34 | 35 | private def track(conn : DB::Connection) 36 | conn.exec("INSERT INTO #{Migrator::MIGRATIONS_TABLE} (version) VALUES ($1)", version) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /src/invidious/database/migrations/0001_create_channels_table.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Database::Migrations 2 | class CreateChannelsTable < Migration 3 | version 1 4 | 5 | def up(conn : DB::Connection) 6 | conn.exec <<-SQL 7 | CREATE TABLE IF NOT EXISTS public.channels 8 | ( 9 | id text NOT NULL, 10 | author text, 11 | updated timestamp with time zone, 12 | deleted boolean, 13 | subscribed timestamp with time zone, 14 | CONSTRAINT channels_id_key UNIQUE (id) 15 | ); 16 | SQL 17 | 18 | conn.exec <<-SQL 19 | GRANT ALL ON TABLE public.channels TO current_user; 20 | SQL 21 | 22 | conn.exec <<-SQL 23 | CREATE INDEX IF NOT EXISTS channels_id_idx 24 | ON public.channels 25 | USING btree 26 | (id COLLATE pg_catalog."default"); 27 | SQL 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /src/invidious/database/migrations/0002_create_videos_table.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Database::Migrations 2 | class CreateVideosTable < Migration 3 | version 2 4 | 5 | def up(conn : DB::Connection) 6 | conn.exec <<-SQL 7 | CREATE UNLOGGED TABLE IF NOT EXISTS public.videos 8 | ( 9 | id text NOT NULL, 10 | info text, 11 | updated timestamp with time zone, 12 | CONSTRAINT videos_pkey PRIMARY KEY (id) 13 | ); 14 | SQL 15 | 16 | conn.exec <<-SQL 17 | GRANT ALL ON TABLE public.videos TO current_user; 18 | SQL 19 | 20 | conn.exec <<-SQL 21 | CREATE UNIQUE INDEX IF NOT EXISTS id_idx 22 | ON public.videos 23 | USING btree 24 | (id COLLATE pg_catalog."default"); 25 | SQL 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/invidious/database/migrations/0003_create_channel_videos_table.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Database::Migrations 2 | class CreateChannelVideosTable < Migration 3 | version 3 4 | 5 | def up(conn : DB::Connection) 6 | conn.exec <<-SQL 7 | CREATE TABLE IF NOT EXISTS public.channel_videos 8 | ( 9 | id text NOT NULL, 10 | title text, 11 | published timestamp with time zone, 12 | updated timestamp with time zone, 13 | ucid text, 14 | author text, 15 | length_seconds integer, 16 | live_now boolean, 17 | premiere_timestamp timestamp with time zone, 18 | views bigint, 19 | CONSTRAINT channel_videos_id_key UNIQUE (id) 20 | ); 21 | SQL 22 | 23 | conn.exec <<-SQL 24 | GRANT ALL ON TABLE public.channel_videos TO current_user; 25 | SQL 26 | 27 | conn.exec <<-SQL 28 | CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx 29 | ON public.channel_videos 30 | USING btree 31 | (ucid COLLATE pg_catalog."default"); 32 | SQL 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /src/invidious/database/migrations/0004_create_users_table.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Database::Migrations 2 | class CreateUsersTable < Migration 3 | version 4 4 | 5 | def up(conn : DB::Connection) 6 | conn.exec <<-SQL 7 | CREATE TABLE IF NOT EXISTS public.users 8 | ( 9 | updated timestamp with time zone, 10 | notifications text[], 11 | subscriptions text[], 12 | email text NOT NULL, 13 | preferences text, 14 | password text, 15 | token text, 16 | watched text[], 17 | feed_needs_update boolean, 18 | CONSTRAINT users_email_key UNIQUE (email) 19 | ); 20 | SQL 21 | 22 | conn.exec <<-SQL 23 | GRANT ALL ON TABLE public.users TO current_user; 24 | SQL 25 | 26 | conn.exec <<-SQL 27 | CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx 28 | ON public.users 29 | USING btree 30 | (lower(email) COLLATE pg_catalog."default"); 31 | SQL 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /src/invidious/database/migrations/0005_create_session_ids_table.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Database::Migrations 2 | class CreateSessionIdsTable < Migration 3 | version 5 4 | 5 | def up(conn : DB::Connection) 6 | conn.exec <<-SQL 7 | CREATE TABLE IF NOT EXISTS public.session_ids 8 | ( 9 | id text NOT NULL, 10 | email text, 11 | issued timestamp with time zone, 12 | CONSTRAINT session_ids_pkey PRIMARY KEY (id) 13 | ); 14 | SQL 15 | 16 | conn.exec <<-SQL 17 | GRANT ALL ON TABLE public.session_ids TO current_user; 18 | SQL 19 | 20 | conn.exec <<-SQL 21 | CREATE INDEX IF NOT EXISTS session_ids_id_idx 22 | ON public.session_ids 23 | USING btree 24 | (id COLLATE pg_catalog."default"); 25 | SQL 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/invidious/database/migrations/0006_create_nonces_table.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Database::Migrations 2 | class CreateNoncesTable < Migration 3 | version 6 4 | 5 | def up(conn : DB::Connection) 6 | conn.exec <<-SQL 7 | CREATE TABLE IF NOT EXISTS public.nonces 8 | ( 9 | nonce text, 10 | expire timestamp with time zone, 11 | CONSTRAINT nonces_id_key UNIQUE (nonce) 12 | ); 13 | SQL 14 | 15 | conn.exec <<-SQL 16 | GRANT ALL ON TABLE public.nonces TO current_user; 17 | SQL 18 | 19 | conn.exec <<-SQL 20 | CREATE INDEX IF NOT EXISTS nonces_nonce_idx 21 | ON public.nonces 22 | USING btree 23 | (nonce COLLATE pg_catalog."default"); 24 | SQL 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/invidious/database/migrations/0007_create_annotations_table.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Database::Migrations 2 | class CreateAnnotationsTable < Migration 3 | version 7 4 | 5 | def up(conn : DB::Connection) 6 | conn.exec <<-SQL 7 | CREATE TABLE IF NOT EXISTS public.annotations 8 | ( 9 | id text NOT NULL, 10 | annotations xml, 11 | CONSTRAINT annotations_id_key UNIQUE (id) 12 | ); 13 | SQL 14 | 15 | conn.exec <<-SQL 16 | GRANT ALL ON TABLE public.annotations TO current_user; 17 | SQL 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /src/invidious/database/migrations/0008_create_playlists_table.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Database::Migrations 2 | class CreatePlaylistsTable < Migration 3 | version 8 4 | 5 | def up(conn : DB::Connection) 6 | if !privacy_type_exists?(conn) 7 | conn.exec <<-SQL 8 | CREATE TYPE public.privacy AS ENUM 9 | ( 10 | 'Public', 11 | 'Unlisted', 12 | 'Private' 13 | ); 14 | SQL 15 | end 16 | 17 | conn.exec <<-SQL 18 | CREATE TABLE IF NOT EXISTS public.playlists 19 | ( 20 | title text, 21 | id text primary key, 22 | author text, 23 | description text, 24 | video_count integer, 25 | created timestamptz, 26 | updated timestamptz, 27 | privacy privacy, 28 | index int8[] 29 | ); 30 | SQL 31 | 32 | conn.exec <<-SQL 33 | GRANT ALL ON public.playlists TO current_user; 34 | SQL 35 | end 36 | 37 | private def privacy_type_exists?(conn : DB::Connection) : Bool 38 | request = <<-SQL 39 | SELECT 1 AS one 40 | FROM pg_type 41 | INNER JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace 42 | WHERE pg_namespace.nspname = 'public' 43 | AND pg_type.typname = 'privacy' 44 | LIMIT 1; 45 | SQL 46 | 47 | !conn.query_one?(request, as: Int32).nil? 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /src/invidious/database/migrations/0009_create_playlist_videos_table.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Database::Migrations 2 | class CreatePlaylistVideosTable < Migration 3 | version 9 4 | 5 | def up(conn : DB::Connection) 6 | conn.exec <<-SQL 7 | CREATE TABLE IF NOT EXISTS public.playlist_videos 8 | ( 9 | title text, 10 | id text, 11 | author text, 12 | ucid text, 13 | length_seconds integer, 14 | published timestamptz, 15 | plid text references playlists(id), 16 | index int8, 17 | live_now boolean, 18 | PRIMARY KEY (index,plid) 19 | ); 20 | SQL 21 | 22 | conn.exec <<-SQL 23 | GRANT ALL ON TABLE public.playlist_videos TO current_user; 24 | SQL 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/invidious/database/migrations/0010_make_videos_unlogged.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Database::Migrations 2 | class MakeVideosUnlogged < Migration 3 | version 10 4 | 5 | def up(conn : DB::Connection) 6 | conn.exec <<-SQL 7 | ALTER TABLE public.videos SET UNLOGGED; 8 | SQL 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /src/invidious/database/migrator.cr: -------------------------------------------------------------------------------- 1 | class Invidious::Database::Migrator 2 | MIGRATIONS_TABLE = "public.invidious_migrations" 3 | 4 | class_getter migrations = [] of Invidious::Database::Migration.class 5 | 6 | def initialize(@db : DB::Database) 7 | end 8 | 9 | def migrate 10 | versions = load_versions 11 | 12 | ran_migration = false 13 | load_migrations.sort_by(&.version) 14 | .each do |migration| 15 | next if versions.includes?(migration.version) 16 | 17 | puts "Running migration: #{migration.class.name}" 18 | migration.migrate 19 | ran_migration = true 20 | end 21 | 22 | puts "No migrations to run." unless ran_migration 23 | end 24 | 25 | def pending_migrations? : Bool 26 | versions = load_versions 27 | 28 | load_migrations.sort_by(&.version) 29 | .any? { |migration| !versions.includes?(migration.version) } 30 | end 31 | 32 | private def load_migrations : Array(Invidious::Database::Migration) 33 | self.class.migrations.map(&.new(@db)) 34 | end 35 | 36 | private def load_versions : Array(Int64) 37 | create_migrations_table 38 | @db.query_all("SELECT version FROM #{MIGRATIONS_TABLE}", as: Int64) 39 | end 40 | 41 | private def create_migrations_table 42 | @db.exec <<-SQL 43 | CREATE TABLE IF NOT EXISTS #{MIGRATIONS_TABLE} ( 44 | id bigserial PRIMARY KEY, 45 | version bigint NOT NULL 46 | ) 47 | SQL 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /src/invidious/database/nonces.cr: -------------------------------------------------------------------------------- 1 | require "./base.cr" 2 | 3 | module Invidious::Database::Nonces 4 | extend self 5 | 6 | # ------------------- 7 | # Insert / Delete 8 | # ------------------- 9 | 10 | def insert(nonce : String, expire : Time) 11 | request = <<-SQL 12 | INSERT INTO nonces 13 | VALUES ($1, $2) 14 | ON CONFLICT DO NOTHING 15 | SQL 16 | 17 | PG_DB.exec(request, nonce, expire) 18 | end 19 | 20 | def delete_expired 21 | request = <<-SQL 22 | DELETE FROM nonces * 23 | WHERE expire < now() 24 | SQL 25 | 26 | PG_DB.exec(request) 27 | end 28 | 29 | # ------------------- 30 | # Update 31 | # ------------------- 32 | 33 | def update_set_expired(nonce : String) 34 | request = <<-SQL 35 | UPDATE nonces 36 | SET expire = $1 37 | WHERE nonce = $2 38 | SQL 39 | 40 | PG_DB.exec(request, Time.utc(1990, 1, 1), nonce) 41 | end 42 | 43 | # ------------------- 44 | # Select 45 | # ------------------- 46 | 47 | def select(nonce : String) : Tuple(String, Time)? 48 | request = <<-SQL 49 | SELECT * FROM nonces 50 | WHERE nonce = $1 51 | SQL 52 | 53 | return PG_DB.query_one?(request, nonce, as: {String, Time}) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /src/invidious/database/sessions.cr: -------------------------------------------------------------------------------- 1 | require "./base.cr" 2 | 3 | module Invidious::Database::SessionIDs 4 | extend self 5 | 6 | # ------------------- 7 | # Insert 8 | # ------------------- 9 | 10 | def insert(sid : String, email : String, handle_conflicts : Bool = false) 11 | request = <<-SQL 12 | INSERT INTO session_ids 13 | VALUES ($1, $2, now()) 14 | SQL 15 | 16 | request += " ON CONFLICT (id) DO NOTHING" if handle_conflicts 17 | 18 | PG_DB.exec(request, sid, email) 19 | end 20 | 21 | # ------------------- 22 | # Delete 23 | # ------------------- 24 | 25 | def delete(*, sid : String) 26 | request = <<-SQL 27 | DELETE FROM session_ids * 28 | WHERE id = $1 29 | SQL 30 | 31 | PG_DB.exec(request, sid) 32 | end 33 | 34 | def delete(*, email : String) 35 | request = <<-SQL 36 | DELETE FROM session_ids * 37 | WHERE email = $1 38 | SQL 39 | 40 | PG_DB.exec(request, email) 41 | end 42 | 43 | def delete(*, sid : String, email : String) 44 | request = <<-SQL 45 | DELETE FROM session_ids * 46 | WHERE id = $1 AND email = $2 47 | SQL 48 | 49 | PG_DB.exec(request, sid, email) 50 | end 51 | 52 | # ------------------- 53 | # Select 54 | # ------------------- 55 | 56 | def select_email(sid : String) : String? 57 | request = <<-SQL 58 | SELECT email FROM session_ids 59 | WHERE id = $1 60 | SQL 61 | 62 | PG_DB.query_one?(request, sid, as: String) 63 | end 64 | 65 | def select_all(email : String) : Array({session: String, issued: Time}) 66 | request = <<-SQL 67 | SELECT id, issued FROM session_ids 68 | WHERE email = $1 69 | ORDER BY issued DESC 70 | SQL 71 | 72 | PG_DB.query_all(request, email, as: {session: String, issued: Time}) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /src/invidious/database/statistics.cr: -------------------------------------------------------------------------------- 1 | require "./base.cr" 2 | 3 | module Invidious::Database::Statistics 4 | extend self 5 | 6 | # ------------------- 7 | # User stats 8 | # ------------------- 9 | 10 | def count_users_total : Int64 11 | request = <<-SQL 12 | SELECT count(*) FROM users 13 | SQL 14 | 15 | PG_DB.query_one(request, as: Int64) 16 | end 17 | 18 | def count_users_active_6m : Int64 19 | request = <<-SQL 20 | SELECT count(*) FROM users 21 | WHERE CURRENT_TIMESTAMP - updated < '6 months' 22 | SQL 23 | 24 | PG_DB.query_one(request, as: Int64) 25 | end 26 | 27 | def count_users_active_1m : Int64 28 | request = <<-SQL 29 | SELECT count(*) FROM users 30 | WHERE CURRENT_TIMESTAMP - updated < '1 month' 31 | SQL 32 | 33 | PG_DB.query_one(request, as: Int64) 34 | end 35 | 36 | # ------------------- 37 | # Channel stats 38 | # ------------------- 39 | 40 | def channel_last_update : Time? 41 | request = <<-SQL 42 | SELECT updated FROM channels 43 | ORDER BY updated DESC 44 | LIMIT 1 45 | SQL 46 | 47 | PG_DB.query_one?(request, as: Time) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /src/invidious/database/videos.cr: -------------------------------------------------------------------------------- 1 | require "./base.cr" 2 | 3 | module Invidious::Database::Videos 4 | extend self 5 | 6 | def insert(video : Video) 7 | request = <<-SQL 8 | INSERT INTO videos 9 | VALUES ($1, $2, $3) 10 | ON CONFLICT (id) DO NOTHING 11 | SQL 12 | 13 | PG_DB.exec(request, video.id, video.info.to_json, video.updated) 14 | end 15 | 16 | def delete(id) 17 | request = <<-SQL 18 | DELETE FROM videos * 19 | WHERE id = $1 20 | SQL 21 | 22 | PG_DB.exec(request, id) 23 | end 24 | 25 | def delete_expired 26 | request = <<-SQL 27 | DELETE FROM videos * 28 | WHERE updated < (now() - interval '6 hours') 29 | SQL 30 | 31 | PG_DB.exec(request) 32 | end 33 | 34 | def update(video : Video) 35 | request = <<-SQL 36 | UPDATE videos 37 | SET (id, info, updated) = ($1, $2, $3) 38 | WHERE id = $1 39 | SQL 40 | 41 | PG_DB.exec(request, video.id, video.info.to_json, video.updated) 42 | end 43 | 44 | def select(id : String) : Video? 45 | request = <<-SQL 46 | SELECT * FROM videos 47 | WHERE id = $1 48 | SQL 49 | 50 | return PG_DB.query_one?(request, id, as: Video) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /src/invidious/exceptions.cr: -------------------------------------------------------------------------------- 1 | # InfoExceptions are for displaying information to the user. 2 | # 3 | # An InfoException might or might not indicate that something went wrong. 4 | # Historically Invidious didn't differentiate between these two options, so to 5 | # maintain previous functionality InfoExceptions do not print backtraces. 6 | class InfoException < Exception 7 | end 8 | 9 | # Exception used to hold the bogus UCID during a channel search. 10 | class ChannelSearchException < InfoException 11 | getter channel : String 12 | 13 | def initialize(@channel) 14 | end 15 | end 16 | 17 | # Exception used to hold the name of the missing item 18 | # Should be used in all parsing functions 19 | class BrokenTubeException < Exception 20 | getter element : String 21 | 22 | def initialize(@element) 23 | end 24 | 25 | def message 26 | return "Missing JSON element \"#{@element}\"" 27 | end 28 | end 29 | 30 | # Exception threw when an element is not found. 31 | class NotFoundException < InfoException 32 | end 33 | 34 | class VideoNotAvailableException < Exception 35 | end 36 | 37 | # Exception used to indicate that the JSON response from YT is missing 38 | # some important informations, and that the query should be sent again. 39 | class RetryOnceException < Exception 40 | end 41 | -------------------------------------------------------------------------------- /src/invidious/frontend/channel_page.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Frontend::ChannelPage 2 | extend self 3 | 4 | enum TabsAvailable 5 | Videos 6 | Shorts 7 | Streams 8 | Podcasts 9 | Releases 10 | Courses 11 | Playlists 12 | Posts 13 | Channels 14 | end 15 | 16 | def generate_tabs_links(locale : String, channel : AboutChannel, selected_tab : TabsAvailable) 17 | return String.build(1500) do |str| 18 | base_url = "/channel/#{channel.ucid}" 19 | 20 | TabsAvailable.each do |tab| 21 | # Ignore playlists, as it is not supported for auto-generated channels yet 22 | next if (tab.playlists? && channel.auto_generated) 23 | 24 | tab_name = tab.to_s.downcase 25 | 26 | if channel.tabs.includes? tab_name 27 | str << %(
\n) 28 | 29 | if tab == selected_tab 30 | str << "\t" 31 | str << translate(locale, "channel_tab_#{tab_name}_label") 32 | str << "\n" 33 | else 34 | # Video tab doesn't have the last path component 35 | url = tab.videos? ? base_url : "#{base_url}/#{tab_name}" 36 | 37 | str << %(\t) 38 | str << translate(locale, "channel_tab_#{tab_name}_label") 39 | str << "\n" 40 | end 41 | 42 | str << "
" 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /src/invidious/frontend/comments_reddit.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Frontend::Comments 2 | extend self 3 | 4 | def template_reddit(root, locale) 5 | String.build do |html| 6 | root.each do |child| 7 | if child.data.is_a?(RedditComment) 8 | child = child.data.as(RedditComment) 9 | body_html = HTML.unescape(child.body_html) 10 | 11 | replies_html = "" 12 | if child.replies.is_a?(RedditThing) 13 | replies = child.replies.as(RedditThing) 14 | replies_html = self.template_reddit(replies.data.as(RedditListing).children, locale) 15 | end 16 | 17 | if child.depth > 0 18 | html << <<-END_HTML 19 |
20 |
21 |
22 |
23 | END_HTML 24 | else 25 | html << <<-END_HTML 26 |
27 |
28 | END_HTML 29 | end 30 | 31 | html << <<-END_HTML 32 |

33 | [ − ] 34 | #{child.author} 35 | #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)} 36 | #{translate(locale, "`x` ago", recode_date(child.created_utc, locale))} 37 | #{translate(locale, "permalink")} 38 |

39 |
40 | #{body_html} 41 | #{replies_html} 42 |
43 |
44 |
45 | END_HTML 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /src/invidious/frontend/misc.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Frontend::Misc 2 | extend self 3 | 4 | def redirect_url(env : HTTP::Server::Context) 5 | prefs = env.get("preferences").as(Preferences) 6 | 7 | if prefs.automatic_instance_redirect 8 | current_page = env.get?("current_page").as(String) 9 | return "/redirect?referer=#{current_page}" 10 | else 11 | return "https://redirect.invidious.io#{env.request.resource}" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /src/invidious/hashtag.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Hashtag 2 | extend self 3 | 4 | def fetch(hashtag : String, page : Int, region : String? = nil) : Array(SearchItem) 5 | cursor = (page - 1) * 60 6 | ctoken = generate_continuation(hashtag, cursor) 7 | 8 | client_config = YoutubeAPI::ClientConfig.new(region: region) 9 | response = YoutubeAPI.browse(continuation: ctoken, client_config: client_config) 10 | 11 | items, _ = extract_items(response) 12 | return items 13 | end 14 | 15 | def generate_continuation(hashtag : String, cursor : Int) 16 | object = { 17 | "80226972:embedded" => { 18 | "2:string" => "FEhashtag", 19 | "3:base64" => { 20 | "1:varint" => 60_i64, # result count 21 | "15:base64" => { 22 | "1:varint" => cursor.to_i64, 23 | "2:varint" => 0_i64, 24 | }, 25 | "93:2:embedded" => { 26 | "1:string" => hashtag, 27 | "2:varint" => 0_i64, 28 | "3:varint" => 1_i64, 29 | }, 30 | }, 31 | "35:string" => "browse-feedFEhashtag", 32 | }, 33 | } 34 | 35 | continuation = object.try { |i| Protodec::Any.cast_json(i) } 36 | .try { |i| Protodec::Any.from_json(i) } 37 | .try { |i| Base64.urlsafe_encode(i) } 38 | .try { |i| URI.encode_www_form(i) } 39 | 40 | return continuation 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /src/invidious/helpers/crystal_class_overrides.cr: -------------------------------------------------------------------------------- 1 | # Override of the TCPSocket and HTTP::Client classes in order to allow an 2 | # IP family to be selected for domains that resolve to both IPv4 and 3 | # IPv6 addresses. 4 | # 5 | class TCPSocket 6 | def initialize(host, port, dns_timeout = nil, connect_timeout = nil, blocking = false, family = Socket::Family::UNSPEC) 7 | Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo| 8 | super(addrinfo.family, addrinfo.type, addrinfo.protocol, blocking) 9 | connect(addrinfo, timeout: connect_timeout) do |error| 10 | close 11 | error 12 | end 13 | end 14 | end 15 | end 16 | 17 | # :ditto: 18 | class HTTP::Client 19 | property family : Socket::Family = Socket::Family::UNSPEC 20 | 21 | private def io 22 | io = @io 23 | return io if io 24 | unless @reconnect 25 | raise "This HTTP::Client cannot be reconnected" 26 | end 27 | 28 | hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host 29 | io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, family: @family 30 | io.read_timeout = @read_timeout if @read_timeout 31 | io.write_timeout = @write_timeout if @write_timeout 32 | io.sync = false 33 | 34 | {% if !flag?(:without_openssl) %} 35 | if tls = @tls 36 | tcp_socket = io 37 | begin 38 | io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host.rchop('.')) 39 | rescue exc 40 | # don't leak the TCP socket when the SSL connection failed 41 | tcp_socket.close 42 | raise exc 43 | end 44 | end 45 | {% end %} 46 | 47 | @io = io 48 | end 49 | end 50 | 51 | # Mute the ClientError exception raised when a connection is flushed. 52 | # This happends when the connection is unexpectedly closed by the client. 53 | # 54 | class HTTP::Server::Response 55 | class Output 56 | private def unbuffered_flush 57 | @io.flush 58 | rescue ex : IO::Error 59 | unbuffered_close 60 | end 61 | end 62 | end 63 | 64 | # TODO: Document this override 65 | # 66 | class PG::ResultSet 67 | def field(index = @column_index) 68 | @fields.not_nil![index] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /src/invidious/helpers/logger.cr: -------------------------------------------------------------------------------- 1 | require "colorize" 2 | 3 | enum LogLevel 4 | All = 0 5 | Trace = 1 6 | Debug = 2 7 | Info = 3 8 | Warn = 4 9 | Error = 5 10 | Fatal = 6 11 | Off = 7 12 | end 13 | 14 | class Invidious::LogHandler < Kemal::BaseLogHandler 15 | def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, use_color : Bool = true) 16 | Colorize.enabled = use_color 17 | Colorize.on_tty_only! 18 | end 19 | 20 | def call(context : HTTP::Server::Context) 21 | elapsed_time = Time.measure { call_next(context) } 22 | elapsed_text = elapsed_text(elapsed_time) 23 | 24 | # Default: full path with parameters 25 | requested_url = context.request.resource 26 | 27 | # Try not to log search queries passed as GET parameters during normal use 28 | # (They will still be logged if log level is 'Debug' or 'Trace') 29 | if @level > LogLevel::Debug && ( 30 | requested_url.downcase.includes?("search") || requested_url.downcase.includes?("q=") 31 | ) 32 | # Log only the path 33 | requested_url = context.request.path 34 | end 35 | 36 | info("#{context.response.status_code} #{context.request.method} #{requested_url} #{elapsed_text}") 37 | 38 | context 39 | end 40 | 41 | def write(message : String) 42 | @io << message 43 | @io.flush 44 | end 45 | 46 | def color(level) 47 | case level 48 | when LogLevel::Trace then :cyan 49 | when LogLevel::Debug then :green 50 | when LogLevel::Info then :white 51 | when LogLevel::Warn then :yellow 52 | when LogLevel::Error then :red 53 | when LogLevel::Fatal then :magenta 54 | else :default 55 | end 56 | end 57 | 58 | {% for level in %w(trace debug info warn error fatal) %} 59 | def {{level.id}}(message : String) 60 | if LogLevel::{{level.id.capitalize}} >= @level 61 | puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}}))) 62 | end 63 | end 64 | {% end %} 65 | 66 | private def elapsed_text(elapsed) 67 | millis = elapsed.total_milliseconds 68 | return "#{millis.round(2)}ms" if millis >= 1 69 | 70 | "#{(millis * 1000).round(2)}µs" 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /src/invidious/helpers/macros.cr: -------------------------------------------------------------------------------- 1 | module DB::Serializable 2 | macro included 3 | {% verbatim do %} 4 | macro finished 5 | def self.type_array 6 | \{{ @type.instance_vars 7 | .reject { |var| var.annotation(::DB::Field) && var.annotation(::DB::Field)[:ignore] } 8 | .map { |name| name.stringify } 9 | }} 10 | end 11 | 12 | def initialize(tuple) 13 | \{% for var in @type.instance_vars %} 14 | \{% ann = var.annotation(::DB::Field) %} 15 | \{% if ann && ann[:ignore] %} 16 | \{% else %} 17 | @\{{var.name}} = tuple[:\{{var.name.id}}] 18 | \{% end %} 19 | \{% end %} 20 | end 21 | 22 | def to_a 23 | \{{ @type.instance_vars 24 | .reject { |var| var.annotation(::DB::Field) && var.annotation(::DB::Field)[:ignore] } 25 | .map { |name| name } 26 | }} 27 | end 28 | end 29 | {% end %} 30 | end 31 | end 32 | 33 | module JSON::Serializable 34 | macro included 35 | {% verbatim do %} 36 | macro finished 37 | def initialize(tuple) 38 | \{% for var in @type.instance_vars %} 39 | \{% ann = var.annotation(::JSON::Field) %} 40 | \{% if ann && ann[:ignore] %} 41 | \{% else %} 42 | @\{{var.name}} = tuple[:\{{var.name.id}}] 43 | \{% end %} 44 | \{% end %} 45 | end 46 | end 47 | {% end %} 48 | end 49 | end 50 | 51 | macro templated(_filename, template = "template", navbar_search = true) 52 | navbar_search = {{navbar_search}} 53 | 54 | {{ filename = "src/invidious/views/" + _filename + ".ecr" }} 55 | {{ layout = "src/invidious/views/" + template + ".ecr" }} 56 | 57 | __content_filename__ = {{filename}} 58 | render {{filename}}, {{layout}} 59 | end 60 | 61 | macro rendered(filename) 62 | render("src/invidious/views/#{{{filename}}}.ecr") 63 | end 64 | 65 | # Similar to Kemals halt method but works in a 66 | # method. 67 | macro haltf(env, status_code = 200, response = "") 68 | {{env}}.response.status_code = {{status_code}} 69 | {{env}}.response.print {{response}} 70 | {{env}}.response.close 71 | return 72 | end 73 | -------------------------------------------------------------------------------- /src/invidious/helpers/signatures.cr: -------------------------------------------------------------------------------- 1 | require "http/params" 2 | require "./sig_helper" 3 | 4 | class Invidious::DecryptFunction 5 | @last_update : Time = Time.utc - 42.days 6 | 7 | def initialize(uri_or_path) 8 | @client = SigHelper::Client.new(uri_or_path) 9 | self.check_update 10 | end 11 | 12 | def check_update 13 | # If we have updated in the last 5 minutes, do nothing 14 | return if (Time.utc - @last_update) < 5.minutes 15 | 16 | # Get the amount of time elapsed since when the player was updated, in the 17 | # event where multiple invidious processes are run in parallel. 18 | update_time_elapsed = (@client.get_player_timestamp || 301).seconds 19 | 20 | if update_time_elapsed > 5.minutes 21 | LOGGER.debug("Signature: Player might be outdated, updating") 22 | @client.force_update 23 | @last_update = Time.utc 24 | end 25 | end 26 | 27 | def decrypt_nsig(n : String) : String? 28 | self.check_update 29 | return @client.decrypt_n_param(n) 30 | rescue ex 31 | LOGGER.debug(ex.message || "Signature: Unknown error") 32 | LOGGER.trace(ex.inspect_with_backtrace) 33 | return nil 34 | end 35 | 36 | def decrypt_signature(str : String) : String? 37 | self.check_update 38 | return @client.decrypt_sig(str) 39 | rescue ex 40 | LOGGER.debug(ex.message || "Signature: Unknown error") 41 | LOGGER.trace(ex.inspect_with_backtrace) 42 | return nil 43 | end 44 | 45 | def get_sts : UInt64? 46 | self.check_update 47 | return @client.get_signature_timestamp 48 | rescue ex 49 | LOGGER.debug(ex.message || "Signature: Unknown error") 50 | LOGGER.trace(ex.inspect_with_backtrace) 51 | return nil 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /src/invidious/helpers/webvtt.cr: -------------------------------------------------------------------------------- 1 | # Namespace for logic relating to generating WebVTT files 2 | # 3 | # Probably not compliant to WebVTT's specs but it is enough for Invidious. 4 | module WebVTT 5 | # A WebVTT builder generates WebVTT files 6 | private class Builder 7 | # See https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API#cue_payload 8 | private ESCAPE_SUBSTITUTIONS = { 9 | '&' => "&", 10 | '<' => "<", 11 | '>' => ">", 12 | '\u200E' => "‎", 13 | '\u200F' => "‏", 14 | '\u00A0' => " ", 15 | } 16 | 17 | def initialize(@io : IO) 18 | end 19 | 20 | # Writes an vtt cue with the specified time stamp and contents 21 | def cue(start_time : Time::Span, end_time : Time::Span, text : String) 22 | timestamp(start_time, end_time) 23 | @io << self.escape(text) 24 | @io << "\n\n" 25 | end 26 | 27 | private def timestamp(start_time : Time::Span, end_time : Time::Span) 28 | timestamp_component(start_time) 29 | @io << " --> " 30 | timestamp_component(end_time) 31 | 32 | @io << '\n' 33 | end 34 | 35 | private def timestamp_component(timestamp : Time::Span) 36 | @io << timestamp.hours.to_s.rjust(2, '0') 37 | @io << ':' << timestamp.minutes.to_s.rjust(2, '0') 38 | @io << ':' << timestamp.seconds.to_s.rjust(2, '0') 39 | @io << '.' << timestamp.milliseconds.to_s.rjust(3, '0') 40 | end 41 | 42 | private def escape(text : String) : String 43 | return text.gsub(ESCAPE_SUBSTITUTIONS) 44 | end 45 | 46 | def document(setting_fields : Hash(String, String)? = nil, &) 47 | @io << "WEBVTT\n" 48 | 49 | if setting_fields 50 | setting_fields.each do |name, value| 51 | @io << name << ": " << value << '\n' 52 | end 53 | end 54 | 55 | @io << '\n' 56 | 57 | yield 58 | end 59 | end 60 | 61 | # Returns the resulting `String` of writing WebVTT to the yielded `WebVTT::Builder` 62 | # 63 | # ``` 64 | # string = WebVTT.build do |vtt| 65 | # vtt.cue(Time::Span.new(seconds: 1), Time::Span.new(seconds: 2), "Line 1") 66 | # vtt.cue(Time::Span.new(seconds: 2), Time::Span.new(seconds: 3), "Line 2") 67 | # end 68 | # 69 | # 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" 70 | # ``` 71 | # 72 | # Accepts an optional settings fields hash to add settings attribute to the resulting vtt file. 73 | def self.build(setting_fields : Hash(String, String)? = nil, &) 74 | String.build do |str| 75 | builder = Builder.new(str) 76 | builder.document(setting_fields) do 77 | yield builder 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /src/invidious/http_server/utils.cr: -------------------------------------------------------------------------------- 1 | require "uri" 2 | 3 | module Invidious::HttpServer 4 | module Utils 5 | extend self 6 | 7 | def proxy_video_url(raw_url : String, *, region : String? = nil, absolute : Bool = false) 8 | url = URI.parse(raw_url) 9 | 10 | # Add some URL parameters 11 | params = url.query_params 12 | params["host"] = url.host.not_nil! # Should never be nil, in theory 13 | params["region"] = region if !region.nil? 14 | url.query_params = params 15 | 16 | if absolute 17 | return "#{HOST_URL}#{url.request_target}" 18 | else 19 | return url.request_target 20 | end 21 | end 22 | 23 | def add_params_to_url(url : String | URI, params : URI::Params) : URI 24 | url = URI.parse(url) if url.is_a?(String) 25 | 26 | url_query = url.query || "" 27 | 28 | # Append the parameters 29 | url.query = String.build do |str| 30 | if !url_query.empty? 31 | str << url_query 32 | str << '&' 33 | end 34 | 35 | str << params 36 | end 37 | 38 | return url 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /src/invidious/jobs.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Jobs 2 | JOBS = [] of BaseJob 3 | 4 | # Automatically generate a structure that wraps the various 5 | # jobs' configs, so that the following YAML config can be used: 6 | # 7 | # jobs: 8 | # job_name: 9 | # enabled: true 10 | # some_property: "value" 11 | # 12 | macro finished 13 | struct JobsConfig 14 | include YAML::Serializable 15 | 16 | {% for sc in BaseJob.subclasses %} 17 | # Voodoo macro to transform `Some::Module::CustomJob` to `custom` 18 | {% class_name = sc.id.split("::").last.id.gsub(/Job$/, "").underscore %} 19 | 20 | getter {{ class_name }} = {{ sc.name }}::Config.new 21 | {% end %} 22 | 23 | def initialize 24 | end 25 | end 26 | end 27 | 28 | def self.register(job : BaseJob) 29 | JOBS << job 30 | end 31 | 32 | def self.start_all 33 | JOBS.each do |job| 34 | # Don't run the main rountine if the job is disabled by config 35 | next if job.disabled? 36 | 37 | spawn { job.begin } 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /src/invidious/jobs/base_job.cr: -------------------------------------------------------------------------------- 1 | abstract class Invidious::Jobs::BaseJob 2 | abstract def begin 3 | 4 | # When this base job class is inherited, make sure to define 5 | # a basic "Config" structure, that contains the "enable" property, 6 | # and to create the associated instance property. 7 | # 8 | macro inherited 9 | macro finished 10 | # This config structure can be expanded as required. 11 | struct Config 12 | include YAML::Serializable 13 | 14 | property enable = true 15 | 16 | def initialize 17 | end 18 | end 19 | 20 | property cfg = Config.new 21 | 22 | # Return true if job is enabled by config 23 | protected def enabled? : Bool 24 | return (@cfg.enable == true) 25 | end 26 | 27 | # Return true if job is disabled by config 28 | protected def disabled? : Bool 29 | return (@cfg.enable == false) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /src/invidious/jobs/clear_expired_items_job.cr: -------------------------------------------------------------------------------- 1 | class Invidious::Jobs::ClearExpiredItemsJob < Invidious::Jobs::BaseJob 2 | # Remove items (videos, nonces, etc..) whose cache is outdated every hour. 3 | # Removes the need for a cron job. 4 | def begin 5 | loop do 6 | failed = false 7 | 8 | LOGGER.info("jobs: running ClearExpiredItems job") 9 | 10 | begin 11 | Invidious::Database::Videos.delete_expired 12 | Invidious::Database::Nonces.delete_expired 13 | rescue DB::Error 14 | failed = true 15 | end 16 | 17 | # Retry earlier than scheduled on DB error 18 | if failed 19 | LOGGER.info("jobs: ClearExpiredItems failed. Retrying in 10 minutes.") 20 | sleep 10.minutes 21 | else 22 | LOGGER.info("jobs: ClearExpiredItems done.") 23 | sleep 1.hour 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/invidious/jobs/pull_popular_videos_job.cr: -------------------------------------------------------------------------------- 1 | class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob 2 | POPULAR_VIDEOS = Atomic.new([] of ChannelVideo) 3 | private getter db : DB::Database 4 | 5 | def initialize(@db) 6 | end 7 | 8 | def begin 9 | loop do 10 | videos = Invidious::Database::ChannelVideos.select_popular_videos 11 | .sort_by!(&.published) 12 | .reverse! 13 | 14 | POPULAR_VIDEOS.set(videos) 15 | 16 | sleep 1.minute 17 | Fiber.yield 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /src/invidious/jobs/refresh_channels_job.cr: -------------------------------------------------------------------------------- 1 | class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob 2 | private getter db : DB::Database 3 | 4 | def initialize(@db) 5 | end 6 | 7 | def begin 8 | max_fibers = CONFIG.channel_threads 9 | lim_fibers = max_fibers 10 | active_fibers = 0 11 | active_channel = ::Channel(Bool).new 12 | backoff = 2.minutes 13 | 14 | loop do 15 | LOGGER.debug("RefreshChannelsJob: Refreshing all channels") 16 | PG_DB.query("SELECT id FROM channels ORDER BY updated") do |rs| 17 | rs.each do 18 | id = rs.read(String) 19 | 20 | if active_fibers >= lim_fibers 21 | LOGGER.trace("RefreshChannelsJob: Fiber limit reached, waiting...") 22 | if active_channel.receive 23 | LOGGER.trace("RefreshChannelsJob: Fiber limit ok, continuing") 24 | active_fibers -= 1 25 | end 26 | end 27 | 28 | LOGGER.debug("RefreshChannelsJob: #{id} : Spawning fiber") 29 | active_fibers += 1 30 | spawn do 31 | begin 32 | LOGGER.trace("RefreshChannelsJob: #{id} fiber : Fetching channel") 33 | channel = fetch_channel(id, pull_all_videos: CONFIG.full_refresh) 34 | 35 | lim_fibers = max_fibers 36 | 37 | LOGGER.trace("RefreshChannelsJob: #{id} fiber : Updating DB") 38 | Invidious::Database::Channels.update_author(id, channel.author) 39 | rescue ex 40 | LOGGER.error("RefreshChannelsJob: #{id} : #{ex.message}") 41 | if ex.message == "Deleted or invalid channel" 42 | Invidious::Database::Channels.update_mark_deleted(id) 43 | else 44 | lim_fibers = 1 45 | LOGGER.error("RefreshChannelsJob: #{id} fiber : backing off for #{backoff}s") 46 | sleep backoff 47 | if backoff < 1.days 48 | backoff += backoff 49 | else 50 | backoff = 1.days 51 | end 52 | end 53 | ensure 54 | LOGGER.debug("RefreshChannelsJob: #{id} fiber : Done") 55 | active_channel.send(true) 56 | end 57 | end 58 | end 59 | end 60 | 61 | LOGGER.debug("RefreshChannelsJob: Done, sleeping for #{CONFIG.channel_refresh_interval}") 62 | sleep CONFIG.channel_refresh_interval 63 | Fiber.yield 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /src/invidious/jobs/statistics_refresh_job.cr: -------------------------------------------------------------------------------- 1 | class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob 2 | STATISTICS = { 3 | "version" => "2.0", 4 | "software" => { 5 | "name" => "invidious", 6 | "version" => "", 7 | "branch" => "", 8 | }, 9 | "openRegistrations" => true, 10 | "usage" => { 11 | "users" => { 12 | "total" => 0_i64, 13 | "activeHalfyear" => 0_i64, 14 | "activeMonth" => 0_i64, 15 | }, 16 | }, 17 | "metadata" => { 18 | "updatedAt" => Time.utc.to_unix, 19 | "lastChannelRefreshedAt" => 0_i64, 20 | }, 21 | 22 | # 23 | # "totalRequests" => 0_i64, 24 | # "successfulRequests" => 0_i64 25 | # "ratio" => 0_i64 26 | # 27 | "playback" => {} of String => Int64 | Float64, 28 | } 29 | 30 | private getter db : DB::Database 31 | 32 | def initialize(@db, @software_config : Hash(String, String)) 33 | end 34 | 35 | def begin 36 | load_initial_stats 37 | 38 | loop do 39 | refresh_stats 40 | sleep 10.minute 41 | Fiber.yield 42 | end 43 | end 44 | 45 | # should only be called once at the very beginning 46 | private def load_initial_stats 47 | STATISTICS["software"] = { 48 | "name" => @software_config["name"], 49 | "version" => @software_config["version"], 50 | "branch" => @software_config["branch"], 51 | } 52 | STATISTICS["openRegistrations"] = CONFIG.registration_enabled 53 | end 54 | 55 | private def refresh_stats 56 | users = STATISTICS.dig("usage", "users").as(Hash(String, Int64)) 57 | 58 | users["total"] = Invidious::Database::Statistics.count_users_total 59 | users["activeHalfyear"] = Invidious::Database::Statistics.count_users_active_6m 60 | users["activeMonth"] = Invidious::Database::Statistics.count_users_active_1m 61 | 62 | STATISTICS["metadata"] = { 63 | "updatedAt" => Time.utc.to_unix, 64 | "lastChannelRefreshedAt" => Invidious::Database::Statistics.channel_last_update.try &.to_unix || 0_i64, 65 | } 66 | 67 | # Reset playback requests tracker 68 | STATISTICS["playback"] = {} of String => Int64 | Float64 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /src/invidious/jobs/subscribe_to_feeds_job.cr: -------------------------------------------------------------------------------- 1 | class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob 2 | private getter db : DB::Database 3 | private getter hmac_key : String 4 | 5 | def initialize(@db, @hmac_key) 6 | end 7 | 8 | def begin 9 | max_fibers = 1 10 | if CONFIG.use_pubsub_feeds.is_a?(Int32) 11 | max_fibers = CONFIG.use_pubsub_feeds.as(Int32) 12 | end 13 | 14 | active_fibers = 0 15 | active_channel = ::Channel(Bool).new 16 | 17 | loop do 18 | db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > interval '4 days' OR subscribed IS NULL") do |rs| 19 | rs.each do 20 | ucid = rs.read(String) 21 | 22 | if active_fibers >= max_fibers.as(Int32) 23 | if active_channel.receive 24 | active_fibers -= 1 25 | end 26 | end 27 | 28 | active_fibers += 1 29 | 30 | spawn do 31 | begin 32 | response = subscribe_pubsub(ucid, hmac_key) 33 | 34 | if response.status_code >= 400 35 | LOGGER.error("SubscribeToFeedsJob: #{ucid} : #{response.body}") 36 | end 37 | rescue ex 38 | LOGGER.error("SubscribeToFeedsJob: #{ucid} : #{ex.message}") 39 | end 40 | 41 | active_channel.send(true) 42 | end 43 | end 44 | end 45 | 46 | sleep 1.minute 47 | Fiber.yield 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /src/invidious/jsonify/api_v1/common.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | module Invidious::JSONify::APIv1 4 | extend self 5 | 6 | def thumbnails(json : JSON::Builder, id : String) 7 | json.array do 8 | build_thumbnails(id).each do |thumbnail| 9 | json.object do 10 | json.field "quality", thumbnail[:name] 11 | json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" 12 | json.field "width", thumbnail[:width] 13 | json.field "height", thumbnail[:height] 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/invidious/routes/api/v1/feeds.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Routes::API::V1::Feeds 2 | def self.trending(env) 3 | locale = env.get("preferences").as(Preferences).locale 4 | 5 | env.response.content_type = "application/json" 6 | 7 | region = env.params.query["region"]? 8 | trending_type = env.params.query["type"]? 9 | 10 | begin 11 | trending, plid = fetch_trending(trending_type, region, locale) 12 | rescue ex 13 | return error_json(500, ex) 14 | end 15 | 16 | videos = JSON.build do |json| 17 | json.array do 18 | trending.each do |video| 19 | video.to_json(locale, json) 20 | end 21 | end 22 | end 23 | 24 | videos 25 | end 26 | 27 | def self.popular(env) 28 | locale = env.get("preferences").as(Preferences).locale 29 | 30 | env.response.content_type = "application/json" 31 | 32 | if !CONFIG.popular_enabled 33 | error_message = {"error" => "Administrator has disabled this endpoint."}.to_json 34 | haltf env, 403, error_message 35 | end 36 | 37 | JSON.build do |json| 38 | json.array do 39 | popular_videos.each do |video| 40 | video.to_json(locale, json) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /src/invidious/routes/api/v1/search.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Routes::API::V1::Search 2 | def self.search(env) 3 | locale = env.get("preferences").as(Preferences).locale 4 | region = env.params.query["region"]? 5 | 6 | env.response.content_type = "application/json" 7 | 8 | query = Invidious::Search::Query.new(env.params.query, :regular, region) 9 | 10 | begin 11 | search_results = query.process 12 | rescue ex 13 | return error_json(400, ex) 14 | end 15 | 16 | JSON.build do |json| 17 | json.array do 18 | search_results.each do |item| 19 | item.to_json(locale, json) 20 | end 21 | end 22 | end 23 | end 24 | 25 | def self.search_suggestions(env) 26 | preferences = env.get("preferences").as(Preferences) 27 | region = env.params.query["region"]? || preferences.region 28 | 29 | env.response.content_type = "application/json" 30 | 31 | query = env.params.query["q"]? || "" 32 | 33 | begin 34 | client = make_client(URI.parse("https://suggestqueries-clients6.youtube.com"), force_youtube_headers: true) 35 | url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&gs_ri=youtube&ds=yt" 36 | 37 | response = client.get(url).body 38 | client.close 39 | 40 | body = JSON.parse(response[19..-2]).as_a 41 | suggestions = body[1].as_a[0..-2] 42 | 43 | JSON.build do |json| 44 | json.object do 45 | json.field "query", body[0].as_s 46 | json.field "suggestions" do 47 | json.array do 48 | suggestions.each do |suggestion| 49 | json.string suggestion[0].as_s 50 | end 51 | end 52 | end 53 | end 54 | end 55 | rescue ex 56 | return error_json(500, ex) 57 | end 58 | end 59 | 60 | def self.hashtag(env) 61 | hashtag = env.params.url["hashtag"] 62 | 63 | page = env.params.query["page"]?.try &.to_i? || 1 64 | 65 | locale = env.get("preferences").as(Preferences).locale 66 | region = env.params.query["region"]? 67 | env.response.content_type = "application/json" 68 | 69 | begin 70 | results = Invidious::Hashtag.fetch(hashtag, page, region) 71 | rescue ex 72 | return error_json(400, ex) 73 | end 74 | 75 | JSON.build do |json| 76 | json.object do 77 | json.field "results" do 78 | json.array do 79 | results.each do |item| 80 | item.to_json(locale, json) 81 | end 82 | end 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /src/invidious/routes/errors.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Routes::ErrorRoutes 2 | def self.error_404(env) 3 | # Workaround for #3117 4 | if HOST_URL.empty? && env.request.path.starts_with?("/v1/storyboards/sb") 5 | return env.redirect "#{env.request.path[15..]}?#{env.params.query}" 6 | end 7 | 8 | if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) 9 | item = md["id"] 10 | 11 | # Check if item is branding URL e.g. https://youtube.com/gaming 12 | response = YT_POOL.client &.get("/#{item}") 13 | 14 | if response.status_code == 301 15 | response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target) 16 | end 17 | 18 | if response.body.empty? 19 | env.response.headers["Location"] = "/" 20 | haltf env, status_code: 302 21 | end 22 | 23 | html = XML.parse_html(response.body) 24 | ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] 25 | 26 | if ucid 27 | env.response.headers["Location"] = "/channel/#{ucid}" 28 | haltf env, status_code: 302 29 | end 30 | 31 | params = [] of String 32 | env.params.query.each do |k, v| 33 | params << "#{k}=#{v}" 34 | end 35 | params = params.join("&") 36 | 37 | url = "/watch?v=#{item}" 38 | if !params.empty? 39 | url += "&#{params}" 40 | end 41 | 42 | # Check if item is video ID 43 | if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404 44 | env.response.headers["Location"] = url 45 | haltf env, status_code: 302 46 | end 47 | end 48 | 49 | env.response.headers["Location"] = "/" 50 | haltf env, status_code: 302 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /src/invidious/routes/misc.cr: -------------------------------------------------------------------------------- 1 | {% skip_file if flag?(:api_only) %} 2 | 3 | module Invidious::Routes::Misc 4 | def self.home(env) 5 | preferences = env.get("preferences").as(Preferences) 6 | locale = preferences.locale 7 | user = env.get? "user" 8 | 9 | case preferences.default_home 10 | when "Popular" 11 | env.redirect "/feed/popular" 12 | when "Trending" 13 | env.redirect "/feed/trending" 14 | when "Subscriptions" 15 | if user 16 | env.redirect "/feed/subscriptions" 17 | else 18 | env.redirect "/feed/popular" 19 | end 20 | when "Playlists" 21 | if user 22 | env.redirect "/feed/playlists" 23 | else 24 | env.redirect "/feed/popular" 25 | end 26 | else 27 | templated "search_homepage", navbar_search: false 28 | end 29 | end 30 | 31 | def self.privacy(env) 32 | locale = env.get("preferences").as(Preferences).locale 33 | templated "privacy" 34 | end 35 | 36 | def self.licenses(env) 37 | locale = env.get("preferences").as(Preferences).locale 38 | rendered "licenses" 39 | end 40 | 41 | def self.cross_instance_redirect(env) 42 | referer = get_referer(env) 43 | 44 | instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"] 45 | # Filter out the current instance 46 | other_available_instances = instance_list.reject { |_, domain| domain == CONFIG.domain } 47 | 48 | if other_available_instances.empty? 49 | # If the current instance is the only one, use the redirect URL as fallback 50 | instance_url = "redirect.invidious.io" 51 | else 52 | # Select other random instance 53 | # Sample returns an array 54 | # Instances are packaged as {region, domain} in the instance list 55 | instance_url = other_available_instances.sample(1)[0][1] 56 | end 57 | 58 | env.redirect "https://#{instance_url}#{referer}" 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /src/invidious/routes/notifications.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Routes::Notifications 2 | # /modify_notifications 3 | # will "ding" all subscriptions. 4 | # /modify_notifications?receive_all_updates=false&receive_no_updates=false 5 | # will "unding" all subscriptions. 6 | def self.modify(env) 7 | locale = env.get("preferences").as(Preferences).locale 8 | 9 | user = env.get? "user" 10 | sid = env.get? "sid" 11 | referer = get_referer(env, "/") 12 | 13 | redirect = env.params.query["redirect"]? 14 | redirect ||= "false" 15 | redirect = redirect == "true" 16 | 17 | if !user 18 | if redirect 19 | return env.redirect referer 20 | else 21 | return error_json(403, "No such user") 22 | end 23 | end 24 | 25 | user = user.as(User) 26 | 27 | if redirect 28 | env.redirect referer 29 | else 30 | env.response.content_type = "application/json" 31 | "{}" 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /src/invidious/search/ctoken.cr: -------------------------------------------------------------------------------- 1 | def produce_channel_search_continuation(ucid, query, page) 2 | if page <= 1 3 | idx = 0_i64 4 | else 5 | idx = 30_i64 * (page - 1) 6 | end 7 | 8 | object = { 9 | "80226972:embedded" => { 10 | "2:string" => ucid, 11 | "3:base64" => { 12 | "2:string" => "search", 13 | "6:varint" => 1_i64, 14 | "7:varint" => 1_i64, 15 | "12:varint" => 1_i64, 16 | "15:base64" => { 17 | "3:varint" => idx, 18 | }, 19 | "23:varint" => 0_i64, 20 | }, 21 | "11:string" => query, 22 | "35:string" => "browse-feed#{ucid}search", 23 | }, 24 | } 25 | 26 | continuation = object.try { |i| Protodec::Any.cast_json(i) } 27 | .try { |i| Protodec::Any.from_json(i) } 28 | .try { |i| Base64.urlsafe_encode(i) } 29 | .try { |i| URI.encode_www_form(i) } 30 | 31 | return continuation 32 | end 33 | -------------------------------------------------------------------------------- /src/invidious/search/processors.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Search 2 | module Processors 3 | extend self 4 | 5 | # Regular search (`/search` endpoint) 6 | def regular(query : Query) : Array(SearchItem) 7 | search_params = query.filters.to_yt_params(page: query.page) 8 | 9 | client_config = YoutubeAPI::ClientConfig.new(region: query.region) 10 | initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config) 11 | 12 | items, _ = extract_items(initial_data) 13 | return items.reject!(Category) 14 | end 15 | 16 | # Search a youtube channel 17 | # TODO: clean code, and rely more on YoutubeAPI 18 | def channel(query : Query) : Array(SearchItem) 19 | response = YT_POOL.client &.get("/channel/#{query.channel}") 20 | 21 | if response.status_code == 404 22 | response = YT_POOL.client &.get("/user/#{query.channel}") 23 | response = YT_POOL.client &.get("/c/#{query.channel}") if response.status_code == 404 24 | initial_data = extract_initial_data(response.body) 25 | ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?) 26 | raise ChannelSearchException.new(query.channel) if !ucid 27 | else 28 | ucid = query.channel 29 | end 30 | 31 | continuation = produce_channel_search_continuation(ucid, query.text, query.page) 32 | response_json = YoutubeAPI.browse(continuation) 33 | 34 | items, _ = extract_items(response_json, "", ucid) 35 | return items.reject!(Category) 36 | end 37 | 38 | # Search inside of user subscriptions 39 | def subscriptions(query : Query, user : Invidious::User) : Array(ChannelVideo) 40 | view_name = "subscriptions_#{sha256(user.email)}" 41 | 42 | return PG_DB.query_all(" 43 | SELECT id,title,published,updated,ucid,author,length_seconds 44 | FROM ( 45 | SELECT *, 46 | to_tsvector(#{view_name}.title) || 47 | to_tsvector(#{view_name}.author) 48 | as document 49 | FROM #{view_name} 50 | ) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", 51 | query.text, (query.page - 1) * 20, 52 | as: ChannelVideo 53 | ) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /src/invidious/trending.cr: -------------------------------------------------------------------------------- 1 | def fetch_trending(trending_type, region, locale) 2 | region ||= "US" 3 | region = region.upcase 4 | 5 | plid = nil 6 | 7 | case trending_type.try &.downcase 8 | when "music" 9 | params = "4gINGgt5dG1hX2NoYXJ0cw%3D%3D" 10 | when "gaming" 11 | params = "4gIcGhpnYW1pbmdfY29ycHVzX21vc3RfcG9wdWxhcg%3D%3D" 12 | when "movies" 13 | params = "4gIKGgh0cmFpbGVycw%3D%3D" 14 | else # Default 15 | params = "" 16 | end 17 | 18 | client_config = YoutubeAPI::ClientConfig.new(region: region) 19 | initial_data = YoutubeAPI.browse("FEtrending", params: params, client_config: client_config) 20 | 21 | items, _ = extract_items(initial_data) 22 | 23 | extracted = [] of SearchItem 24 | 25 | deduplicate = items.size > 1 26 | 27 | items.each do |itm| 28 | if itm.is_a?(Category) 29 | # Ignore the smaller categories, as they generally contain a sponsored 30 | # channel, which brings a lot of noise on the trending page. 31 | # See: https://github.com/iv-org/invidious/issues/2989 32 | next if (itm.contents.size < 24 && deduplicate) 33 | 34 | extracted.concat itm.contents.select(SearchItem) 35 | else 36 | extracted << itm 37 | end 38 | end 39 | 40 | # Deduplicate items before returning results 41 | return extracted.select(SearchVideo | ProblematicTimelineItem).uniq!(&.id), plid 42 | end 43 | -------------------------------------------------------------------------------- /src/invidious/user/converters.cr: -------------------------------------------------------------------------------- 1 | def convert_theme(theme) 2 | case theme 3 | when "true" 4 | "dark" 5 | when "false" 6 | "light" 7 | when "", nil 8 | nil 9 | else 10 | theme 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /src/invidious/user/cookies.cr: -------------------------------------------------------------------------------- 1 | require "http/cookie" 2 | 3 | struct Invidious::User 4 | module Cookies 5 | extend self 6 | 7 | # Note: we use ternary operator because the two variables 8 | # used in here are not booleans. 9 | SECURE = (Kemal.config.ssl || CONFIG.https_only) ? true : false 10 | 11 | # Session ID (SID) cookie 12 | # Parameter "domain" comes from the global config 13 | def sid(domain : String?, sid) : HTTP::Cookie 14 | return HTTP::Cookie.new( 15 | name: "SID", 16 | domain: domain, 17 | value: sid, 18 | expires: Time.utc + 2.years, 19 | secure: SECURE, 20 | http_only: true, 21 | samesite: HTTP::Cookie::SameSite::Lax 22 | ) 23 | end 24 | 25 | # Preferences (PREFS) cookie 26 | # Parameter "domain" comes from the global config 27 | def prefs(domain : String?, preferences : Preferences) : HTTP::Cookie 28 | return HTTP::Cookie.new( 29 | name: "PREFS", 30 | domain: domain, 31 | value: URI.encode_www_form(preferences.to_json), 32 | expires: Time.utc + 2.years, 33 | secure: SECURE, 34 | http_only: false, 35 | samesite: HTTP::Cookie::SameSite::Lax 36 | ) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /src/invidious/user/exports.cr: -------------------------------------------------------------------------------- 1 | struct Invidious::User 2 | module Export 3 | extend self 4 | 5 | def to_invidious(user : User) 6 | playlists = Invidious::Database::Playlists.select_like_iv(user.email) 7 | 8 | return JSON.build do |json| 9 | json.object do 10 | json.field "subscriptions", user.subscriptions 11 | json.field "watch_history", user.watched 12 | json.field "preferences", user.preferences 13 | json.field "playlists" do 14 | json.array do 15 | playlists.each do |playlist| 16 | json.object do 17 | json.field "title", playlist.title 18 | json.field "description", html_to_content(playlist.description_html) 19 | json.field "privacy", playlist.privacy.to_s 20 | json.field "videos" do 21 | json.array do 22 | Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: CONFIG.playlist_length_limit).each do |video_id| 23 | json.string video_id 24 | end 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end # module 35 | end 36 | -------------------------------------------------------------------------------- /src/invidious/user/user.cr: -------------------------------------------------------------------------------- 1 | require "db" 2 | 3 | struct Invidious::User 4 | include DB::Serializable 5 | 6 | property updated : Time 7 | property notifications : Array(String) 8 | property subscriptions : Array(String) 9 | property email : String 10 | 11 | @[DB::Field(converter: Invidious::User::PreferencesConverter)] 12 | property preferences : Preferences 13 | property password : String? 14 | property token : String 15 | property watched : Array(String) 16 | property feed_needs_update : Bool? 17 | 18 | module PreferencesConverter 19 | def self.from_rs(rs) 20 | begin 21 | Preferences.from_json(rs.read(String)) 22 | rescue ex 23 | Preferences.from_json("{}") 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/invidious/videos/clip.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | # returns start_time, end_time and clip_title 4 | def parse_clip_parameters(params) : {Float64?, Float64?, String?} 5 | decoded_protobuf = params.try { |i| URI.decode_www_form(i) } 6 | .try { |i| Base64.decode(i) } 7 | .try { |i| IO::Memory.new(i) } 8 | .try { |i| Protodec::Any.parse(i) } 9 | 10 | start_time = decoded_protobuf 11 | .try(&.["50:0:embedded"]["2:1:varint"].as_i64) 12 | .try { |i| i/1000 } 13 | 14 | end_time = decoded_protobuf 15 | .try(&.["50:0:embedded"]["3:2:varint"].as_i64) 16 | .try { |i| i/1000 } 17 | 18 | clip_title = decoded_protobuf 19 | .try(&.["50:0:embedded"]["4:3:string"].as_s) 20 | 21 | return start_time, end_time, clip_title 22 | end 23 | -------------------------------------------------------------------------------- /src/invidious/videos/description.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "uri" 3 | 4 | private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int 5 | copied = 0 6 | while copied < count 7 | cp = iter.next 8 | break if cp.is_a?(Iterator::Stop) 9 | 10 | if cp == 0x26 # Ampersand (&) 11 | str << "&" 12 | elsif cp == 0x27 # Single quote (') 13 | str << "'" 14 | elsif cp == 0x22 # Double quote (") 15 | str << """ 16 | elsif cp == 0x3C # Less-than (<) 17 | str << "<" 18 | elsif cp == 0x3E # Greater than (>) 19 | str << ">" 20 | else 21 | str << cp.chr 22 | end 23 | 24 | # A codepoint from the SMP counts twice 25 | copied += 1 if cp > 0xFFFF 26 | copied += 1 27 | end 28 | 29 | return copied 30 | end 31 | 32 | def parse_description(desc, video_id : String) : String? 33 | return "" if desc.nil? 34 | 35 | content = desc["content"].as_s 36 | return "" if content.empty? 37 | 38 | commands = desc["commandRuns"]?.try &.as_a 39 | if commands.nil? 40 | # Slightly faster than HTML.escape, as we're only doing one pass on 41 | # the string instead of five for the standard library 42 | return String.build do |str| 43 | copy_string(str, content.each_codepoint, content.size) 44 | end 45 | end 46 | 47 | # Not everything is stored in UTF-8 on youtube's side. The SMP codepoints 48 | # (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are 49 | # automatically decoded by the JSON parser. It means that we need to count 50 | # copied byte in a special manner, preventing the use of regular string copy. 51 | iter = content.each_codepoint 52 | 53 | index = 0 54 | 55 | return String.build do |str| 56 | commands.each do |command| 57 | cmd_start = command["startIndex"].as_i 58 | cmd_length = command["length"].as_i 59 | 60 | # Copy the text chunk between this command and the previous if needed. 61 | length = cmd_start - index 62 | index += copy_string(str, iter, length) 63 | 64 | # We need to copy the command's text using the iterator 65 | # and the special function defined above. 66 | cmd_content = String.build(cmd_length) do |str2| 67 | copy_string(str2, iter, cmd_length) 68 | end 69 | 70 | link = cmd_content 71 | if on_tap = command.dig?("onTap", "innertubeCommand") 72 | link = parse_link_endpoint(on_tap, cmd_content, video_id) 73 | end 74 | str << link 75 | index += cmd_length 76 | end 77 | 78 | # Copy the end of the string (past the last command). 79 | remaining_length = content.size - index 80 | copy_string(str, iter, remaining_length) if remaining_length > 0 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /src/invidious/videos/music.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | struct VideoMusic 4 | include JSON::Serializable 5 | 6 | property song : String 7 | property album : String 8 | property artist : String 9 | property license : String 10 | 11 | def initialize(@song : String, @album : String, @artist : String, @license : String) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /src/invidious/videos/regions.cr: -------------------------------------------------------------------------------- 1 | # List of geographical regions that Youtube recognizes. 2 | # This is used to determine if a video is either restricted to a list 3 | # of allowed regions (= whitelisted) or if it can't be watched in 4 | # a set of regions (= blacklisted). 5 | REGIONS = { 6 | "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", 7 | "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", 8 | "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", 9 | "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", 10 | "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", 11 | "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", 12 | "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", 13 | "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", 14 | "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", 15 | "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", 16 | "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", 17 | "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", 18 | "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", 19 | "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", 20 | "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", 21 | "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", 22 | "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", 23 | "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", 24 | "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", 25 | "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", 26 | "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW", 27 | } 28 | -------------------------------------------------------------------------------- /src/invidious/views/add_playlist_items.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= playlist.title %> - Invidious 3 | 4 | <% end %> 5 | 6 |
7 |
8 |
9 |
10 |
11 | <%= translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %> 12 | 13 |
14 | value="<%= HTML.escape(query.text) %>"<% end %> 16 | placeholder="<%= translate(locale, "Search for videos") %>"> 17 | 18 |
19 |
20 |
21 |
22 |
23 |
24 | 25 | 32 | 33 | 34 | 35 | <%= rendered "components/items_paginated" %> 36 | -------------------------------------------------------------------------------- /src/invidious/views/channel.ecr: -------------------------------------------------------------------------------- 1 | <%- 2 | ucid = channel.ucid 3 | author = HTML.escape(channel.author) 4 | channel_profile_pic = URI.parse(channel.author_thumbnail).request_target 5 | 6 | relative_url = 7 | case selected_tab 8 | when .shorts? then "/channel/#{ucid}/shorts" 9 | when .streams? then "/channel/#{ucid}/streams" 10 | when .playlists? then "/channel/#{ucid}/playlists" 11 | when .channels? then "/channel/#{ucid}/channels" 12 | when .podcasts? then "/channel/#{ucid}/podcasts" 13 | when .releases? then "/channel/#{ucid}/releases" 14 | when .courses? then "/channel/#{ucid}/courses" 15 | else 16 | "/channel/#{ucid}" 17 | end 18 | 19 | youtube_url = "https://www.youtube.com#{relative_url}" 20 | redirect_url = Invidious::Frontend::Misc.redirect_url(env) 21 | 22 | page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale, 23 | base_url: relative_url, 24 | ctoken: next_continuation, 25 | first_page: continuation.nil?, 26 | params: env.params.query, 27 | ) 28 | %> 29 | 30 | <% content_for "header" do %> 31 | <%- if selected_tab.videos? -%> 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | <%- end -%> 45 | 46 | 47 | 48 | 49 | <%= author %> - Invidious 50 | <% end %> 51 | 52 | <%= rendered "components/channel_info" %> 53 | 54 |
55 |
56 |
57 | 58 | 59 | <%= rendered "components/items_paginated" %> 60 | -------------------------------------------------------------------------------- /src/invidious/views/community.ecr: -------------------------------------------------------------------------------- 1 | <%- 2 | ucid = channel.ucid 3 | author = HTML.escape(channel.author) 4 | channel_profile_pic = URI.parse(channel.author_thumbnail).request_target 5 | 6 | relative_url = "/channel/#{ucid}/community" 7 | youtube_url = "https://www.youtube.com#{relative_url}" 8 | redirect_url = Invidious::Frontend::Misc.redirect_url(env) 9 | 10 | selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Posts 11 | -%> 12 | 13 | <% content_for "header" do %> 14 | 15 | <%= author %> - Invidious 16 | <% end %> 17 | 18 | <%= rendered "components/channel_info" %> 19 | 20 |
21 |
22 |
23 | 24 | <% if error_message %> 25 |
26 |

<%= error_message %>

27 |
28 | <% else %> 29 |
30 | <%= IV::Frontend::Comments.template_youtube(items.not_nil!, locale, thin_mode) %> 31 |
32 | <% end %> 33 | 34 | 46 | 47 | -------------------------------------------------------------------------------- /src/invidious/views/components/channel_info.ecr: -------------------------------------------------------------------------------- 1 | <% if channel.banner %> 2 |
3 | " alt="" /> 4 |
5 | 6 |
7 |
8 |
9 | <% end %> 10 | 11 |
12 |
13 |
14 | 15 | <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> 16 |
17 |
18 | 19 |
20 |
21 | <% sub_count_text = number_to_short_text(channel.sub_count) %> 22 | <%= rendered "components/subscribe_widget" %> 23 |
24 | 25 | 30 |
31 |
32 | 33 |
34 |

<%= channel.description_html %>

35 |
36 | 37 |
38 |
39 | 42 | 45 | 46 | <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %> 47 |
48 |
49 |
50 | <% sort_options.each do |sort| %> 51 |
52 | <% if sort_by == sort %> 53 | <%= translate(locale, sort) %> 54 | <% else %> 55 | <%= translate(locale, sort) %> 56 | <% end %> 57 |
58 | <% end %> 59 |
60 |
61 |
62 | -------------------------------------------------------------------------------- /src/invidious/views/components/feed_menu.ecr: -------------------------------------------------------------------------------- 1 |
2 | <% feed_menu = env.get("preferences").as(Preferences).feed_menu.dup %> 3 | <% if !env.get?("user") %> 4 | <% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %> 5 | <% end %> 6 | <% feed_menu.each do |feed| %> 7 | 8 | <%= translate(locale, feed) %> 9 | 10 | <% end %> 11 |
12 | -------------------------------------------------------------------------------- /src/invidious/views/components/items_paginated.ecr: -------------------------------------------------------------------------------- 1 | <%= page_nav_html %> 2 | 3 |
4 | <%- items.each do |item| -%> 5 | <%= rendered "components/item" %> 6 | <%- end -%> 7 |
8 | 9 | <%= page_nav_html %> 10 | 11 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/invidious/views/components/player_sources.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | <% if params.annotations %> 19 | 20 | 21 | <% end %> 22 | 23 | <% if params.listen || params.quality != "dash" %> 24 | 25 | 26 | <% end %> 27 | 28 | <% if !params.listen && params.vr_mode %> 29 | 30 | 31 | <% end %> 32 | -------------------------------------------------------------------------------- /src/invidious/views/components/search_box.ecr: -------------------------------------------------------------------------------- 1 |
2 |
3 | autofocus<% end %> 5 | name="q" placeholder="<%= translate(locale, "search") %>" 6 | title="<%= translate(locale, "search") %>" 7 | value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> 8 |
9 | 12 |
13 | -------------------------------------------------------------------------------- /src/invidious/views/components/subscribe_widget.ecr: -------------------------------------------------------------------------------- 1 | <% if user %> 2 | <% if subscriptions.includes? ucid %> 3 |
" method="post"> 4 | "> 5 | 8 |
9 | <% else %> 10 |
" method="post"> 11 | "> 12 | 15 |
16 | <% end %> 17 | 18 | 30 | 31 | <% else %> 32 | "> 34 | <%= translate(locale, "Subscribe") %> | <%= sub_count_text %> 35 | 36 | <% end %> 37 | -------------------------------------------------------------------------------- /src/invidious/views/components/video-context-buttons.ecr: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/invidious/views/create_playlist.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Create playlist") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 |
8 |
9 |
10 |
11 | <%= translate(locale, "Create playlist") %> 12 | 13 |
14 | 15 | "> 16 |
17 | 18 |
19 | 20 | 25 |
26 | 27 |
28 | 31 |
32 | 33 | 34 |
35 |
36 |
37 |
38 |
39 |
40 | -------------------------------------------------------------------------------- /src/invidious/views/delete_playlist.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Delete playlist") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 | <%= translate(locale, "Delete playlist `x`?", %|"#{HTML.escape(playlist.title)}"|) %> 8 | 9 |
10 |
11 | 14 |
15 | 20 |
21 | 22 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /src/invidious/views/edit_playlist.ecr: -------------------------------------------------------------------------------- 1 | <% title = HTML.escape(playlist.title) %> 2 | 3 | <% content_for "header" do %> 4 | <%= title %> - Invidious 5 | 6 | <% end %> 7 | 8 |
9 |
10 |
11 | 16 |
17 | 20 |
21 | 26 |
27 |
28 | 29 |
30 |
31 |

32 |
33 |
34 | 35 |
36 |
37 | 38 | <%= HTML.escape(playlist.author) %> | 39 | <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> | 40 | 41 | 46 |
47 |
48 | 49 |
50 | 51 |
52 | 53 |
54 | 55 |
56 |
57 |
58 | 59 | 60 | <%= rendered "components/items_paginated" %> 61 | -------------------------------------------------------------------------------- /src/invidious/views/embed.ecr: -------------------------------------------------------------------------------- 1 | 2 | "> 3 | 4 | 5 | 6 | 7 | 8 | <%= rendered "components/player_sources" %> 9 | 10 | 11 | 12 | 13 | <%= HTML.escape(video.title) %> - Invidious 14 | 15 | 16 | 17 | 18 | 33 | 34 | <%= rendered "components/player" %> 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/invidious/views/error.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= "Error" %> - Invidious 3 | <% end %> 4 | 5 |
6 | <%= error_message %> 7 | <%= next_steps %> 8 |
9 | -------------------------------------------------------------------------------- /src/invidious/views/feeds/history.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "History") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 |

<%= translate_count(locale, "generic_videos_count", user.watched.size, NumberFormatting::HtmlSpan) %>

8 |
9 | 14 | 19 |
20 | 21 | 28 | 29 | 30 |
31 | <% watched.each do |item| %> 32 |
33 |
34 |
35 | 36 | 37 | 38 | 39 |
40 |
" method="post"> 41 | "> 42 | 44 |
45 |
46 |
47 |

48 |
49 |
50 | <% end %> 51 |
52 | 53 | <%= 54 | IV::Frontend::Pagination.nav_numeric(locale, 55 | base_url: base_url, 56 | current_page: page, 57 | show_next: (watched.size >= max_results) 58 | ) 59 | %> 60 | -------------------------------------------------------------------------------- /src/invidious/views/feeds/playlists.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Playlists") %> - Invidious 3 | <% end %> 4 | 5 | <%= rendered "components/feed_menu" %> 6 | 7 |
8 |
9 |

<%= translate(locale, "user_created_playlists", %(#{items_created.size})) %>

10 |
11 | 16 | 23 |
24 | 25 |
26 | <% items_created.each do |item| %> 27 | <%= rendered "components/item" %> 28 | <% end %> 29 |
30 | 31 |
32 |
33 |

<%= translate(locale, "user_saved_playlists", %(#{items_saved.size})) %>

34 |
35 |
36 | 37 |
38 | <% items_saved.each do |item| %> 39 | <%= rendered "components/item" %> 40 | <% end %> 41 |
42 | 43 | 44 | -------------------------------------------------------------------------------- /src/invidious/views/feeds/popular.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | "> 3 | 4 | <% if env.get("preferences").as(Preferences).default_home != "Popular" %> 5 | <%= translate(locale, "Popular") %> - Invidious 6 | <% else %> 7 | Invidious 8 | <% end %> 9 | 10 | <% end %> 11 | 12 | <%= rendered "components/feed_menu" %> 13 | 14 |
15 | <% popular_videos.each do |item| %> 16 | <%= rendered "components/item" %> 17 | <% end %> 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /src/invidious/views/feeds/subscriptions.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Subscriptions") %> - Invidious 3 | 4 | <% end %> 5 | 6 | <%= rendered "components/feed_menu" %> 7 | 8 |
9 | 14 | 19 |
20 |

21 | 22 |

23 |
24 |
25 | 26 | <% if CONFIG.enable_user_notifications %> 27 | 28 |
29 | <%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %> 30 |
31 | 32 | <% if !notifications.empty? %> 33 |
34 |
35 |
36 | <% end %> 37 | 38 |
39 | <% notifications.each do |item| %> 40 | <%= rendered "components/item" %> 41 | <% end %> 42 |
43 | 44 | <% end %> 45 | 46 |
47 |
48 |
49 | 50 | 57 | 58 | 59 | 60 |
61 | <% videos.each do |item| %> 62 | <%= rendered "components/item" %> 63 | <% end %> 64 |
65 | 66 | 67 | 68 | <%= 69 | IV::Frontend::Pagination.nav_numeric(locale, 70 | base_url: base_url, 71 | current_page: page, 72 | show_next: ((videos.size + notifications.size) == max_results) 73 | ) 74 | %> 75 | -------------------------------------------------------------------------------- /src/invidious/views/feeds/trending.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | "> 3 | 4 | <% if env.get("preferences").as(Preferences).default_home != "Trending" %> 5 | <%= translate(locale, "Trending") %> - Invidious 6 | <% else %> 7 | Invidious 8 | <% end %> 9 | 10 | <% end %> 11 | 12 | <%= rendered "components/feed_menu" %> 13 | 14 |
15 |
16 | <% if plid %> 17 | 18 | <%= translate(locale, "View as playlist") %> 19 | 20 | <% end %> 21 |
22 |
23 |
24 | <% {"Default", "Music", "Gaming", "Movies"}.each do |option| %> 25 |
26 | <% if trending_type == option %> 27 | <%= translate(locale, option) %> 28 | <% else %> 29 | 30 | <%= translate(locale, option) %> 31 | 32 | <% end %> 33 |
34 | <% end %> 35 |
36 |
37 |
38 | 39 |
40 |
41 |
42 | 43 |
44 | <% trending.each do |item| %> 45 | <%= rendered "components/item" %> 46 | <% end %> 47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /src/invidious/views/hashtag.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= HTML.escape(hashtag) %> - Invidious 3 | <% end %> 4 | 5 |
6 | 7 | 8 | <%= rendered "components/items_paginated" %> 9 | -------------------------------------------------------------------------------- /src/invidious/views/message.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | "> 3 | 4 | Invidious 5 | 6 | <% end %> 7 | 8 | <%= rendered "components/feed_menu" %> 9 | 10 |

11 | <%= message %> 12 |

13 | -------------------------------------------------------------------------------- /src/invidious/views/mix.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= HTML.escape(mix.title) %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 |

<%= HTML.escape(mix.title) %>

8 |
9 |
10 |

11 | 12 |

13 |
14 |
15 | 16 |
17 | <% mix.videos.each do |item| %> 18 | <%= rendered "components/item" %> 19 | <% end %> 20 |
21 | -------------------------------------------------------------------------------- /src/invidious/views/post.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | Invidious 3 | <% end %> 4 | 5 |
6 |
7 | <%= IV::Frontend::Comments.template_youtube(post_response.not_nil!, locale, thin_mode) %> 8 |
9 | 10 | <% if nojs %> 11 |
12 | <% end %> 13 |
14 | 15 |
16 | <% if nojs %> 17 | <%= comment_html %> 18 | <% else %> 19 | 24 | <% end %> 25 |
26 |
27 | 28 | 47 | 48 | -------------------------------------------------------------------------------- /src/invidious/views/search.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "…" : HTML.escape(query.text) %> - Invidious 3 | 4 | <% end %> 5 | 6 | 7 | <%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %> 8 |
9 | 10 | 11 | <%- if items.empty? -%> 12 |
13 |
14 | <%= translate(locale, "search_message_no_results") %>

15 | <%= translate(locale, "search_message_change_filters_or_query") %>

16 | <%= translate(locale, "search_message_use_another_instance", redirect_url) %> 17 |
18 |
19 | <%- else -%> 20 | <%= rendered "components/items_paginated" %> 21 | <%- end -%> 22 | -------------------------------------------------------------------------------- /src/invidious/views/search_homepage.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | "> 3 | 4 | Invidious - <%= translate(locale, "search") %> 5 | 6 | 7 | <% end %> 8 | 9 | <%= rendered "components/feed_menu" %> 10 | 11 |
12 | 15 |
16 | 19 |
20 |
21 | -------------------------------------------------------------------------------- /src/invidious/views/user/change_password.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Change password") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 |
8 |
9 |
10 | <%= translate(locale, "Change password") %> 11 | 12 |
13 | 14 | "> 15 | 16 | 17 | "> 18 | 19 | 20 | "> 21 | 22 | 25 | 26 | 27 |
28 |
29 |
30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /src/invidious/views/user/clear_watch_history.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Clear watch history") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 | <%= translate(locale, "Clear watch history?") %> 8 | 9 |
10 |
11 | 14 |
15 | 20 |
21 | 22 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /src/invidious/views/user/delete_account.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Delete account") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 | <%= translate(locale, "Delete account?") %> 8 | 9 |
10 |
11 | 14 |
15 | 20 |
21 | 22 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /src/invidious/views/user/login.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Log in") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 |
8 |
9 | <% case account_type when %> 10 | <% else # "invidious" %> 11 |
12 |
13 | <% if email %> 14 | 15 | <% else %> 16 | 17 | "> 18 | <% end %> 19 | 20 | <% if password %> 21 | 22 | <% else %> 23 | 24 | "> 25 | <% end %> 26 | 27 | <% if captcha %> 28 | <% captcha = captcha.not_nil! %> 29 | 30 | <% captcha[:tokens].each_with_index do |token, i| %> 31 | 32 | <% end %> 33 | 34 | 35 | 36 | 39 | <% else %> 40 | 43 | <% end %> 44 |
45 |
46 | <% end %> 47 |
48 |
49 |
50 |
51 | -------------------------------------------------------------------------------- /src/invidious/views/user/subscription_manager.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Subscription manager") %> - Invidious 3 | <% end %> 4 | 5 | 28 | 29 | <% subscriptions.each do |channel| %> 30 |
31 |
32 | 37 |
38 |
39 |

40 |
" method="post"> 41 | "> 42 | "> 43 |
44 |

45 |
46 |
47 | 48 | <% if subscriptions[-1].author != channel.author %> 49 |
50 | <% end %> 51 |
52 | <% end %> 53 | -------------------------------------------------------------------------------- /src/invidious/views/user/token_manager.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Token manager") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 |

8 | <%= translate_count(locale, "tokens_count", tokens.size, NumberFormatting::HtmlSpan) %> 9 |

10 |
11 |
12 | 17 |
18 | 19 | <% tokens.each do |token| %> 20 |
21 |
22 |
23 |

24 | <%= token[:session] %> 25 |

26 |
27 |
28 |

<%= translate(locale, "`x` ago", recode_date(token[:issued], locale)) %>

29 |
30 |
31 |

32 |
" method="post"> 33 | "> 34 | "> 35 |
36 |

37 |
38 |
39 | 40 | <% if tokens[-1].try &.[:session]? != token[:session] %> 41 |
42 | <% end %> 43 |
44 | <% end %> 45 | -------------------------------------------------------------------------------- /src/invidious/yt_backend/extractors_utils.cr: -------------------------------------------------------------------------------- 1 | # Extracts text from InnerTube response 2 | # 3 | # InnerTube can package text in three different formats 4 | # "runs": [ 5 | # {"text": "something"}, 6 | # {"text": "cont"}, 7 | # ... 8 | # ] 9 | # 10 | # "SimpleText": "something" 11 | # 12 | # Or sometimes just none at all as with the data returned from 13 | # category continuations. 14 | # 15 | # In order to facilitate calling this function with `#[]?`: 16 | # A nil will be accepted. Of course, since nil cannot be parsed, 17 | # another nil will be returned. 18 | def extract_text(item : JSON::Any?) : String? 19 | if item.nil? 20 | return nil 21 | end 22 | 23 | if text_container = item["simpleText"]? 24 | return text_container.as_s 25 | elsif text_container = item["runs"]? 26 | return text_container.as_a.map(&.["text"].as_s).join("") 27 | else 28 | nil 29 | end 30 | end 31 | 32 | # Check if an "ownerBadges" or a "badges" element contains a verified badge. 33 | # There is currently two known types of verified badges: 34 | # 35 | # "ownerBadges": [{ 36 | # "metadataBadgeRenderer": { 37 | # "icon": { "iconType": "CHECK_CIRCLE_THICK" }, 38 | # "style": "BADGE_STYLE_TYPE_VERIFIED", 39 | # "tooltip": "Verified", 40 | # "accessibilityData": { "label": "Verified" } 41 | # } 42 | # }], 43 | # 44 | # "ownerBadges": [{ 45 | # "metadataBadgeRenderer": { 46 | # "icon": { "iconType": "OFFICIAL_ARTIST_BADGE" }, 47 | # "style": "BADGE_STYLE_TYPE_VERIFIED_ARTIST", 48 | # "tooltip": "Official Artist Channel", 49 | # "accessibilityData": { "label": "Official Artist Channel" } 50 | # } 51 | # }], 52 | # 53 | def has_verified_badge?(badges : JSON::Any?) 54 | return false if badges.nil? 55 | 56 | badges.as_a.each do |badge| 57 | style = badge.dig("metadataBadgeRenderer", "style").as_s 58 | 59 | return true if style == "BADGE_STYLE_TYPE_VERIFIED" 60 | return true if style == "BADGE_STYLE_TYPE_VERIFIED_ARTIST" 61 | end 62 | 63 | return false 64 | rescue ex 65 | LOGGER.debug("Unable to parse owner badges. Got exception: #{ex.message}") 66 | LOGGER.trace("Owner badges data: #{badges.to_json}") 67 | 68 | return false 69 | end 70 | 71 | # This function extracts SearchVideo items from a Category. 72 | # Categories are commonly returned in search results and trending pages. 73 | def extract_category(category : Category) : Array(SearchVideo) 74 | return category.contents.select(SearchVideo) 75 | end 76 | 77 | # :ditto: 78 | def extract_category(category : Category, &) 79 | category.contents.select(SearchVideo).each do |item| 80 | yield item 81 | end 82 | end 83 | 84 | def extract_selected_tab(tabs) 85 | # Extract the selected tab from the array of tabs Youtube returns 86 | return tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"] 87 | end 88 | -------------------------------------------------------------------------------- /videojs-dependencies.yml: -------------------------------------------------------------------------------- 1 | # Due to a 'video append of' error (see #3011), we're stuck on 7.12.1. 2 | video.js: 3 | version: 7.12.1 4 | shasum: 1d12eeb1f52e3679e8e4c987d9b9eb37e2247fa2 5 | 6 | videojs-contrib-quality-levels: 7 | version: 2.1.0 8 | shasum: 046e9e21ed01043f512b83a1916001d552457083 9 | 10 | videojs-http-source-selector: 11 | version: 1.1.6 12 | shasum: 073aadbea0106ba6c98d6b611094dbf8554ffa1f 13 | 14 | videojs-markers: 15 | version: 1.0.1 16 | shasum: d7f8d804253fd587813271f8db308a22b9f7df34 17 | 18 | videojs-mobile-ui: 19 | version: 0.6.1 20 | shasum: 0e146c4c481cbee0729cb5e162e558b455562cd0 21 | 22 | videojs-overlay: 23 | version: 2.1.4 24 | shasum: 5a103b25374dbb753eb87960d8360c2e8f39cc05 25 | 26 | videojs-share: 27 | version: 3.2.1 28 | shasum: 0a3024b981387b9d21c058c829760a72c14b8ceb 29 | 30 | videojs-vr: 31 | version: 1.8.0 32 | shasum: 7f2f07f760d8a329c615acd316e49da6ee8edd34 33 | 34 | videojs-vtt-thumbnails: 35 | version: 0.0.13 36 | shasum: d1e7d47f4ed80bb52f5fc4f4bad4bfc871f5970f 37 | 38 | # We're using iv-org's fork of videojs-quality-selector, 39 | # which isn't published on NPM, and doesn't have any 40 | # easy way of fetching the compiled variant. 41 | # 42 | # silvermine-videojs-quality-selector: 43 | # version: 1.1.2 44 | # shasum: 94033ff9ee52ba6da1263b97c9a74d5b3dfdf711 45 | 46 | 47 | # Ditto. Although this extension contains the complied variant in its git repo, 48 | # it lacks any sort of versioning. As such, the script will ignore it. 49 | # 50 | # videojs-youtube-annotations: 51 | # github: https://github.com/afrmtbl/videojs-youtube-annotations 52 | 53 | 54 | 55 | --------------------------------------------------------------------------------