diff --git a/docker/Dockerfile b/docker/Dockerfile index 4cfc3c72..fbf0802a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,6 +3,7 @@ FROM crystallang/crystal:1.16.3-alpine AS builder RUN apk add --no-cache sqlite-static yaml-static ARG release +ARG api_only WORKDIR /invidious COPY ./shard.yml ./shard.yml @@ -21,16 +22,13 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml RUN crystal spec --warnings all \ --link-flags "-lxml2 -llzma" -RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \ - crystal build ./src/invidious.cr \ - --release \ - --static --warnings all \ - --link-flags "-lxml2 -llzma"; \ - else \ - crystal build ./src/invidious.cr \ - --static --warnings all \ - --link-flags "-lxml2 -llzma"; \ - fi + +RUN --mount=type=cache,target=/root/.cache/crystal \ + crystal build ./src/invidious.cr \ + ${release:+--release} \ + --static --warnings all \ + --link-flags "-lxml2 -llzma" \ + ${api_only:+-Dapi_only -Dskip_videojs_download} FROM alpine:3.21 RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata diff --git a/docker/Dockerfile.arm64 b/docker/Dockerfile.arm64 index 758e7950..85274f75 100644 --- a/docker/Dockerfile.arm64 +++ b/docker/Dockerfile.arm64 @@ -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 ARG release +ARG api_only WORKDIR /invidious COPY ./shard.yml ./shard.yml @@ -22,16 +23,12 @@ COPY ./videojs-dependencies.yml ./videojs-dependencies.yml RUN crystal spec --warnings all \ --link-flags "-lxml2 -llzma" -RUN --mount=type=cache,target=/root/.cache/crystal if [[ "${release}" == 1 ]] ; then \ - crystal build ./src/invidious.cr \ - --release \ - --static --warnings all \ - --link-flags "-lxml2 -llzma"; \ - else \ - crystal build ./src/invidious.cr \ - --static --warnings all \ - --link-flags "-lxml2 -llzma"; \ - fi +RUN --mount=type=cache,target=/root/.cache/crystal \ + crystal build ./src/invidious.cr \ + ${release:+--release} \ + --static --warnings all \ + --link-flags "-lxml2 -llzma" \ + ${api_only:+-Dapi_only -Dskip_videojs_download} FROM alpine:3.21 RUN apk add --no-cache rsvg-convert ttf-opensans tini tzdata diff --git a/src/invidious.cr b/src/invidious.cr index 69f8a26c..7ac84d09 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -31,24 +31,33 @@ require "yaml" require "compress/zip" require "protodec/utils" -require "./invidious/database/*" -require "./invidious/database/migrations/*" +# Database requires +{% 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/helpers/*" require "./invidious/yt_backend/*" require "./invidious/frontend/*" require "./invidious/videos/*" - require "./invidious/jsonify/**" - -require "./invidious/*" +require "./invidious/requires" require "./invidious/comments/*" require "./invidious/channels/*" require "./invidious/user/*" require "./invidious/search/*" 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 module Invidious @@ -60,7 +69,13 @@ alias IV = Invidious CONFIG = Config.load 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") PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") REDDIT_URL = URI.parse("https://www.reddit.com") @@ -133,7 +148,11 @@ Kemal.config.extra_options do |parser| exit end 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 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) # 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. # # Running the script by itself would show some colorful feedback while this doesn't. @@ -175,38 +196,48 @@ DECRYPT_FUNCTION = # Start jobs -if CONFIG.channel_threads > 0 - Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB) -end +{% unless flag?(:api_only) %} + if CONFIG.channel_threads > 0 + Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB) + end -if CONFIG.feed_threads > 0 - Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB) -end + if CONFIG.feed_threads > 0 + Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB) + end -if CONFIG.statistics_enabled - Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE) -end + if CONFIG.statistics_enabled + Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE) + 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) - Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, HMAC_KEY) -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) + Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, HMAC_KEY) + end -if CONFIG.popular_enabled - Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) -end + if CONFIG.popular_enabled + Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) + end -NOTIFICATION_CHANNEL = ::Channel(VideoNotification).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) + NOTIFICATION_CHANNEL = ::Channel(VideoNotification).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::ClearExpiredItemsJob.new - -Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new - -Invidious::Jobs.start_all + Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new + + Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new + + 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 - Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get + {% if flag?(:api_only) %} + [] of ChannelVideo + {% else %} + Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get + {% end %} end # Routing diff --git a/src/invidious/api_only_types.cr b/src/invidious/api_only_types.cr new file mode 100644 index 00000000..13cc6086 --- /dev/null +++ b/src/invidious/api_only_types.cr @@ -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 \ No newline at end of file diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 4d69854c..a057f445 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -107,7 +107,9 @@ class Config property full_refresh : Bool = false # 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:// property https_only : Bool? @@ -285,21 +287,28 @@ class Config end # Build database_url from db.* if it's not set directly - if config.database_url.to_s.empty? - if db = config.db - config.database_url = URI.new( - scheme: "postgres", - user: db.user, - password: db.password, - host: db.host, - port: db.port, - path: db.dbname, - ) - else - puts "Config: Either database_url or db.* is required" - exit(1) + {% unless flag?(:api_only) %} + if config.database_url.to_s.empty? + if db = config.db + config.database_url = URI.new( + scheme: "postgres", + user: db.user, + password: db.password, + host: db.host, + port: db.port, + path: db.dbname, + ) + else + puts "Config: Either database_url or db.* is required" + exit(1) + 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 if sb = config.socket_binding diff --git a/src/invidious/database/api_only_stubs.cr b/src/invidious/database/api_only_stubs.cr new file mode 100644 index 00000000..b3df636c --- /dev/null +++ b/src/invidious/database/api_only_stubs.cr @@ -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 \ No newline at end of file diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 6add0237..2709d776 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -186,15 +186,24 @@ end # # Creates a new tracker when unavailable. def get_playback_statistic - if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]) && tracker.as(Hash).empty? - tracker = { + {% unless flag?(:api_only) %} + 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, "successfulRequests" => 0_i64, "ratio" => 0_f64, } - - Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"] = tracker - end - - return tracker.as(Hash(String, Int64 | Float64)) + {% end %} end diff --git a/src/invidious/jobs.cr b/src/invidious/jobs.cr index b6b673f7..ce2772d9 100644 --- a/src/invidious/jobs.cr +++ b/src/invidious/jobs.cr @@ -1,3 +1,4 @@ +{% unless flag?(:api_only) %} module Invidious::Jobs JOBS = [] of BaseJob @@ -38,3 +39,4 @@ module Invidious::Jobs end end end +{% end %} diff --git a/src/invidious/requires.cr b/src/invidious/requires.cr new file mode 100644 index 00000000..f3ec64e0 --- /dev/null +++ b/src/invidious/requires.cr @@ -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 %} \ No newline at end of file diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 4f5b58da..501a5dcf 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -6,23 +6,28 @@ module Invidious::Routes::API::V1::Misc if !CONFIG.statistics_enabled return {"software" => SOFTWARE}.to_json else - # Calculate playback success rate - if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]?) - tracker = tracker.as(Hash(String, Int64 | Float64)) + {% unless flag?(:api_only) %} + # Calculate playback success rate + if (tracker = Invidious::Jobs::StatisticsRefreshJob::STATISTICS["playback"]?) + tracker = tracker.as(Hash(String, Int64 | Float64)) - if !tracker.empty? - total_requests = tracker["totalRequests"] - success_count = tracker["successfulRequests"] + if !tracker.empty? + total_requests = tracker["totalRequests"] + success_count = tracker["successfulRequests"] - if total_requests.zero? - tracker["ratio"] = 1_i64 - else - tracker["ratio"] = (success_count / (total_requests)).round(2) + if total_requests.zero? + tracker["ratio"] = 1_i64 + else + tracker["ratio"] = (success_count / (total_requests)).round(2) + 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 diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index b5269668..4cd88d4a 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -72,24 +72,26 @@ module Invidious::Routes::BeforeAll raise "Cannot use token as SID" end - if email = Database::SessionIDs.select_email(sid) - user = Database::Users.select!(email: email) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, 1.week) + {% unless flag?(:api_only) %} + if email = Database::SessionIDs.select_email(sid) + user = Database::Users.select!(email: email) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, 1.week) - preferences = user.preferences - env.set "preferences", preferences + preferences = user.preferences + env.set "preferences", preferences - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - end + env.set "sid", sid + env.set "csrf_token", csrf_token + env.set "user", user + end + {% end %} end dark_mode = convert_theme(env.params.query["dark_mode"]?) || preferences.dark_mode.to_s