# Velour Phase 4: Federation Phase 4 enables federation between multiple Velour instances, allowing users to share and access video libraries across different servers while maintaining security and access controls. ## Federation Architecture ### Overview Velour federation allows instances to: - Share video libraries with other trusted instances - Stream videos from remote servers - Sync metadata and work information - Maintain access control and authentication ### Federation Storage Location Type ```ruby class StorageLocation < ApplicationRecord # ... existing code ... validates :storage_type, inclusion: { in: %w[local s3 jellyfin web velour] } store :configuration, accessors: [ # Existing configurations... # Velour federation configuration :remote_instance_url, :api_key, :instance_name, :trusted_instances ], coder: JSON enum storage_type: { local: 0, s3: 1, jellyfin: 2, web: 3, velour: 4 } def federation_client return nil unless velour? @federation_client ||= VelourFederationClient.new( instance_url: remote_instance_url, api_key: api_key ) end def accessible? case storage_type # ... existing cases ... when 'velour' federation_client&.ping? else false end end def scanner case storage_type # ... existing cases ... when 'velour' VelourFederationScanner.new(self) else super end end def streamer case storage_type # ... existing cases ... when 'velour' VelourFederationStreamer.new(self) else super end end end ``` ## Federation API Authentication ### API Key Management ```ruby class ApiKey < ApplicationRecord belongs_to :user has_many :federation_connections, dependent: :destroy validates :key, presence: true, uniqueness: true validates :name, presence: true validates :permissions, presence: true store :permissions, coder: JSON, accessors: [:can_read, :can_stream, :can_metadata] before_validation :generate_key, on: :create def self.generate_key SecureRandom.hex(32) end private def generate_key self.key ||= self.class.generate_key end end ``` ### Federation Connections ```ruby class FederationConnection < ApplicationRecord belongs_to :api_key belongs_to :storage_location validates :remote_instance_url, presence: true, uniqueness: { scope: :api_key_id } validates :status, inclusion: { in: %w[pending active suspended rejected] } enum status: { pending: 0, active: 1, suspended: 2, rejected: 3 } def active? status == "active" end end ``` ## Federation Client ### Velour Federation Client ```ruby class VelourFederationClient def initialize(instance_url:, api_key:) @instance_url = instance_url.chomp('/') @api_key = api_key @http = Faraday.new(url: @instance_url) do |faraday| faraday.headers['Authorization'] = "Bearer #{@api_key}" faraday.headers['User-Agent'] = "Velour/#{Velour::VERSION}" faraday.request :json faraday.response :json faraday.adapter Faraday.default_adapter end end def ping? response = @http.get('/api/v1/ping') response.success? rescue false end def instance_info response = @http.get('/api/v1/instance') response.success? ? response.body : nil end def works(page: 1, per_page: 50) response = @http.get('/api/v1/works', params: { page: page, per_page: per_page }) response.success? ? response.body : [] end def work_details(work_id) response = @http.get("/api/v1/works/#{work_id}") response.success? ? response.body : nil end def video_stream_url(video_id) "#{@instance_url}/api/v1/videos/#{video_id}/stream" end def video_metadata(video_id) response = @http.get("/api/v1/videos/#{video_id}") response.success? ? response.body : nil end def search_videos(query:, page: 1, per_page: 20) response = @http.get('/api/v1/videos/search', params: { query: query, page: page, per_page: per_page }) response.success? ? response.body : [] end end ``` ## Federation Scanner ### Velour Federation Scanner ```ruby class VelourFederationScanner def initialize(storage_location) @storage_location = storage_location @client = @storage_location.federation_client end def scan return failure_result("Remote Velour instance not accessible") unless @storage_location.accessible? instance_info = @client.instance_info return failure_result("Failed to get instance info") unless instance_info works = @client.works(per_page: 100) # Start with first 100 works new_videos = process_remote_works(works) success_result(new_videos, instance_info) rescue => e failure_result("Federation error: #{e.message}") end private def process_remote_works(works) new_videos = [] works.each do |work_data| # Create or find local work work = Work.find_or_create_by( title: work_data['title'], year: work_data['year'] ) do |w| w.description = work_data['description'] w.director = work_data['director'] w.rating = work_data['rating'] end # Process videos for this work work_data['videos'].each do |video_data| video = Video.find_or_initialize_by( filename: video_data['id'], # Use remote ID as filename storage_location: @storage_location ) if video.new_record? video.update!( work: work, file_size: video_data['file_size'], web_compatible: video_data['web_compatible'], video_metadata: { remote_video_id: video_data['id'], remote_instance_url: @storage_location.remote_instance_url, duration: video_data['duration'], width: video_data['width'], height: video_data['height'], video_codec: video_data['video_codec'], audio_codec: video_data['audio_codec'] }, fingerprints: video_data['fingerprints'] ) new_videos << video # Note: We don't process remote videos locally # We just catalog them for streaming end end end new_videos end def success_result(videos = [], instance_info = {}) { success: true, videos: videos, message: "Found #{videos.length} videos from #{@storage_location.instance_name}", instance_info: instance_info } end def failure_result(message) { success: false, message: message } end end ``` ## Federation Streamer ### Velour Federation Streamer ```ruby class VelourFederationStreamer def initialize(storage_location) @storage_location = storage_location @client = @storage_location.federation_client end def stream(video, range: nil) remote_video_id = video.video_metadata['remote_video_id'] stream_url = @client.video_stream_url(remote_video_id) if range proxy_stream_with_range(stream_url, range) else proxy_stream(stream_url) end end def thumbnail_url(video) remote_video_id = video.video_metadata['remote_video_id'] "#{@storage_location.remote_instance_url}/api/v1/videos/#{remote_video_id}/thumbnail" end private def proxy_stream(url) response = Faraday.get(url) do |req| req.headers['Authorization'] = "Bearer #{@storage_location.api_key}" end { body: response.body, status: response.status, headers: response.headers } end def proxy_stream_with_range(url, range) response = Faraday.get(url) do |req| req.headers['Authorization'] = "Bearer #{@storage_location.api_key}" req.headers['Range'] = "bytes=#{range}" end { body: response.body, status: response.status, headers: response.headers } end end ``` ## Federation API Endpoints ### API Controllers ```ruby # app/controllers/api/v1/base_controller.rb class Api::V1::BaseController < ActionController::Base before_action :authenticate_api_request private def authenticate_api_request token = request.headers['Authorization']&.gsub(/^Bearer\s+/, '') api_key = ApiKey.find_by(key: token) if api_key&.active? @current_api_key = api_key @current_user = api_key.user else render json: { error: 'Unauthorized' }, status: :unauthorized end end def authorize_federation head :forbidden unless @current_api_key&.can_read end def authorize_streaming head :forbidden unless @current_api_key&.can_stream end end # app/controllers/api/v1/instance_controller.rb class Api::V1::InstanceController < Api::V1::BaseController before_action :authorize_federation def ping render json: { status: 'ok', timestamp: Time.current.iso8601 } end def show render json: { name: 'Velour Instance', version: Velour::VERSION, description: 'Video library federation node', total_works: Work.count, total_videos: Video.count, total_storage: Video.sum(:file_size) } end end # app/controllers/api/v1/works_controller.rb class Api::V1::WorksController < Api::V1::BaseController before_action :authorize_federation def index works = Work.includes(:videos) .order(:title) .page(params[:page]) .per(params[:per_page] || 50) render json: { works: works.map { |work| serialize_work(work) }, pagination: { current_page: works.current_page, total_pages: works.total_pages, total_count: works.total_count } } end def show work = Work.includes(videos: :storage_location).find(params[:id]) render json: serialize_work(work) end private def serialize_work(work) { id: work.id, title: work.title, year: work.year, description: work.description, director: work.director, rating: work.rating, organized: work.organized, created_at: work.created_at.iso8601, videos: work.videos.map { |video| serialize_video(video) } } end def serialize_video(video) { id: video.id, filename: video.filename, file_size: video.file_size, web_compatible: video.web_compatible?, duration: video.duration, width: video.width, height: video.height, video_codec: video.video_codec, audio_codec: video.audio_codec, fingerprints: video.fingerprints, storage_type: video.storage_location.storage_type, created_at: video.created_at.iso8601 } end end # app/controllers/api/v1/videos_controller.rb class Api::V1::VideosController < Api::V1::BaseController before_action :set_video, only: [:show, :stream, :thumbnail] before_action :authorize_federation, except: [:stream, :thumbnail] before_action :authorize_streaming, only: [:stream, :thumbnail] def show render json: serialize_video(@video) end def stream streamer = @video.storage_location.streamer result = streamer.stream(@video, range: request.headers['Range']) send_data result[:body], type: result[:headers]['Content-Type'] || 'video/mp4', disposition: 'inline', status: result[:status], headers: result[:headers].slice('Content-Range', 'Content-Length', 'Accept-Ranges') end def thumbnail if @video.video_assets.where(asset_type: 'thumbnail').exists? asset = @video.video_assets.where(asset_type: 'thumbnail').first redirect_to rails_blob_url(asset.file, disposition: 'inline') else head :not_found end end def search query = params[:query] return render json: [] if query.blank? videos = Video.joins(:work) .where('works.title ILIKE ?', "%#{query}%") .includes(:work, :storage_location) .page(params[:page]) .per(params[:per_page] || 20) render json: { videos: videos.map { |video| serialize_video(video) }, pagination: { current_page: videos.current_page, total_pages: videos.total_pages, total_count: videos.total_count } } end private def set_video @video = Video.find(params[:id]) end def serialize_video(video) { id: video.id, filename: video.filename, work_id: video.work_id, work_title: video.work.title, file_size: video.file_size, web_compatible: video.web_compatible?, duration: video.duration, width: video.width, height: video.height, video_codec: video.video_codec, audio_codec: video.audio_codec, fingerprints: video.fingerprints, storage_type: video.storage_location.storage_type, created_at: video.created_at.iso8601 } end end ``` ## Federation Management UI ### Federation Connections Management ```erb

