├── locales ├── af.json ├── or.json └── bn_BD.json ├── kubernetes ├── .gitignore ├── Chart.lock ├── templates │ ├── configmap.yaml │ ├── _helpers.tpl │ ├── service.yaml │ ├── hpa.yaml │ └── deployment.yaml ├── Chart.yaml ├── README.md └── values.yaml ├── assets ├── robots.txt ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── fonts │ ├── ionicons.eot │ ├── ionicons.ttf │ ├── ionicons.woff │ └── ionicons.woff2 ├── mstile-150x150.png ├── apple-touch-icon.png ├── videojs │ └── .gitignore ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── browserconfig.xml ├── css │ ├── empty.css │ ├── quality-selector.css │ ├── embed.css │ ├── videojs-youtube-annotations.min.css │ └── search.css ├── site.webmanifest ├── js │ ├── watched_indicator.js │ ├── watched_widget.js │ ├── playlist_widget.js │ ├── themes.js │ ├── subscribe_widget.js │ ├── embed.js │ └── community.js └── safari-pinned-tab.svg ├── .github ├── FUNDING.yml ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── enhancement.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── stale.yml │ ├── auto-close-duplicate.yaml │ └── ci.yml ├── TRANSLATION ├── .gitmodules ├── screenshots ├── 01_player.png ├── 02_preferences.png ├── 04_description.png ├── 05_preferences.png ├── 03_subscriptions.png ├── 06_subscriptions.png └── native_notification.png ├── .gitignore ├── .gitattributes ├── .editorconfig ├── src ├── invidious │ ├── views │ │ ├── error.ecr │ │ ├── message.ecr │ │ ├── components │ │ │ ├── search_box.ecr │ │ │ ├── feed_menu.ecr │ │ │ ├── video-context-buttons.ecr │ │ │ ├── player_sources.ecr │ │ │ ├── subscribe_widget.ecr │ │ │ └── channel_info.ecr │ │ ├── mix.ecr │ │ ├── feeds │ │ │ ├── popular.ecr │ │ │ ├── playlists.ecr │ │ │ ├── trending.ecr │ │ │ ├── subscriptions.ecr │ │ │ └── history.ecr │ │ ├── search_homepage.ecr │ │ ├── user │ │ │ ├── delete_account.ecr │ │ │ ├── clear_watch_history.ecr │ │ │ ├── change_password.ecr │ │ │ ├── token_manager.ecr │ │ │ ├── subscription_manager.ecr │ │ │ └── authorize_token.ecr │ │ ├── delete_playlist.ecr │ │ ├── hashtag.ecr │ │ ├── embed.ecr │ │ ├── community.ecr │ │ ├── create_playlist.ecr │ │ ├── search.ecr │ │ ├── channel.ecr │ │ └── add_playlist_items.ecr │ ├── user │ │ ├── converters.cr │ │ ├── user.cr │ │ ├── cookies.cr │ │ └── exports.cr │ ├── database │ │ ├── migrations │ │ │ ├── 0010_make_videos_unlogged.cr │ │ │ ├── 0007_create_annotations_table.cr │ │ │ ├── 0006_create_nonces_table.cr │ │ │ ├── 0009_create_playlist_videos_table.cr │ │ │ ├── 0002_create_videos_table.cr │ │ │ ├── 0005_create_session_ids_table.cr │ │ │ ├── 0001_create_channels_table.cr │ │ │ ├── 0004_create_users_table.cr │ │ │ ├── 0003_create_channel_videos_table.cr │ │ │ └── 0008_create_playlists_table.cr │ │ ├── annotations.cr │ │ ├── migration.cr │ │ ├── statistics.cr │ │ ├── videos.cr │ │ ├── nonces.cr │ │ ├── migrator.cr │ │ └── sessions.cr │ ├── videos │ │ ├── music.cr │ │ ├── regions.cr │ │ └── description.cr │ ├── jobs │ │ ├── update_decrypt_function_job.cr │ │ ├── pull_popular_videos_job.cr │ │ ├── notification_job.cr │ │ ├── clear_expired_items_job.cr │ │ ├── base_job.cr │ │ ├── subscribe_to_feeds_job.cr │ │ ├── statistics_refresh_job.cr │ │ ├── refresh_channels_job.cr │ │ └── refresh_feeds_job.cr │ ├── frontend │ │ ├── misc.cr │ │ ├── channel_page.cr │ │ └── comments_reddit.cr │ ├── jsonify │ │ └── api_v1 │ │ │ └── common.cr │ ├── http_server │ │ └── utils.cr │ ├── search │ │ ├── ctoken.cr │ │ └── processors.cr │ ├── routes │ │ ├── notifications.cr │ │ ├── api │ │ │ └── v1 │ │ │ │ ├── feeds.cr │ │ │ │ └── search.cr │ │ ├── misc.cr │ │ └── errors.cr │ ├── jobs.cr │ ├── channels │ │ └── playlists.cr │ ├── exceptions.cr │ ├── trending.cr │ ├── hashtag.cr │ ├── comments │ │ ├── reddit_types.cr │ │ ├── reddit.cr │ │ └── links_util.cr │ ├── helpers │ │ ├── logger.cr │ │ ├── crystal_class_overrides.cr │ │ ├── macros.cr │ │ └── signatures.cr │ └── yt_backend │ │ └── extractors_utils.cr └── ext │ └── kemal_content_for.cr ├── config ├── migrate-scripts │ ├── migrate-db-3bcb98e.sh │ ├── migrate-db-52cb239.sh │ ├── migrate-db-701b5ea.sh │ ├── migrate-db-88b7097.sh │ ├── migrate-db-30e6d29.sh │ ├── migrate-db-17cf077.sh │ ├── migrate-db-6e51189.sh │ ├── migrate-db-8e884fe.sh │ ├── migrate-db-3646395.sh │ ├── migrate-db-1c8075c.sh │ └── migrate-db-1eca969.sh └── sql │ ├── annotations.sql │ ├── playlist_videos.sql │ ├── nonces.sql │ ├── videos.sql │ ├── session_ids.sql │ ├── playlists.sql │ ├── channels.sql │ ├── users.sql │ └── channel_videos.sql ├── invidious.service ├── spec ├── spec_helper.cr ├── parsers_helper.cr └── invidious │ ├── user │ └── imports_spec.cr │ └── utils_spec.cr ├── scripts ├── git │ └── pre-commit └── deploy-database.sh ├── docker ├── init-invidious-db.sh ├── Dockerfile └── Dockerfile.arm64 ├── shard.yml ├── shard.lock ├── videojs-dependencies.yml ├── docker-compose.yml ├── .ameba.yml └── Makefile /locales/af.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /locales/or.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /kubernetes/.gitignore: -------------------------------------------------------------------------------- 1 | /charts/*.tgz 2 | -------------------------------------------------------------------------------- /assets/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://invidious.io/donate/ 2 | -------------------------------------------------------------------------------- /TRANSLATION: -------------------------------------------------------------------------------- 1 | https://hosted.weblate.org/projects/invidious/ 2 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "mocks"] 2 | path = mocks 3 | url = ../mocks 4 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/assets/favicon.ico -------------------------------------------------------------------------------- /assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/assets/favicon-16x16.png -------------------------------------------------------------------------------- /assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/assets/favicon-32x32.png -------------------------------------------------------------------------------- /assets/fonts/ionicons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/assets/fonts/ionicons.eot -------------------------------------------------------------------------------- /assets/fonts/ionicons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/assets/fonts/ionicons.ttf -------------------------------------------------------------------------------- /assets/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/assets/mstile-150x150.png -------------------------------------------------------------------------------- /screenshots/01_player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/screenshots/01_player.png -------------------------------------------------------------------------------- /assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/fonts/ionicons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/assets/fonts/ionicons.woff -------------------------------------------------------------------------------- /assets/fonts/ionicons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/assets/fonts/ionicons.woff2 -------------------------------------------------------------------------------- /assets/videojs/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /screenshots/02_preferences.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/screenshots/02_preferences.png -------------------------------------------------------------------------------- /screenshots/04_description.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/screenshots/04_description.png -------------------------------------------------------------------------------- /screenshots/05_preferences.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/screenshots/05_preferences.png -------------------------------------------------------------------------------- /screenshots/03_subscriptions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/screenshots/03_subscriptions.png -------------------------------------------------------------------------------- /screenshots/06_subscriptions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/screenshots/06_subscriptions.png -------------------------------------------------------------------------------- /assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /screenshots/native_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/invidious/master/screenshots/native_notification.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /dev/ 3 | /lib/ 4 | /bin/ 5 | /.shards/ 6 | /.vscode/ 7 | /invidious 8 | /sentry 9 | /config/config.yml 10 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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-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 | -------------------------------------------------------------------------------- /kubernetes/Chart.lock: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: postgresql 3 | repository: https://charts.bitnami.com/bitnami/ 4 | version: 12.1.9 5 | digest: sha256:71ff342a6c0a98bece3d7fe199983afb2113f8db65a3e3819de875af2c45add7 6 | generated: "2023-01-20T20:42:32.757707004Z" 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 | -------------------------------------------------------------------------------- /assets/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2b5797 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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-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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /kubernetes/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ template "invidious.fullname" . }} 5 | labels: 6 | app: {{ template "invidious.name" . }} 7 | chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" 8 | release: {{ .Release.Name }} 9 | data: 10 | INVIDIOUS_CONFIG: | 11 | {{ toYaml .Values.config | indent 4 }} 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/invidious/jobs/update_decrypt_function_job.cr: -------------------------------------------------------------------------------- 1 | class Invidious::Jobs::UpdateDecryptFunctionJob < Invidious::Jobs::BaseJob 2 | def begin 3 | loop do 4 | begin 5 | DECRYPT_FUNCTION.update_decrypt_function 6 | rescue ex 7 | LOGGER.error("UpdateDecryptFunctionJob : #{ex.message}") 8 | ensure 9 | sleep 1.minute 10 | Fiber.yield 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /src/ext/kemal_content_for.cr: -------------------------------------------------------------------------------- 1 | # Overrides for Kemal's `content_for` macro in order to keep using 2 | # kilt as it was before Kemal v1.1.1 (Kemal PR #618). 3 | 4 | require "kemal" 5 | require "kilt" 6 | 7 | macro content_for(key, file = __FILE__) 8 | %proc = ->() { 9 | __kilt_io__ = IO::Memory.new 10 | {{ yield }} 11 | __kilt_io__.to_s 12 | } 13 | 14 | CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, %proc 15 | nil 16 | end 17 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/invidious/views/components/search_box.ecr: -------------------------------------------------------------------------------- 1 |
2 |
3 | autofocus<% end %> 5 | name="q" placeholder="<%= translate(locale, "search") %>" 6 | title="<%= translate(locale, "search") %>" 7 | value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> 8 |
9 |
10 | -------------------------------------------------------------------------------- /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 | redirect_url = "/redirect?referer=#{current_page}" 10 | else 11 | redirect_url = "https://redirect.invidious.io#{env.request.resource}" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /src/invidious/views/components/feed_menu.ecr: -------------------------------------------------------------------------------- 1 |
2 | <% feed_menu = env.get("preferences").as(Preferences).feed_menu.dup %> 3 | <% if !env.get?("user") %> 4 | <% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %> 5 | <% end %> 6 | <% feed_menu.each do |feed| %> 7 | 8 | <%= translate(locale, feed) %> 9 | 10 | <% end %> 11 |
12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Default and lowest precedence. If none of the below matches, @iv-org/developers would be requested for review. 2 | * @iv-org/developers 3 | 4 | docker-compose.yml @unixfox 5 | docker/ @unixfox 6 | kubernetes/ @unixfox 7 | 8 | README.md @thefrenchghosty 9 | config/config.example.yml @thefrenchghosty @SamantazFox @unixfox 10 | 11 | scripts/ @syeopite 12 | shards.lock @syeopite 13 | shards.yml @syeopite 14 | 15 | locales/ @SamantazFox 16 | src/invidious/helpers/i18n.cr @SamantazFox 17 | 18 | src/invidious/helpers/youtube_api.cr @SamantazFox 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/views/mix.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= HTML.escape(mix.title) %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 |

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

8 |
9 |
10 |

11 | 12 |

13 |
14 |
15 | 16 |
17 | <% mix.videos.each do |item| %> 18 | <%= rendered "components/item" %> 19 | <% end %> 20 |
21 | -------------------------------------------------------------------------------- /kubernetes/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "invidious.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | */}} 13 | {{- define "invidious.fullname" -}} 14 | {{- $name := default .Chart.Name .Values.nameOverride -}} 15 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 16 | {{- end -}} 17 | -------------------------------------------------------------------------------- /kubernetes/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ template "invidious.fullname" . }} 5 | labels: 6 | app: {{ template "invidious.name" . }} 7 | chart: {{ .Chart.Name }} 8 | release: {{ .Release.Name }} 9 | spec: 10 | type: {{ .Values.service.type }} 11 | ports: 12 | - name: http 13 | port: {{ .Values.service.port }} 14 | targetPort: 3000 15 | selector: 16 | app: {{ template "invidious.name" . }} 17 | release: {{ .Release.Name }} 18 | {{- if .Values.service.loadBalancerIP }} 19 | loadBalancerIP: {{ .Values.service.loadBalancerIP }} 20 | {{- end }} 21 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/invidious/http_server/utils.cr: -------------------------------------------------------------------------------- 1 | module Invidious::HttpServer 2 | module Utils 3 | extend self 4 | 5 | def proxy_video_url(raw_url : String, *, region : String? = nil, absolute : Bool = false) 6 | url = URI.parse(raw_url) 7 | 8 | # Add some URL parameters 9 | params = url.query_params 10 | params["host"] = url.host.not_nil! # Should never be nil, in theory 11 | params["region"] = region if !region.nil? 12 | 13 | if absolute 14 | return "#{HOST_URL}#{url.request_target}?#{params}" 15 | else 16 | return "#{url.request_target}?#{params}" 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /kubernetes/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: invidious 3 | description: Invidious is an alternative front-end to YouTube 4 | version: 1.1.1 5 | appVersion: 0.20.1 6 | keywords: 7 | - youtube 8 | - proxy 9 | - video 10 | - privacy 11 | home: https://invidio.us/ 12 | icon: https://raw.githubusercontent.com/iv-org/invidious/05988c1c49851b7d0094fca16aeaf6382a7f64ab/assets/favicon-32x32.png 13 | sources: 14 | - https://github.com/iv-org/invidious 15 | maintainers: 16 | - name: Leon Klingele 17 | email: mail@leonklingele.de 18 | dependencies: 19 | - name: postgresql 20 | version: ~12.1.6 21 | repository: "https://charts.bitnami.com/bitnami/" 22 | engine: gotpl 23 | -------------------------------------------------------------------------------- /kubernetes/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ template "invidious.fullname" . }} 6 | labels: 7 | app: {{ template "invidious.name" . }} 8 | chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" 9 | release: {{ .Release.Name }} 10 | spec: 11 | scaleTargetRef: 12 | apiVersion: apps/v1 13 | kind: Deployment 14 | name: {{ template "invidious.fullname" . }} 15 | minReplicas: {{ .Values.autoscaling.minReplicas }} 16 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 17 | targetCPUUtilizationPercentage: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 18 | {{- end }} 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/invidious/jobs/notification_job.cr: -------------------------------------------------------------------------------- 1 | class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob 2 | private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)}) 3 | private getter pg_url : URI 4 | 5 | def initialize(@connection_channel, @pg_url) 6 | end 7 | 8 | def begin 9 | connections = [] of ::Channel(PQ::Notification) 10 | 11 | PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) } 12 | 13 | loop do 14 | action, connection = connection_channel.receive 15 | 16 | case action 17 | when true 18 | connections << connection 19 | when false 20 | connections.delete(connection) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/views/search_homepage.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | "> 3 | 4 | Invidious - <%= translate(locale, "search") %> 5 | 6 | 7 | <% end %> 8 | 9 | <%= rendered "components/feed_menu" %> 10 | 11 |
12 | 15 |
16 | 19 |
20 |
21 | -------------------------------------------------------------------------------- /src/invidious/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: invidious 2 | version: 0.20.1 3 | 4 | authors: 5 | - Omar Roth 6 | - Invidous team 7 | 8 | targets: 9 | invidious: 10 | main: src/invidious.cr 11 | 12 | dependencies: 13 | pg: 14 | github: will/crystal-pg 15 | version: ~> 0.24.0 16 | sqlite3: 17 | github: crystal-lang/crystal-sqlite3 18 | version: ~> 0.18.0 19 | kemal: 20 | github: kemalcr/kemal 21 | version: ~> 1.1.2 22 | kilt: 23 | github: jeromegn/kilt 24 | version: ~> 0.6.1 25 | protodec: 26 | github: iv-org/protodec 27 | version: ~> 0.1.5 28 | lsquic: 29 | github: iv-org/lsquic.cr 30 | version: ~> 2.18.1-2 31 | athena-negotiation: 32 | github: athena-framework/negotiation 33 | version: ~> 0.1.1 34 | 35 | development_dependencies: 36 | spectator: 37 | github: icy-arctic-fox/spectator 38 | version: ~> 0.10.4 39 | ameba: 40 | github: crystal-ameba/ameba 41 | version: ~> 0.14.3 42 | 43 | crystal: ">= 1.0.0, < 2.0.0" 44 | 45 | license: AGPLv3 46 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/invidious/views/user/delete_account.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Delete account") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 | <%= translate(locale, "Delete account?") %> 8 | 9 |
10 |
11 | 14 |
15 | 20 |
21 | 22 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/views/user/clear_watch_history.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Clear watch history") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 | <%= translate(locale, "Clear watch history?") %> 8 | 9 |
10 |
11 | 14 |
15 | 20 |
21 | 22 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /src/invidious/views/components/video-context-buttons.ecr: -------------------------------------------------------------------------------- 1 |
2 | 21 |
-------------------------------------------------------------------------------- /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/views/delete_playlist.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Delete playlist") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 | <%= translate(locale, "Delete playlist `x`?", %|"#{HTML.escape(playlist.title)}"|) %> 8 | 9 |
10 |
11 | 14 |
15 | 20 |
21 | 22 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /kubernetes/README.md: -------------------------------------------------------------------------------- 1 | # Invidious Helm chart 2 | 3 | Easily deploy Invidious to Kubernetes. 4 | 5 | ## Installing Helm chart 6 | 7 | ```sh 8 | # Build Helm dependencies 9 | $ helm dep build 10 | 11 | # Add PostgreSQL init scripts 12 | $ kubectl create configmap invidious-postgresql-init \ 13 | --from-file=../config/sql/channels.sql \ 14 | --from-file=../config/sql/videos.sql \ 15 | --from-file=../config/sql/channel_videos.sql \ 16 | --from-file=../config/sql/users.sql \ 17 | --from-file=../config/sql/session_ids.sql \ 18 | --from-file=../config/sql/nonces.sql \ 19 | --from-file=../config/sql/annotations.sql \ 20 | --from-file=../config/sql/playlists.sql \ 21 | --from-file=../config/sql/playlist_videos.sql 22 | 23 | # Install Helm app to your Kubernetes cluster 24 | $ helm install invidious ./ 25 | ``` 26 | 27 | ## Upgrading 28 | 29 | ```sh 30 | # Upgrading is easy, too! 31 | $ helm upgrade invidious ./ 32 | ``` 33 | 34 | ## Uninstall 35 | 36 | ```sh 37 | # Get rid of everything (except database) 38 | $ helm delete invidious 39 | 40 | # To also delete the database, remove all invidious-postgresql PVCs 41 | ``` 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_1m : 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_6m : 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 | -------------------------------------------------------------------------------- /.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 | 19 | 20 | 21 | **Describe the bug** 22 | 23 | 24 | **Steps to Reproduce** 25 | 31 | 32 | **Logs** 33 | 34 | 35 | **Screenshots** 36 | 37 | 38 | **Additional context** 39 | 43 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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=1&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=1&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/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 | -------------------------------------------------------------------------------- /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, 400, 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/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/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/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/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 | items.each do |itm| 26 | if itm.is_a?(Category) 27 | # Ignore the smaller categories, as they generally contain a sponsored 28 | # channel, which brings a lot of noise on the trending page. 29 | # See: https://github.com/iv-org/invidious/issues/2989 30 | next if itm.contents.size < 24 31 | 32 | extracted.concat extract_category(itm) 33 | else 34 | extracted << itm 35 | end 36 | end 37 | 38 | # Deduplicate items before returning results 39 | return extracted.select(SearchVideo).uniq!(&.id), plid 40 | end 41 | -------------------------------------------------------------------------------- /kubernetes/values.yaml: -------------------------------------------------------------------------------- 1 | name: invidious 2 | 3 | image: 4 | repository: quay.io/invidious/invidious 5 | tag: latest 6 | pullPolicy: Always 7 | 8 | replicaCount: 1 9 | 10 | autoscaling: 11 | enabled: false 12 | minReplicas: 1 13 | maxReplicas: 16 14 | targetCPUUtilizationPercentage: 50 15 | 16 | service: 17 | type: ClusterIP 18 | port: 3000 19 | #loadBalancerIP: 20 | 21 | resources: {} 22 | #requests: 23 | # cpu: 100m 24 | # memory: 64Mi 25 | #limits: 26 | # cpu: 800m 27 | # memory: 512Mi 28 | 29 | securityContext: 30 | allowPrivilegeEscalation: false 31 | runAsUser: 1000 32 | runAsGroup: 1000 33 | fsGroup: 1000 34 | 35 | # See https://github.com/bitnami/charts/tree/master/bitnami/postgresql 36 | postgresql: 37 | image: 38 | tag: 13 39 | auth: 40 | username: kemal 41 | password: kemal 42 | database: invidious 43 | primary: 44 | initdb: 45 | username: kemal 46 | password: kemal 47 | scriptsConfigMap: invidious-postgresql-init 48 | 49 | # Adapted from ../config/config.yml 50 | config: 51 | channel_threads: 1 52 | feed_threads: 1 53 | db: 54 | user: kemal 55 | password: kemal 56 | host: invidious-postgresql 57 | port: 5432 58 | dbname: invidious 59 | full_refresh: false 60 | https_only: false 61 | domain: 62 | -------------------------------------------------------------------------------- /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 | instance_url = fetch_random_instance 44 | env.redirect "https://#{instance_url}#{referer}" 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | athena-negotiation: 4 | git: https://github.com/athena-framework/negotiation.git 5 | version: 0.1.1 6 | 7 | backtracer: 8 | git: https://github.com/sija/backtracer.cr.git 9 | version: 1.2.1 10 | 11 | db: 12 | git: https://github.com/crystal-lang/crystal-db.git 13 | version: 0.10.1 14 | 15 | exception_page: 16 | git: https://github.com/crystal-loot/exception_page.git 17 | version: 0.2.2 18 | 19 | kemal: 20 | git: https://github.com/kemalcr/kemal.git 21 | version: 1.1.2 22 | 23 | kilt: 24 | git: https://github.com/jeromegn/kilt.git 25 | version: 0.6.1 26 | 27 | lsquic: 28 | git: https://github.com/iv-org/lsquic.cr.git 29 | version: 2.18.1-2 30 | 31 | pg: 32 | git: https://github.com/will/crystal-pg.git 33 | version: 0.24.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.4 46 | 47 | sqlite3: 48 | git: https://github.com/crystal-lang/crystal-sqlite3.git 49 | version: 0.18.0 50 | 51 | ameba: 52 | git: https://github.com/crystal-ameba/ameba.git 53 | version: 0.14.3 54 | -------------------------------------------------------------------------------- /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/frontend/channel_page.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Frontend::ChannelPage 2 | extend self 3 | 4 | enum TabsAvailable 5 | Videos 6 | Shorts 7 | Streams 8 | Playlists 9 | Community 10 | Channels 11 | end 12 | 13 | def generate_tabs_links(locale : String, channel : AboutChannel, selected_tab : TabsAvailable) 14 | return String.build(1500) do |str| 15 | base_url = "/channel/#{channel.ucid}" 16 | 17 | TabsAvailable.each do |tab| 18 | # Ignore playlists, as it is not supported for auto-generated channels yet 19 | next if (tab.playlists? && channel.auto_generated) 20 | 21 | tab_name = tab.to_s.downcase 22 | 23 | if channel.tabs.includes? tab_name 24 | str << %(
\n) 25 | 26 | if tab == selected_tab 27 | str << "\t" 28 | str << translate(locale, "channel_tab_#{tab_name}_label") 29 | str << "\n" 30 | else 31 | # Video tab doesn't have the last path component 32 | url = tab.videos? ? base_url : "#{base_url}/#{tab_name}" 33 | 34 | str << %(\t) 35 | str << translate(locale, "channel_tab_#{tab_name}_label") 36 | str << "\n" 37 | end 38 | 39 | str << "
" 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /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/views/hashtag.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= HTML.escape(hashtag) %> - Invidious 3 | <% end %> 4 | 5 |
6 | 7 |
8 |
9 | <%- if page > 1 -%> 10 | <%= translate(locale, "Previous page") %> 11 | <%- end -%> 12 |
13 |
14 |
15 | <%- if videos.size >= 60 -%> 16 | <%= translate(locale, "Next page") %> 17 | <%- end -%> 18 |
19 |
20 | 21 |
22 | <%- videos.each do |item| -%> 23 | <%= rendered "components/item" %> 24 | <%- end -%> 25 |
26 | 27 | 28 | 29 |
30 |
31 | <%- if page > 1 -%> 32 | <%= translate(locale, "Previous page") %> 33 | <%- end -%> 34 |
35 |
36 |
37 | <%- if videos.size >= 60 -%> 38 | <%= translate(locale, "Next page") %> 39 | <%- end -%> 40 |
41 |
42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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@v5 14 | with: 15 | repo-token: ${{ secrets.GITHUB_TOKEN }} 16 | days-before-stale: 365 17 | days-before-pr-stale: 45 # PRs should be active. Anything that hasn't had activity in more than 45 days should be considered abandoned. 18 | days-before-close: 30 19 | exempt-pr-labels: blocked 20 | 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.' 21 | stale-pr-message: 'This pull request has been automatically marked as stale and will be closed in 30 days because it has not had recent activity and is much likely abandoned or outdated. If you think this pull request is still relevant and applicable, you just have to post a comment and it will be unmarked.' 22 | stale-issue-label: "stale" 23 | stale-pr-label: "stale" 24 | ascending: true 25 | # Never mark feature requests/enhancements as stale 26 | exempt-issue-labels: "feature-request,enhancement,exempt-stale" 27 | -------------------------------------------------------------------------------- /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/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/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/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/routes/errors.cr: -------------------------------------------------------------------------------- 1 | module Invidious::Routes::ErrorRoutes 2 | def self.error_404(env) 3 | if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) 4 | item = md["id"] 5 | 6 | # Check if item is branding URL e.g. https://youtube.com/gaming 7 | response = YT_POOL.client &.get("/#{item}") 8 | 9 | if response.status_code == 301 10 | response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target) 11 | end 12 | 13 | if response.body.empty? 14 | env.response.headers["Location"] = "/" 15 | haltf env, status_code: 302 16 | end 17 | 18 | html = XML.parse_html(response.body) 19 | ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] 20 | 21 | if ucid 22 | env.response.headers["Location"] = "/channel/#{ucid}" 23 | haltf env, status_code: 302 24 | end 25 | 26 | params = [] of String 27 | env.params.query.each do |k, v| 28 | params << "#{k}=#{v}" 29 | end 30 | params = params.join("&") 31 | 32 | url = "/watch?v=#{item}" 33 | if !params.empty? 34 | url += "&#{params}" 35 | end 36 | 37 | # Check if item is video ID 38 | if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404 39 | env.response.headers["Location"] = url 40 | haltf env, status_code: 302 41 | end 42 | end 43 | 44 | env.response.headers["Location"] = "/" 45 | haltf env, status_code: 302 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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::Community 11 | -%> 12 | 13 | <% content_for "header" do %> 14 | 15 | <%= author %> - Invidious 16 | <% end %> 17 | 18 | <%= rendered "components/channel_info" %> 19 | 20 |
21 |
22 |
23 | 24 | <% if error_message %> 25 |
26 |

