548 lines
14 KiB
Markdown
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. |