Federation Connections

<% if @api_keys.any? %>
<%= link_to 'Create New Connection', new_admin_federation_connection_path, class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %>
<% else %>

You need to create an API key before you can establish federation connections.

<%= link_to 'Create API Key', new_admin_api_key_path, class: 'text-yellow-700 underline hover:text-yellow-600' %>
<% end %>
``` ## Security Considerations ### Federation Security Best Practices 1. **API Key Management**: Regular rotation and limited scope 2. **Access Control**: Minimum required permissions 3. **Rate Limiting**: Prevent abuse from federated instances 4. **Audit Logging**: Track all federated access 5. **Network Security**: HTTPS-only federation connections ### Rate Limiting ```ruby # app/controllers/concerns/rate_limited.rb module RateLimited extend ActiveSupport::Concern included do before_action :check_rate_limit, only: [:index, :show, :stream] end private def check_rate_limit return unless @current_api_key key = "federation_rate_limit:#{@current_api_key.id}" count = Rails.cache.increment(key, 1, expires_in: 1.hour) if count > rate_limit Rails.logger.warn "Rate limit exceeded for API key #{@current_api_key.id}" render json: { error: 'Rate limit exceeded' }, status: :too_many_requests end end def rate_limit case action_name when 'stream' 1000 # requests per hour else 5000 # requests per hour end end end ``` ## Environment Configuration ### Federation Configuration ```bash # Federation settings FEDERATION_ENABLED=true MAX_FEDERATED_CONNECTIONS=10 FEDERATION_RATE_LIMIT_PER_HOUR=5000 # Federation security FEDERATION_REQUIRE_HTTPS=true FEDERATION_TOKEN_EXPIRY_HOURS=24 ``` Phase 4 enables a distributed network of Velour instances that can share video libraries while maintaining security and access controls. This allows organizations to federate their video collections across multiple servers or locations.