<%= error_message %>

27 |
28 | <% else %> 29 |
30 | <%= IV::Frontend::Comments.template_youtube(items.not_nil!, locale, thin_mode) %> 31 |
32 | <% end %> 33 | 34 | 46 | 47 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/invidious/views/feeds/trending.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | "> 3 | 4 | <% if env.get("preferences").as(Preferences).default_home != "Trending" %> 5 | <%= translate(locale, "Trending") %> - Invidious 6 | <% else %> 7 | Invidious 8 | <% end %> 9 | 10 | <% end %> 11 | 12 | <%= rendered "components/feed_menu" %> 13 | 14 |
15 |
16 | <% if plid %> 17 | 18 | <%= translate(locale, "View as playlist") %> 19 | 20 | <% end %> 21 |
22 |
23 |
24 | <% {"Default", "Music", "Gaming", "Movies"}.each do |option| %> 25 |
26 | <% if trending_type == option %> 27 | <%= translate(locale, option) %> 28 | <% else %> 29 | 30 | <%= translate(locale, option) %> 31 | 32 | <% end %> 33 |
34 | <% end %> 35 |
36 |
37 |
38 | 39 |
40 |
41 |
42 | 43 |
44 | <% trending.each do |item| %> 45 | <%= rendered "components/item" %> 46 | <% end %> 47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/invidious/views/user/change_password.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Change password") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 |
8 |
9 |
10 | <%= translate(locale, "Change password") %> 11 | 12 |
13 | 14 | "> 15 | 16 | 17 | "> 18 | 19 | 20 | "> 21 | 22 | 25 | 26 | 27 |
28 |
29 |
30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /src/invidious/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 | -------------------------------------------------------------------------------- /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=1&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=1&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=1&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/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/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=1&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=1&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 | -------------------------------------------------------------------------------- /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/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 | private getter db : DB::Database 24 | 25 | def initialize(@db, @software_config : Hash(String, String)) 26 | end 27 | 28 | def begin 29 | load_initial_stats 30 | 31 | loop do 32 | refresh_stats 33 | sleep 1.minute 34 | Fiber.yield 35 | end 36 | end 37 | 38 | # should only be called once at the very beginning 39 | private def load_initial_stats 40 | STATISTICS["software"] = { 41 | "name" => @software_config["name"], 42 | "version" => @software_config["version"], 43 | "branch" => @software_config["branch"], 44 | } 45 | STATISTICS["openRegistrations"] = CONFIG.registration_enabled 46 | end 47 | 48 | private def refresh_stats 49 | users = STATISTICS.dig("usage", "users").as(Hash(String, Int64)) 50 | 51 | users["total"] = Invidious::Database::Statistics.count_users_total 52 | users["activeHalfyear"] = Invidious::Database::Statistics.count_users_active_1m 53 | users["activeMonth"] = Invidious::Database::Statistics.count_users_active_6m 54 | 55 | STATISTICS["metadata"] = { 56 | "updatedAt" => Time.utc.to_unix, 57 | "lastChannelRefreshedAt" => Invidious::Database::Statistics.channel_last_update.try &.to_unix || 0_i64, 58 | } 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | healthcheck: 34 | test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1 35 | interval: 30s 36 | timeout: 5s 37 | retries: 2 38 | depends_on: 39 | - invidious-db 40 | 41 | invidious-db: 42 | image: docker.io/library/postgres:13 43 | restart: unless-stopped 44 | volumes: 45 | - postgresdata:/var/lib/postgresql/data 46 | - ./config/sql:/config/sql 47 | - ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh 48 | environment: 49 | POSTGRES_DB: invidious 50 | POSTGRES_USER: kemal 51 | POSTGRES_PASSWORD: kemal 52 | healthcheck: 53 | test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] 54 | 55 | volumes: 56 | postgresdata: 57 | -------------------------------------------------------------------------------- /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/helpers/logger.cr: -------------------------------------------------------------------------------- 1 | enum LogLevel 2 | All = 0 3 | Trace = 1 4 | Debug = 2 5 | Info = 3 6 | Warn = 4 7 | Error = 5 8 | Fatal = 6 9 | Off = 7 10 | end 11 | 12 | class Invidious::LogHandler < Kemal::BaseLogHandler 13 | def initialize(@io : IO = STDOUT, @level = LogLevel::Debug) 14 | end 15 | 16 | def call(context : HTTP::Server::Context) 17 | elapsed_time = Time.measure { call_next(context) } 18 | elapsed_text = elapsed_text(elapsed_time) 19 | 20 | # Default: full path with parameters 21 | requested_url = context.request.resource 22 | 23 | # Try not to log search queries passed as GET parameters during normal use 24 | # (They will still be logged if log level is 'Debug' or 'Trace') 25 | if @level > LogLevel::Debug && ( 26 | requested_url.downcase.includes?("search") || requested_url.downcase.includes?("q=") 27 | ) 28 | # Log only the path 29 | requested_url = context.request.path 30 | end 31 | 32 | info("#{context.response.status_code} #{context.request.method} #{requested_url} #{elapsed_text}") 33 | 34 | context 35 | end 36 | 37 | def puts(message : String) 38 | @io << message << '\n' 39 | @io.flush 40 | end 41 | 42 | def write(message : String) 43 | @io << message 44 | @io.flush 45 | end 46 | 47 | def set_log_level(level : String) 48 | @level = LogLevel.parse(level) 49 | end 50 | 51 | def set_log_level(level : LogLevel) 52 | @level = level 53 | end 54 | 55 | {% for level in %w(trace debug info warn error fatal) %} 56 | def {{level.id}}(message : String) 57 | if LogLevel::{{level.id.capitalize}} >= @level 58 | puts("#{Time.utc} [{{level.id}}] #{message}") 59 | end 60 | end 61 | {% end %} 62 | 63 | private def elapsed_text(elapsed) 64 | millis = elapsed.total_milliseconds 65 | return "#{millis.round(2)}ms" if millis >= 1 66 | 67 | "#{(millis * 1000).round(2)}µs" 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /src/invidious/views/user/token_manager.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Token manager") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 |

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

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

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

