Files
velour/docs/phases/phase_1.md
Dan Milne 88a906064f
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Much base work started
2025-10-31 14:36:14 +11:00

548 lines
14 KiB
Markdown

# 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
<!-- videos/show.html.erb -->
<turbo-frame id="video-player-frame" data-turbo-permanent>
<div class="video-container">
<video
id="video-player"
data-controller="video-player"
data-video-player-video-id-value="<%= @video.id %>"
data-video-player-start-position-value="<%= @last_position %>"
class="w-full"
controls>
<source src="<%= stream_video_path(@video) %>" type="video/mp4">
</video>
</div>
</turbo-frame>
<turbo-frame id="video-info">
<h1><%= @work.title %></h1>
<p>Duration: <%= format_duration(@video.duration) %></p>
</turbo-frame>
```
### 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.