Complete API-only mode implementation

This commit completes the API-only mode feature that allows Invidious to be built and run without GUI/frontend components, significantly reducing the binary size and dependencies.

Changes include:
- Add conditional compilation flags throughout the codebase to exclude frontend-specific code
- Create stub implementations for database operations in API-only mode
- Update Docker configurations to support API-only builds
- Refactor require statements for better modularity
- Add DummyDB and stub types for API-only mode
- Ensure all routes work correctly without frontend dependencies

The API-only mode can be enabled by:
- Using -Dapi_only flag during compilation
- Setting API_ONLY=1 when using make
- Using --build-arg api_only=1 with Docker builds

This is particularly useful for:
- Microservice architectures where only the API is needed
- Reducing resource usage in containerized environments
- Creating lightweight API servers for mobile/desktop applications
This commit is contained in:
Sunghyun Kim 2025-08-04 16:40:41 +09:00
parent df8839d1f0
commit 0b2ec108d1
11 changed files with 466 additions and 105 deletions

View File

@ -3,6 +3,7 @@ FROM crystallang/crystal:1.16.3-alpine AS builder
RUN apk add --no-cache sqlite-static yaml-static RUN apk add --no-cache sqlite-static yaml-static
ARG release ARG release
ARG api_only
WORKDIR /invidious WORKDIR /invidious
COPY ./shard.yml ./shard.yml COPY ./shard.yml ./shard.yml
@ -21,16 +22,13 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
RUN crystal spec --warnings all \ RUN crystal spec --warnings all \
--link-flags "-lxml2 -llzma" --link-flags "-lxml2 -llzma"
RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \
crystal build ./src/invidious.cr \ RUN --mount=type=cache,target=/root/.cache/crystal \
--release \ crystal build ./src/invidious.cr \
--static --warnings all \ ${release:+--release} \
--link-flags "-lxml2 -llzma"; \ --static --warnings all \
else \ --link-flags "-lxml2 -llzma" \
crystal build ./src/invidious.cr \ ${api_only:+-Dapi_only -Dskip_videojs_download}
--static --warnings all \
--link-flags "-lxml2 -llzma"; \
fi
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata

View File

@ -3,6 +3,7 @@ RUN apk add --no-cache 'crystal=1.14.0-r0' shards sqlite-static yaml-static yaml
zlib-static openssl-libs-static openssl-dev musl-dev xz-static zlib-static openssl-libs-static openssl-dev musl-dev xz-static
ARG release ARG release
ARG api_only
WORKDIR /invidious WORKDIR /invidious
COPY ./shard.yml ./shard.yml COPY ./shard.yml ./shard.yml
@ -22,16 +23,12 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml
RUN crystal spec --warnings all \ RUN crystal spec --warnings all \
--link-flags "-lxml2 -llzma" --link-flags "-lxml2 -llzma"
RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \ RUN --mount=type=cache,target=/root/.cache/crystal \
crystal build ./src/invidious.cr \ crystal build ./src/invidious.cr \
--release \ ${release:+--release} \
--static --warnings all \ --static --warnings all \
--link-flags "-lxml2 -llzma"; \ --link-flags "-lxml2 -llzma" \
else \ ${api_only:+-Dapi_only -Dskip_videojs_download}
crystal build ./src/invidious.cr \
--static --warnings all \
--link-flags "-lxml2 -llzma"; \
fi
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata

View File