26 |
27 |
28 |

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

29 |
30 |
31 |

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

37 |
38 |
39 | 40 | <% if tokens[-1].try &.[:session]? != token[:session] %> 41 |
42 | <% end %> 43 |
44 | <% end %> 45 | -------------------------------------------------------------------------------- /src/invidious/views/create_playlist.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Create playlist") %> - Invidious 3 | <% end %> 4 | 5 |
6 |
7 |
8 |
9 |
10 |
11 | <%= translate(locale, "Create playlist") %> 12 | 13 |
14 | 15 | "> 16 |
17 | 18 |
19 | 20 | 25 |
26 | 27 |
28 | 31 |
32 | 33 | 34 |
35 |
36 |
37 |
38 |
39 |
40 | -------------------------------------------------------------------------------- /src/invidious/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 | str << cp.chr 11 | 12 | # A codepoint from the SMP counts twice 13 | copied += 1 if cp > 0xFFFF 14 | copied += 1 15 | end 16 | 17 | return copied 18 | end 19 | 20 | def parse_description(desc, video_id : String) : String? 21 | return "" if desc.nil? 22 | 23 | content = desc["content"].as_s 24 | return "" if content.empty? 25 | 26 | commands = desc["commandRuns"]?.try &.as_a 27 | return content if commands.nil? 28 | 29 | # Not everything is stored in UTF-8 on youtube's side. The SMP codepoints 30 | # (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are 31 | # automatically decoded by the JSON parser. It means that we need to count 32 | # copied byte in a special manner, preventing the use of regular string copy. 33 | iter = content.each_codepoint 34 | 35 | index = 0 36 | 37 | return String.build do |str| 38 | commands.each do |command| 39 | cmd_start = command["startIndex"].as_i 40 | cmd_length = command["length"].as_i 41 | 42 | # Copy the text chunk between this command and the previous if needed. 43 | length = cmd_start - index 44 | index += copy_string(str, iter, length) 45 | 46 | # We need to copy the command's text using the iterator 47 | # and the special function defined above. 48 | cmd_content = String.build(cmd_length) do |str2| 49 | copy_string(str2, iter, cmd_length) 50 | end 51 | 52 | link = cmd_content 53 | if on_tap = command.dig?("onTap", "innertubeCommand") 54 | link = parse_link_endpoint(on_tap, cmd_content, video_id) 55 | end 56 | str << link 57 | index += cmd_length 58 | end 59 | 60 | # Copy the end of the string (past the last command). 61 | remaining_length = content.size - index 62 | copy_string(str, iter, remaining_length) if remaining_length > 0 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /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 |
12 | <%- if query.page > 1 -%> 13 | <%= translate(locale, "Previous page") %> 14 | <%- end -%> 15 |
16 |
17 |
18 | <%- if videos.size >= 20 -%> 19 | <%= translate(locale, "Next page") %> 20 | <%- end -%> 21 |
22 |
23 | 24 | <%- if videos.empty? -%> 25 |
26 |
27 | <%= translate(locale, "search_message_no_results") %>

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

