70 KiB
Velour - Video Library Application Architecture Plan
Technology Stack
Backend
- Framework: Ruby on Rails 8.x
- Database: SQLite3 (with potential migration path to PostgreSQL later)
- Background Jobs: Solid Queue (Rails 8 default)
- Caching: Solid Cache (Rails 8 default)
- File Storage:
- Active Storage (thumbnails/sprites/previews only)
- Direct filesystem paths for video files
- S3 SDK (aws-sdk-s3 gem) for remote video storage
- Video Processing: FFmpeg via streamio-ffmpeg gem
Frontend
- Framework: Hotwire (Turbo + Stimulus)
- Video Player: Video.js 8.x with custom plugins
- Asset Pipeline: Importmap-rails or esbuild
- Styling: TailwindCSS
Authentication (Phase 2)
- OIDC: omniauth-openid-connect gem
- Session Management: Rails sessions with encrypted cookies
Database Schema
Core Models
Works (canonical representation)
- Represents the conceptual "work" (e.g., "Batman [1989]")
- Has many Videos (different versions/qualities)
- title (string, required)
- year (integer)
- director (string)
- description (text)
- rating (decimal)
- organized (boolean, default: false)
- poster_path (string)
- backdrop_path (string)
- metadata (jsonb)
Videos (instances of works)
- Physical video files across all sources
- Belongs to a Work
- work_id (references works)
- storage_location_id (references storage_locations)
- title (string)
- file_path (string, required) # relative path or S3 key
- file_hash (string, indexed)
- file_size (bigint)
- duration (float)
- width (integer)
- height (integer)
- resolution_label (string)
- video_codec (string)
- audio_codec (string)
- bit_rate (integer)
- frame_rate (float)
- format (string)
- has_subtitles (boolean)
- version_type (string)
- source_type (string) # "local", "s3", "jellyfin", "web", "velour"
- source_url (string) # full URL for remote sources
- imported (boolean, default: false) # copied to writable storage?
- metadata (jsonb)
StorageLocations
- All video sources (readable and writable)
- name (string, required)
- path (string) # local path or S3 bucket name
- location_type (string) # "local", "s3", "jellyfin", "web", "velour"
- writable (boolean, default: false) # can we import videos here?
- enabled (boolean, default: true)
- scan_subdirectories (boolean, default: true)
- priority (integer, default: 0) # for unified view ordering
- settings (jsonb) # config per type:
# S3: {region, access_key_id, secret_access_key, endpoint}
# JellyFin: {api_url, api_key, user_id}
# Web: {base_url, username, password, auth_type}
# Velour: {api_url, api_key}
- last_scanned_at (datetime)
VideoAssets
- Generated assets (stored via Active Storage)
- video_id (references videos)
- asset_type (string) # "thumbnail", "preview", "sprite", "vtt"
- metadata (jsonb)
# Active Storage attachments
PlaybackSessions
- video_id (references videos)
- user_id (references users, nullable)
- position (float)
- duration_watched (float)
- last_watched_at (datetime)
- completed (boolean)
- play_count (integer, default: 0)
ImportJobs (Phase 3)
- Track video imports from remote sources
- video_id (references videos) # source video
- destination_location_id (references storage_locations)
- destination_path (string)
- status (string) # "pending", "downloading", "completed", "failed"
- progress (float) # 0-100%
- bytes_transferred (bigint)
- error_message (text)
- started_at (datetime)
- completed_at (datetime)
Users (Phase 2)
- email (string, required, unique)
- name (string)
- role (integer, default: 0) # enum: member, admin
- provider (string)
- uid (string)
Database Schema Implementation Notes
SQLite Limitations:
- SQLite does NOT support
jsonbtype - must usetextwithserialize :metadata, coder: JSON - SQLite does NOT support
enumtypes - use integers with Rails enums - Consider PostgreSQL migration path for production deployments
Rails Migration Best Practices:
# db/migrate/20240101000001_create_works.rb
class CreateWorks < ActiveRecord::Migration[8.1]
def change
create_table :works do |t|
t.string :title, null: false
t.integer :year
t.string :director
t.text :description
t.decimal :rating, precision: 3, scale: 1
t.boolean :organized, default: false, null: false
t.string :poster_path
t.string :backdrop_path
t.text :metadata # SQLite: use text, serialize in model
t.timestamps
end
add_index :works, :title
add_index :works, [:title, :year], unique: true
add_index :works, :organized
end
end
# db/migrate/20240101000002_create_storage_locations.rb
class CreateStorageLocations < ActiveRecord::Migration[8.1]
def change
create_table :storage_locations do |t|
t.string :name, null: false
t.string :path
t.integer :location_type, null: false, default: 0 # enum
t.boolean :writable, default: false, null: false
t.boolean :enabled, default: true, null: false
t.boolean :scan_subdirectories, default: true, null: false
t.integer :priority, default: 0, null: false
t.text :settings # SQLite: encrypted text column
t.datetime :last_scanned_at
t.timestamps
end
add_index :storage_locations, :name, unique: true
add_index :storage_locations, :location_type
add_index :storage_locations, :enabled
add_index :storage_locations, :priority
end
end
# db/migrate/20240101000003_create_videos.rb
class CreateVideos < ActiveRecord::Migration[8.1]
def change
create_table :videos do |t|
t.references :work, null: true, foreign_key: true, index: true
t.references :storage_location, null: false, foreign_key: true, index: true
t.string :title
t.string :file_path, null: false
t.string :file_hash, index: true
t.bigint :file_size
t.float :duration
t.integer :width
t.integer :height
t.string :resolution_label
t.string :video_codec
t.string :audio_codec
t.integer :bit_rate
t.float :frame_rate
t.string :format
t.boolean :has_subtitles, default: false
t.string :version_type
t.integer :source_type, null: false, default: 0 # enum
t.string :source_url
t.boolean :imported, default: false, null: false
t.boolean :processing_failed, default: false
t.text :error_message
t.text :metadata
t.timestamps
end
add_index :videos, [:storage_location_id, :file_path], unique: true
add_index :videos, :source_type
add_index :videos, :file_hash
add_index :videos, :imported
add_index :videos, [:work_id, :resolution_label]
end
end
# db/migrate/20240101000004_create_video_assets.rb
class CreateVideoAssets < ActiveRecord::Migration[8.1]
def change
create_table :video_assets do |t|
t.references :video, null: false, foreign_key: true, index: true
t.integer :asset_type, null: false # enum
t.text :metadata
t.timestamps
end
add_index :video_assets, [:video_id, :asset_type], unique: true
end
end
# db/migrate/20240101000005_create_playback_sessions.rb
class CreatePlaybackSessions < ActiveRecord::Migration[8.1]
def change
create_table :playback_sessions do |t|
t.references :video, null: false, foreign_key: true, index: true
t.references :user, null: true, foreign_key: true, index: true
t.float :position, default: 0.0
t.float :duration_watched, default: 0.0
t.datetime :last_watched_at
t.boolean :completed, default: false, null: false
t.integer :play_count, default: 0, null: false
t.timestamps
end
add_index :playback_sessions, [:video_id, :user_id], unique: true
add_index :playback_sessions, :last_watched_at
end
end
# db/migrate/20240101000006_create_import_jobs.rb
class CreateImportJobs < ActiveRecord::Migration[8.1]
def change
create_table :import_jobs do |t|
t.references :video, null: false, foreign_key: true, index: true
t.references :destination_location, null: false, foreign_key: { to_table: :storage_locations }
t.string :destination_path
t.integer :status, null: false, default: 0 # enum
t.float :progress, default: 0.0
t.bigint :bytes_transferred, default: 0
t.text :error_message
t.datetime :started_at
t.datetime :completed_at
t.timestamps
end
add_index :import_jobs, :status
add_index :import_jobs, [:video_id, :status]
end
end
# db/migrate/20240101000007_create_users.rb (Phase 2)
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email, null: false
t.string :name
t.integer :role, default: 0, null: false # enum
t.string :provider
t.string :uid
t.timestamps
end
add_index :users, :email, unique: true
add_index :users, [:provider, :uid], unique: true
add_index :users, :role
end
end
Enum Definitions:
# app/models/video.rb
class Video < ApplicationRecord
enum source_type: {
local: 0,
s3: 1,
jellyfin: 2,
web: 3,
velour: 4
}
end
# app/models/storage_location.rb
class StorageLocation < ApplicationRecord
enum location_type: {
local: 0,
s3: 1,
jellyfin: 2,
web: 3,
velour: 4
}
end
# app/models/video_asset.rb
class VideoAsset < ApplicationRecord
enum asset_type: {
thumbnail: 0,
preview: 1,
sprite: 2,
vtt: 3
}
end
# app/models/import_job.rb
class ImportJob < ApplicationRecord
enum status: {
pending: 0,
downloading: 1,
processing: 2,
completed: 3,
failed: 4,
cancelled: 5
}
end
# app/models/user.rb (Phase 2)
class User < ApplicationRecord
enum role: {
member: 0,
admin: 1
}
end
Storage Architecture
Storage Location Types
1. Local Filesystem (Readable + Writable)
settings: {}
path: "/path/to/videos"
writable: true
2. S3 Compatible Storage (Readable + Writable)
settings: {
region: "us-east-1",
access_key_id: "...",
secret_access_key: "...",
endpoint: "https://s3.amazonaws.com" # or Wasabi, Backblaze, etc.
}
path: "bucket-name"
writable: true
3. JellyFin Server (Readable only)
settings: {
api_url: "https://jellyfin.example.com",
api_key: "...",
user_id: "..."
}
path: null
writable: false
4. Web Directory (Readable only)
settings: {
base_url: "https://videos.example.com",
auth_type: "basic", # or "bearer", "none"
username: "...",
password: "..."
}
path: null
writable: false
5. Velour Instance (Readable only - Phase 4)
settings: {
api_url: "https://velour.example.com",
api_key: "..."
}
path: null
writable: false
Unified View Strategy
Library Aggregation:
- Videos table contains entries from ALL sources
storage_location_idlinks each video to its source- Unified
/videosview queries across all enabled locations - Filter/group by
storage_locationto see source breakdown
Video Streaming Strategy:
- Local:
send_filewith byte-range support - S3: Generate presigned URLs (configurable expiry)
- JellyFin: Proxy through Rails or redirect to JellyFin stream
- Web: Proxy through Rails with auth forwarding
- Velour: Proxy or redirect to federated instance
Storage Adapter Pattern
Architecture: Strategy pattern for pluggable storage backends.
Base Adapter Interface:
# app/services/storage_adapters/base_adapter.rb
module StorageAdapters
class BaseAdapter
def initialize(storage_location)
@storage_location = storage_location
end
# Scan for video files and return array of relative paths
def scan
raise NotImplementedError, "#{self.class} must implement #scan"
end
# Generate streaming URL for a video
def stream_url(video)
raise NotImplementedError, "#{self.class} must implement #stream_url"
end
# Check if file exists at path
def exists?(file_path)
raise NotImplementedError, "#{self.class} must implement #exists?"
end
# Check if storage can be read from
def readable?
raise NotImplementedError, "#{self.class} must implement #readable?"
end
# Check if storage can be written to
def writable?
@storage_location.writable?
end
# Write/copy file to storage
def write(source_path, dest_path)
raise NotImplementedError, "#{self.class} must implement #write"
end
# Download file to local temp path (for processing)
def download_to_temp(video)
raise NotImplementedError, "#{self.class} must implement #download_to_temp"
end
end
end
Example: Local Adapter
# app/services/storage_adapters/local_adapter.rb
module StorageAdapters
class LocalAdapter < BaseAdapter
VIDEO_EXTENSIONS = %w[.mp4 .mkv .avi .mov .wmv .flv .webm .m4v].freeze
def scan
return [] unless readable?
pattern = if @storage_location.scan_subdirectories
File.join(@storage_location.path, "**", "*{#{VIDEO_EXTENSIONS.join(',')}}")
else
File.join(@storage_location.path, "*{#{VIDEO_EXTENSIONS.join(',')}}")
end
Dir.glob(pattern, File::FNM_CASEFOLD).map do |full_path|
full_path.sub(@storage_location.path + "/", "")
end
end
def stream_url(video)
full_path(video)
end
def exists?(file_path)
File.exist?(full_path_from_relative(file_path))
end
def readable?
File.directory?(@storage_location.path) && File.readable?(@storage_location.path)
end
def writable?
super && File.writable?(@storage_location.path)
end
def write(source_path, dest_path)
dest_full_path = full_path_from_relative(dest_path)
FileUtils.mkdir_p(File.dirname(dest_full_path))
FileUtils.cp(source_path, dest_full_path)
dest_path
end
def download_to_temp(video)
# Already local, return path
full_path(video)
end
private
def full_path(video)
full_path_from_relative(video.file_path)
end
def full_path_from_relative(file_path)
File.join(@storage_location.path, file_path)
end
end
end
Example: S3 Adapter
# app/services/storage_adapters/s3_adapter.rb
require 'aws-sdk-s3'
module StorageAdapters
class S3Adapter < BaseAdapter
VIDEO_EXTENSIONS = %w[.mp4 .mkv .avi .mov .wmv .flv .webm .m4v].freeze
def scan
return [] unless readable?
prefix = @storage_location.scan_subdirectories ? "" : nil
s3_client.list_objects_v2(
bucket: bucket_name,
prefix: prefix
).contents.select do |obj|
VIDEO_EXTENSIONS.any? { |ext| obj.key.downcase.end_with?(ext) }
end.map(&:key)
end
def stream_url(video)
s3_client.presigned_url(
:get_object,
bucket: bucket_name,
key: video.file_path,
expires_in: 3600 # 1 hour
)
end
def exists?(file_path)
s3_client.head_object(bucket: bucket_name, key: file_path)
true
rescue Aws::S3::Errors::NotFound
false
end
def readable?
s3_client.head_bucket(bucket: bucket_name)
true
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::Forbidden
false
end
def write(source_path, dest_path)
File.open(source_path, 'rb') do |file|
s3_client.put_object(
bucket: bucket_name,
key: dest_path,
body: file
)
end
dest_path
end
def download_to_temp(video)
temp_file = Tempfile.new(['velour-video', File.extname(video.file_path)])
s3_client.get_object(
bucket: bucket_name,
key: video.file_path,
response_target: temp_file.path
)
temp_file.path
end
private
def s3_client
@s3_client ||= Aws::S3::Client.new(
region: settings['region'],
access_key_id: settings['access_key_id'],
secret_access_key: settings['secret_access_key'],
endpoint: settings['endpoint']
)
end
def bucket_name
@storage_location.path
end
def settings
@storage_location.settings
end
end
end
Model Integration:
# app/models/storage_location.rb
class StorageLocation < ApplicationRecord
encrypts :settings
serialize :settings, coder: JSON
enum location_type: {
local: 0,
s3: 1,
jellyfin: 2,
web: 3,
velour: 4
}
def adapter
@adapter ||= adapter_class.new(self)
end
private
def adapter_class
"StorageAdapters::#{location_type.classify}Adapter".constantize
end
end
File Organization:
app/
└── services/
└── storage_adapters/
├── base_adapter.rb
├── local_adapter.rb
├── s3_adapter.rb
├── jellyfin_adapter.rb # Phase 3
├── web_adapter.rb # Phase 3
└── velour_adapter.rb # Phase 4
Video Processing Pipeline
Asset Generation Jobs
VideoProcessorJob
- Only runs for videos we can access (local, S3, or importable)
- Downloads temp copy if remote
- Generates:
- Thumbnail (1920x1080 JPEG at 10% mark)
- Preview clip (30s MP4 at 720p)
- VTT sprite sheet (160x90 tiles, 5s intervals)
- Metadata extraction (FFprobe)
- Stores assets via Active Storage (S3 or local)
- Cleans up temp files
VideoImportJob (Phase 3)
- Downloads video from remote source
- Shows progress (bytes transferred, %)
- Saves to writable storage location
- Creates new Video record for imported copy
- Links to same Work as source
- Triggers VideoProcessorJob on completion
Service Objects Architecture
Service objects encapsulate complex business logic that doesn't belong in models or controllers. They follow the single responsibility principle and make code more testable.
1. FileScannerService
Orchestrates scanning a storage location for video files.
# app/services/file_scanner_service.rb
class FileScannerService
def initialize(storage_location)
@storage_location = storage_location
@adapter = storage_location.adapter
end
def call
return Result.failure("Storage location not readable") unless @adapter.readable?
@storage_location.update!(last_scanned_at: Time.current)
file_paths = @adapter.scan
new_videos = []
file_paths.each do |file_path|
video = find_or_create_video(file_path)
new_videos << video if video.previously_new_record?
# Queue processing for new or unprocessed videos
VideoProcessorJob.perform_later(video.id) if video.duration.nil?
end
Result.success(videos_found: file_paths.size, new_videos: new_videos.size)
end
private
def find_or_create_video(file_path)
Video.find_or_create_by!(
storage_location: @storage_location,
file_path: file_path
) do |video|
video.title = extract_title_from_path(file_path)
video.source_type = @storage_location.location_type
end
end
def extract_title_from_path(file_path)
File.basename(file_path, ".*")
.gsub(/[\._]/, " ")
.gsub(/\[.*?\]/, "")
.strip
end
end
# Usage:
# result = FileScannerService.new(@storage_location).call
# if result.success?
# flash[:notice] = "Found #{result.videos_found} videos (#{result.new_videos} new)"
# end
2. VideoMetadataExtractor
Extracts video metadata using FFprobe.
# app/services/video_metadata_extractor.rb
require 'streamio-ffmpeg'
class VideoMetadataExtractor
def initialize(video)
@video = video
end
def call
file_path = @video.storage_location.adapter.download_to_temp(@video)
movie = FFMPEG::Movie.new(file_path)
@video.update!(
duration: movie.duration,
width: movie.width,
height: movie.height,
video_codec: movie.video_codec,
audio_codec: movie.audio_codec,
bit_rate: movie.bitrate,
frame_rate: movie.frame_rate,
format: movie.container,
file_size: movie.size,
resolution_label: calculate_resolution_label(movie.height),
file_hash: calculate_file_hash(file_path)
)
Result.success
rescue FFMPEG::Error => e
@video.update!(processing_failed: true, error_message: e.message)
Result.failure(e.message)
ensure
# Clean up temp file if it was downloaded
File.delete(file_path) if file_path && File.exist?(file_path) && file_path.include?('tmp')
end
private
def calculate_resolution_label(height)
case height
when 0..480 then "SD"
when 481..720 then "720p"
when 721..1080 then "1080p"
when 1081..1440 then "1440p"
when 1441..2160 then "4K"
else "8K+"
end
end
def calculate_file_hash(file_path)
# Hash first and last 64KB for speed (like Plex/Emby)
Digest::MD5.file(file_path).hexdigest
end
end
3. DuplicateDetectorService
Finds potential duplicate videos based on file hash and title similarity.
# app/services/duplicate_detector_service.rb
class DuplicateDetectorService
def initialize(video = nil)
@video = video
end
def call
# Find all videos without a work assigned
unorganized_videos = Video.where(work_id: nil)
# Group by similar titles and file hashes
potential_groups = []
unorganized_videos.group_by(&:file_hash).each do |hash, videos|
next if videos.size < 2
potential_groups << {
type: :exact_duplicate,
videos: videos,
confidence: :high
}
end
# Find similar titles using Levenshtein distance or fuzzy matching
unorganized_videos.find_each do |video|
similar = find_similar_titles(video, unorganized_videos)
if similar.any?
potential_groups << {
type: :similar_title,
videos: [video] + similar,
confidence: :medium
}
end
end
Result.success(groups: potential_groups.uniq)
end
private
def find_similar_titles(video, candidates)
return [] unless video.title
candidates.select do |candidate|
next false if candidate.id == video.id
next false unless candidate.title
similarity_score(video.title, candidate.title) > 0.8
end
end
def similarity_score(str1, str2)
# Simple implementation - could use gems like 'fuzzy_match' or 'levenshtein'
str1_clean = normalize_title(str1)
str2_clean = normalize_title(str2)
return 1.0 if str1_clean == str2_clean
# Jaccard similarity on words
words1 = str1_clean.split
words2 = str2_clean.split
intersection = (words1 & words2).size
union = (words1 | words2).size
intersection.to_f / union
end
def normalize_title(title)
title.downcase
.gsub(/\[.*?\]/, "") # Remove brackets
.gsub(/\(.*?\)/, "") # Remove parentheses
.gsub(/[^\w\s]/, "") # Remove special chars
.strip
end
end
4. WorkGrouperService
Groups videos into a work.
# app/services/work_grouper_service.rb
class WorkGrouperService
def initialize(video_ids, work_attributes = {})
@video_ids = video_ids
@work_attributes = work_attributes
end
def call
videos = Video.where(id: @video_ids).includes(:work)
return Result.failure("No videos found") if videos.empty?
# Use existing work if all videos belong to the same one
existing_work = videos.first.work if videos.all? { |v| v.work_id == videos.first.work_id }
ActiveRecord::Base.transaction do
work = existing_work || create_work_from_videos(videos)
videos.each do |video|
video.update!(work: work)
end
Result.success(work: work)
end
end
private
def create_work_from_videos(videos)
representative = videos.max_by(&:height) || videos.first
Work.create!(
title: @work_attributes[:title] || extract_base_title(representative.title),
year: @work_attributes[:year],
organized: false
)
end
def extract_base_title(title)
# Extract base title by removing resolution, format markers, etc.
title.gsub(/\d{3,4}p/, "")
.gsub(/\b(BluRay|WEB-?DL|HDRip|DVDRip|4K|UHD)\b/i, "")
.gsub(/\[.*?\]/, "")
.strip
end
end
5. VideoImporterService (Phase 3)
Handles downloading a video from remote source to writable storage.
# app/services/video_importer_service.rb
class VideoImporterService
def initialize(video, destination_location, destination_path = nil)
@source_video = video
@destination_location = destination_location
@destination_path = destination_path || video.file_path
@import_job = nil
end
def call
return Result.failure("Destination is not writable") unless @destination_location.writable?
return Result.failure("Source is not readable") unless @source_video.storage_location.adapter.readable?
# Create import job
@import_job = ImportJob.create!(
video: @source_video,
destination_location: @destination_location,
destination_path: @destination_path,
status: :pending
)
# Queue background job
VideoImportJob.perform_later(@import_job.id)
Result.success(import_job: @import_job)
end
end
File Organization:
app/
└── services/
├── storage_adapters/ # Storage backends
├── file_scanner_service.rb
├── video_metadata_extractor.rb
├── duplicate_detector_service.rb
├── work_grouper_service.rb
├── video_importer_service.rb # Phase 3
└── result.rb # Simple result object
Result Object Pattern:
# app/services/result.rb
class Result
attr_reader :data, :error
def initialize(success:, data: {}, error: nil)
@success = success
@data = data
@error = error
end
def success?
@success
end
def failure?
!@success
end
def self.success(data = {})
new(success: true, data: data)
end
def self.failure(error)
new(success: false, error: error)
end
# Allow accessing data as methods
def method_missing(method, *args)
return @data[method] if @data.key?(method)
super
end
def respond_to_missing?(method, include_private = false)
@data.key?(method) || super
end
end
Model Organization
Rails models follow a specific organization pattern for readability and maintainability.
Complete Model Examples
Work Model:
# app/models/work.rb
class Work < ApplicationRecord
# 1. Includes/Concerns
include Searchable
# 2. Serialization
serialize :metadata, coder: JSON
# 3. Associations
has_many :videos, dependent: :nullify
has_one :primary_video, -> { order(height: :desc) }, class_name: "Video"
# 4. Validations
validates :title, presence: true
validates :year, numericality: { only_integer: true, greater_than: 1800 }, allow_nil: true
validates :rating, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }, allow_nil: true
# 5. Scopes
scope :organized, -> { where(organized: true) }
scope :unorganized, -> { where(organized: false) }
scope :recent, -> { order(created_at: :desc) }
scope :by_title, -> { order(:title) }
scope :with_year, -> { where.not(year: nil) }
# 6. Delegations
delegate :resolution_label, :duration, to: :primary_video, prefix: true, allow_nil: true
# 7. Class methods
def self.search(query)
where("title LIKE ? OR director LIKE ?", "%#{query}%", "%#{query}%")
end
# 8. Instance methods
def display_title
year ? "#{title} (#{year})" : title
end
def video_count
videos.count
end
def total_duration
videos.sum(:duration)
end
def available_versions
videos.group_by(&:resolution_label)
end
end
Video Model:
# app/models/video.rb
class Video < ApplicationRecord
# 1. Includes/Concerns
include Streamable
include Processable
# 2. Serialization
serialize :metadata, coder: JSON
# 3. Enums
enum source_type: {
local: 0,
s3: 1,
jellyfin: 2,
web: 3,
velour: 4
}
# 4. Associations
belongs_to :work, optional: true, touch: true
belongs_to :storage_location
has_many :video_assets, dependent: :destroy
has_many :playback_sessions, dependent: :destroy
has_one :import_job, dependent: :nullify
has_one_attached :thumbnail
has_one_attached :preview_video
# 5. Validations
validates :title, presence: true
validates :file_path, presence: true
validates :file_hash, presence: true
validates :source_type, presence: true
validate :file_exists_on_storage, on: :create
# 6. Callbacks
before_save :normalize_title
after_create :queue_processing
# 7. Scopes
scope :unprocessed, -> { where(duration: nil, processing_failed: false) }
scope :processed, -> { where.not(duration: nil) }
scope :failed, -> { where(processing_failed: true) }
scope :by_source, ->(type) { where(source_type: type) }
scope :imported, -> { where(imported: true) }
scope :recent, -> { order(created_at: :desc) }
scope :by_resolution, -> { order(height: :desc) }
scope :with_work, -> { where.not(work_id: nil) }
scope :without_work, -> { where(work_id: nil) }
# 8. Delegations
delegate :name, :location_type, :adapter, to: :storage_location, prefix: true
delegate :display_title, to: :work, prefix: true, allow_nil: true
# 9. Class methods
def self.search(query)
left_joins(:work)
.where("videos.title LIKE ? OR works.title LIKE ?", "%#{query}%", "%#{query}%")
.distinct
end
def self.by_duration(min: nil, max: nil)
scope = all
scope = scope.where("duration >= ?", min) if min
scope = scope.where("duration <= ?", max) if max
scope
end
# 10. Instance methods
def display_title
work_display_title || title || filename
end
def filename
File.basename(file_path)
end
def file_extension
File.extname(file_path).downcase
end
def formatted_duration
return "Unknown" unless duration
hours = (duration / 3600).to_i
minutes = ((duration % 3600) / 60).to_i
seconds = (duration % 60).to_i
if hours > 0
"%d:%02d:%02d" % [hours, minutes, seconds]
else
"%d:%02d" % [minutes, seconds]
end
end
def formatted_file_size
return "Unknown" unless file_size
units = ['B', 'KB', 'MB', 'GB', 'TB']
size = file_size.to_f
unit_index = 0
while size >= 1024 && unit_index < units.length - 1
size /= 1024.0
unit_index += 1
end
"%.2f %s" % [size, units[unit_index]]
end
def processable?
!processing_failed && storage_location_adapter.readable?
end
def streamable?
duration.present? && storage_location.enabled? && storage_location_adapter.readable?
end
private
def normalize_title
self.title = title.strip if title.present?
end
def queue_processing
VideoProcessorJob.perform_later(id) if processable?
end
def file_exists_on_storage
return if storage_location_adapter.exists?(file_path)
errors.add(:file_path, "does not exist on storage")
end
end
StorageLocation Model:
# app/models/storage_location.rb
class StorageLocation < ApplicationRecord
# 1. Encryption & Serialization
encrypts :settings
serialize :settings, coder: JSON
# 2. Enums
enum location_type: {
local: 0,
s3: 1,
jellyfin: 2,
web: 3,
velour: 4
}
# 3. Associations
has_many :videos, dependent: :restrict_with_error
has_many :destination_imports, class_name: "ImportJob", foreign_key: :destination_location_id
# 4. Validations
validates :name, presence: true, uniqueness: true
validates :location_type, presence: true
validates :path, presence: true, if: -> { local? || s3? }
validate :adapter_is_valid
# 5. Scopes
scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) }
scope :writable, -> { where(writable: true) }
scope :readable, -> { enabled }
scope :by_priority, -> { order(priority: :desc) }
scope :by_type, ->(type) { where(location_type: type) }
# 6. Instance methods
def adapter
@adapter ||= adapter_class.new(self)
end
def scan!
FileScannerService.new(self).call
end
def accessible?
adapter.readable?
rescue StandardError
false
end
def video_count
videos.count
end
def last_scan_ago
return "Never" unless last_scanned_at
distance_of_time_in_words(last_scanned_at, Time.current)
end
private
def adapter_class
"StorageAdapters::#{location_type.classify}Adapter".constantize
end
def adapter_is_valid
adapter.readable?
rescue StandardError => e
errors.add(:base, "Cannot access storage: #{e.message}")
end
end
PlaybackSession Model:
# app/models/playback_session.rb
class PlaybackSession < ApplicationRecord
# 1. Associations
belongs_to :video
belongs_to :user, optional: true
# 2. Validations
validates :video, presence: true
validates :position, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :duration_watched, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
# 3. Callbacks
before_save :check_completion
# 4. Scopes
scope :recent, -> { order(last_watched_at: :desc) }
scope :completed, -> { where(completed: true) }
scope :in_progress, -> { where(completed: false).where.not(position: 0) }
scope :for_user, ->(user) { where(user: user) }
# 5. Class methods
def self.update_position(video, user, position, duration_watched = 0)
session = find_or_initialize_by(video: video, user: user)
session.position = position
session.duration_watched = (session.duration_watched || 0) + duration_watched
session.last_watched_at = Time.current
session.play_count += 1 if session.position.zero?
session.save!
end
# 6. Instance methods
def progress_percentage
return 0 unless video.duration && position
((position / video.duration) * 100).round(1)
end
def resume_position
completed? ? 0 : position
end
private
def check_completion
if video.duration && position
self.completed = (position / video.duration) > 0.9
end
end
end
Model Concerns
Streamable Concern:
# app/models/concerns/streamable.rb
module Streamable
extend ActiveSupport::Concern
included do
# Add any class-level includes here
end
def stream_url
storage_location.adapter.stream_url(self)
end
def streamable?
duration.present? && storage_location.enabled? && storage_location.adapter.readable?
end
def stream_type
case source_type
when "s3" then :presigned
when "local" then :direct
else :proxy
end
end
end
Processable Concern:
# app/models/concerns/processable.rb
module Processable
extend ActiveSupport::Concern
included do
scope :processing_pending, -> { where(duration: nil, processing_failed: false) }
end
def processed?
duration.present?
end
def processing_pending?
!processed? && !processing_failed?
end
def mark_processing_failed!(error_message)
update!(processing_failed: true, error_message: error_message)
end
def retry_processing!
update!(processing_failed: false, error_message: nil)
VideoProcessorJob.perform_later(id)
end
end
Searchable Concern:
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
class_methods do
def search(query)
return all if query.blank?
where("title LIKE ?", "%#{sanitize_sql_like(query)}%")
end
end
end
Frontend Architecture
Main Views (Hotwire)
Library Views:
videos/index- Unified library grid with filters by sourcevideos/show- Video player pageworks/index- Works library (grouped by work)works/show- Work details with all versions/sources
Admin Views (Phase 2+):
storage_locations/index- Manage all sourcesstorage_locations/new- Add new source (local/S3/JellyFin/etc)import_jobs/index- Monitor import progressvideos/:id/import- Import modal/form
Stimulus Controllers
VideoPlayerController
- Initialize Video.js with source detection
- Handle different streaming strategies (local/S3/proxy)
- Track playback position
- Quality switching for multiple versions
LibraryScanController
- Trigger scans per storage location
- Real-time progress via Turbo Streams
- Show scan results and new videos found
VideoImportController
- Select destination storage location
- Show import progress
- Cancel import jobs
WorkMergeController
- Group videos into works
- Drag-and-drop UI
- Show all versions/sources for a work
Video.js Custom Plugins
- resume-plugin - Auto-resume from saved position
- track-plugin - Send playback stats to Rails API
- quality-selector - Switch between versions (same work, different sources/resolutions)
- thumbnails-plugin - VTT sprite preview on seek
Authorization & Security
Phase 1: No Authentication (MVP)
For MVP, all features are accessible without authentication. However, the authorization structure is designed to be auth-ready.
Current Pattern (Request Context):
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user
attribute :request_id
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_current_attributes
private
def set_current_attributes
Current.user = current_user if respond_to?(:current_user)
Current.request_id = request.uuid
end
end
Phase 2: OIDC Authentication
User Model with Authorization:
# app/models/user.rb
class User < ApplicationRecord
enum role: { member: 0, admin: 1 }
validates :email, presence: true, uniqueness: true
def admin?
role == "admin"
end
def self.admin_from_env
admin_email = ENV['ADMIN_EMAIL']
return nil unless admin_email
find_by(email: admin_email)
end
end
OIDC Configuration:
# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :openid_connect,
name: :oidc,
issuer: ENV['OIDC_ISSUER'],
client_id: ENV['OIDC_CLIENT_ID'],
client_secret: ENV['OIDC_CLIENT_SECRET'],
scope: [:openid, :email, :profile]
end
Sessions Controller:
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
skip_before_action :authenticate_user!, only: [:create]
def create
auth_hash = request.env['omniauth.auth']
user = User.find_or_create_from_omniauth(auth_hash)
session[:user_id] = user.id
redirect_to root_path, notice: "Signed in successfully"
end
def destroy
session[:user_id] = nil
redirect_to root_path, notice: "Signed out successfully"
end
end
Model-Level Authorization
# app/models/storage_location.rb
class StorageLocation < ApplicationRecord
def editable_by?(user)
return true if user.nil? # Phase 1: no auth
user.admin? # Phase 2+: only admins
end
def deletable_by?(user)
return true if user.nil?
user.admin? && videos.count.zero?
end
end
# app/models/work.rb
class Work < ApplicationRecord
def editable_by?(user)
return true if user.nil?
user.present? # Any authenticated user
end
end
Controller-Level Guards:
# app/controllers/admin/base_controller.rb
module Admin
class BaseController < ApplicationController
before_action :require_admin!
private
def require_admin!
return if Current.user&.admin?
redirect_to root_path, alert: "Access denied"
end
end
end
# app/controllers/admin/storage_locations_controller.rb
module Admin
class StorageLocationsController < Admin::BaseController
before_action :set_storage_location, only: [:edit, :update, :destroy]
before_action :authorize_storage_location, only: [:edit, :update, :destroy]
def index
@storage_locations = StorageLocation.all
end
def update
if @storage_location.update(storage_location_params)
redirect_to admin_storage_locations_path, notice: "Updated successfully"
else
render :edit, status: :unprocessable_entity
end
end
private
def authorize_storage_location
head :forbidden unless @storage_location.editable_by?(Current.user)
end
end
end
Credentials Management
Encrypted Settings:
# app/models/storage_location.rb
class StorageLocation < ApplicationRecord
encrypts :settings # Rails 7+ built-in encryption
serialize :settings, coder: JSON
end
# Generate encryption key:
# bin/rails db:encryption:init
# Add to config/credentials.yml.enc
Rails Credentials:
# Edit credentials
bin/rails credentials:edit
# Add:
# aws:
# access_key_id: <%= ENV["AWS_ACCESS_KEY_ID"] %>
# secret_access_key: <%= ENV["AWS_SECRET_ACCESS_KEY"] %>
API Authentication (Phase 4 - Federation)
API Key Authentication:
# app/controllers/api/federation/base_controller.rb
module Api
module Federation
class BaseController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate_api_key!
private
def authenticate_api_key!
return if Rails.env.development?
return unless ENV['ALLOW_FEDERATION'] == 'true'
authenticate_or_request_with_http_token do |token, options|
ActiveSupport::SecurityUtils.secure_compare(
token,
ENV.fetch('VELOUR_API_KEY')
)
end
end
end
end
end
Rate Limiting:
# config/initializers/rack_attack.rb (optional, recommended)
class Rack::Attack
throttle('api/ip', limit: 300, period: 5.minutes) do |req|
req.ip if req.path.start_with?('/api/')
end
throttle('federation/api_key', limit: 1000, period: 1.hour) do |req|
req.env['HTTP_AUTHORIZATION'] if req.path.start_with?('/api/federation/')
end
end
API Controller Architecture
Controller Organization
app/
└── controllers/
├── application_controller.rb
├── videos_controller.rb # HTML views
├── works_controller.rb
├── admin/
│ ├── base_controller.rb
│ ├── storage_locations_controller.rb
│ └── import_jobs_controller.rb
└── api/
├── base_controller.rb
└── v1/
├── videos_controller.rb
├── works_controller.rb
├── playback_sessions_controller.rb
└── storage_locations_controller.rb
└── federation/ # Phase 4
├── base_controller.rb
├── videos_controller.rb
└── works_controller.rb
API Base Controllers
Internal API Base:
# app/controllers/api/base_controller.rb
module Api
class BaseController < ActionController::API
include ActionController::Cookies
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
private
def not_found
render json: { error: "Not found" }, status: :not_found
end
def unprocessable_entity(exception)
render json: {
error: "Validation failed",
details: exception.record.errors.full_messages
}, status: :unprocessable_entity
end
end
end
Example API Controller:
# app/controllers/api/v1/videos_controller.rb
module Api
module V1
class VideosController < Api::BaseController
before_action :set_video, only: [:show, :stream]
def index
@videos = Video.includes(:work, :storage_location)
.order(created_at: :desc)
.page(params[:page])
.per(params[:per] || 50)
render json: {
videos: @videos.as_json(include: [:work, :storage_location]),
meta: pagination_meta(@videos)
}
end
def show
render json: @video.as_json(
include: {
work: { only: [:id, :title, :year] },
storage_location: { only: [:id, :name, :location_type] }
},
methods: [:stream_url, :formatted_duration]
)
end
def stream
# Route to appropriate streaming strategy
case @video.stream_type
when :presigned
render json: { stream_url: @video.stream_url }
when :direct
send_file @video.stream_url,
type: @video.format,
disposition: 'inline',
stream: true
when :proxy
# Implement proxy logic
redirect_to @video.stream_url, allow_other_host: true
end
end
private
def set_video
@video = Video.find(params[:id])
end
def pagination_meta(collection)
{
current_page: collection.current_page,
next_page: collection.next_page,
prev_page: collection.prev_page,
total_pages: collection.total_pages,
total_count: collection.total_count
}
end
end
end
end
Playback Tracking API:
# app/controllers/api/v1/playback_sessions_controller.rb
module Api
module V1
class PlaybackSessionsController < Api::BaseController
def update
video = Video.find(params[:video_id])
user = Current.user # nil in Phase 1, actual user in Phase 2
session = PlaybackSession.update_position(
video,
user,
params[:position].to_f,
params[:duration_watched].to_f
)
render json: { success: true, session: session }
rescue ActiveRecord::RecordNotFound
render json: { error: "Video not found" }, status: :not_found
end
end
end
end
Turbo Streams & Real-Time Updates
Scan Progress Broadcasting
Job with Turbo Streams:
# app/jobs/file_scanner_job.rb
class FileScannerJob < ApplicationJob
queue_as :default
def perform(storage_location_id)
@storage_location = StorageLocation.find(storage_location_id)
broadcast_update(status: "started", progress: 0)
result = FileScannerService.new(@storage_location).call
if result.success?
broadcast_update(
status: "completed",
progress: 100,
videos_found: result.videos_found,
new_videos: result.new_videos
)
else
broadcast_update(status: "failed", error: result.error)
end
end
private
def broadcast_update(**data)
Turbo::StreamsChannel.broadcast_replace_to(
"storage_location_#{@storage_location.id}",
target: "scan_status",
partial: "admin/storage_locations/scan_status",
locals: { storage_location: @storage_location, **data }
)
end
end
View with Turbo Stream:
<%# app/views/admin/storage_locations/show.html.erb %>
<%= turbo_stream_from "storage_location_#{@storage_location.id}" %>
<div id="scan_status">
<%= render "scan_status", storage_location: @storage_location, status: "idle" %>
</div>
<%= button_to "Scan Now", scan_admin_storage_location_path(@storage_location),
method: :post,
data: { turbo_frame: "_top" },
class: "btn btn-primary" %>
Partial:
<%# app/views/admin/storage_locations/_scan_status.html.erb %>
<div class="scan-status">
<% case status %>
<% when "started" %>
<div class="alert alert-info">
<p>Scanning... <%= progress %>%</p>
</div>
<% when "completed" %>
<div class="alert alert-success">
<p>Scan completed! Found <%= videos_found %> videos (<%= new_videos %> new)</p>
</div>
<% when "failed" %>
<div class="alert alert-danger">
<p>Scan failed: <%= error %></p>
</div>
<% else %>
<p class="text-muted">Ready to scan</p>
<% end %>
</div>
Import Progress Updates
Similar pattern for VideoImportJob with progress broadcasting.
Testing Strategy
Test Organization
test/
├── models/
│ ├── video_test.rb
│ ├── work_test.rb
│ ├── storage_location_test.rb
│ ├── playback_session_test.rb
│ └── concerns/
│ ├── streamable_test.rb
│ └── processable_test.rb
├── services/
│ ├── file_scanner_service_test.rb
│ ├── video_metadata_extractor_test.rb
│ ├── duplicate_detector_service_test.rb
│ └── storage_adapters/
│ ├── local_adapter_test.rb
│ └── s3_adapter_test.rb
├── jobs/
│ ├── video_processor_job_test.rb
│ └── file_scanner_job_test.rb
├── controllers/
│ ├── videos_controller_test.rb
│ ├── works_controller_test.rb
│ └── api/
│ └── v1/
│ └── videos_controller_test.rb
└── system/
├── video_playback_test.rb
├── library_browsing_test.rb
└── work_grouping_test.rb
Example Tests
Model Test:
# test/models/video_test.rb
require "test_helper"
class VideoTest < ActiveSupport::TestCase
test "belongs to storage location" do
video = videos(:one)
assert_instance_of StorageLocation, video.storage_location
end
test "validates presence of required fields" do
video = Video.new
assert_not video.valid?
assert_includes video.errors[:title], "can't be blank"
assert_includes video.errors[:file_path], "can't be blank"
end
test "formatted_duration returns correct format" do
video = videos(:one)
video.duration = 3665 # 1 hour, 1 minute, 5 seconds
assert_equal "1:01:05", video.formatted_duration
end
test "streamable? returns true when video is processed and storage is enabled" do
video = videos(:one)
video.duration = 100
video.storage_location.update!(enabled: true)
assert video.streamable?
end
end
Service Test:
# test/services/file_scanner_service_test.rb
require "test_helper"
class FileScannerServiceTest < ActiveSupport::TestCase
setup do
@storage_location = storage_locations(:local_movies)
end
test "scans directory and creates video records" do
# Stub adapter scan method
@storage_location.adapter.stub :scan, ["movie1.mp4", "movie2.mkv"] do
result = FileScannerService.new(@storage_location).call
assert result.success?
assert_equal 2, result.videos_found
end
end
test "updates last_scanned_at timestamp" do
@storage_location.adapter.stub :scan, [] do
FileScannerService.new(@storage_location).call
@storage_location.reload
assert_not_nil @storage_location.last_scanned_at
end
end
end
System Test:
# test/system/video_playback_test.rb
require "application_system_test_case"
class VideoPlaybackTest < ApplicationSystemTestCase
test "playing a video updates playback position" do
video = videos(:one)
visit video_path(video)
assert_selector "video#video-player"
# Simulate video playback (would need JS execution)
# assert_changes -> { video.playback_sessions.first&.position }
end
test "resume functionality loads saved position" do
video = videos(:one)
PlaybackSession.create!(video: video, position: 30.0)
visit video_path(video)
# Assert player starts at saved position
# (implementation depends on Video.js setup)
end
end
API Design (RESTful)
Video Playback API (Internal)
GET /api/v1/videos/:id/stream # Stream video (route to appropriate source)
GET /api/v1/videos/:id/presigned # Get presigned S3 URL
GET /api/v1/videos/:id/metadata # Get video metadata
POST /api/v1/videos/:id/playback # Update playback position
GET /api/v1/videos/:id/assets # Get thumbnails, sprites
Library Management API
GET /api/v1/videos # List all videos (unified view)
GET /api/v1/works # List works with grouped videos
POST /api/v1/works/:id/merge # Merge videos into work
GET /api/v1/storage_locations # List all sources
POST /api/v1/storage_locations # Add new source
POST /api/v1/storage_locations/:id/scan # Trigger scan
GET /api/v1/storage_locations/:id/scan_status
Import API (Phase 3)
POST /api/v1/videos/:id/import # Import video to writable storage
GET /api/v1/import_jobs # List import jobs
GET /api/v1/import_jobs/:id # Get import status
DELETE /api/v1/import_jobs/:id # Cancel import
Federation API (Phase 4) - Public to other Velour instances
GET /api/v1/federation/videos # List available videos
GET /api/v1/federation/videos/:id # Get video details
GET /api/v1/federation/videos/:id/stream # Stream video (with API key auth)
GET /api/v1/federation/works # List works
Required Gems
Add these to the Gemfile:
# Gemfile
# Core gems (already present in Rails 8)
gem "rails", "~> 8.1.1"
gem "sqlite3", ">= 2.1"
gem "puma", ">= 5.0"
gem "importmap-rails"
gem "turbo-rails"
gem "stimulus-rails"
gem "tailwindcss-rails"
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"
gem "image_processing", "~> 1.2"
# Video processing
gem "streamio-ffmpeg" # FFmpeg wrapper for metadata extraction
# Pagination
gem "pagy" # Fast, lightweight pagination
# AWS SDK for S3 support (Phase 3, but add early)
gem "aws-sdk-s3"
# Phase 2: Authentication
# gem "omniauth-openid-connect"
# gem "omniauth-rails_csrf_protection"
# Phase 3: Remote sources
# gem "httparty" # For JellyFin/Web APIs
# gem "down" # For downloading remote files
# Development & Test
group :development, :test do
gem "debug", platforms: %i[mri windows]
gem "bundler-audit"
gem "brakeman"
gem "rubocop-rails-omakase"
end
group :development do
gem "web-console"
gem "bullet" # N+1 query detection
gem "strong_migrations" # Catch unsafe migrations
end
group :test do
gem "capybara"
gem "selenium-webdriver"
gem "mocha" # Stubbing/mocking
end
# Optional but recommended
# gem "rack-attack" # Rate limiting (Phase 4)
Route Structure
# config/routes.rb
Rails.application.routes.draw do
# Health check
get "up" => "rails/health#show", as: :rails_health_check
# Root
root "videos#index"
# Main UI routes
resources :videos, only: [:index, :show] do
member do
get :watch # Player page
post :import # Phase 3: Import to writable storage
end
end
resources :works, only: [:index, :show] do
member do
post :merge # Merge videos into this work
end
end
# Admin routes (Phase 2+)
namespace :admin do
root "dashboard#index"
resources :storage_locations do
member do
post :scan
get :scan_status
end
end
resources :import_jobs, only: [:index, :show, :destroy] do
member do
post :cancel
end
end
resources :users, only: [:index, :edit, :update] # Phase 2
end
# Internal API (for JS/Stimulus)
namespace :api do
namespace :v1 do
resources :videos, only: [:index, :show] do
member do
get :stream
get :presigned # For S3 presigned URLs
end
end
resources :works, only: [:index, :show]
resources :playback_sessions, only: [] do
collection do
post :update # POST /api/v1/playback_sessions/update
end
end
resources :storage_locations, only: [:index]
end
# Federation API (Phase 4)
namespace :federation do
resources :videos, only: [:index, :show] do
member do
get :stream
end
end
resources :works, only: [:index, :show]
end
end
# Authentication routes (Phase 2)
# get '/auth/:provider/callback', to: 'sessions#create'
# delete '/sign_out', to: 'sessions#destroy', as: :sign_out
end
Development Phases
Phase 1: MVP (Local Filesystem)
Phase 1 is broken into 4 sub-phases for manageable milestones:
Phase 1A: Core Foundation (Week 1-2)
Goal: Basic models and database setup
-
Generate models with migrations:
- Works
- Videos
- StorageLocations
- PlaybackSessions (basic structure)
-
Implement models:
- Add validations, scopes, associations
- Add concerns (Streamable, Processable, Searchable)
- Serialize metadata fields
-
Create storage adapter pattern:
- BaseAdapter interface
- LocalAdapter implementation
- Test adapter with sample directory
-
Create basic services:
- FileScannerService (scan local directory)
- Result object pattern
-
Simple UI:
- Videos index page (list only, no thumbnails yet)
- Basic TailwindCSS styling
- Storage locations admin page
Deliverable: Can scan a local directory and see videos in database
Phase 1B: Video Playback (Week 3)
Goal: Working video player with streaming
-
Video streaming:
- Videos controller with show action
- Stream action for serving video files
- Byte-range support for seeking
-
Video.js integration:
- Add Video.js via Importmap
- Create VideoPlayerController (Stimulus)
- Basic player UI on videos#show page
-
Playback tracking:
- Implement PlaybackSession.update_position
- Create API endpoint for position updates
- Basic resume functionality (load last position)
-
Playback tracking plugin:
- Custom Video.js plugin to track position
- Send updates to Rails API every 10 seconds
- Save position on pause/stop
Deliverable: Can watch videos with resume functionality
Phase 1C: Processing Pipeline (Week 4)
Goal: Video metadata extraction and asset generation
-
Video metadata extraction:
- VideoMetadataExtractor service
- FFmpeg integration via streamio-ffmpeg
- Extract duration, resolution, codecs, file hash
-
Background processing:
- VideoProcessorJob
- Queue processing for new videos
- Error handling and retry logic
-
Thumbnail generation:
- Generate thumbnail at 10% mark
- Store via Active Storage
- Display thumbnails on index page
-
Video assets:
- VideoAssets model
- Store thumbnails, previews (phase 1C: thumbnails only)
- VTT sprites (defer to Phase 1D if time-constrained)
-
Processing UI:
- Show processing status on video cards
- Processing failed indicator
- Retry processing action
Deliverable: Videos automatically processed with thumbnails
Phase 1D: Works & Grouping (Week 5)
Goal: Group duplicate videos into works
-
Works functionality:
- Works model fully implemented
- Works index/show pages
- Display videos grouped by work
-
Duplicate detection:
- DuplicateDetectorService
- Find videos with same file hash
- Find videos with similar titles
-
Work grouping UI:
- WorkGrouperService
- Manual grouping interface
- Drag-and-drop or checkbox selection
- Create work from selected videos
-
Works display:
- Works index with thumbnails
- Works show with all versions
- Version selector (resolution, format)
-
Polish:
- Search functionality
- Filtering by source, resolution
- Sorting options
- Pagination with Pagy
Deliverable: Full MVP with work grouping and polished UI
Phase 2: Authentication & Multi-User
- User model and OIDC integration
- Admin role management (ENV: ADMIN_EMAIL)
- Per-user playback history
- User management UI
- Storage location management UI
Phase 3: Remote Sources & Import
- S3 Storage Location:
- S3 scanner (list bucket objects)
- Presigned URL streaming
- Import to/from S3
- JellyFin Integration:
- JellyFin API client
- Sync metadata
- Proxy streaming
- Web Directories:
- HTTP directory parser
- Auth support (basic, bearer)
- Import System:
- VideoImportJob with progress tracking
- Import UI with destination selection
- Background download with resume support
- Unified Library View:
- Filter by source
- Show source badges on videos
- Multi-source search
Phase 4: Federation
- Public API for other Velour instances
- API key authentication
- Velour storage location type
- Federated video discovery
- Cross-instance streaming
File Organization
Local Storage Structure
storage/
├── assets/ # Active Storage (thumbnails, previews, sprites)
│ └── [active_storage_blobs]
└── tmp/ # Temporary processing files
Video Files
- Local: Direct filesystem paths (not copied/moved)
- S3: Stored in configured bucket
- Remote: Referenced by URL, optionally imported to local/S3
Key Implementation Decisions
Storage Flexibility
- Videos are NOT managed by Active Storage
- Local videos: store absolute/relative paths
- S3 videos: store bucket + key
- Remote videos: store source URL + metadata
- Active Storage ONLY for generated assets (thumbnails, etc.)
Import Strategy (Phase 3)
- User selects video from remote source
- User selects destination (writable storage location)
- VideoImportJob starts download
- Progress tracked via Turbo Streams
- On completion:
- New Video record created (imported: true)
- Linked to same Work as source
- Assets generated
- Original remote video remains linked
Unified View
- Single
/videosindex shows all sources - Filter dropdown: "All Sources", "Local", "S3", "JellyFin", etc.
- Source badge on each video card
- Search across all sources
- Sort by: title, date added, last watched, etc.
Streaming Strategy by Source
# Local filesystem
send_file video.full_path, type: video.mime_type, disposition: 'inline'
# S3
redirect_to s3_client.presigned_url(:get_object, bucket: ..., key: ..., expires_in: 3600)
# JellyFin
redirect_to "#{jellyfin_url}/Videos/#{video.source_id}/stream?api_key=..."
# Web directory
# Proxy through Rails with auth headers
# Velour federation
redirect_to "#{velour_url}/api/v1/federation/videos/#{video.source_id}/stream?api_key=..."
Performance Considerations
- Lazy asset generation (only when video viewed)
- S3 presigned URLs (no proxy for large files)
- Caching of metadata and thumbnails
- Cursor-based pagination for large libraries
- Background scanning with incremental updates
Configuration (ENV)
# Admin
ADMIN_EMAIL=admin@example.com
# OIDC (Phase 2)
OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=velour
OIDC_CLIENT_SECRET=secret
# Default Storage
DEFAULT_SCAN_PATH=/path/to/videos
# S3 (optional default)
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_S3_BUCKET=velour-videos
# Processing
FFMPEG_THREADS=4
THUMBNAIL_SIZE=1920x1080
PREVIEW_DURATION=30
SPRITE_INTERVAL=5
# Federation (Phase 4)
VELOUR_API_KEY=secret-key-for-federation
ALLOW_FEDERATION=true
Video.js Implementation (Inspiration from Stash)
Based on analysis of the Stash application, we'll use:
- Video.js v8.x as the core player
- Custom plugins for:
- Resume functionality
- Playback tracking
- Quality/version selector
- VTT thumbnails on seek bar
- Streaming format support:
- Direct MP4/MKV streaming with byte-range
- DASH/HLS for adaptive streaming (future)
- Multi-quality source selection
Key Features from Stash Worth Implementing:
- Scene markers on timeline (our "chapters" equivalent)
- Thumbnail sprite preview on hover
- Keyboard shortcuts
- Mobile-optimized controls
- Resume from last position
- Play duration and count tracking
- AirPlay/Chromecast support (future)
Error Handling & Job Configuration
Job Retry Strategy
# app/jobs/video_processor_job.rb
class VideoProcessorJob < ApplicationJob
queue_as :default
# Retry with exponential backoff
retry_on StandardError, wait: :polynomially_longer, attempts: 3
# Don't retry if video not found
discard_on ActiveRecord::RecordNotFound do |job, error|
Rails.logger.warn "Video not found for processing: #{error.message}"
end
def perform(video_id)
video = Video.find(video_id)
# Extract metadata
result = VideoMetadataExtractor.new(video).call
return unless result.success?
# Generate thumbnail (Phase 1C)
ThumbnailGeneratorService.new(video).call
rescue FFMPEG::Error => e
video.mark_processing_failed!(e.message)
raise # Will retry
end
end
Background Job Monitoring
# app/controllers/admin/jobs_controller.rb (optional)
module Admin
class JobsController < Admin::BaseController
def index
@running_jobs = SolidQueue::Job.where(finished_at: nil).limit(50)
@failed_jobs = SolidQueue::Job.where.not(error: nil).limit(50)
end
def retry
job = SolidQueue::Job.find(params[:id])
job.retry!
redirect_to admin_jobs_path, notice: "Job queued for retry"
end
end
end
Configuration Summary
Environment Variables
# .env (Phase 1)
DEFAULT_SCAN_PATH=/path/to/your/videos
FFMPEG_THREADS=4
THUMBNAIL_SIZE=1920x1080
# .env (Phase 2)
ADMIN_EMAIL=admin@example.com
OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=velour
OIDC_CLIENT_SECRET=your-secret
# .env (Phase 3)
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_S3_BUCKET=velour-videos
# .env (Phase 4)
VELOUR_API_KEY=your-api-key
ALLOW_FEDERATION=true
Database Encryption
# Generate encryption keys
bin/rails db:encryption:init
# Add output to config/credentials.yml.enc:
# active_record_encryption:
# primary_key: ...
# deterministic_key: ...
# key_derivation_salt: ...
Implementation Checklist
Phase 1A: Core Foundation
- Install required gems (
streamio-ffmpeg,pagy) - Generate models with proper migrations
- Implement model validations and associations
- Create model concerns (Streamable, Processable, Searchable)
- Build storage adapter pattern (BaseAdapter, LocalAdapter)
- Implement FileScannerService
- Create Result object pattern
- Build videos index page with TailwindCSS
- Create admin storage locations CRUD
- Write tests for models and adapters
Phase 1B: Video Playback
- Create videos#show action with byte-range support
- Add Video.js via Importmap
- Build VideoPlayerController (Stimulus)
- Implement PlaybackSession.update_position
- Create API endpoint for position tracking
- Build custom Video.js tracking plugin
- Implement resume functionality
- Write system tests for playback
Phase 1C: Processing Pipeline
- Implement VideoMetadataExtractor service
- Create VideoProcessorJob with retry logic
- Build ThumbnailGeneratorService
- Set up Active Storage for thumbnails
- Display thumbnails on index page
- Add processing status indicators
- Implement retry processing action
- Write tests for processing services
Phase 1D: Works & Grouping
- Fully implement Works model
- Create Works index/show pages
- Implement DuplicateDetectorService
- Build WorkGrouperService
- Create work grouping UI
- Add version selector on work pages
- Implement search functionality
- Add filtering and sorting
- Integrate Pagy pagination
- Polish UI with TailwindCSS
Next Steps
To Begin Implementation:
-
Review this architecture document with team/stakeholders
-
Set up development environment:
- Install FFmpeg (
brew install ffmpegon macOS) - Verify Rails 8.1.1+ installed
- Create new Rails app (already done in
/Users/dkam/Development/velour)
- Install FFmpeg (
-
Start Phase 1A:
# Add gems bundle add streamio-ffmpeg pagy aws-sdk-s3 # Generate models rails generate model Work title:string year:integer director:string description:text rating:decimal organized:boolean poster_path:string backdrop_path:string metadata:text rails generate model StorageLocation name:string path:string location_type:integer writable:boolean enabled:boolean scan_subdirectories:boolean priority:integer settings:text last_scanned_at:datetime rails generate model Video work:references storage_location:references title:string file_path:string file_hash:string file_size:bigint duration:float width:integer height:integer resolution_label:string video_codec:string audio_codec:string bit_rate:integer frame_rate:float format:string has_subtitles:boolean version_type:string source_type:integer source_url:string imported:boolean processing_failed:boolean error_message:text metadata:text rails generate model VideoAsset video:references asset_type:integer metadata:text rails generate model PlaybackSession video:references user:references position:float duration_watched:float last_watched_at:datetime completed:boolean play_count:integer # Run migrations rails db:migrate # Start server bin/dev -
Follow the Phase 1A checklist (see above)
-
Iterate through Phase 1B, 1C, 1D
Architecture Decision Records
Key architectural decisions made:
- SQLite for MVP - Simple, file-based, perfect for single-user. Migration path to PostgreSQL documented.
- Storage Adapter Pattern - Pluggable backends allow adding S3, JellyFin, etc. without changing core logic.
- Service Objects - Complex business logic extracted from controllers/models for testability.
- Hotwire over React - Server-rendered HTML with Turbo Streams for real-time updates. Less JS complexity.
- Video.js - Proven, extensible, well-documented player with broad format support.
- Rails Enums (integers) - SQLite-compatible, performant, database-friendly.
- Active Storage for assets only - Videos managed by storage adapters, not Active Storage.
- Pagy over Kaminari - Faster, simpler pagination with smaller footprint.
- Model-level authorization - Simple for MVP, easy upgrade path to Pundit/Action Policy.
- Phase 1 broken into 4 sub-phases - Manageable milestones with clear deliverables.
Support & Resources
- Rails Guides: https://guides.rubyonrails.org
- Hotwire Docs: https://hotwired.dev
- Video.js Docs: https://docs.videojs.com
- FFmpeg Docs: https://ffmpeg.org/documentation.html
- TailwindCSS: https://tailwindcss.com/docs
Document Version: 1.0 Last Updated: <%= Time.current.strftime("%Y-%m-%d") %> Status: Ready for Phase 1A Implementation