@ -31,24 +31,33 @@ require "yaml"
require "compress/zip" require "compress/zip"
require "protodec/utils" require "protodec/utils"
require "./invidious/database/*" # Database requires
require "./invidious/database/migrations/*" {% unless flag?(:api_only) %}
require "./invidious/database/*"
require "./invidious/database/migrations/*"
{% else %}
require "./invidious/database/api_only_stubs"
{% end %}
# Core requires
require "./invidious/http_server/*" require "./invidious/http_server/*"
require "./invidious/helpers/*" require "./invidious/helpers/*"
require "./invidious/yt_backend/*" require "./invidious/yt_backend/*"
require "./invidious/frontend/*" require "./invidious/frontend/*"
require "./invidious/videos/*" require "./invidious/videos/*"
require "./invidious/jsonify/**" require "./invidious/jsonify/**"
require "./invidious/requires"
require "./invidious/*"
require "./invidious/comments/*" require "./invidious/comments/*"
require "./invidious/channels/*" require "./invidious/channels/*"
require "./invidious/user/*" require "./invidious/user/*"
require "./invidious/search/*" require "./invidious/search/*"
require "./invidious/routes/**" require "./invidious/routes/**"
require "./invidious/jobs/base_job"
require "./invidious/jobs/*" # Jobs (not needed in API-only mode)
{% unless flag?(:api_only) %}
require "./invidious/jobs/base_job"
require "./invidious/jobs/*"
{% end %}
# Declare the base namespace for invidious # Declare the base namespace for invidious
module Invidious module Invidious
@ -60,7 +69,13 @@ alias IV = Invidious
CONFIG = Config.load CONFIG = Config.load
HMAC_KEY = CONFIG.hmac_key HMAC_KEY = CONFIG.hmac_key
PG_DB = DB.open CONFIG.database_url # Database connection
{% unless flag?(:api_only) %}
PG_DB = DB.open CONFIG.database_url
{% else %}
require "./invidious/api_only_types"
PG_DB = DummyDB.new
{% end %}
ARCHIVE_URL = URI.parse("https://archive.org") ARCHIVE_URL = URI.parse("https://archive.org")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
REDDIT_URL = URI.parse("https://www.reddit.com") REDDIT_URL = URI.parse("https://www.reddit.com")
@ -133,7 +148,11 @@ Kemal.config.extra_options do |parser|
exit exit
end end
parser.on("--migrate", "Run any migrations (beta, use at your own risk!!") do parser.on("--migrate", "Run any migrations (beta, use at your own risk!!") do
Invidious::Database::Migrator.new(PG_DB).migrate {% unless flag?(:api_only) %}
Invidious::Database::Migrator.new(PG_DB).migrate
{% else %}
puts "Database migrations are not available in API-only mode"
{% end %}
exit exit
end end
end end
@ -147,9 +166,11 @@ OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mo
LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level, CONFIG.colorize_logs) LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level, CONFIG.colorize_logs)
# Check table integrity # Check table integrity
Invidious::Database.check_integrity(CONFIG) {% unless flag?(:api_only) %}
Invidious::Database.check_integrity(CONFIG)
{% end %}
{% if !flag?(:skip_videojs_download) %} {% if !flag?(:skip_videojs_download) && !flag?(:api_only) %}
# Resolve player dependencies. This is done at compile time. # Resolve player dependencies. This is done at compile time.
# #
# Running the script by itself would show some colorful feedback while this doesn't. # Running the script by itself would show some colorful feedback while this doesn't.
@ -175,38 +196,48 @@ DECRYPT_FUNCTION =
# Start jobs # Start jobs
if CONFIG.channel_threads > 0 {% unless flag?(:api_only) %}
Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB) if CONFIG.channel_threads > 0
end Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB)
end
if CONFIG.feed_threads > 0 if CONFIG.feed_threads > 0
Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB) Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB)
end end
if CONFIG.statistics_enabled if CONFIG.statistics_enabled
Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE) Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE)
end end
if (CONFIG.use_pubsub_feeds.is_a?(Bool) && CONFIG.use_pubsub_feeds.as(Bool)) || (CONFIG.use_pubsub_feeds.is_a?(Int32) && CONFIG.use_pubsub_feeds.as(Int32) > 0) if (CONFIG.use_pubsub_feeds.is_a?(Bool) && CONFIG.use_pubsub_feeds.as(Bool)) || (CONFIG.use_pubsub_feeds.is_a?(Int32) && CONFIG.use_pubsub_feeds.as(Int32) > 0)
Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, HMAC_KEY) Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, HMAC_KEY)
end end
if CONFIG.popular_enabled if CONFIG.popular_enabled
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end end
NOTIFICATION_CHANNEL = ::Channel(VideoNotification).new(32) NOTIFICATION_CHANNEL = ::Channel(VideoNotification).new(32)
CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32) CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32)
Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(NOTIFICATION_CHANNEL, CONNECTION_CHANNEL, CONFIG.database_url) Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(NOTIFICATION_CHANNEL, CONNECTION_CHANNEL, CONFIG.database_url)
Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new
Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new
Invidious::Jobs.start_all Invidious::Jobs.start_all
{% else %}
# Define channels for API-only mode (even though they won't be used)
NOTIFICATION_CHANNEL = ::Channel(VideoNotification).new(1)
CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(1)
{% end %}
def popular_videos def popular_videos
Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get {% if flag?(:api_only) %}
[] of ChannelVideo
{% else %}
Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get
{% end %}
end end
# Routing # Routing

View File