29 | <%= translate(locale, "search_message_use_another_instance", redirect_url) %> 30 |
31 |
32 | <%- else -%> 33 |
34 | <%- videos.each do |item| -%> 35 | <%= rendered "components/item" %> 36 | <%- end -%> 37 |
38 | <%- end -%> 39 | 40 | 41 | 42 |
43 |
44 | <%- if query.page > 1 -%> 45 | <%= translate(locale, "Previous page") %> 46 | <%- end -%> 47 |
48 |
49 |
50 | <%- if videos.size >= 20 -%> 51 | <%= translate(locale, "Next page") %> 52 | <%- end -%> 53 |
54 |
55 | -------------------------------------------------------------------------------- /assets/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 22 | 24 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM crystallang/crystal:1.4.1-alpine AS builder 2 | RUN apk add --no-cache sqlite-static yaml-static 3 | 4 | ARG release 5 | ARG disable_quic 6 | 7 | WORKDIR /invidious 8 | COPY ./shard.yml ./shard.yml 9 | COPY ./shard.lock ./shard.lock 10 | RUN shards install --production 11 | 12 | COPY --from=quay.io/invidious/lsquic-compiled /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a 13 | 14 | COPY ./src/ ./src/ 15 | # TODO: .git folder is required for building – this is destructive. 16 | # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. 17 | COPY ./.git/ ./.git/ 18 | 19 | # Required for fetching player dependencies 20 | COPY ./scripts/ ./scripts/ 21 | COPY ./assets/ ./assets/ 22 | COPY ./videojs-dependencies.yml ./videojs-dependencies.yml 23 | 24 | RUN crystal spec --warnings all \ 25 | --link-flags "-lxml2 -llzma" 26 | 27 | RUN if [[ "${release}" == 1 && "${disable_quic}" == 1 ]] ; then \ 28 | crystal build ./src/invidious.cr \ 29 | --release \ 30 | -Ddisable_quic \ 31 | --static --warnings all \ 32 | --link-flags "-lxml2 -llzma"; \ 33 | elif [[ "${release}" == 1 ]] ; then \ 34 | crystal build ./src/invidious.cr \ 35 | --release \ 36 | --static --warnings all \ 37 | --link-flags "-lxml2 -llzma"; \ 38 | else \ 39 | crystal build ./src/invidious.cr \ 40 | --static --warnings all \ 41 | --link-flags "-lxml2 -llzma"; \ 42 | fi 43 | 44 | 45 | FROM alpine:3.16 46 | RUN apk add --no-cache librsvg ttf-opensans tini 47 | WORKDIR /invidious 48 | RUN addgroup -g 1000 -S invidious && \ 49 | adduser -u 1000 -S invidious -G invidious 50 | COPY --chown=invidious ./config/config.* ./config/ 51 | RUN mv -n config/config.example.yml config/config.yml 52 | RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: invidious-db/' config/config.yml 53 | COPY ./config/sql/ ./config/sql/ 54 | COPY ./locales/ ./locales/ 55 | COPY --from=builder /invidious/assets ./assets/ 56 | COPY --from=builder /invidious/invidious . 57 | RUN chmod o+rX -R ./assets ./config ./locales 58 | 59 | EXPOSE 3000 60 | USER invidious 61 | ENTRYPOINT ["/sbin/tini", "--"] 62 | CMD [ "/invidious/invidious" ] 63 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /kubernetes/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ template "invidious.fullname" . }} 5 | labels: 6 | app: {{ template "invidious.name" . }} 7 | chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" 8 | release: {{ .Release.Name }} 9 | spec: 10 | replicas: {{ .Values.replicaCount }} 11 | selector: 12 | matchLabels: 13 | app: {{ template "invidious.name" . }} 14 | release: {{ .Release.Name }} 15 | template: 16 | metadata: 17 | labels: 18 | app: {{ template "invidious.name" . }} 19 | chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" 20 | release: {{ .Release.Name }} 21 | spec: 22 | securityContext: 23 | runAsUser: {{ .Values.securityContext.runAsUser }} 24 | runAsGroup: {{ .Values.securityContext.runAsGroup }} 25 | fsGroup: {{ .Values.securityContext.fsGroup }} 26 | initContainers: 27 | - name: wait-for-postgresql 28 | image: postgres 29 | args: 30 | - /bin/sh 31 | - -c 32 | - until pg_isready -h {{ .Values.config.db.host }} -p {{ .Values.config.db.port }} -U {{ .Values.config.db.user }}; do echo waiting for database; sleep 2; done; 33 | containers: 34 | - name: {{ .Chart.Name }} 35 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 36 | imagePullPolicy: {{ .Values.image.pullPolicy }} 37 | ports: 38 | - containerPort: 3000 39 | env: 40 | - name: INVIDIOUS_CONFIG 41 | valueFrom: 42 | configMapKeyRef: 43 | key: INVIDIOUS_CONFIG 44 | name: {{ template "invidious.fullname" . }} 45 | securityContext: 46 | allowPrivilegeEscalation: {{ .Values.securityContext.allowPrivilegeEscalation }} 47 | capabilities: 48 | drop: 49 | - ALL 50 | resources: 51 | {{ toYaml .Values.resources | indent 10 }} 52 | readinessProbe: 53 | httpGet: 54 | port: 3000 55 | path: / 56 | livenessProbe: 57 | httpGet: 58 | port: 3000 59 | path: / 60 | initialDelaySeconds: 15 61 | restartPolicy: Always 62 | -------------------------------------------------------------------------------- /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 : String, port, dns_timeout = nil, connect_timeout = nil, family = Socket::Family::UNSPEC) 7 | Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo| 8 | super(addrinfo.family, addrinfo.type, addrinfo.protocol) 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 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) 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/views/components/subscribe_widget.ecr: -------------------------------------------------------------------------------- 1 | <% if user %> 2 | <% if subscriptions.includes? ucid %> 3 |

