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/comments/reddit.cr:
--------------------------------------------------------------------------------
1 | module Invidious::Comments
2 | extend self
3 |
4 | def fetch_reddit(id, sort_by = "confidence")
5 | client = make_client(REDDIT_URL)
6 | headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"}
7 |
8 | # TODO: Use something like #479 for a static list of instances to use here
9 | query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"})
10 | search_results = client.get("/search.json?#{query}", headers)
11 |
12 | if search_results.status_code == 200
13 | search_results = RedditThing.from_json(search_results.body)
14 |
15 | # For videos that have more than one thread, choose the one with the highest score
16 | threads = search_results.data.as(RedditListing).children
17 | thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink))
18 | result = thread.try do |t|
19 | body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body
20 | Array(RedditThing).from_json(body)
21 | end
22 | result ||= [] of RedditThing
23 | elsif search_results.status_code == 302
24 | # Previously, if there was only one result then the API would redirect to that result.
25 | # Now, it appears it will still return a listing so this section is likely unnecessary.
26 |
27 | result = client.get(search_results.headers["Location"], headers).body
28 | result = Array(RedditThing).from_json(result)
29 |
30 | thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink)
31 | else
32 | raise NotFoundException.new("Comments not found.")
33 | end
34 |
35 | client.close
36 |
37 | comments = result[1]?.try(&.data.as(RedditListing).children)
38 | comments ||= [] of RedditThing
39 | return comments, thread
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/src/invidious/routes/misc.cr:
--------------------------------------------------------------------------------
1 | {% skip_file if flag?(:api_only) %}
2 |
3 | module Invidious::Routes::Misc
4 | def self.home(env)
5 | preferences = env.get("preferences").as(Preferences)
6 | locale = preferences.locale
7 | user = env.get? "user"
8 |
9 | case preferences.default_home
10 | when "Popular"
11 | env.redirect "/feed/popular"
12 | when "Trending"
13 | env.redirect "/feed/trending"
14 | when "Subscriptions"
15 | if user
16 | env.redirect "/feed/subscriptions"
17 | else
18 | env.redirect "/feed/popular"
19 | end
20 | when "Playlists"
21 | if user
22 | env.redirect "/feed/playlists"
23 | else
24 | env.redirect "/feed/popular"
25 | end
26 | else
27 | templated "search_homepage", navbar_search: false
28 | end
29 | end
30 |
31 | def self.privacy(env)
32 | locale = env.get("preferences").as(Preferences).locale
33 | templated "privacy"
34 | end
35 |
36 | def self.licenses(env)
37 | locale = env.get("preferences").as(Preferences).locale
38 | rendered "licenses"
39 | end
40 |
41 | def self.cross_instance_redirect(env)
42 | referer = get_referer(env)
43 |
44 | instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"]
45 | # Filter out the current instance
46 | other_available_instances = instance_list.reject { |_, domain| domain == CONFIG.domain }
47 |
48 | if other_available_instances.empty?
49 | # If the current instance is the only one, use the redirect URL as fallback
50 | instance_url = "redirect.invidious.io"
51 | else
52 | # Select other random instance
53 | # Sample returns an array
54 | # Instances are packaged as {region, domain} in the instance list
55 | instance_url = other_available_instances.sample(1)[0][1]
56 | end
57 |
58 | env.redirect "https://#{instance_url}#{referer}"
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/src/invidious/views/user/token_manager.ecr:
--------------------------------------------------------------------------------
1 | <% content_for "header" do %>
2 |
<%= translate(locale, "Token manager") %> - Invidious
3 | <% end %>
4 |
5 |
6 |
7 |
8 | <%= translate_count(locale, "tokens_count", tokens.size, NumberFormatting::HtmlSpan) %>
9 |
10 |
11 |
12 |
17 |
18 |
19 | <% tokens.each do |token| %>
20 |
21 |
22 |
23 |
24 | <%= token[:session] %>
25 |
26 |
27 |
28 |
<%= translate(locale, "`x` ago", recode_date(token[:issued], locale)) %>
29 |
30 |
31 |
32 |
36 |
37 |
38 |
39 |
40 | <% if tokens[-1].try &.[:session]? != token[:session] %>
41 |
42 | <% end %>
43 |
44 | <% end %>
45 |
--------------------------------------------------------------------------------
/src/invidious/views/create_playlist.ecr:
--------------------------------------------------------------------------------
1 | <% content_for "header" do %>
2 |
<%= translate(locale, "Create playlist") %> - Invidious
3 | <% end %>
4 |
5 |
40 |
--------------------------------------------------------------------------------
/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 | depends_on:
18 | invidious-db:
19 | condition: service_healthy
20 | restart: true
21 | environment:
22 | # Please read the following file for a comprehensive list of all available
23 | # configuration options and their associated syntax:
24 | # https://github.com/iv-org/invidious/blob/master/config/config.example.yml
25 | INVIDIOUS_CONFIG: |
26 | db:
27 | dbname: invidious
28 | user: kemal
29 | password: kemal
30 | host: invidious-db
31 | port: 5432
32 | check_tables: true
33 | # external_port:
34 | # domain:
35 | # https_only: false
36 | # statistics_enabled: false
37 | hmac_key: "CHANGE_ME!!"
38 | healthcheck:
39 | test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/stats || exit 1
40 | interval: 30s
41 | timeout: 5s
42 | retries: 2
43 |
44 | invidious-db:
45 | image: docker.io/library/postgres:14
46 | restart: unless-stopped
47 | volumes:
48 | - postgresdata:/var/lib/postgresql/data
49 | - ./config/sql:/config/sql
50 | - ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh
51 | environment:
52 | POSTGRES_DB: invidious
53 | POSTGRES_USER: kemal
54 | POSTGRES_PASSWORD: kemal
55 | healthcheck:
56 | test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
57 |
58 | volumes:
59 | postgresdata:
60 |
--------------------------------------------------------------------------------
/.github/workflows/build-stable-container.yml:
--------------------------------------------------------------------------------
1 | name: Build and release container
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | tags:
7 | - "v*"
8 |
9 | jobs:
10 | release:
11 | strategy:
12 | matrix:
13 | include:
14 | - os: ubuntu-latest
15 | platform: linux/amd64
16 | name: "AMD64"
17 | dockerfile: "docker/Dockerfile"
18 | tag_suffix: ""
19 | # GitHub doesn't have a ubuntu-latest-arm runner
20 | - os: ubuntu-24.04-arm
21 | platform: linux/arm64/v8
22 | name: "ARM64"
23 | dockerfile: "docker/Dockerfile.arm64"
24 | tag_suffix: "-arm64"
25 |
26 | runs-on: ${{ matrix.os }}
27 |
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v6
31 |
32 | - name: Set up Docker Buildx
33 | uses: docker/setup-buildx-action@v3
34 |
35 | - name: Login to registry
36 | uses: docker/login-action@v3
37 | with:
38 | registry: quay.io
39 | username: ${{ secrets.QUAY_USERNAME }}
40 | password: ${{ secrets.QUAY_PASSWORD }}
41 |
42 | - name: Docker meta
43 | id: meta
44 | uses: docker/metadata-action@v5
45 | with:
46 | images: quay.io/invidious/invidious
47 | flavor: |
48 | latest=false
49 | suffix=${{ matrix.tag_suffix }}
50 | tags: |
51 | type=semver,pattern={{version}}
52 | type=raw,value=latest
53 | labels: |
54 | quay.expires-after=12w
55 |
56 | - name: Build and push Docker ${{ matrix.name }} image for Push Event
57 | uses: docker/build-push-action@v6
58 | with:
59 | context: .
60 | file: ${{ matrix.dockerfile }}
61 | platforms: ${{ matrix.platform }}
62 | labels: ${{ steps.meta.outputs.labels }}
63 | push: true
64 | tags: ${{ steps.meta.outputs.tags }}
65 | build-args: |
66 | "release=1"
67 |
--------------------------------------------------------------------------------
/src/invidious/views/feeds/subscriptions.ecr:
--------------------------------------------------------------------------------
1 | <% content_for "header" do %>
2 |
<%= translate(locale, "Subscriptions") %> - Invidious
3 |
4 | <% end %>
5 |
6 | <%= rendered "components/feed_menu" %>
7 |
8 |
25 |
26 | <% if CONFIG.enable_user_notifications %>
27 |
28 |
29 | <%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
30 |
31 |
32 | <% if !notifications.empty? %>
33 |
34 |
35 |
36 | <% end %>
37 |
38 |
39 | <% notifications.each do |item| %>
40 | <%= rendered "components/item" %>
41 | <% end %>
42 |
43 |
44 | <% end %>
45 |
46 |
47 |
48 |
49 |
50 |
57 |
58 |
59 |
60 |
61 | <% videos.each do |item| %>
62 | <%= rendered "components/item" %>
63 | <% end %>
64 |
65 |
66 |
67 |
68 | <%=
69 | IV::Frontend::Pagination.nav_numeric(locale,
70 | base_url: base_url,
71 | current_page: page,
72 | show_next: ((videos.size + notifications.size) == max_results)
73 | )
74 | %>
75 |
--------------------------------------------------------------------------------
/src/invidious/views/components/subscribe_widget.ecr:
--------------------------------------------------------------------------------
1 | <% if user %>
2 | <% if subscriptions.includes? ucid %>
3 |
9 | <% else %>
10 |
16 | <% end %>
17 |
18 |
30 |
31 | <% else %>
32 |
">
34 | <%= translate(locale, "Subscribe") %> | <%= sub_count_text %>
35 |
36 | <% end %>
37 |
--------------------------------------------------------------------------------
/assets/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
36 |
--------------------------------------------------------------------------------
/src/invidious/views/components/player_sources.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | <% if params.annotations %>
19 |
20 |
21 | <% end %>
22 |
23 | <% if params.listen || params.quality != "dash" %>
24 |
25 |
26 | <% end %>
27 |
28 | <% if !params.listen && params.vr_mode %>
29 |
30 |
31 | <% end %>
32 |
--------------------------------------------------------------------------------
/src/invidious/helpers/logger.cr:
--------------------------------------------------------------------------------
1 | require "colorize"
2 |
3 | enum LogLevel
4 | All = 0
5 | Trace = 1
6 | Debug = 2
7 | Info = 3
8 | Warn = 4
9 | Error = 5
10 | Fatal = 6
11 | Off = 7
12 | end
13 |
14 | class Invidious::LogHandler < Kemal::BaseLogHandler
15 | def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, use_color : Bool = true)
16 | Colorize.enabled = use_color
17 | Colorize.on_tty_only!
18 | end
19 |
20 | def call(context : HTTP::Server::Context)
21 | elapsed_time = Time.measure { call_next(context) }
22 | elapsed_text = elapsed_text(elapsed_time)
23 |
24 | # Default: full path with parameters
25 | requested_url = context.request.resource
26 |
27 | # Try not to log search queries passed as GET parameters during normal use
28 | # (They will still be logged if log level is 'Debug' or 'Trace')
29 | if @level > LogLevel::Debug && (
30 | requested_url.downcase.includes?("search") || requested_url.downcase.includes?("q=")
31 | )
32 | # Log only the path
33 | requested_url = context.request.path
34 | end
35 |
36 | info("#{context.response.status_code} #{context.request.method} #{requested_url} #{elapsed_text}")
37 |
38 | context
39 | end
40 |
41 | def write(message : String)
42 | @io << message
43 | @io.flush
44 | end
45 |
46 | def color(level)
47 | case level
48 | when LogLevel::Trace then :cyan
49 | when LogLevel::Debug then :green
50 | when LogLevel::Info then :white
51 | when LogLevel::Warn then :yellow
52 | when LogLevel::Error then :red
53 | when LogLevel::Fatal then :magenta
54 | else :default
55 | end
56 | end
57 |
58 | {% for level in %w(trace debug info warn error fatal) %}
59 | def {{level.id}}(message : String)
60 | if LogLevel::{{level.id.capitalize}} >= @level
61 | puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}})))
62 | end
63 | end
64 | {% end %}
65 |
66 | private def elapsed_text(elapsed)
67 | millis = elapsed.total_milliseconds
68 | return "#{millis.round(2)}ms" if millis >= 1
69 |
70 | "#{(millis * 1000).round(2)}µs"
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/src/invidious/helpers/macros.cr:
--------------------------------------------------------------------------------
1 | module DB::Serializable
2 | macro included
3 | {% verbatim do %}
4 | macro finished
5 | def self.type_array
6 | \{{ @type.instance_vars
7 | .reject { |var| var.annotation(::DB::Field) && var.annotation(::DB::Field)[:ignore] }
8 | .map { |name| name.stringify }
9 | }}
10 | end
11 |
12 | def initialize(tuple)
13 | \{% for var in @type.instance_vars %}
14 | \{% ann = var.annotation(::DB::Field) %}
15 | \{% if ann && ann[:ignore] %}
16 | \{% else %}
17 | @\{{var.name}} = tuple[:\{{var.name.id}}]
18 | \{% end %}
19 | \{% end %}
20 | end
21 |
22 | def to_a
23 | \{{ @type.instance_vars
24 | .reject { |var| var.annotation(::DB::Field) && var.annotation(::DB::Field)[:ignore] }
25 | .map { |name| name }
26 | }}
27 | end
28 | end
29 | {% end %}
30 | end
31 | end
32 |
33 | module JSON::Serializable
34 | macro included
35 | {% verbatim do %}
36 | macro finished
37 | def initialize(tuple)
38 | \{% for var in @type.instance_vars %}
39 | \{% ann = var.annotation(::JSON::Field) %}
40 | \{% if ann && ann[:ignore] %}
41 | \{% else %}
42 | @\{{var.name}} = tuple[:\{{var.name.id}}]
43 | \{% end %}
44 | \{% end %}
45 | end
46 | end
47 | {% end %}
48 | end
49 | end
50 |
51 | macro templated(_filename, template = "template", navbar_search = true)
52 | navbar_search = {{navbar_search}}
53 |
54 | {{ filename = "src/invidious/views/" + _filename + ".ecr" }}
55 | {{ layout = "src/invidious/views/" + template + ".ecr" }}
56 |
57 | __content_filename__ = {{filename}}
58 | render {{filename}}, {{layout}}
59 | end
60 |
61 | macro rendered(filename)
62 | render("src/invidious/views/#{{{filename}}}.ecr")
63 | end
64 |
65 | # Similar to Kemals halt method but works in a
66 | # method.
67 | macro haltf(env, status_code = 200, response = "")
68 | {{env}}.response.status_code = {{status_code}}
69 | {{env}}.response.print {{response}}
70 | {{env}}.response.close
71 | return
72 | end
73 |
--------------------------------------------------------------------------------
/src/invidious/jobs/statistics_refresh_job.cr:
--------------------------------------------------------------------------------
1 | class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob
2 | STATISTICS = {
3 | "version" => "2.0",
4 | "software" => {
5 | "name" => "invidious",
6 | "version" => "",
7 | "branch" => "",
8 | },
9 | "openRegistrations" => true,
10 | "usage" => {
11 | "users" => {
12 | "total" => 0_i64,
13 | "activeHalfyear" => 0_i64,
14 | "activeMonth" => 0_i64,
15 | },
16 | },
17 | "metadata" => {
18 | "updatedAt" => Time.utc.to_unix,
19 | "lastChannelRefreshedAt" => 0_i64,
20 | },
21 |
22 | #
23 | # "totalRequests" => 0_i64,
24 | # "successfulRequests" => 0_i64
25 | # "ratio" => 0_i64
26 | #
27 | "playback" => {} of String => Int64 | Float64,
28 | }
29 |
30 | private getter db : DB::Database
31 |
32 | def initialize(@db, @software_config : Hash(String, String))
33 | end
34 |
35 | def begin
36 | load_initial_stats
37 |
38 | loop do
39 | refresh_stats
40 | sleep 10.minute
41 | Fiber.yield
42 | end
43 | end
44 |
45 | # should only be called once at the very beginning
46 | private def load_initial_stats
47 | STATISTICS["software"] = {
48 | "name" => @software_config["name"],
49 | "version" => @software_config["version"],
50 | "branch" => @software_config["branch"],
51 | }
52 | STATISTICS["openRegistrations"] = CONFIG.registration_enabled
53 | end
54 |
55 | private def refresh_stats
56 | users = STATISTICS.dig("usage", "users").as(Hash(String, Int64))
57 |
58 | users["total"] = Invidious::Database::Statistics.count_users_total
59 | users["activeHalfyear"] = Invidious::Database::Statistics.count_users_active_6m
60 | users["activeMonth"] = Invidious::Database::Statistics.count_users_active_1m
61 |
62 | STATISTICS["metadata"] = {
63 | "updatedAt" => Time.utc.to_unix,
64 | "lastChannelRefreshedAt" => Invidious::Database::Statistics.channel_last_update.try &.to_unix || 0_i64,
65 | }
66 |
67 | # Reset playback requests tracker
68 | STATISTICS["playback"] = {} of String => Int64 | Float64
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/src/invidious/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, "110:embedded": {"1:embedded": {"8:string": ""}}}
10 | "EglwbGF5bGlzdHMYBCABMAHyBgQKAkIA"
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, "110:embedded": {"1:embedded": {"8:string": ""}}}
15 | "EglwbGF5bGlzdHMYAiABMAHyBgQKAkIA"
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, "110:embedded": {"1:embedded": {"8:string": ""}}}
21 | "EglwbGF5bGlzdHMYAyABMAHyBgQKAkIA"
22 | end
23 |
24 | initial_data = YoutubeAPI.browse(ucid, params: params || "")
25 | end
26 |
27 | return extract_items(initial_data, author, ucid)
28 | end
29 |
30 | def fetch_channel_podcasts(ucid, author, continuation)
31 | if continuation
32 | initial_data = YoutubeAPI.browse(continuation)
33 | else
34 | initial_data = YoutubeAPI.browse(ucid, params: "Eghwb2RjYXN0c_IGBQoDugEA")
35 | end
36 | return extract_items(initial_data, author, ucid)
37 | end
38 |
39 | def fetch_channel_releases(ucid, author, continuation)
40 | if continuation
41 | initial_data = YoutubeAPI.browse(continuation)
42 | else
43 | initial_data = YoutubeAPI.browse(ucid, params: "EghyZWxlYXNlc_IGBQoDsgEA")
44 | end
45 | return extract_items(initial_data, author, ucid)
46 | end
47 |
48 | def fetch_channel_courses(ucid, author, continuation)
49 | if continuation
50 | initial_data = YoutubeAPI.browse(continuation)
51 | else
52 | initial_data = YoutubeAPI.browse(ucid, params: "Egdjb3Vyc2Vz8gYFCgPCAQA%3D")
53 | end
54 | return extract_items(initial_data, author, ucid)
55 | end
56 |
--------------------------------------------------------------------------------
/src/invidious/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 |
--------------------------------------------------------------------------------
/scripts/generate_js_licenses.cr:
--------------------------------------------------------------------------------
1 | # This file automatically generates Crystal strings of rows within an HTML Javascript licenses table
2 | #
3 | # These strings will then be placed within a `<%= %>` statement in licenses.ecr at compile time which
4 | # will be interpolated at run-time. This interpolation is only for the translation of the "source" string
5 | # so maybe we can just switch to a non-translated string to simplify the logic here.
6 | #
7 | # The Javascript Web Labels table defined at https://www.gnu.org/software/librejs/free-your-javascript.html#step3
8 | # for example just reiterates the name of the source file rather than use a "source" string.
9 | all_javascript_files = Dir.glob("assets/**/*.js")
10 |
11 | videojs_js = [] of String
12 | invidious_js = [] of String
13 |
14 | all_javascript_files.each do |js_path|
15 | if js_path.starts_with?("assets/videojs/")
16 | videojs_js << js_path[7..]
17 | else
18 | invidious_js << js_path[7..]
19 | end
20 | end
21 |
22 | def create_licence_tr(path, file_name, licence_name, licence_link, source_location)
23 | tr = <<-HTML
24 | "
25 | | #{file_name} |
26 | #{licence_name} |
27 | \#{translate(locale, "source")} |
28 |
"
29 | HTML
30 |
31 | # New lines are removed as to allow for using String.join and StringLiteral.split
32 | # to get a clean list of each table row.
33 | tr.gsub('\n', "")
34 | end
35 |
36 | # TODO Use videojs-dependencies.yml to generate license info for videojs javascript
37 | jslicence_table_rows = [] of String
38 |
39 | invidious_js.each do |path|
40 | file_name = path.split('/')[-1]
41 |
42 | # A couple non Invidious JS files are also shipped alongside Invidious due to various reasons
43 | next if {
44 | "sse.js", "silvermine-videojs-quality-selector.min.js", "videojs-youtube-annotations.min.js",
45 | }.includes?(file_name)
46 |
47 | jslicence_table_rows << create_licence_tr(
48 | path: path,
49 | file_name: file_name,
50 | licence_name: "AGPL-3.0",
51 | licence_link: "https://www.gnu.org/licenses/agpl-3.0.html",
52 | source_location: path
53 | )
54 | end
55 |
56 | puts jslicence_table_rows.join("\n")
57 |
--------------------------------------------------------------------------------
/src/invidious/views/user/subscription_manager.ecr:
--------------------------------------------------------------------------------
1 | <% content_for "header" do %>
2 |
<%= translate(locale, "Subscription manager") %> - Invidious
3 | <% end %>
4 |
5 |
28 |
29 | <% subscriptions.each do |channel| %>
30 |
31 |
32 |
37 |
38 |
39 |
40 |
44 |
45 |
46 |
47 |
48 | <% if subscriptions[-1].author != channel.author %>
49 |
50 | <% end %>
51 |
52 | <% end %>
53 |
--------------------------------------------------------------------------------
/spec/helpers/vtt/builder_spec.cr:
--------------------------------------------------------------------------------
1 | require "../../spec_helper.cr"
2 |
3 | MockLines = ["Line 1", "Line 2"]
4 | MockLinesWithEscapableCharacter = ["
", "&Line 2>", '\u200E' + "Line\u200F 3", "\u00A0Line 4"]
5 |
6 | Spectator.describe "WebVTT::Builder" do
7 | it "correctly builds a vtt file" do
8 | result = WebVTT.build do |vtt|
9 | 2.times do |i|
10 | vtt.cue(
11 | Time::Span.new(seconds: i),
12 | Time::Span.new(seconds: i + 1),
13 | MockLines[i]
14 | )
15 | end
16 | end
17 |
18 | expect(result).to eq([
19 | "WEBVTT",
20 | "",
21 | "00:00:00.000 --> 00:00:01.000",
22 | "Line 1",
23 | "",
24 | "00:00:01.000 --> 00:00:02.000",
25 | "Line 2",
26 | "",
27 | "",
28 | ].join('\n'))
29 | end
30 |
31 | it "correctly builds a vtt file with setting fields" do
32 | setting_fields = {
33 | "Kind" => "captions",
34 | "Language" => "en",
35 | }
36 |
37 | result = WebVTT.build(setting_fields) do |vtt|
38 | 2.times do |i|
39 | vtt.cue(
40 | Time::Span.new(seconds: i),
41 | Time::Span.new(seconds: i + 1),
42 | MockLines[i]
43 | )
44 | end
45 | end
46 |
47 | expect(result).to eq([
48 | "WEBVTT",
49 | "Kind: captions",
50 | "Language: en",
51 | "",
52 | "00:00:00.000 --> 00:00:01.000",
53 | "Line 1",
54 | "",
55 | "00:00:01.000 --> 00:00:02.000",
56 | "Line 2",
57 | "",
58 | "",
59 | ].join('\n'))
60 | end
61 |
62 | it "properly escapes characters" do
63 | result = WebVTT.build do |vtt|
64 | 4.times do |i|
65 | vtt.cue(Time::Span.new(seconds: i), Time::Span.new(seconds: i + 1), MockLinesWithEscapableCharacter[i])
66 | end
67 | end
68 |
69 | expect(result).to eq([
70 | "WEBVTT",
71 | "",
72 | "00:00:00.000 --> 00:00:01.000",
73 | "<Line 1>",
74 | "",
75 | "00:00:01.000 --> 00:00:02.000",
76 | "&Line 2>",
77 | "",
78 | "00:00:02.000 --> 00:00:03.000",
79 | "Line 3",
80 | "",
81 | "00:00:03.000 --> 00:00:04.000",
82 | " Line 4",
83 | "",
84 | "",
85 | ].join('\n'))
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/src/invidious/views/channel.ecr:
--------------------------------------------------------------------------------
1 | <%-
2 | ucid = channel.ucid
3 | author = HTML.escape(channel.author)
4 | channel_profile_pic = URI.parse(channel.author_thumbnail).request_target
5 |
6 | relative_url =
7 | case selected_tab
8 | when .shorts? then "/channel/#{ucid}/shorts"
9 | when .streams? then "/channel/#{ucid}/streams"
10 | when .playlists? then "/channel/#{ucid}/playlists"
11 | when .channels? then "/channel/#{ucid}/channels"
12 | when .podcasts? then "/channel/#{ucid}/podcasts"
13 | when .releases? then "/channel/#{ucid}/releases"
14 | when .courses? then "/channel/#{ucid}/courses"
15 | else
16 | "/channel/#{ucid}"
17 | end
18 |
19 | youtube_url = "https://www.youtube.com#{relative_url}"
20 | redirect_url = Invidious::Frontend::Misc.redirect_url(env)
21 |
22 | page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale,
23 | base_url: relative_url,
24 | ctoken: next_continuation,
25 | first_page: continuation.nil?,
26 | params: env.params.query,
27 | )
28 | %>
29 |
30 | <% content_for "header" do %>
31 | <%- if selected_tab.videos? -%>
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | <%- end -%>
45 |
46 |
47 |
48 |
49 | <%= author %> - Invidious
50 | <% end %>
51 |
52 | <%= rendered "components/channel_info" %>
53 |
54 |
55 |
56 |
57 |
58 |
59 | <%= rendered "components/items_paginated" %>
60 |
--------------------------------------------------------------------------------
/.github/workflows/build-nightly-container.yml:
--------------------------------------------------------------------------------
1 | name: Build and release container directly from master
2 |
3 | on:
4 | push:
5 | branches:
6 | - "master"
7 | paths-ignore:
8 | - "*.md"
9 | - LICENCE
10 | - TRANSLATION
11 | - invidious.service
12 | - .git*
13 | - .editorconfig
14 | - screenshots/*
15 | - .github/ISSUE_TEMPLATE/*
16 | - kubernetes/**
17 |
18 | jobs:
19 | release:
20 | strategy:
21 | matrix:
22 | include:
23 | - os: ubuntu-latest
24 | platform: linux/amd64
25 | name: "AMD64"
26 | dockerfile: "docker/Dockerfile"
27 | tag_suffix: ""
28 | # GitHub doesn't have a ubuntu-latest-arm runner
29 | - os: ubuntu-24.04-arm
30 | platform: linux/arm64/v8
31 | name: "ARM64"
32 | dockerfile: "docker/Dockerfile.arm64"
33 | tag_suffix: "-arm64"
34 |
35 | runs-on: ${{ matrix.os }}
36 |
37 | steps:
38 | - name: Checkout
39 | uses: actions/checkout@v6
40 |
41 | - name: Set up Docker Buildx
42 | uses: docker/setup-buildx-action@v3
43 |
44 | - name: Login to registry
45 | uses: docker/login-action@v3
46 | with:
47 | registry: quay.io
48 | username: ${{ secrets.QUAY_USERNAME }}
49 | password: ${{ secrets.QUAY_PASSWORD }}
50 |
51 | - name: Docker meta
52 | id: meta
53 | uses: docker/metadata-action@v5
54 | with:
55 | images: quay.io/invidious/invidious
56 | flavor: |
57 | suffix=${{ matrix.tag_suffix }}
58 | tags: |
59 | type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
60 | type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
61 | labels: |
62 | quay.expires-after=12w
63 |
64 | - name: Build and push Docker ${{ matrix.name }} image for Push Event
65 | uses: docker/build-push-action@v6
66 | with:
67 | context: .
68 | file: ${{ matrix.dockerfile }}
69 | platforms: ${{ matrix.platform }}
70 | labels: ${{ steps.meta.outputs.labels }}
71 | push: true
72 | tags: ${{ steps.meta.outputs.tags }}
73 | build-args: |
74 | "release=1"
75 |
--------------------------------------------------------------------------------
/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/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/views/feeds/history.ecr:
--------------------------------------------------------------------------------
1 | <% content_for "header" do %>
2 | <%= translate(locale, "History") %> - Invidious
3 | <% end %>
4 |
5 |
6 |
7 |
<%= translate_count(locale, "generic_videos_count", user.watched.size, NumberFormatting::HtmlSpan) %>
8 |
9 |
14 |
19 |
20 |
21 |
28 |
29 |
30 |
31 | <% watched.each do |item| %>
32 |
50 | <% end %>
51 |
52 |
53 | <%=
54 | IV::Frontend::Pagination.nav_numeric(locale,
55 | base_url: base_url,
56 | current_page: page,
57 | show_next: (watched.size >= max_results)
58 | )
59 | %>
60 |
--------------------------------------------------------------------------------
/src/invidious/jobs/refresh_channels_job.cr:
--------------------------------------------------------------------------------
1 | class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
2 | private getter db : DB::Database
3 |
4 | def initialize(@db)
5 | end
6 |
7 | def begin
8 | max_fibers = CONFIG.channel_threads
9 | lim_fibers = max_fibers
10 | active_fibers = 0
11 | active_channel = ::Channel(Bool).new
12 | backoff = 2.minutes
13 |
14 | loop do
15 | LOGGER.debug("RefreshChannelsJob: Refreshing all channels")
16 | PG_DB.query("SELECT id FROM channels ORDER BY updated") do |rs|
17 | rs.each do
18 | id = rs.read(String)
19 |
20 | if active_fibers >= lim_fibers
21 | LOGGER.trace("RefreshChannelsJob: Fiber limit reached, waiting...")
22 | if active_channel.receive
23 | LOGGER.trace("RefreshChannelsJob: Fiber limit ok, continuing")
24 | active_fibers -= 1
25 | end
26 | end
27 |
28 | LOGGER.debug("RefreshChannelsJob: #{id} : Spawning fiber")
29 | active_fibers += 1
30 | spawn do
31 | begin
32 | LOGGER.trace("RefreshChannelsJob: #{id} fiber : Fetching channel")
33 | channel = fetch_channel(id, pull_all_videos: CONFIG.full_refresh)
34 |
35 | lim_fibers = max_fibers
36 |
37 | LOGGER.trace("RefreshChannelsJob: #{id} fiber : Updating DB")
38 | Invidious::Database::Channels.update_author(id, channel.author)
39 | rescue ex
40 | LOGGER.error("RefreshChannelsJob: #{id} : #{ex.message}")
41 | if ex.message == "Deleted or invalid channel"
42 | Invidious::Database::Channels.update_mark_deleted(id)
43 | else
44 | lim_fibers = 1
45 | LOGGER.error("RefreshChannelsJob: #{id} fiber : backing off for #{backoff}s")
46 | sleep backoff
47 | if backoff < 1.days
48 | backoff += backoff
49 | else
50 | backoff = 1.days
51 | end
52 | end
53 | ensure
54 | LOGGER.debug("RefreshChannelsJob: #{id} fiber : Done")
55 | active_channel.send(true)
56 | end
57 | end
58 | end
59 | end
60 |
61 | LOGGER.debug("RefreshChannelsJob: Done, sleeping for #{CONFIG.channel_refresh_interval}")
62 | sleep CONFIG.channel_refresh_interval
63 | Fiber.yield
64 | end
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/src/invidious/views/components/channel_info.ecr:
--------------------------------------------------------------------------------
1 | <% if channel.banner %>
2 |
3 |

" alt="" />
4 |
5 |
6 |
7 |
8 |
9 | <% end %>
10 |
11 |
12 |
13 |
14 |

15 |
<%= author %><% if !channel.verified.nil? && channel.verified %>
<% end %>
16 |
17 |
18 |
19 |
31 |
32 |
33 |
34 |
<%= channel.description_html %>
35 |
36 |
37 |
38 |
39 |
42 |
45 |
46 | <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %>
47 |
48 |
49 |
50 | <% sort_options.each do |sort| %>
51 |
58 | <% end %>
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/src/invidious/routes/api/v1/search.cr:
--------------------------------------------------------------------------------
1 | module Invidious::Routes::API::V1::Search
2 | def self.search(env)
3 | locale = env.get("preferences").as(Preferences).locale
4 | region = env.params.query["region"]?
5 |
6 | env.response.content_type = "application/json"
7 |
8 | query = Invidious::Search::Query.new(env.params.query, :regular, region)
9 |
10 | begin
11 | search_results = query.process
12 | rescue ex
13 | return error_json(400, ex)
14 | end
15 |
16 | JSON.build do |json|
17 | json.array do
18 | search_results.each do |item|
19 | item.to_json(locale, json)
20 | end
21 | end
22 | end
23 | end
24 |
25 | def self.search_suggestions(env)
26 | preferences = env.get("preferences").as(Preferences)
27 | region = env.params.query["region"]? || preferences.region
28 |
29 | env.response.content_type = "application/json"
30 |
31 | query = env.params.query["q"]? || ""
32 |
33 | begin
34 | client = make_client(URI.parse("https://suggestqueries-clients6.youtube.com"), force_youtube_headers: true)
35 | url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&gs_ri=youtube&ds=yt"
36 |
37 | response = client.get(url).body
38 | client.close
39 |
40 | body = JSON.parse(response[19..-2]).as_a
41 | suggestions = body[1].as_a[0..-2]
42 |
43 | JSON.build do |json|
44 | json.object do
45 | json.field "query", body[0].as_s
46 | json.field "suggestions" do
47 | json.array do
48 | suggestions.each do |suggestion|
49 | json.string suggestion[0].as_s
50 | end
51 | end
52 | end
53 | end
54 | end
55 | rescue ex
56 | return error_json(500, ex)
57 | end
58 | end
59 |
60 | def self.hashtag(env)
61 | hashtag = env.params.url["hashtag"]
62 |
63 | page = env.params.query["page"]?.try &.to_i? || 1
64 |
65 | locale = env.get("preferences").as(Preferences).locale
66 | region = env.params.query["region"]?
67 | env.response.content_type = "application/json"
68 |
69 | begin
70 | results = Invidious::Hashtag.fetch(hashtag, page, region)
71 | rescue ex
72 | return error_json(400, ex)
73 | end
74 |
75 | JSON.build do |json|
76 | json.object do
77 | json.field "results" do
78 | json.array do
79 | results.each do |item|
80 | item.to_json(locale, json)
81 | end
82 | end
83 | end
84 | end
85 | end
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/src/invidious/views/edit_playlist.ecr:
--------------------------------------------------------------------------------
1 | <% title = HTML.escape(playlist.title) %>
2 |
3 | <% content_for "header" do %>
4 | <%= title %> - Invidious
5 |
6 | <% end %>
7 |
8 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | <%= rendered "components/items_paginated" %>
61 |
--------------------------------------------------------------------------------
/src/invidious/helpers/webvtt.cr:
--------------------------------------------------------------------------------
1 | # Namespace for logic relating to generating WebVTT files
2 | #
3 | # Probably not compliant to WebVTT's specs but it is enough for Invidious.
4 | module WebVTT
5 | # A WebVTT builder generates WebVTT files
6 | private class Builder
7 | # See https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API#cue_payload
8 | private ESCAPE_SUBSTITUTIONS = {
9 | '&' => "&",
10 | '<' => "<",
11 | '>' => ">",
12 | '\u200E' => "",
13 | '\u200F' => "",
14 | '\u00A0' => " ",
15 | }
16 |
17 | def initialize(@io : IO)
18 | end
19 |
20 | # Writes an vtt cue with the specified time stamp and contents
21 | def cue(start_time : Time::Span, end_time : Time::Span, text : String)
22 | timestamp(start_time, end_time)
23 | @io << self.escape(text)
24 | @io << "\n\n"
25 | end
26 |
27 | private def timestamp(start_time : Time::Span, end_time : Time::Span)
28 | timestamp_component(start_time)
29 | @io << " --> "
30 | timestamp_component(end_time)
31 |
32 | @io << '\n'
33 | end
34 |
35 | private def timestamp_component(timestamp : Time::Span)
36 | @io << timestamp.hours.to_s.rjust(2, '0')
37 | @io << ':' << timestamp.minutes.to_s.rjust(2, '0')
38 | @io << ':' << timestamp.seconds.to_s.rjust(2, '0')
39 | @io << '.' << timestamp.milliseconds.to_s.rjust(3, '0')
40 | end
41 |
42 | private def escape(text : String) : String
43 | return text.gsub(ESCAPE_SUBSTITUTIONS)
44 | end
45 |
46 | def document(setting_fields : Hash(String, String)? = nil, &)
47 | @io << "WEBVTT\n"
48 |
49 | if setting_fields
50 | setting_fields.each do |name, value|
51 | @io << name << ": " << value << '\n'
52 | end
53 | end
54 |
55 | @io << '\n'
56 |
57 | yield
58 | end
59 | end
60 |
61 | # Returns the resulting `String` of writing WebVTT to the yielded `WebVTT::Builder`
62 | #
63 | # ```
64 | # string = WebVTT.build do |vtt|
65 | # vtt.cue(Time::Span.new(seconds: 1), Time::Span.new(seconds: 2), "Line 1")
66 | # vtt.cue(Time::Span.new(seconds: 2), Time::Span.new(seconds: 3), "Line 2")
67 | # end
68 | #
69 | # string # => "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nLine 1\n\n00:00:02.000 --> 00:00:03.000\nLine 2\n\n"
70 | # ```
71 | #
72 | # Accepts an optional settings fields hash to add settings attribute to the resulting vtt file.
73 | def self.build(setting_fields : Hash(String, String)? = nil, &)
74 | String.build do |str|
75 | builder = Builder.new(str)
76 | builder.document(setting_fields) do
77 | yield builder
78 | end
79 | end
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/src/invidious/videos/description.cr:
--------------------------------------------------------------------------------
1 | require "json"
2 | require "uri"
3 |
4 | private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int
5 | copied = 0
6 | while copied < count
7 | cp = iter.next
8 | break if cp.is_a?(Iterator::Stop)
9 |
10 | if cp == 0x26 # Ampersand (&)
11 | str << "&"
12 | elsif cp == 0x27 # Single quote (')
13 | str << "'"
14 | elsif cp == 0x22 # Double quote (")
15 | str << """
16 | elsif cp == 0x3C # Less-than (<)
17 | str << "<"
18 | elsif cp == 0x3E # Greater than (>)
19 | str << ">"
20 | else
21 | str << cp.chr
22 | end
23 |
24 | # A codepoint from the SMP counts twice
25 | copied += 1 if cp > 0xFFFF
26 | copied += 1
27 | end
28 |
29 | return copied
30 | end
31 |
32 | def parse_description(desc, video_id : String) : String?
33 | return "" if desc.nil?
34 |
35 | content = desc["content"].as_s
36 | return "" if content.empty?
37 |
38 | commands = desc["commandRuns"]?.try &.as_a
39 | if commands.nil?
40 | # Slightly faster than HTML.escape, as we're only doing one pass on
41 | # the string instead of five for the standard library
42 | return String.build do |str|
43 | copy_string(str, content.each_codepoint, content.size)
44 | end
45 | end
46 |
47 | # Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
48 | # (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
49 | # automatically decoded by the JSON parser. It means that we need to count
50 | # copied byte in a special manner, preventing the use of regular string copy.
51 | iter = content.each_codepoint
52 |
53 | index = 0
54 |
55 | return String.build do |str|
56 | commands.each do |command|
57 | cmd_start = command["startIndex"].as_i
58 | cmd_length = command["length"].as_i
59 |
60 | # Copy the text chunk between this command and the previous if needed.
61 | length = cmd_start - index
62 | index += copy_string(str, iter, length)
63 |
64 | # We need to copy the command's text using the iterator
65 | # and the special function defined above.
66 | cmd_content = String.build(cmd_length) do |str2|
67 | copy_string(str2, iter, cmd_length)
68 | end
69 |
70 | link = cmd_content
71 | if on_tap = command.dig?("onTap", "innertubeCommand")
72 | link = parse_link_endpoint(on_tap, cmd_content, video_id)
73 | end
74 | str << link
75 | index += cmd_length
76 | end
77 |
78 | # Copy the end of the string (past the last command).
79 | remaining_length = content.size - index
80 | copy_string(str, iter, remaining_length) if remaining_length > 0
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/assets/css/search.css:
--------------------------------------------------------------------------------
1 | #filters-collapse summary {
2 | /* This should hide the marker */
3 | display: block;
4 |
5 | font-size: 1.17em;
6 | font-weight: bold;
7 | margin: 0 auto 10px auto;
8 | cursor: pointer;
9 | }
10 |
11 | #filters-collapse summary::-webkit-details-marker,
12 | #filters-collapse summary::marker { display: none; }
13 |
14 | #filters-collapse summary:before {
15 | border-radius: 5px;
16 | content: "[ + ]";
17 | margin: -2px 10px 0 10px;
18 | padding: 1px 0 3px 0;
19 | text-align: center;
20 | width: 40px;
21 | }
22 |
23 | #filters-collapse details[open] > summary:before { content: "[ − ]"; }
24 |
25 |
26 | #filters-box {
27 | padding: 10px 20px 20px 10px;
28 | margin: 10px 15px;
29 | }
30 | #filters-flex {
31 | display: flex;
32 | flex-wrap: wrap;
33 | flex-direction: row;
34 | align-items: flex-start;
35 | align-content: flex-start;
36 | justify-content: flex-start;
37 | }
38 |
39 |
40 | fieldset, legend {
41 | display: contents !important;
42 | border: none !important;
43 | margin: 0 !important;
44 | padding: 0 !important;
45 | }
46 |
47 |
48 | .filter-column {
49 | display: inline-block;
50 | display: inline-flex;
51 | width: max-content;
52 | min-width: max-content;
53 | max-width: 16em;
54 | margin: 15px;
55 | flex-grow: 2;
56 | flex-basis: auto;
57 | flex-direction: column;
58 | }
59 | .filter-name, .filter-options {
60 | display: block;
61 | padding: 5px 10px;
62 | margin: 0;
63 | text-align: start;
64 | }
65 |
66 | .filter-options div { margin: 6px 0; }
67 | .filter-options div * { vertical-align: middle; }
68 | .filter-options label { margin: 0 10px; }
69 |
70 |
71 | #filters-apply {
72 | text-align: right; /* IE11 only */
73 | text-align: end; /* Override for compatible browsers */
74 | }
75 |
76 | /* Error message */
77 |
78 | .no-results-error {
79 | text-align: center;
80 | line-height: 180%;
81 | font-size: 110%;
82 | padding: 15px 15px 125px 15px;
83 | }
84 |
85 | /* Responsive rules */
86 |
87 | @media only screen and (max-width: 800px) {
88 | summary { font-size: 1.30em; }
89 | #filters-box {
90 | margin: 10px 0 0 0;
91 | padding: 0;
92 | }
93 | #filters-apply {
94 | text-align: center;
95 | padding: 15px;
96 | }
97 | }
98 |
99 | /* Light theme */
100 |
101 | .light-theme #filters-box {
102 | background: #dfdfdf;
103 | }
104 |
105 | @media (prefers-color-scheme: light) {
106 | .no-theme #filters-box {
107 | background: #dfdfdf;
108 | }
109 | }
110 |
111 | /* Dark theme */
112 |
113 | .dark-theme #filters-box {
114 | background: #373737;
115 | }
116 |
117 | @media (prefers-color-scheme: dark) {
118 | .no-theme #filters-box {
119 | background: #373737;
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/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 | "Sign In": "সাইন ইন",
43 | "Register": "নিবন্ধন",
44 | "E-mail": "ই-মেইল",
45 | "Preferences": "পছন্দসমূহ",
46 | "preferences_category_player": "প্লেয়ারের পছন্দসমূহ",
47 | "preferences_video_loop_label": "সর্বদা লুপ: ",
48 | "preferences_autoplay_label": "স্বয়ংক্রিয় চালু: ",
49 | "preferences_continue_label": "ডিফল্টভাবে পরবর্তী চালাও: ",
50 | "preferences_continue_autoplay_label": "পরবর্তী ভিডিও স্বয়ংক্রিয়ভাবে চালাও: ",
51 | "preferences_listen_label": "সহজাতভাবে শোনো: ",
52 | "preferences_local_label": "ভিডিও প্রক্সি করো: ",
53 | "preferences_speed_label": "সহজাত গতি: ",
54 | "preferences_quality_label": "পছন্দের ভিডিও মান: ",
55 | "preferences_volume_label": "প্লেয়ার শব্দের মাত্রা: "
56 | }
57 |
--------------------------------------------------------------------------------
/src/invidious/yt_backend/extractors_utils.cr:
--------------------------------------------------------------------------------
1 | # Extracts text from InnerTube response
2 | #
3 | # InnerTube can package text in three different formats
4 | # "runs": [
5 | # {"text": "something"},
6 | # {"text": "cont"},
7 | # ...
8 | # ]
9 | #
10 | # "SimpleText": "something"
11 | #
12 | # Or sometimes just none at all as with the data returned from
13 | # category continuations.
14 | #
15 | # In order to facilitate calling this function with `#[]?`:
16 | # A nil will be accepted. Of course, since nil cannot be parsed,
17 | # another nil will be returned.
18 | def extract_text(item : JSON::Any?) : String?
19 | if item.nil?
20 | return nil
21 | end
22 |
23 | if text_container = item["simpleText"]?
24 | return text_container.as_s
25 | elsif text_container = item["runs"]?
26 | return text_container.as_a.map(&.["text"].as_s).join("")
27 | else
28 | nil
29 | end
30 | end
31 |
32 | # Check if an "ownerBadges" or a "badges" element contains a verified badge.
33 | # There is currently two known types of verified badges:
34 | #
35 | # "ownerBadges": [{
36 | # "metadataBadgeRenderer": {
37 | # "icon": { "iconType": "CHECK_CIRCLE_THICK" },
38 | # "style": "BADGE_STYLE_TYPE_VERIFIED",
39 | # "tooltip": "Verified",
40 | # "accessibilityData": { "label": "Verified" }
41 | # }
42 | # }],
43 | #
44 | # "ownerBadges": [{
45 | # "metadataBadgeRenderer": {
46 | # "icon": { "iconType": "OFFICIAL_ARTIST_BADGE" },
47 | # "style": "BADGE_STYLE_TYPE_VERIFIED_ARTIST",
48 | # "tooltip": "Official Artist Channel",
49 | # "accessibilityData": { "label": "Official Artist Channel" }
50 | # }
51 | # }],
52 | #
53 | def has_verified_badge?(badges : JSON::Any?)
54 | return false if badges.nil?
55 |
56 | badges.as_a.each do |badge|
57 | style = badge.dig("metadataBadgeRenderer", "style").as_s
58 |
59 | return true if style == "BADGE_STYLE_TYPE_VERIFIED"
60 | return true if style == "BADGE_STYLE_TYPE_VERIFIED_ARTIST"
61 | end
62 |
63 | return false
64 | rescue ex
65 | LOGGER.debug("Unable to parse owner badges. Got exception: #{ex.message}")
66 | LOGGER.trace("Owner badges data: #{badges.to_json}")
67 |
68 | return false
69 | end
70 |
71 | # This function extracts SearchVideo items from a Category.
72 | # Categories are commonly returned in search results and trending pages.
73 | def extract_category(category : Category) : Array(SearchVideo)
74 | return category.contents.select(SearchVideo)
75 | end
76 |
77 | # :ditto:
78 | def extract_category(category : Category, &)
79 | category.contents.select(SearchVideo).each do |item|
80 | yield item
81 | end
82 | end
83 |
84 | def extract_selected_tab(tabs)
85 | # Extract the selected tab from the array of tabs Youtube returns
86 | return tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"]
87 | end
88 |
--------------------------------------------------------------------------------
/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 | {% if compare_versions(Crystal::VERSION, "1.18.0-dev") >= 0 %}
7 | def initialize(host : String, port, dns_timeout = nil, connect_timeout = nil, blocking = false, family = Socket::Family::UNSPEC)
8 | Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo|
9 | super(family: addrinfo.family, type: addrinfo.type, protocol: addrinfo.protocol)
10 | Socket.set_blocking(self.fd, blocking)
11 | connect(addrinfo, timeout: connect_timeout) do |error|
12 | close
13 | error
14 | end
15 | end
16 | end
17 | {% else %}
18 | def initialize(host : String, port, dns_timeout = nil, connect_timeout = nil, blocking = false, family = Socket::Family::UNSPEC)
19 | Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo|
20 | super(addrinfo.family, addrinfo.type, addrinfo.protocol, blocking)
21 | connect(addrinfo, timeout: connect_timeout) do |error|
22 | close
23 | error
24 | end
25 | end
26 | end
27 | {% end %}
28 | end
29 |
30 | # :ditto:
31 | class HTTP::Client
32 | property family : Socket::Family = Socket::Family::UNSPEC
33 |
34 | private def io
35 | io = @io
36 | return io if io
37 | unless @reconnect
38 | raise "This HTTP::Client cannot be reconnected"
39 | end
40 |
41 | hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host
42 | io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, family: @family
43 | io.read_timeout = @read_timeout if @read_timeout
44 | io.write_timeout = @write_timeout if @write_timeout
45 | io.sync = false
46 |
47 | {% if !flag?(:without_openssl) %}
48 | if tls = @tls
49 | tcp_socket = io
50 | begin
51 | io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host.rchop('.'))
52 | rescue exc
53 | # don't leak the TCP socket when the SSL connection failed
54 | tcp_socket.close
55 | raise exc
56 | end
57 | end
58 | {% end %}
59 |
60 | @io = io
61 | end
62 | end
63 |
64 | # Mute the ClientError exception raised when a connection is flushed.
65 | # This happends when the connection is unexpectedly closed by the client.
66 | #
67 | class HTTP::Server::Response
68 | class Output
69 | private def unbuffered_flush
70 | @io.flush
71 | rescue ex : IO::Error
72 | unbuffered_close
73 | end
74 | end
75 | end
76 |
77 | # TODO: Document this override
78 | #
79 | class PG::ResultSet
80 | def field(index = @column_index)
81 | @fields.not_nil![index]
82 | end
83 | end
84 |
--------------------------------------------------------------------------------
/src/invidious/views/user/login.ecr:
--------------------------------------------------------------------------------
1 | <% content_for "header" do %>
2 | <%= translate(locale, "Log in") %> - Invidious
3 | <% end %>
4 |
5 |
51 |
--------------------------------------------------------------------------------
/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 | NO_DBG_SYMBOLS := 0
9 |
10 | # Enable multi-threading.
11 | # Warning: Experimental feature!!
12 | # invidious is not stable when MT is enabled.
13 | MT := 0
14 |
15 |
16 | FLAGS ?=
17 |
18 |
19 | ifeq ($(RELEASE), 1)
20 | FLAGS += --release
21 | endif
22 |
23 | ifeq ($(STATIC), 1)
24 | FLAGS += --static
25 | endif
26 |
27 | ifeq ($(MT), 1)
28 | FLAGS += -Dpreview_mt
29 | endif
30 |
31 |
32 | ifeq ($(NO_DBG_SYMBOLS), 1)
33 | FLAGS += --no-debug
34 | else
35 | FLAGS += --debug
36 | endif
37 |
38 | ifeq ($(API_ONLY), 1)
39 | FLAGS += -Dapi_only
40 | endif
41 |
42 |
43 | # -----------------------
44 | # Main
45 | # -----------------------
46 |
47 | all: invidious
48 |
49 | get-libs:
50 | shards install --production
51 |
52 | # TODO: add support for ARM64 via cross-compilation
53 | invidious: get-libs
54 | crystal build src/invidious.cr $(FLAGS) --progress --stats --error-trace
55 |
56 |
57 | run: invidious
58 | ./invidious
59 |
60 |
61 | # -----------------------
62 | # Development
63 | # -----------------------
64 |
65 |
66 | format:
67 | crystal tool format
68 |
69 | test:
70 | crystal spec
71 |
72 | verify:
73 | crystal build src/invidious.cr -Dskip_videojs_download \
74 | --no-codegen --progress --stats --error-trace
75 |
76 |
77 | # -----------------------
78 | # (Un)Install
79 | # -----------------------
80 |
81 | # TODO
82 |
83 |
84 | # -----------------------
85 | # Cleaning
86 | # -----------------------
87 |
88 | clean:
89 | rm invidious
90 |
91 | distclean: clean
92 | rm -rf libs
93 | rm -rf ~/.cache/{crystal,shards}
94 |
95 |
96 | # -----------------------
97 | # Help page
98 | # -----------------------
99 |
100 | help:
101 | @echo "Targets available in this Makefile:"
102 | @echo ""
103 | @echo " get-libs Fetch Crystal libraries"
104 | @echo " invidious Build Invidious"
105 | @echo " run Launch Invidious"
106 | @echo ""
107 | @echo " format Run the Crystal formatter"
108 | @echo " test Run tests"
109 | @echo " verify Just make sure that the code compiles, but without"
110 | @echo " generating any binaries. Useful to search for errors"
111 | @echo ""
112 | @echo " clean Remove build artifacts"
113 | @echo " distclean Remove build artifacts and libraries"
114 | @echo ""
115 | @echo ""
116 | @echo "Build options available for this Makefile:"
117 | @echo ""
118 | @echo " RELEASE Make a release build (Default: 1)"
119 | @echo " STATIC Link libraries statically (Default: 0)"
120 | @echo ""
121 | @echo " API_ONLY Build invidious without a GUI (Default: 0)"
122 | @echo " NO_DBG_SYMBOLS Strip debug symbols (Default: 0)"
123 |
124 |
125 |
126 | # No targets generates an output named after themselves
127 | .PHONY: all get-libs build amd64 run
128 | .PHONY: format test verify clean distclean help
129 |
--------------------------------------------------------------------------------
/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | # https://github.com/openssl/openssl/releases/tag/openssl-3.5.2
2 | ARG OPENSSL_VERSION='3.5.2'
3 | ARG OPENSSL_SHA256='c53a47e5e441c930c3928cf7bf6fb00e5d129b630e0aa873b08258656e7345ec'
4 |
5 | FROM crystallang/crystal:1.16.3-alpine AS dependabot-crystal
6 |
7 | # We compile openssl ourselves due to a memory leak in how crystal interacts
8 | # with openssl
9 | # Reference: https://github.com/iv-org/invidious/issues/1438#issuecomment-3087636228
10 | FROM dependabot-crystal AS openssl-builder
11 | RUN apk add --no-cache curl perl linux-headers
12 |
13 | WORKDIR /
14 |
15 | ARG OPENSSL_VERSION
16 | ARG OPENSSL_SHA256
17 | RUN curl -Ls "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz" --output openssl-${OPENSSL_VERSION}.tar.gz
18 | RUN echo "${OPENSSL_SHA256} openssl-${OPENSSL_VERSION}.tar.gz" | sha256sum -c
19 | RUN tar -xzvf openssl-${OPENSSL_VERSION}.tar.gz
20 |
21 | RUN cd openssl-${OPENSSL_VERSION} && ./Configure --openssldir=/etc/ssl && make -j$(nproc)
22 |
23 | FROM dependabot-crystal AS builder
24 |
25 | RUN apk add --no-cache sqlite-static yaml-static
26 | RUN apk del openssl-dev openssl-libs-static
27 |
28 | ARG release
29 |
30 | WORKDIR /invidious
31 | COPY ./shard.yml ./shard.yml
32 | COPY ./shard.lock ./shard.lock
33 | RUN shards install --production
34 |
35 | COPY ./src/ ./src/
36 | # TODO: .git folder is required for building – this is destructive.
37 | # See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION.
38 | COPY ./.git/ ./.git/
39 |
40 | # Required for fetching player dependencies
41 | COPY ./scripts/ ./scripts/
42 | COPY ./assets/ ./assets/
43 | COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
44 |
45 | RUN crystal spec --warnings all \
46 | --link-flags "-lxml2 -llzma"
47 |
48 | ARG OPENSSL_VERSION
49 | COPY --from=openssl-builder /openssl-${OPENSSL_VERSION} /openssl-${OPENSSL_VERSION}
50 |
51 | RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \
52 | PKG_CONFIG_PATH=/openssl-${OPENSSL_VERSION} \
53 | crystal build ./src/invidious.cr \
54 | --release \
55 | --static --warnings all \
56 | --link-flags "-lxml2 -llzma"; \
57 | else \
58 | PKG_CONFIG_PATH=/openssl-${OPENSSL_VERSION} \
59 | crystal build ./src/invidious.cr \
60 | --static --warnings all \
61 | --link-flags "-lxml2 -llzma"; \
62 | fi
63 |
64 | FROM alpine:3.23
65 | RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata
66 | WORKDIR /invidious
67 | RUN addgroup -g 1000 -S invidious && \
68 | adduser -u 1000 -S invidious -G invidious
69 | COPY --chown=invidious ./config/config.* ./config/
70 | RUN mv -n config/config.example.yml config/config.yml
71 | RUN sed -i 's/host: \(127.0.0.1\|localhost\)/host: invidious-db/' config/config.yml
72 | COPY ./config/sql/ ./config/sql/
73 | COPY ./locales/ ./locales/
74 | COPY --from=builder /invidious/assets ./assets/
75 | COPY --from=builder /invidious/invidious .
76 | RUN chmod o+rX -R ./assets ./config ./locales
77 |
78 | EXPOSE 3000
79 | USER invidious
80 | ENTRYPOINT ["/sbin/tini", "--"]
81 | CMD [ "/invidious/invidious" ]
82 |
--------------------------------------------------------------------------------