@ -0,0 +1,50 @@
# API-only mode type definitions
# This file provides dummy type definitions when running in API-only mode
# Dummy DB class for API-only mode
class DummyDB
def query_all(*args, as : T.class) forall T
[] of T
end
def query_one(*args, as : T.class) forall T
raise "Database not available in API-only mode"
end
def query_one?(*args, as : T.class) forall T
nil
end
def scalar(*args)
0
end
def exec(*args)
nil
end
end
# VideoNotification struct for API-only mode
struct VideoNotification
property video_id : String
property channel_id : String
property published : Time
def initialize(@video_id = "", @channel_id = "", @published = Time.utc)
end
def self.from_video(video : ChannelVideo) : VideoNotification
VideoNotification.new(video.id, video.ucid, video.published)
end
end
# PQ module with Notification for API-only mode
module PQ
struct Notification
property channel : String = ""
property payload : String = ""
def initialize(@channel = "", @payload = "")
end
end
end

View File

@ -107,7 +107,9 @@ class Config
property full_refresh : Bool = false property full_refresh : Bool = false
# Jobs config structure. See jobs.cr and jobs/base_job.cr # Jobs config structure. See jobs.cr and jobs/base_job.cr
property jobs = Invidious::Jobs::JobsConfig.new {% unless flag?(:api_only) %}
property jobs = Invidious::Jobs::JobsConfig.new
{% end %}
# Used to tell Invidious it is behind a proxy, so links to resources should be https:// # Used to tell Invidious it is behind a proxy, so links to resources should be https://
property https_only : Bool? property https_only : Bool?
@ -285,21 +287,28 @@ class Config
end end
# Build database_url from db.* if it's not set directly # Build database_url from db.* if it's not set directly
if config.database_url.to_s.empty? {% unless flag?(:api_only) %}
if db = config.db if config.database_url.to_s.empty?
config.database_url = URI.new( if db = config.db
scheme: "postgres", config.database_url = URI.new(
user: db.user, scheme: "postgres",
password: db.password, user: db.user,
host: db.host, password: db.password,
port: db.port, host: db.host,
path: db.dbname, port: db.port,
) path: db.dbname,
else )
puts "Config: Either database_url or db.* is required" else
exit(1) puts "Config: Either database_url or db.* is required"
exit(1)
end
end end
end {% else %}
# In API-only mode, database is optional
if config.database_url.to_s.empty?
config.database_url = URI.parse("postgres://dummy:dummy@localhost/dummy")
end
{% end %}
# Check if the socket configuration is valid # Check if the socket configuration is valid
if sb = config.socket_binding if sb = config.socket_binding

View File

@ -0,0 +1,240 @@
# API-only mode database stubs
# This file provides dummy implementations for database modules when running in API-only mode
module Invidious::Database
module SessionIDs
def self.select_email(sid : String) : String?
nil
end
def self.select_all(email : String)
[] of {session: String, issued: Time}
end
def self.delete(sid : String)
nil
end
def self.insert(session : String, email : String)
nil
end
end
module Users
def self.select!(email : String)
raise "Database not available in API-only mode"
end
def self.update_preferences(user)
nil
end
def self.mark_watched(user, id)
nil
end
def self.mark_unwatched(user, id)
nil
end
def self.clear_watch_history(user)
nil
end
def self.subscribe_channel(user, ucid)
nil
end
def self.unsubscribe_channel(user, ucid)
nil
end
def self.update_subscriptions(user)
nil
end
def self.update_watch_history(user)
nil
end
def self.update_password(user, password : String)
nil
end
def self.update(user)
nil
end
def self.insert(user)
nil
end
def self.delete(user)
nil
end
def self.select_notifications(user)
[] of String
end
def self.mark_notifications_as_read(user)
nil
end
def self.feed_needs_update(user)
false
end
def self.update_feed_watched(user)
nil
end
end
module Channels
def self.select(subscriptions)
[] of InvidiousChannel
end
def self.select(id : String)
nil
end
def self.insert(channel, update_on_conflict : Bool = false)
nil
end
end
module ChannelVideos
def self.select_notfications(*args)
[] of ChannelVideo
end
def self.select_latest_videos(*args)
[] of ChannelVideo
end
def self.insert(*args)
nil
end
def self.select(notifications)
[] of ChannelVideo
end
end
module Playlists
def self.select_all(author : String)
[] of InvidiousPlaylist
end
def self.count_owned_by(email : String)
0
end
def self.select(id : String)
nil
end
def self.update(*args)
nil
end
def self.delete(id : String)
nil
end
def self.update_video_added(*args)
nil
end
def self.update_video_removed(*args)
nil
end
def self.select_like_iv(email : String)
[] of InvidiousPlaylist
end
def self.insert(playlist)
nil
end
def self.update_description(id : String, description : String)
nil
end
end
module PlaylistVideos
def self.insert(*args)
nil
end
def self.delete(*args)
nil
end
def self.select_ids(*args, limit : Int32? = nil)
[] of String
end
def self.select(*args, limit : Int32? = nil)
[] of PlaylistVideo
end
end
module Annotations
def self.select(id : String)
nil
end
def self.insert(id : String, annotations : String)
nil
end
end
module Videos
def self.select(id : String)
nil
end
def self.insert(*args)
nil
end
def self.update(*args)
nil
end
def self.delete(id : String)
nil
end
end
module Nonces
def self.select(nonce : String)
nil
end
def self.insert(nonce : String, expire : Time? = nil)
nil
end
def self.delete(nonce : String)
nil
end
end
# Migrator stub
class Migrator
def initialize(db)
end
def migrate
puts "Database migrations are not available in API-only mode"
end
end
def self.check_integrity(config)
# Skip integrity check in API-only mode
end
end