4 |

" method="post"> 5 | "> 6 | 9 |
10 |

11 | <% else %> 12 |

13 |

" method="post"> 14 | "> 15 | 18 |
19 |

20 | <% end %> 21 | 22 | 34 | 35 | <% else %> 36 |

37 | "> 39 | <%= translate(locale, "Subscribe") %> | <%= sub_count_text %> 40 | 41 |

42 | <% end %> 43 | -------------------------------------------------------------------------------- /docker/Dockerfile.arm64: -------------------------------------------------------------------------------- 1 | FROM alpine:3.16 AS builder 2 | RUN apk add --no-cache 'crystal=1.4.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev 3 | 4 | ARG release 5 | ARG disable_quic 6 | 7 | WORKDIR /invidious 8 | COPY ./shard.yml ./shard.yml 9 | COPY ./shard.lock ./shard.lock 10 | RUN shards install --production 11 | 12 | COPY --from=quay.io/invidious/lsquic-compiled /root/liblsquic.a ./lib/lsquic/src/lsquic/ext/liblsquic.a 13 | 14 | COPY ./src/ ./src/ 15 | # TODO: .git folder is required for building – this is destructive. 16 | # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION. 17 | COPY ./.git/ ./.git/ 18 | 19 | # Required for fetching player dependencies 20 | COPY ./scripts/ ./scripts/ 21 | COPY ./assets/ ./assets/ 22 | COPY ./videojs-dependencies.yml ./videojs-dependencies.yml 23 | 24 | RUN crystal spec --warnings all \ 25 | --link-flags "-lxml2 -llzma" 26 | 27 | RUN if [[ "${release}" == 1 && "${disable_quic}" == 1 ]] ; then \ 28 | crystal build ./src/invidious.cr \ 29 | --release \ 30 | -Ddisable_quic \ 31 | --static --warnings all \ 32 | --link-flags "-lxml2 -llzma"; \ 33 | elif [[ "${release}" == 1 ]] ; then \ 34 | crystal build ./src/invidious.cr \ 35 | --release \ 36 | --static --warnings all \ 37 | --link-flags "-lxml2 -llzma"; \ 38 | else \ 39 | crystal build ./src/invidious.cr \ 40 | --static --warnings all \ 41 | --link-flags "-lxml2 -llzma"; \ 42 | fi 43 | 44 | FROM alpine:3.16 45 | RUN apk add --no-cache librsvg ttf-opensans tini 46 | WORKDIR /invidious 47 | RUN addgroup -g 1000 -S invidious && \ 48 | adduser -u 1000 -S invidious -G invidious 49 | COPY --chown=invidious ./config/config.* ./config/ 50 | RUN mv -n config/config.example.yml config/config.yml 51 | RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: invidious-db/' config/config.yml 52 | COPY ./config/sql/ ./config/sql/ 53 | COPY ./locales/ ./locales/ 54 | COPY --from=builder /invidious/assets ./assets/ 55 | COPY --from=builder /invidious/invidious . 56 | RUN chmod o+rX -R ./assets ./config ./locales 57 | 58 | EXPOSE 3000 59 | USER invidious 60 | ENTRYPOINT ["/sbin/tini", "--"] 61 | CMD [ "/invidious/invidious" ] 62 | -------------------------------------------------------------------------------- /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/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 | content = Kilt.render({{filename}}) 59 | Kilt.render({{layout}}) 60 | end 61 | 62 | macro rendered(filename) 63 | Kilt.render("src/invidious/views/#{{{filename}}}.ecr") 64 | end 65 | 66 | # Similar to Kemals halt method but works in a 67 | # method. 68 | macro haltf(env, status_code = 200, response = "") 69 | {{env}}.response.status_code = {{status_code}} 70 | {{env}}.response.print {{response}} 71 | {{env}}.response.close 72 | return 73 | end 74 | -------------------------------------------------------------------------------- /src/invidious/views/user/subscription_manager.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Subscription manager") %> - Invidious 3 | <% end %> 4 | 5 | 28 | 29 | <% subscriptions.each do |channel| %> 30 |
31 |
32 | 37 |
38 |
39 |

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

