# Velour Phase 1: MVP (Local Filesystem) Phase 1 delivers a complete video library application for local files with grouping, transcoding, and playback. This is the foundation that provides immediate value. **Architecture Note**: This phase implements a extensible MediaFile architecture using Single Table Inheritance (STI). Video inherits from MediaFile, preparing the system for future audio support in Phase 5. ## Technology Stack ### Core Components - **Ruby on Rails 8.x** with SQLite3 - **Hotwire** (Turbo + Stimulus) for frontend - **Solid Queue** for background jobs - **Video.js** for video playback - **FFmpeg** for video processing - **Active Storage** for thumbnails/assets - **TailwindCSS** for styling ## Database Schema (Core Models) ### Work Model ```ruby class Work < ApplicationRecord validates :title, presence: true has_many :videos, dependent: :destroy has_many :external_ids, dependent: :destroy scope :organized, -> { where(organized: true) } scope :unorganized, -> { where(organized: false) } def primary_video videos.order(created_at: :desc).first end end ``` ### MediaFile Model (Base Class) ```ruby class MediaFile < ApplicationRecord # Base class for all media files using STI include Streamable, Processable # Common associations belongs_to :work belongs_to :storage_location has_many :playback_sessions, dependent: :destroy # Common metadata stores store :fingerprints, accessors: [:xxhash64, :md5, :oshash, :phash] store :media_metadata, accessors: [:duration, :codec, :bit_rate, :format] # Common validations and methods validates :filename, presence: true validates :xxhash64, presence: true, uniqueness: { scope: :storage_location_id } scope :web_compatible, -> { where(web_compatible: true) } scope :needs_transcoding, -> { where(web_compatible: false) } def display_title work&.display_title || filename end def full_file_path File.join(storage_location.path, filename) end def format_duration # Duration formatting logic end end ``` ### Video Model (Inherits from MediaFile) ```ruby class Video < MediaFile # Video-specific associations has_many :video_assets, dependent: :destroy # Video-specific metadata store :video_metadata, accessors: [:width, :height, :video_codec, :audio_codec, :frame_rate] # Video-specific methods def resolution_label # Resolution-based quality labeling (SD, 720p, 1080p, 4K, etc.) end end ``` ### StorageLocation Model ```ruby class StorageLocation < ApplicationRecord has_many :videos, dependent: :destroy validates :name, presence: true validates :path, presence: true, uniqueness: true validates :storage_type, presence: true, inclusion: { in: %w[local] } validate :path_must_exist_and_be_readable def accessible? File.exist?(path) && File.readable?(path) end private def path_must_exist_and_be_readable errors.add(:path, "must exist and be readable") unless accessible? end end ``` ## Storage Architecture (Local Only) ### Directory Structure ```bash # User's original media directories (read-only references) /movies/action/Die Hard (1988).mkv /movies/anime/Your Name (2016).webm /movies/scifi/The Matrix (1999).avi # Velour transcoded files (same directories, web-compatible) /movies/action/Die Hard (1988).web.mp4 /movies/anime/Your Name (2016).web.mp4 /movies/scifi/The Matrix (1999).web.mp4 # Velour managed directories ./velour_data/ ├── assets/ # Active Storage for generated content │ └── thumbnails/ # Video screenshots └── tmp/ # Temporary processing files └── transcodes/ # Temporary transcode storage ``` ### Docker Volume Mounting with Heuristic Discovery Users mount their video directories under `/videos` and Velour automatically discovers and categorizes them: ```yaml # docker-compose.yml volumes: - /path/to/user/movies:/videos/movies:ro - /path/to/user/tv_shows:/videos/tv:ro - /path/to/user/documentaries:/videos/docs:ro - /path/to/user/anime:/videos/anime:ro - ./velour_data:/app/velour_data ``` ### Automatic Storage Location Discovery ```ruby # app/services/storage_discovery_service.rb class StorageDiscoveryService CATEGORIES = { 'movies' => 'Movies', 'tv' => 'TV Shows', 'tv_shows' => 'TV Shows', 'series' => 'TV Shows', 'docs' => 'Documentaries', 'documentaries' => 'Documentaries', 'anime' => 'Anime', 'cartoons' => 'Animation', 'animation' => 'Animation', 'sports' => 'Sports', 'music' => 'Music Videos', 'music_videos' => 'Music Videos', 'kids' => 'Kids Content', 'family' => 'Family Content' }.freeze def self.discover_and_create base_path = '/videos' return [] unless Dir.exist?(base_path) discovered = [] Dir.children(base_path).each do |subdir| dir_path = File.join(base_path, subdir) next unless Dir.exist?(dir_path) category = categorize_directory(subdir) storage = StorageLocation.find_or_create_by!( name: "#{category}: #{subdir.titleize}", path: dir_path, storage_type: 'local' ) discovered << storage end discovered end def self.categorize_directory(dirname) downcase = dirname.downcase CATEGORIES[downcase] || CATEGORIES.find { |key, _| downcase.include?(key) }&.last || 'Other' end end ``` ## Video Processing Pipeline ### Background Job with ActiveJob 8.1 Continuations ```ruby class VideoProcessorJob < ApplicationJob include ActiveJob::Statuses def perform(video_id) video = Video.find(video_id) progress.update(stage: "metadata", total: 100, current: 0) metadata = VideoMetadataExtractor.new(video.full_file_path).extract video.update!(video_metadata: metadata) progress.update(stage: "metadata", total: 100, current: 100) progress.update(stage: "thumbnail", total: 100, current: 0) generate_thumbnail(video) progress.update(stage: "thumbnail", total: 100, current: 100) unless video.web_compatible? progress.update(stage: "transcode", total: 100, current: 0) transcode_video(video) progress.update(stage: "transcode", total: 100, current: 100) end progress.update(stage: "complete", total: 100, current: 100) end private def generate_thumbnail(video) # Generate thumbnail at 10% of duration thumbnail_path = VideoTranscoder.new.extract_frame(video.full_file_path, video.duration * 0.1) video.video_assets.create!(asset_type: "thumbnail", file: File.open(thumbnail_path)) end def transcode_video(video) transcoder = VideoTranscoder.new output_path = video.full_file_path.gsub(/\.[^.]+$/, '.web.mp4') transcoder.transcode_for_web( input_path: video.full_file_path, output_path: output_path, on_progress: ->(current, total) { progress.update(current: current) } ) video.update!( transcoded_path: File.basename(output_path), transcoded_permanently: true, web_compatible: true ) end end ``` ## Frontend Architecture ### Uninterrupted Video Playback ```erb

