├── .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 |
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 |
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 |
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 |
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 |
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 |
58 | <% end %>
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/src/invidious/views/components/feed_menu.ecr:
--------------------------------------------------------------------------------
1 |
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 |
13 |
--------------------------------------------------------------------------------
/src/invidious/views/components/subscribe_widget.ecr:
--------------------------------------------------------------------------------
1 | <% if user %>
2 | <% if subscriptions.includes? ucid %>
3 |
9 | <% else %>
10 |
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 |
40 |
--------------------------------------------------------------------------------
/src/invidious/views/delete_playlist.ecr:
--------------------------------------------------------------------------------
1 | <% content_for "header" do %>
2 | <%= translate(locale, "Delete playlist") %> - Invidious
3 | <% end %>
4 |
5 |
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 |
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 |
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 |
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 |
22 |
23 |
24 | <% {"Default", "Music", "Gaming", "Movies"}.each do |option| %>
25 |
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 |
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 |
9 |
10 | <% if nojs %>
11 |
12 | <% end %>
13 |
14 |
15 |
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 |
21 |
--------------------------------------------------------------------------------
/src/invidious/views/user/change_password.ecr:
--------------------------------------------------------------------------------
1 | <% content_for "header" do %>
2 | <%= translate(locale, "Change password") %> - Invidious
3 | <% end %>
4 |
5 |
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 |
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 |
24 |
25 |
--------------------------------------------------------------------------------
/src/invidious/views/user/login.ecr:
--------------------------------------------------------------------------------
1 | <% content_for "header" do %>
2 | <%= translate(locale, "Log in") %> - Invidious
3 | <% end %>
4 |
5 |
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 |
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 |
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 |
--------------------------------------------------------------------------------