45 |
46 |
47 | 48 | <% if subscriptions[-1].author != channel.author %> 49 |
50 | <% end %> 51 |
52 | <% end %> 53 | -------------------------------------------------------------------------------- /src/invidious/views/components/channel_info.ecr: -------------------------------------------------------------------------------- 1 | <% if channel.banner %> 2 |
3 | " alt="" /> 4 |
5 | 6 |
7 |
8 |
9 | <% end %> 10 | 11 |
12 |
13 |
14 | 15 | <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> 16 |
17 |
18 |
19 |

20 | 21 |

22 |
23 |
24 | 25 |
26 |
27 |

<%= channel.description_html %>

28 |
29 |
30 | 31 |
32 | <% sub_count_text = number_to_short_text(channel.sub_count) %> 33 | <%= rendered "components/subscribe_widget" %> 34 |
35 | 36 |
37 |
38 | 41 | 44 | 45 | <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %> 46 |
47 |
48 |
49 | <% sort_options.each do |sort| %> 50 |
51 | <% if sort_by == sort %> 52 | <%= translate(locale, sort) %> 53 | <% else %> 54 | <%= translate(locale, sort) %> 55 | <% end %> 56 |
57 | <% end %> 58 |
59 |
60 |
61 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | else 13 | "/channel/#{ucid}" 14 | end 15 | 16 | youtube_url = "https://www.youtube.com#{relative_url}" 17 | redirect_url = Invidious::Frontend::Misc.redirect_url(env) 18 | -%> 19 | 20 | <% content_for "header" do %> 21 | <%- if selected_tab.videos? -%> 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | <%- end -%> 35 | 36 | 37 | <%= author %> - Invidious 38 | <% end %> 39 | 40 | <%= rendered "components/channel_info" %> 41 | 42 |
43 |
44 |
45 | 46 |
47 | <% items.each do |item| %> 48 | <%= rendered "components/item" %> 49 | <% end %> 50 |
51 | 52 | 53 | 54 |
55 |
56 |
57 | <% if next_continuation %> 58 | 59 | <%= translate(locale, "Next page") %> 60 | 61 | <% end %> 62 |
63 |
64 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | 24 | # 25 | # Style 26 | # 27 | 28 | Style/RedundantBegin: 29 | Enabled: false 30 | 31 | Style/RedundantReturn: 32 | Enabled: false 33 | 34 | 35 | # 36 | # Metrics 37 | # 38 | 39 | # Ignore function complexity (number of if/else & case/when branches) 40 | # For some functions that can hardly be simplified for now 41 | Metrics/CyclomaticComplexity: 42 | Excluded: 43 | # get_about_info(ucid, locale) => [17/10] 44 | - src/invidious/channels/about.cr 45 | 46 | # fetch_channel_community(ucid, continuation, ...) => [34/10] 47 | - src/invidious/channels/community.cr 48 | 49 | # create_notification_stream(env, topics, connection_channel) => [14/10] 50 | - src/invidious/helpers/helpers.cr:84:5 51 | 52 | # get_index(plural_form, count) => [25/10] 53 | - src/invidious/helpers/i18next.cr 54 | 55 | # call(context) => [18/10] 56 | - src/invidious/helpers/static_file_handler.cr 57 | 58 | # show(env) => [38/10] 59 | - src/invidious/routes/embed.cr 60 | 61 | # get_video_playback(env) => [45/10] 62 | - src/invidious/routes/video_playback.cr 63 | 64 | # handle(env) => [40/10] 65 | - src/invidious/routes/watch.cr 66 | 67 | # playlist_ajax(env) => [24/10] 68 | - src/invidious/routes/playlists.cr 69 | 70 | # fetch_youtube_comments(id, cursor, ....) => [40/10] 71 | # template_youtube_comments(comments, locale, ...) => [16/10] 72 | # content_to_comment_html(content) => [14/10] 73 | - src/invidious/comments.cr 74 | 75 | # to_json(locale, json) => [21/10] 76 | # extract_video_info(video_id, ...) => [44/10] 77 | # process_video_params(query, preferences) => [20/10] 78 | - src/invidious/videos.cr 79 | 80 | 81 | 82 | #src/invidious/playlists.cr:327:5 83 | #[C] Metrics/CyclomaticComplexity: Cyclomatic complexity too high [19/10] 84 | # fetch_playlist(plid : String) 85 | 86 | #src/invidious/playlists.cr:436:5 87 | #[C] Metrics/CyclomaticComplexity: Cyclomatic complexity too high [11/10] 88 | # extract_playlist_videos(initial_data : Hash(String, JSON::Any)) 89 | -------------------------------------------------------------------------------- /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 = HTTP::Client.new("suggestqueries-clients6.youtube.com") 35 | url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&xssi=t&gs_ri=youtube&ds=yt" 36 | 37 | response = client.get(url).body 38 | 39 | body = JSON.parse(response[5..-1]).as_a 40 | suggestions = body[1].as_a[0..-2] 41 | 42 | JSON.build do |json| 43 | json.object do 44 | json.field "query", body[0].as_s 45 | json.field "suggestions" do 46 | json.array do 47 | suggestions.each do |suggestion| 48 | json.string suggestion[0].as_s 49 | end 50 | end 51 | end 52 | end 53 | end 54 | rescue ex 55 | return error_json(500, ex) 56 | end 57 | end 58 | 59 | def self.hashtag(env) 60 | hashtag = env.params.url["hashtag"] 61 | 62 | page = env.params.query["page"]?.try &.to_i? || 1 63 | 64 | locale = env.get("preferences").as(Preferences).locale 65 | region = env.params.query["region"]? 66 | env.response.content_type = "application/json" 67 | 68 | begin 69 | results = Invidious::Hashtag.fetch(hashtag, page, region) 70 | rescue ex 71 | return error_json(400, ex) 72 | end 73 | 74 | JSON.build do |json| 75 | json.object do 76 | json.field "results" do 77 | json.array do 78 | results.each do |item| 79 | item.to_json(locale, json) 80 | end 81 | end 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /src/invidious/views/add_playlist_items.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= playlist.title %> - Invidious 3 | 4 | <% end %> 5 | 6 |
7 |
8 |
9 |
10 |
11 | <%= translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %> 12 | 13 |
14 | value="<%= HTML.escape(query.text) %>"<% end %> 16 | placeholder="<%= translate(locale, "Search for videos") %>"> 17 | 18 |
19 |
20 |
21 |
22 |
23 |
24 | 25 | 32 | 33 | 34 |
35 | <% videos.each_slice(4) do |slice| %> 36 | <% slice.each do |item| %> 37 | <%= rendered "components/item" %> 38 | <% end %> 39 | <% end %> 40 |
41 | 42 | 43 | 44 | <% if query %> 45 | <%- query_encoded = URI.encode_www_form(query.text, space_to_plus: true) -%> 46 |
47 |
48 | <% if query.page > 1 %> 49 | 50 | <%= translate(locale, "Previous page") %> 51 | 52 | <% end %> 53 |
54 |
55 |
56 | <% if videos.size >= 20 %> 57 | 58 | <%= translate(locale, "Next page") %> 59 | 60 | <% end %> 61 |
62 |
63 | <% end %> 64 | -------------------------------------------------------------------------------- /assets/css/search.css: -------------------------------------------------------------------------------- 1 | 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 | summary::-webkit-details-marker, 12 | summary::marker { display: none; } 13 | 14 | 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 | 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 | -------------------------------------------------------------------------------- /src/invidious/views/feeds/subscriptions.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Subscriptions") %> - Invidious 3 | 4 | <% end %> 5 | 6 | <%= rendered "components/feed_menu" %> 7 | 8 |
9 | 14 | 19 |
20 |