<%= @work.title %>

Duration: <%= format_duration(@video.duration) %>

``` ### Video Player Stimulus Controller ```javascript // app/javascript/controllers/video_player_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { videoId: Number, startPosition: Number } connect() { this.player = videojs(this.element, { controls: true, responsive: true, fluid: true, playbackRates: [0.5, 1, 1.25, 1.5, 2] }) this.player.ready(() => { if (this.startPositionValue > 0) { this.player.currentTime(this.startPositionValue) } }) // Save position every 10 seconds this.interval = setInterval(() => { this.savePosition() }, 10000) // Save on pause this.player.on("pause", () => this.savePosition()) } disconnect() { clearInterval(this.interval) this.player.dispose() } savePosition() { const position = Math.floor(this.player.currentTime()) fetch(`/videos/${this.videoIdValue}/playback-position`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ position }) }) } } ``` ## Service Objects ### File Scanner Service ```ruby class FileScannerService def initialize(storage_location) @storage_location = storage_location end def scan return failure_result("Storage location not accessible") unless @storage_location.accessible? video_files = find_video_files new_videos = process_files(video_files) success_result(new_videos) rescue => e failure_result(e.message) end private def find_video_files Dir.glob(File.join(@storage_location.path, "**", "*.{mp4,avi,mkv,mov,wmv,flv,webm,m4v}")) end def process_files(file_paths) new_videos = [] file_paths.each do |file_path| filename = File.basename(file_path) next if Video.exists?(filename: filename, storage_location: @storage_location) video = Video.create!( filename: filename, storage_location: @storage_location, work: Work.find_or_create_by(title: extract_title(filename)) ) new_videos << video VideoProcessorJob.perform_later(video.id) end new_videos end def extract_title(filename) # Simple title extraction - can be enhanced File.basename(filename, ".*").gsub(/[\[\(].*?[\]\)]/, "").strip end def success_result(videos = []) { success: true, videos: videos, message: "Found #{videos.length} new videos" } end def failure_result(message) { success: false, message: message } end end ``` ### Video Transcoder Service ```ruby class VideoTranscoder def transcode_for_web(input_path:, output_path:, on_progress: nil) movie = FFMPEG::Movie.new(input_path) # Calculate progress callback progress_callback = ->(progress) { on_progress&.call(progress, 100) } movie.transcode(output_path, { video_codec: "libx264", audio_codec: "aac", custom: %w[ -pix_fmt yuv420p -preset medium -crf 23 -movflags +faststart -tune fastdecode ] }, &progress_callback) end def extract_frame(input_path, seconds) movie = FFMPEG::Movie.new(input_path) output_path = "#{Rails.root}/tmp/thumbnail_#{SecureRandom.hex(8)}.jpg" movie.screenshot(output_path, seek_time: seconds, resolution: "320x240") output_path end end ``` ## Routes ```ruby Rails.application.routes.draw do root "storage_locations#index" resources :storage_locations, only: [:index, :show, :create, :destroy] do member do post :scan end end resources :works, only: [:index, :show] do resources :videos, only: [:show] end resources :videos, only: [] do member do get :stream patch :playback_position post :retry_processing end resources :playback_sessions, only: [:create] end # Real-time job progress resources :jobs, only: [:show] do member do get :progress end end end ``` ## Implementation Sub-Phases ### Phase 1A: Core Foundation (Week 1-2) 1. Generate models with migrations 2. Implement model validations and associations 3. Create storage adapter pattern 4. Create FileScannerService 5. Basic UI for storage locations and video listing ### Phase 1B: Video Playback (Week 3) 1. Video streaming controller with byte-range support 2. Video.js integration with Stimulus controller 3. Playback session tracking 4. Resume functionality ### Phase 1C: Processing Pipeline (Week 4) 1. Video metadata extraction with FFmpeg 2. Background job processing with progress tracking 3. Thumbnail generation and storage 4. Processing UI with status indicators ### Phase 1D: Works & Grouping (Week 5) 1. Duplicate detection by file hash 2. Manual grouping interface 3. Works display with version selection 4. Search, filtering, and pagination ## Configuration ### Environment Variables ```bash # Required RAILS_ENV=development RAILS_MASTER_KEY=your_master_key # Video processing FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe # Storage VIDEOS_PATH=./velour_data/videos MAX_TRANSCODE_SIZE_GB=50 # Background jobs (SolidQueue runs with defaults) SOLID_QUEUE_PROCESSES="*:2" # 2 workers for all queues ``` ### Docker Configuration ```dockerfile FROM ruby:3.3-alpine # Install FFmpeg and other dependencies RUN apk add --no-cache \ ffmpeg \ imagemagick \ sqlite-dev \ nodejs \ npm # Install Rails and other gems COPY Gemfile* /app/ RUN bundle install # Copy application code COPY . /app/ # Create directories RUN mkdir -p /app/velour_data/{assets,tmp} EXPOSE 3000 CMD ["rails", "server", "-b", "0.0.0.0"] ``` ## Testing Strategy ### Model Tests - Model validations and associations - Scopes and class methods - JSON store accessor functionality ### Service Tests - FileScannerService with sample directory - VideoTranscoder service methods - Background job processing ### System Tests - Video playback functionality - File scanning workflow - Work grouping interface This Phase 1 delivers a complete, useful video library application that provides immediate value while establishing the foundation for future enhancements.