View File

@ -186,15 +186,24 @@ end
# #
# Creates a new tracker when unavailable. # Creates a new tracker when unavailable.
def get_playback_statistic def get_playback_statistic
if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]) && tracker.as(Hash).empty? {% unless flag?(:api_only) %}
tracker = { if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]) && tracker.as(Hash).empty?
tracker = {
"totalRequests" => 0_i64,
"successfulRequests" => 0_i64,
"ratio" => 0_f64,
}
Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"] = tracker
end
return tracker.as(Hash(String, Int64 | Float64))
{% else %}
# Return empty statistics in API-only mode
return {
"totalRequests" => 0_i64, "totalRequests" => 0_i64,
"successfulRequests" => 0_i64, "successfulRequests" => 0_i64,
"ratio" => 0_f64, "ratio" => 0_f64,
} }
{% end %}
Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"] = tracker
end
return tracker.as(Hash(String, Int64 | Float64))
end end

View File

@ -1,3 +1,4 @@
{% unless flag?(:api_only) %}
module Invidious::Jobs module Invidious::Jobs
JOBS = [] of BaseJob JOBS = [] of BaseJob
@ -38,3 +39,4 @@ module Invidious::Jobs
end end
end end
end end
{% end %}

18
src/invidious/requires.cr Normal file
View File

@ -0,0 +1,18 @@
# Common requires for Invidious
# This file contains the require statements organized by category
# Core requires
require "./config"
require "./exceptions"
require "./hashtag"
require "./mixes"
require "./playlists"
require "./routing"
require "./trending"
require "./users"
require "./videos"
# Jobs (only if not in API-only mode)
{% unless flag?(:api_only) %}
require "./jobs"
{% end %}

View File

@ -6,23 +6,28 @@ module Invidious::Routes::API::V1::Misc
if !CONFIG.statistics_enabled if !CONFIG.statistics_enabled
return {"software" => SOFTWARE}.to_json return {"software" => SOFTWARE}.to_json
else else
# Calculate playback success rate {% unless flag?(:api_only) %}
if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]?) # Calculate playback success rate
tracker = tracker.as(Hash(String, Int64 | Float64)) if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]?)
tracker = tracker.as(Hash(String, Int64 | Float64))
if !tracker.empty? if !tracker.empty?
total_requests = tracker["totalRequests"] total_requests = tracker["totalRequests"]
success_count = tracker["successfulRequests"] success_count = tracker["successfulRequests"]
if total_requests.zero? if total_requests.zero?
tracker["ratio"] = 1_i64 tracker["ratio"] = 1_i64
else else
tracker["ratio"] = (success_count / (total_requests)).round(2) tracker["ratio"] = (success_count / (total_requests)).round(2)
end
end end
end end
end
return Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json return Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json
{% else %}
# Return empty statistics in API-only mode
return {"software" => SOFTWARE}.to_json
{% end %}
end end
end end

View File

@ -72,24 +72,26 @@ module Invidious::Routes::BeforeAll
raise "Cannot use token as SID" raise "Cannot use token as SID"
end end
if email = Database::SessionIDs.select_email(sid) {% unless flag?(:api_only) %}
user = Database::Users.select!(email: email) if email = Database::SessionIDs.select_email(sid)
csrf_token = generate_response(sid, { user = Database::Users.select!(email: email)
":authorize_token", csrf_token = generate_response(sid, {
":playlist_ajax", ":authorize_token",
":signout", ":playlist_ajax",
":subscription_ajax", ":signout",
":token_ajax", ":subscription_ajax",
":watch_ajax", ":token_ajax",
}, HMAC_KEY, 1.week) ":watch_ajax",
}, HMAC_KEY, 1.week)
preferences = user.preferences preferences = user.preferences
env.set "preferences", preferences env.set "preferences", preferences
env.set "sid", sid env.set "sid", sid
env.set "csrf_token", csrf_token env.set "csrf_token", csrf_token
env.set "user", user env.set "user", user
end end
{% end %}
end end
dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s