21 | 22 |

23 |
24 |
25 | 26 | <% if CONFIG.enable_user_notifications %> 27 | 28 |
29 | <%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %> 30 |
31 | 32 | <% if !notifications.empty? %> 33 |
34 |
35 |
36 | <% end %> 37 | 38 |
39 | <% notifications.each do |item| %> 40 | <%= rendered "components/item" %> 41 | <% end %> 42 |
43 | 44 | <% end %> 45 | 46 |
47 |
48 |
49 | 50 | 57 | 58 | 59 |
60 | <% videos.each do |item| %> 61 | <%= rendered "components/item" %> 62 | <% end %> 63 |
64 | 65 | 66 | 67 |
68 | 75 |
76 |
77 | <% if (videos.size + notifications.size) == max_results %> 78 | &max_results=<%= max_results %><% end %>"> 79 | <%= translate(locale, "Next page") %> 80 | 81 | <% end %> 82 |
83 |
84 | -------------------------------------------------------------------------------- /src/invidious/helpers/signatures.cr: -------------------------------------------------------------------------------- 1 | alias SigProc = Proc(Array(String), Int32, Array(String)) 2 | 3 | struct DecryptFunction 4 | @decrypt_function = [] of {SigProc, Int32} 5 | @decrypt_time = Time.monotonic 6 | 7 | def initialize(@use_polling = true) 8 | end 9 | 10 | def update_decrypt_function 11 | @decrypt_function = fetch_decrypt_function 12 | end 13 | 14 | private def fetch_decrypt_function(id = "CvFH_6DNRCY") 15 | document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body 16 | url = document.match(/src="(?\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"] 17 | player = YT_POOL.client &.get(url).body 18 | 19 | function_name = player.match(/^(?[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"] 20 | function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?[^}]+)}/m).not_nil!["body"] 21 | function_body = function_body.split(";")[1..-2] 22 | 23 | var_name = function_body[0][0, 2] 24 | var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?(.*?))};/).not_nil!["body"] 25 | 26 | operations = {} of String => SigProc 27 | var_body.split("},").each do |operation| 28 | op_name = operation.match(/^[^:]+/).not_nil![0] 29 | op_body = operation.match(/\{[^}]+/).not_nil![0] 30 | 31 | case op_body 32 | when "{a.reverse()" 33 | operations[op_name] = ->(a : Array(String), _b : Int32) { a.reverse } 34 | when "{a.splice(0,b)" 35 | operations[op_name] = ->(a : Array(String), b : Int32) { a.delete_at(0..(b - 1)); a } 36 | else 37 | operations[op_name] = ->(a : Array(String), b : Int32) { c = a[0]; a[0] = a[b % a.size]; a[b % a.size] = c; a } 38 | end 39 | end 40 | 41 | decrypt_function = [] of {SigProc, Int32} 42 | function_body.each do |function| 43 | function = function.lchop(var_name).delete("[].") 44 | 45 | op_name = function.match(/[^\(]+/).not_nil![0] 46 | value = function.match(/\(\w,(?[\d]+)\)/).not_nil!["value"].to_i 47 | 48 | decrypt_function << {operations[op_name], value} 49 | end 50 | 51 | return decrypt_function 52 | end 53 | 54 | def decrypt_signature(fmt : Hash(String, JSON::Any)) 55 | return "" if !fmt["s"]? || !fmt["sp"]? 56 | 57 | sp = fmt["sp"].as_s 58 | sig = fmt["s"].as_s.split("") 59 | if !@use_polling 60 | now = Time.monotonic 61 | if now - @decrypt_time > 60.seconds || @decrypt_function.size == 0 62 | @decrypt_function = fetch_decrypt_function 63 | @decrypt_time = Time.monotonic 64 | end 65 | end 66 | 67 | @decrypt_function.each do |proc, value| 68 | sig = proc.call(sig, value) 69 | end 70 | 71 | return "&#{sp}=#{sig.join("")}" 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /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 selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"] 87 | end 88 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # ----------------------- 2 | # Compilation options 3 | # ----------------------- 4 | 5 | RELEASE := 1 6 | STATIC := 0 7 | 8 | DISABLE_QUIC := 1 9 | NO_DBG_SYMBOLS := 0 10 | 11 | 12 | FLAGS ?= 13 | 14 | 15 | ifeq ($(RELEASE), 1) 16 | FLAGS += --release 17 | endif 18 | 19 | ifeq ($(STATIC), 1) 20 | FLAGS += --static 21 | endif 22 | 23 | 24 | ifeq ($(NO_DBG_SYMBOLS), 1) 25 | FLAGS += --no-debug 26 | else 27 | FLAGS += --debug 28 | endif 29 | 30 | ifeq ($(DISABLE_QUIC), 1) 31 | FLAGS += -Ddisable_quic 32 | endif 33 | 34 | ifeq ($(API_ONLY), 1) 35 | FLAGS += -Dapi_only 36 | endif 37 | 38 | 39 | # ----------------------- 40 | # Main 41 | # ----------------------- 42 | 43 | all: invidious 44 | 45 | get-libs: 46 | shards install --production 47 | 48 | # TODO: add support for ARM64 via cross-compilation 49 | invidious: get-libs 50 | crystal build src/invidious.cr $(FLAGS) --progress --stats --error-trace 51 | 52 | 53 | run: invidious 54 | ./invidious 55 | 56 | 57 | # ----------------------- 58 | # Development 59 | # ----------------------- 60 | 61 | 62 | format: 63 | crystal tool format 64 | 65 | test: 66 | crystal spec 67 | 68 | verify: 69 | crystal build src/invidious.cr -Dskip_videojs_download \ 70 | --no-codegen --progress --stats --error-trace 71 | 72 | 73 | # ----------------------- 74 | # (Un)Install 75 | # ----------------------- 76 | 77 | # TODO 78 | 79 | 80 | # ----------------------- 81 | # Cleaning 82 | # ----------------------- 83 | 84 | clean: 85 | rm invidious 86 | 87 | distclean: clean 88 | rm -rf libs 89 | 90 | 91 | # ----------------------- 92 | # Help page 93 | # ----------------------- 94 | 95 | help: 96 | @echo "Targets available in this Makefile:" 97 | @echo "" 98 | @echo " get-libs Fetch Crystal libraries" 99 | @echo " invidious Build Invidious" 100 | @echo " run Launch Invidious" 101 | @echo "" 102 | @echo " format Run the Crystal formatter" 103 | @echo " test Run tests" 104 | @echo " verify Just make sure that the code compiles, but without" 105 | @echo " generating any binaries. Useful to search for errors" 106 | @echo "" 107 | @echo " clean Remove build artifacts" 108 | @echo " distclean Remove build artifacts and libraries" 109 | @echo "" 110 | @echo "" 111 | @echo "Build options available for this Makefile:" 112 | @echo "" 113 | @echo " RELEASE Make a release build (Default: 1)" 114 | @echo " STATIC Link libraries statically (Default: 0)" 115 | @echo "" 116 | @echo " API_ONLY Build invidious without a GUI (Default: 0)" 117 | @echo " DISABLE_QUIC Disable support for QUIC (Default: 0)" 118 | @echo " NO_DBG_SYMBOLS Strip debug symbols (Default: 0)" 119 | 120 | 121 | 122 | # No targets generates an output named after themselves 123 | .PHONY: all get-libs build amd64 run 124 | .PHONY: format test verify clean distclean help 125 | -------------------------------------------------------------------------------- /src/invidious/views/user/authorize_token.ecr: -------------------------------------------------------------------------------- 1 | <% content_for "header" do %> 2 | <%= translate(locale, "Token") %> - Invidious 3 | <% end %> 4 | 5 | <% if env.get? "access_token" %> 6 |
7 |
8 |

9 | <%= translate(locale, "Token") %> 10 |

11 |
12 | 17 | 22 |
23 | 24 |
25 |

26 | <%= env.get "access_token" %> 27 |

28 |
29 | <% else %> 30 |
31 |
32 | <% if callback_url %> 33 | <%= translate(locale, "Authorize token for `x`?", "#{callback_url.scheme}://#{callback_url.host}") %> 34 | <% else %> 35 | <%= translate(locale, "Authorize token?") %> 36 | <% end %> 37 | 38 |
39 |
40 |
    41 | <% scopes.each do |scope| %> 42 |
  • <%= HTML.escape(scope) %>
  • 43 | <% end %> 44 |
45 |
46 |
47 | 48 |
49 |
50 | 53 |
54 | 63 |
64 | 65 | <% scopes.each_with_index do |scope, i| %> 66 | 67 | <% end %> 68 | <% if callback_url %> 69 | 70 | <% end %> 71 | <% if expire %> 72 | 73 | <% end %> 74 | 75 | 76 |
77 |
78 | <% end %> 79 | -------------------------------------------------------------------------------- /src/invidious/jobs/refresh_feeds_job.cr: -------------------------------------------------------------------------------- 1 | class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob 2 | private getter db : DB::Database 3 | 4 | def initialize(@db) 5 | end 6 | 7 | def begin 8 | max_fibers = CONFIG.feed_threads 9 | active_fibers = 0 10 | active_channel = ::Channel(Bool).new 11 | 12 | loop do 13 | db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs| 14 | rs.each do 15 | email = rs.read(String) 16 | view_name = "subscriptions_#{sha256(email)}" 17 | 18 | if active_fibers >= max_fibers 19 | if active_channel.receive 20 | active_fibers -= 1 21 | end 22 | end 23 | 24 | active_fibers += 1 25 | spawn do 26 | begin 27 | # Drop outdated views 28 | column_array = Invidious::Database.get_column_array(db, view_name) 29 | ChannelVideo.type_array.each_with_index do |name, i| 30 | if name != column_array[i]? 31 | LOGGER.info("RefreshFeedsJob: DROP MATERIALIZED VIEW #{view_name}") 32 | db.exec("DROP MATERIALIZED VIEW #{view_name}") 33 | raise "view does not exist" 34 | end 35 | end 36 | 37 | if !db.query_one("SELECT pg_get_viewdef('#{view_name}')", as: String).includes? "WHERE ((cv.ucid = ANY (u.subscriptions))" 38 | LOGGER.info("RefreshFeedsJob: Materialized view #{view_name} is out-of-date, recreating...") 39 | db.exec("DROP MATERIALIZED VIEW #{view_name}") 40 | end 41 | 42 | db.exec("REFRESH MATERIALIZED VIEW #{view_name}") 43 | db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email) 44 | rescue ex 45 | # Rename old views 46 | begin 47 | legacy_view_name = "subscriptions_#{sha256(email)[0..7]}" 48 | 49 | db.exec("SELECT * FROM #{legacy_view_name} LIMIT 0") 50 | LOGGER.info("RefreshFeedsJob: RENAME MATERIALIZED VIEW #{legacy_view_name}") 51 | db.exec("ALTER MATERIALIZED VIEW #{legacy_view_name} RENAME TO #{view_name}") 52 | rescue ex 53 | begin 54 | # While iterating through, we may have an email stored from a deleted account 55 | if db.query_one?("SELECT true FROM users WHERE email = $1", email, as: Bool) 56 | LOGGER.info("RefreshFeedsJob: CREATE #{view_name}") 57 | db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(email)}") 58 | db.exec("UPDATE users SET feed_needs_update = false WHERE email = $1", email) 59 | end 60 | rescue ex 61 | LOGGER.error("RefreshFeedJobs: REFRESH #{email} : #{ex.message}") 62 | end 63 | end 64 | end 65 | 66 | active_channel.send(true) 67 | end 68 | end 69 | end 70 | 71 | sleep 5.seconds 72 | Fiber.yield 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /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 | 52 | 53 |
54 | 61 |
62 |
63 | <% if watched.size >= max_results %> 64 | &max_results=<%= max_results %><% end %>"> 65 | <%= translate(locale, "Next page") %> 66 | 67 | <% end %> 68 |
69 |
70 | -------------------------------------------------------------------------------- /assets/js/community.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var community_data = JSON.parse(document.getElementById('community_data').textContent); 3 | 4 | function hide_youtube_replies(event) { 5 | var target = event.target; 6 | 7 | var sub_text = target.getAttribute('data-inner-text'); 8 | var inner_text = target.getAttribute('data-sub-text'); 9 | 10 | var body = target.parentNode.parentNode.children[1]; 11 | body.style.display = 'none'; 12 | 13 | target.innerHTML = sub_text; 14 | target.onclick = show_youtube_replies; 15 | target.setAttribute('data-inner-text', inner_text); 16 | target.setAttribute('data-sub-text', sub_text); 17 | } 18 | 19 | function show_youtube_replies(event) { 20 | var target = event.target; 21 | 22 | var sub_text = target.getAttribute('data-inner-text'); 23 | var inner_text = target.getAttribute('data-sub-text'); 24 | 25 | var body = target.parentNode.parentNode.children[1]; 26 | body.style.display = ''; 27 | 28 | target.innerHTML = sub_text; 29 | target.onclick = hide_youtube_replies; 30 | target.setAttribute('data-inner-text', inner_text); 31 | target.setAttribute('data-sub-text', sub_text); 32 | } 33 | 34 | function get_youtube_replies(target, load_more) { 35 | var continuation = target.getAttribute('data-continuation'); 36 | 37 | var body = target.parentNode.parentNode; 38 | var fallback = body.innerHTML; 39 | body.innerHTML = 40 | '

'; 41 | 42 | var url = '/api/v1/channels/comments/' + community_data.ucid + 43 | '?format=html' + 44 | '&hl=' + community_data.preferences.locale + 45 | '&thin_mode=' + community_data.preferences.thin_mode + 46 | '&continuation=' + continuation; 47 | 48 | helpers.xhr('GET', url, {}, { 49 | on200: function (response) { 50 | if (load_more) { 51 | body = body.parentNode.parentNode; 52 | body.removeChild(body.lastElementChild); 53 | body.innerHTML += response.contentHtml; 54 | } else { 55 | body.removeChild(body.lastElementChild); 56 | 57 | var p = document.createElement('p'); 58 | var a = document.createElement('a'); 59 | p.appendChild(a); 60 | 61 | a.href = 'javascript:void(0)'; 62 | a.onclick = hide_youtube_replies; 63 | a.setAttribute('data-sub-text', community_data.hide_replies_text); 64 | a.setAttribute('data-inner-text', community_data.show_replies_text); 65 | a.textContent = community_data.hide_replies_text; 66 | 67 | var div = document.createElement('div'); 68 | div.innerHTML = response.contentHtml; 69 | 70 | body.appendChild(p); 71 | body.appendChild(div); 72 | } 73 | }, 74 | onNon200: function (xhr) { 75 | body.innerHTML = fallback; 76 | }, 77 | onTimeout: function (xhr) { 78 | console.warn('Pulling comments failed'); 79 | body.innerHTML = fallback; 80 | } 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Invidious CI 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" # Every day at 00:00 6 | push: 7 | branches: 8 | - "master" 9 | - "api-only" 10 | pull_request: 11 | branches: "*" 12 | paths-ignore: 13 | - "*.md" 14 | - LICENCE 15 | - TRANSLATION 16 | - invidious.service 17 | - .git* 18 | - .editorconfig 19 | 20 | - screenshots/* 21 | - assets/** 22 | - locales/* 23 | - config/** 24 | - .github/ISSUE_TEMPLATE/* 25 | - kubernetes/** 26 | 27 | jobs: 28 | build: 29 | 30 | runs-on: ubuntu-latest 31 | 32 | name: "build - crystal: ${{ matrix.crystal }}, stable: ${{ matrix.stable }}" 33 | 34 | continue-on-error: ${{ !matrix.stable }} 35 | 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | stable: [true] 40 | crystal: 41 | - 1.4.1 42 | - 1.5.1 43 | - 1.6.2 44 | - 1.7.3 45 | - 1.8.1 46 | include: 47 | - crystal: nightly 48 | stable: false 49 | 50 | steps: 51 | - uses: actions/checkout@v3 52 | with: 53 | submodules: true 54 | 55 | - name: Install Crystal 56 | uses: crystal-lang/install-crystal@v1.7.0 57 | with: 58 | crystal: ${{ matrix.crystal }} 59 | 60 | - name: Cache Shards 61 | uses: actions/cache@v3 62 | with: 63 | path: ./lib 64 | key: shards-${{ hashFiles('shard.lock') }} 65 | 66 | - name: Install Shards 67 | run: | 68 | if ! shards check; then 69 | shards install 70 | fi 71 | 72 | - name: Run tests 73 | run: crystal spec 74 | 75 | - name: Run lint 76 | run: | 77 | if ! crystal tool format --check; then 78 | crystal tool format 79 | git diff 80 | exit 1 81 | fi 82 | 83 | - name: Build 84 | run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr 85 | 86 | build-docker: 87 | 88 | runs-on: ubuntu-latest 89 | 90 | steps: 91 | - uses: actions/checkout@v3 92 | 93 | - name: Build Docker 94 | run: docker-compose build --build-arg release=0 95 | 96 | - name: Run Docker 97 | run: docker-compose up -d 98 | 99 | - name: Test Docker 100 | run: while curl -Isf http://localhost:3000; do sleep 1; done 101 | 102 | build-docker-arm64: 103 | 104 | runs-on: ubuntu-latest 105 | 106 | steps: 107 | - uses: actions/checkout@v3 108 | 109 | - name: Set up QEMU 110 | uses: docker/setup-qemu-action@v2 111 | with: 112 | platforms: arm64 113 | 114 | - name: Set up Docker Buildx 115 | uses: docker/setup-buildx-action@v2 116 | 117 | - name: Build Docker ARM64 image 118 | uses: docker/build-push-action@v3 119 | with: 120 | context: . 121 | file: docker/Dockerfile.arm64 122 | platforms: linux/arm64/v8 123 | build-args: release=0 124 | 125 | - name: Test Docker 126 | run: while curl -Isf http://localhost:3000; do sleep 1; done 127 | 128 | 129 | --------------------------------------------------------------------------------