Much base work started
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

This commit is contained in:
Dan Milne
2025-10-31 14:36:14 +11:00
parent 4a35bf6758
commit 88a906064f
97 changed files with 5333 additions and 2774 deletions

View File

@@ -20,7 +20,7 @@ gem "tailwindcss-rails"
gem "jbuilder" gem "jbuilder"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7" gem "bcrypt", "~> 3.1.7"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ] gem "tzinfo-data", platforms: %i[ windows jruby ]
@@ -66,3 +66,9 @@ group :test do
gem "capybara" gem "capybara"
gem "selenium-webdriver" gem "selenium-webdriver"
end end
gem "streamio-ffmpeg", "~> 3.0"
gem "pagy", "~> 9.4"
gem "aws-sdk-s3", "~> 1.202"
gem "xxhash", "~> 0.7.0"

View File

@@ -78,7 +78,27 @@ GEM
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3) ast (2.4.3)
aws-eventstream (1.4.0)
aws-partitions (1.1178.0)
aws-sdk-core (3.235.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.115.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.202.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1)
bigdecimal (3.3.1) bigdecimal (3.3.1)
bindex (0.8.1) bindex (0.8.1)
@@ -142,6 +162,7 @@ GEM
jbuilder (2.14.1) jbuilder (2.14.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
jmespath (1.6.2)
json (2.15.2) json (2.15.2)
kamal (2.8.2) kamal (2.8.2)
activesupport (>= 7.0) activesupport (>= 7.0)
@@ -173,6 +194,7 @@ GEM
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.26.0) minitest (5.26.0)
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.17.0)
net-imap (0.5.12) net-imap (0.5.12)
date date
net-protocol net-protocol
@@ -203,6 +225,7 @@ GEM
nokogiri (1.18.10-x86_64-linux-musl) nokogiri (1.18.10-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
ostruct (0.6.3) ostruct (0.6.3)
pagy (9.4.0)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.10.0) parser (3.3.10.0)
ast (~> 2.4.1) ast (~> 2.4.1)
@@ -343,6 +366,8 @@ GEM
ostruct ostruct
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
streamio-ffmpeg (3.0.2)
multi_json (~> 1.8)
stringio (3.1.7) stringio (3.1.7)
tailwindcss-rails (4.4.0) tailwindcss-rails (4.4.0)
railties (>= 7.0.0) railties (>= 7.0.0)
@@ -382,6 +407,7 @@ GEM
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
xxhash (0.7.0)
zeitwerk (2.7.3) zeitwerk (2.7.3)
PLATFORMS PLATFORMS
@@ -396,6 +422,8 @@ PLATFORMS
x86_64-linux-musl x86_64-linux-musl
DEPENDENCIES DEPENDENCIES
aws-sdk-s3 (~> 1.202)
bcrypt (~> 3.1.7)
bootsnap bootsnap
brakeman brakeman
bundler-audit bundler-audit
@@ -405,6 +433,7 @@ DEPENDENCIES
importmap-rails importmap-rails
jbuilder jbuilder
kamal kamal
pagy (~> 9.4)
propshaft propshaft
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.1.1) rails (~> 8.1.1)
@@ -415,11 +444,13 @@ DEPENDENCIES
solid_queue solid_queue
sqlite3 (>= 2.1) sqlite3 (>= 2.1)
stimulus-rails stimulus-rails
streamio-ffmpeg (~> 3.0)
tailwindcss-rails tailwindcss-rails
thruster thruster
turbo-rails turbo-rails
tzinfo-data tzinfo-data
web-console web-console
xxhash (~> 0.7.0)
BUNDLED WITH BUNDLED WITH
2.7.2 2.7.2

View File

@@ -1,2 +1,2 @@
web: bin/rails server web: bin/rails server -b 0.0.0.0 -p 3057
css: bin/rails tailwindcss:watch css: bin/rails tailwindcss:watch

View File

@@ -0,0 +1,16 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
end

View File

@@ -0,0 +1,62 @@
module Admin
class StorageLocationsController < ApplicationController
before_action :set_storage_location, only: [:show, :edit, :update, :destroy]
def index
@storage_locations = StorageLocation.all
end
def show
end
def new
@storage_location = StorageLocation.new
end
def create
@storage_location = StorageLocation.new(storage_location_params)
if @storage_location.save
redirect_to [:admin, @storage_location], notice: "Storage location was successfully created."
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @storage_location.update(storage_location_params)
redirect_to [:admin, @storage_location], notice: "Storage location was successfully updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@storage_location.destroy
redirect_to admin_storage_locations_url, notice: "Storage location was successfully destroyed."
end
def scan
# Placeholder for scan functionality
redirect_to [:admin, @storage_location], notice: "Scan functionality will be implemented."
end
def scan_status
# Placeholder for scan status
render json: { status: "idle" }
end
private
def set_storage_location
@storage_location = StorageLocation.find(params[:id])
end
def storage_location_params
params.require(:storage_location).permit(:name, :path, :location_type, :writable, :enabled, :scan_subdirectories, :priority, :settings)
end
end
end

View File

@@ -1,4 +1,5 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include Authentication
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern allow_browser versions: :modern

View File

@@ -0,0 +1,52 @@
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
end

View File

@@ -0,0 +1,35 @@
class PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
end

View File

@@ -0,0 +1,21 @@
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
end

View File

@@ -0,0 +1,49 @@
class StorageLocationsController < ApplicationController
before_action :set_storage_location, only: [:show, :destroy, :scan]
def index
@storage_locations = StorageLocation.all
# Auto-discover storage locations on index page load
StorageDiscoveryService.discover_and_create
end
def show
@videos = @storage_location.videos.includes(:work).recent
end
def create
@storage_location = StorageLocation.new(storage_location_params)
if @storage_location.save
redirect_to @storage_location, notice: 'Storage location was successfully created.'
else
render :new, status: :unprocessable_entity
end
end
def destroy
@storage_location.destroy
redirect_to storage_locations_url, notice: 'Storage location was successfully destroyed.'
end
def scan
scanner = FileScannerService.new(@storage_location)
result = scanner.scan
if result[:success]
redirect_to @storage_location, notice: result[:message]
else
redirect_to @storage_location, alert: result[:message]
end
end
private
def set_storage_location
@storage_location = StorageLocation.find(params[:id])
end
def storage_location_params
params.require(:storage_location).permit(:name, :path, :storage_type)
end
end

View File

@@ -0,0 +1,58 @@
class VideosController < ApplicationController
before_action :set_video, only: [:show, :stream, :playback_position, :retry_processing]
def show
@work = @video.work
@last_position = get_last_playback_position
end
def stream
file_path = @video.web_stream_path
unless file_path && File.exist?(file_path)
head :not_found
return
end
send_file file_path,
filename: @video.filename,
type: 'video/mp4',
disposition: 'inline',
stream: true,
buffer_size: 4096
end
def playback_position
position = params[:position].to_i
session = get_or_create_playback_session
session.update!(position: position, last_played_at: Time.current)
head :ok
end
def retry_processing
VideoProcessorJob.perform_later(@video.id)
redirect_to @video, notice: 'Video processing has been queued.'
end
private
def set_video
@video = Video.find(params[:id])
end
def get_last_playback_position
# Get from current user's session or cookie
session_key = "video_position_#{@video.id}"
session[session_key] || 0
end
def get_or_create_playback_session
# For Phase 1, we'll use a simple session-based approach
# Phase 2 will use proper user authentication
PlaybackSession.find_or_initialize_by(
video: @video,
session_id: session.id.to_s,
user_id: nil # Will be populated in Phase 2
)
end
end

View File

@@ -0,0 +1,18 @@
class WorksController < ApplicationController
before_action :set_work, only: [:show]
def index
@works = Work.includes(:videos).recent
end
def show
@videos = @work.videos.includes(:storage_location).recent
@primary_video = @work.primary_video
end
private
def set_work
@work = Work.find(params[:id])
end
end

View File

@@ -0,0 +1,2 @@
module VideosHelper
end

View File

@@ -0,0 +1,63 @@
class VideoProcessorJob < ApplicationJob
queue_as :default
def perform(video_id)
video = Video.find(video_id)
# Extract metadata
metadata = VideoMetadataExtractor.new(video.full_file_path).extract
video.update!(video_metadata: metadata)
# Check if web compatible
transcoder = VideoTranscoder.new
web_compatible = transcoder.web_compatible?(video.full_file_path)
video.update!(web_compatible: web_compatible)
# Generate thumbnail
generate_thumbnail(video)
# Transcode if needed
unless web_compatible
transcode_video(video, transcoder)
end
video.update!(processed: true)
rescue => e
video.update!(processing_errors: e.message)
raise
end
private
def generate_thumbnail(video)
transcoder = VideoTranscoder.new
# Generate thumbnail at 10% of duration or 5 seconds if duration unknown
thumbnail_time = video.duration ? video.duration * 0.1 : 5
thumbnail_path = transcoder.extract_frame(video.full_file_path, thumbnail_time)
# Attach thumbnail as video asset
video.video_assets.create!(
asset_type: 'thumbnail',
file: File.open(thumbnail_path)
)
# Clean up temporary file
File.delete(thumbnail_path) if File.exist?(thumbnail_path)
end
def transcode_video(video, transcoder)
output_path = video.full_file_path.gsub(/\.[^.]+$/, '.web.mp4')
transcoder.transcode_for_web(
input_path: video.full_file_path,
output_path: output_path
)
video.update!(
transcoded_path: File.basename(output_path),
transcoded_permanently: true,
web_compatible: true
)
end
end

View File

@@ -0,0 +1,6 @@
class PasswordsMailer < ApplicationMailer
def reset(user)
@user = user
mail subject: "Reset your password", to: user.email_address
end
end

View File

@@ -0,0 +1,26 @@
module Processable
extend ActiveSupport::Concern
included do
scope :processing_pending, -> { where("video_metadata->>'duration' IS NULL") }
end
def processed?
duration.present?
end
def processing_pending?
!processed? && processing_errors.blank?
end
def mark_processing_failed!(error_message)
self.processing_errors = [error_message]
save!
end
def retry_processing!
self.processing_errors = []
save!
# VideoProcessorJob.perform_later(id) - will be implemented later
end
end

View File

@@ -0,0 +1,11 @@
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

View File

@@ -0,0 +1,19 @@
module Streamable
extend ActiveSupport::Concern
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

4
app/models/current.rb Normal file
View File

@@ -0,0 +1,4 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end

View File

@@ -0,0 +1,3 @@
class ExternalId < ApplicationRecord
belongs_to :work
end

75
app/models/media_file.rb Normal file
View File

@@ -0,0 +1,75 @@
class MediaFile < ApplicationRecord
# Base class for all media files (Video, Audio, etc.)
# Uses Single Table Inheritance (STI) via the 'type' column
self.table_name = 'videos'
self.abstract_class = true
include Streamable
include Processable
# Common JSON stores for flexible metadata
store :fingerprints, accessors: [:xxhash64, :md5, :oshash, :phash]
store :media_metadata, accessors: [:duration, :codec, :bit_rate, :format]
# Common associations
belongs_to :work
belongs_to :storage_location
has_many :playback_sessions, dependent: :destroy
# Common validations
validates :filename, presence: true
validates :xxhash64, presence: true, uniqueness: { scope: :storage_location_id }
# Common scopes
scope :web_compatible, -> { where(web_compatible: true) }
scope :needs_transcoding, -> { where(web_compatible: false) }
scope :recent, -> { order(created_at: :desc) }
# Common delegations
delegate :display_title, to: :work, prefix: true, allow_nil: true
# Common instance methods
def display_title
work&.display_title || filename
end
def full_file_path
File.join(storage_location.path, filename)
end
def format_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
# Template method for subclasses to override
def web_stream_path
# Default implementation - subclasses can override for specific behavior
if transcoded_permanently? && transcoded_path && File.exist?(transcoded_full_path)
return transcoded_full_path
end
if transcoded_path && File.exist?(temp_transcoded_full_path)
return temp_transcoded_full_path
end
full_file_path if web_compatible?
end
def transcoded_full_path
return nil unless transcoded_path
File.join(storage_location.path, transcoded_path)
end
def temp_transcoded_full_path
return nil unless transcoded_path
File.join(Rails.root, 'tmp', 'transcodes', storage_location.id.to_s, transcoded_path)
end
end

View File

@@ -0,0 +1,4 @@
class PlaybackSession < ApplicationRecord
belongs_to :video
belongs_to :user
end

3
app/models/session.rb Normal file
View File

@@ -0,0 +1,3 @@
class Session < ApplicationRecord
belongs_to :user
end

View File

@@ -0,0 +1,27 @@
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
def video_count
videos.count
end
def display_name
name
end
private
def path_must_exist_and_be_readable
errors.add(:path, "must exist and be readable") unless accessible?
end
end

6
app/models/user.rb Normal file
View File

@@ -0,0 +1,6 @@
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
end

20
app/models/video.rb Normal file
View File

@@ -0,0 +1,20 @@
class Video < MediaFile
# Video-specific associations
has_many :video_assets, dependent: :destroy
# Video-specific metadata store
store :video_metadata, accessors: [:width, :height, :video_codec, :audio_codec, :frame_rate]
# Video-specific instance methods
def resolution_label
return "Unknown" unless 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
end

View File

@@ -0,0 +1,3 @@
class VideoAsset < ApplicationRecord
belongs_to :video
end

100
app/models/work.rb Normal file
View File

@@ -0,0 +1,100 @@
class Work < ApplicationRecord
# 1. Includes/Concerns
include Searchable
# 2. JSON Store for flexible metadata
store :metadata, accessors: [:tmdb_data, :imdb_data, :custom_fields]
store :tmdb_data, accessors: [:overview, :poster_path, :backdrop_path, :release_date, :genres]
store :imdb_data, accessors: [:plot, :rating, :votes, :runtime, :director]
store :custom_fields
# 3. Associations
has_many :videos, dependent: :nullify
has_many :external_ids, dependent: :destroy
has_one :primary_video, -> { order("(video_metadata->>'height')::int 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 ?", "%#{sanitize_sql_like(query)}%", "%#{sanitize_sql_like(query)}%")
end
def self.find_by_external_id(source, value)
joins(:external_ids).find_by(external_ids: { source: source, value: value })
end
# 8. Instance methods
def display_title
year ? "#{title} (#{year})" : title
end
def video_count
videos.count
end
def total_duration
videos.sum("(video_metadata->>'duration')::float")
end
def available_versions
videos.group_by(&:resolution_label)
end
def has_external_ids?
external_ids.exists?
end
def poster_url
poster_path || tmdb_data['poster_path']
end
def backdrop_url
backdrop_path || tmdb_data['backdrop_path']
end
def description
return read_attribute(:description) if read_attribute(:description).present?
tmdb_data['overview'] || imdb_data['plot']
end
def effective_director
return read_attribute(:director) if read_attribute(:director).present?
imdb_data['director']
end
def effective_rating
return read_attribute(:rating) if read_attribute(:rating).present?
imdb_data['rating']&.to_f
end
# Convenience accessors for common external IDs
# Auto-generated for all sources (will be implemented when we add ExternalId model logic)
# ExternalId.sources.keys.each do |source_name|
# define_method("#{source_name}_id") do
# external_ids.find_by(source: source_name)&.value
# end
#
# define_method("#{source_name}_id=") do |value|
# return if value.blank?
# external_ids.find_or_initialize_by(source: source_name).update!(value: value)
# end
#
# define_method("#{source_name}_url") do
# external_ids.find_by(source: source_name)&.url
# end
# end
end

View File

@@ -0,0 +1,56 @@
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

35
app/services/result.rb Normal file
View File

@@ -0,0 +1,35 @@
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

View File

@@ -0,0 +1,46 @@
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
protected
attr_reader :storage_location
end
end

View File

@@ -0,0 +1,59 @@
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?
return false unless storage_location.path.present?
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
def full_path(video)
full_path_from_relative(video.file_path)
end
private
def full_path_from_relative(file_path)
File.join(storage_location.path, file_path)
end
end
end

View File

@@ -0,0 +1,46 @@
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

View File

@@ -0,0 +1,12 @@
class VideoMetadataExtractor
def initialize(file_path)
@file_path = file_path
@transcoder = VideoTranscoder.new
end
def extract
return {} unless File.exist?(@file_path)
@transcoder.extract_metadata(@file_path)
end
end

View File

@@ -0,0 +1,75 @@
class VideoTranscoder
require 'streamio-ffmpeg'
def initialize
@ffmpeg_path = ENV['FFMPEG_PATH'] || 'ffmpeg'
@ffprobe_path = ENV['FFPROBE_PATH'] || 'ffprobe'
end
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)
}
# Transcoding options for web compatibility
options = {
video_codec: 'libx264',
audio_codec: 'aac',
custom: [
'-pix_fmt yuv420p',
'-preset medium',
'-crf 23',
'-movflags +faststart',
'-tune fastdecode'
]
}
movie.transcode(output_path, options, &progress_callback)
output_path
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
def extract_metadata(input_path)
movie = FFMPEG::Movie.new(input_path)
{
width: movie.width,
height: movie.height,
duration: movie.duration,
video_codec: movie.video_codec,
audio_codec: movie.audio_codec,
bit_rate: movie.bitrate,
frame_rate: movie.frame_rate,
format: movie.container
}
end
def web_compatible?(input_path)
movie = FFMPEG::Movie.new(input_path)
# Check if video is already web-compatible
return false unless movie.valid?
# Common web-compatible formats
web_formats = %w[mp4 webm]
web_video_codecs = %w[h264 av1 vp9]
web_audio_codecs = %w[aac opus]
format_compatible = web_formats.include?(movie.container.downcase)
video_compatible = web_video_codecs.include?(movie.video_codec&.downcase)
audio_compatible = movie.audio_codec.blank? || web_audio_codecs.include?(movie.audio_codec&.downcase)
format_compatible && video_compatible && audio_compatible
end
end

View File

@@ -0,0 +1,21 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<h1 class="font-bold text-4xl">Update your password</h1>
<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
<div class="my-5">
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>
<div class="inline">
<%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,17 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<h1 class="font-bold text-4xl">Forgot your password?</h1>
<%= form_with url: passwords_path, class: "contents" do |form| %>
<div class="my-5">
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>
<div class="inline">
<%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,6 @@
<p>
You can reset your password on
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
</p>

View File

@@ -0,0 +1,4 @@
You can reset your password on
<%= edit_password_url(@user.password_reset_token) %>
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.

View File

@@ -0,0 +1,31 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<% if notice = flash[:notice] %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<h1 class="font-bold text-4xl">Sign in</h1>
<%= form_with url: session_url, class: "contents" do |form| %>
<div class="my-5">
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>
<div class="col-span-6 sm:flex sm:items-center sm:gap-4">
<div class="inline">
<%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
</div>
<div class="mt-4 text-sm text-gray-500 sm:mt-0">
<%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %>
</div>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,4 @@
<div>
<h1 class="font-bold text-4xl">StorageLocations#create</h1>
<p>Find me in app/views/storage_locations/create.html.erb</p>
</div>

View File

@@ -0,0 +1,4 @@
<div>
<h1 class="font-bold text-4xl">StorageLocations#destroy</h1>
<p>Find me in app/views/storage_locations/destroy.html.erb</p>
</div>

View File

@@ -0,0 +1,81 @@
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Video Library</h1>
<% if @storage_locations.any? %>
<%= link_to "New Storage Location", new_storage_location_path,
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors" %>
<% end %>
</div>
<% if @storage_locations.empty? %>
<div class="text-center py-12 bg-white rounded-lg shadow">
<div class="text-gray-500 text-lg mb-4">No storage locations found</div>
<p class="text-gray-600 mb-6">
Storage locations are automatically discovered from directories mounted under <code class="bg-gray-100 px-2 py-1 rounded">/videos</code>
</p>
<div class="text-sm text-gray-500">
<p class="mb-2">Example Docker volume mounts:</p>
<code class="block bg-gray-100 p-3 rounded text-left">
/path/to/movies:/videos/movies:ro<br>
/path/to/tv_shows:/videos/tv:ro<br>
/path/to/documentaries:/videos/docs:ro
</code>
</div>
</div>
<% else %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<% @storage_locations.each do |storage_location| %>
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow">
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-2">
<%= link_to storage_location.name, storage_location,
class: "hover:text-blue-600 transition-colors" %>
</h2>
<div class="text-gray-600 text-sm mb-4">
<p class="mb-1">
<span class="font-medium">Path:</span>
<code class="bg-gray-100 px-1 py-0.5 rounded text-xs"><%= storage_location.path %></code>
</p>
<p class="mb-1">
<span class="font-medium">Type:</span>
<%= storage_location.storage_type.titleize %>
</p>
<p>
<span class="font-medium">Videos:</span>
<%= storage_location.video_count %>
</p>
</div>
<% if storage_location.accessible? %>
<div class="flex items-center text-green-600 text-sm mb-4">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
Accessible
</div>
<% else %>
<div class="flex items-center text-red-600 text-sm mb-4">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
Not Accessible
</div>
<% end %>
<div class="flex space-x-2">
<%= link_to "View Videos", storage_location,
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-3 rounded text-sm transition-colors" %>
<%= form_with(url: scan_storage_location_path(storage_location), method: :post,
class: "inline-flex") do |form| %>
<%= form.submit "Scan",
class: "bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-3 rounded text-sm cursor-pointer transition-colors" %>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,118 @@
<div class="container mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900"><%= @storage_location.name %></h1>
<p class="text-gray-600 mt-2">
<span class="font-medium">Path:</span>
<code class="bg-gray-100 px-2 py-1 rounded"><%= @storage_location.path %></code>
</p>
</div>
<div class="flex space-x-3">
<%= link_to "← Back to Library", storage_locations_path,
class: "bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 rounded-lg transition-colors" %>
<%= form_with(url: scan_storage_location_path(@storage_location), method: :post,
class: "inline-flex") do |form| %>
<%= form.submit "Scan for Videos",
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg cursor-pointer transition-colors" %>
<% end %>
</div>
</div>
<% if @videos.empty? %>
<div class="text-center py-12 bg-white rounded-lg shadow">
<div class="text-gray-500 text-lg mb-4">No videos found</div>
<p class="text-gray-600">
This storage location doesn't contain any video files yet. Try scanning for videos to add them to your library.
</p>
</div>
<% else %>
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900">
Videos (<%= @videos.count %>)
</h2>
</div>
<div class="divide-y divide-gray-200">
<% @videos.each do |video| %>
<div class="p-6 hover:bg-gray-50 transition-colors">
<div class="flex items-start space-x-4">
<!-- Thumbnail placeholder -->
<div class="flex-shrink-0">
<% if video.video_assets.where(asset_type: 'thumbnail').any? %>
<%= image_tag video.video_assets.where(asset_type: 'thumbnail').first.file,
class: "w-24 h-16 object-cover rounded", alt: video.display_title %>
<% else %>
<div class="w-24 h-16 bg-gray-200 rounded flex items-center justify-center">
<svg class="w-8 h-8 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/>
</svg>
</div>
<% end %>
</div>
<!-- Video info -->
<div class="flex-1 min-w-0">
<h3 class="text-lg font-medium text-gray-900 mb-1">
<%= link_to video.display_title, video,
class: "hover:text-blue-600 transition-colors" %>
</h3>
<div class="flex flex-wrap gap-4 text-sm text-gray-600 mb-2">
<span>
<span class="font-medium">Duration:</span>
<%= video.format_duration %>
</span>
<span>
<span class="font-medium">Resolution:</span>
<%= video.resolution_label %>
</span>
<span>
<span class="font-medium">Size:</span>
<%= number_to_human_size(video.video_metadata['file_size']) rescue "Unknown" %>
</span>
</div>
<div class="flex items-center space-x-3 text-sm">
<% if video.web_compatible? %>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Web Compatible
</span>
<% else %>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Needs Transcoding
</span>
<% end %>
<% if video.processed? %>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Processed
</span>
<% else %>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Processing
</span>
<% end %>
<% if video.work&.title && video.work.title != video.display_title %>
<span class="text-gray-500">
Part of: <%= link_to video.work.title, video.work,
class: "hover:text-blue-600 transition-colors" %>
</span>
<% end %>
</div>
</div>
<!-- Actions -->
<div class="flex-shrink-0">
<%= link_to "Watch", video,
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition-colors" %>
</div>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,76 @@
<% content_for :title, "Videos" %>
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Video Library</h1>
<div class="flex gap-2">
<% if @storage_locations.any? %>
<select class="rounded-md border-gray-300 border px-3 py-2 text-sm" id="storage-filter">
<option value="">All Sources</option>
<% @storage_locations.each do |location| %>
<option value="<%= location.id %>"><%= location.display_name %></option>
<% end %>
</select>
<% end %>
<%= link_to "New Storage Location", new_admin_storage_location_path, class: "bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %>
</div>
</div>
<% if @videos.any? %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<% @videos.each do |video| %>
<div class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
<div class="aspect-video bg-gray-200 relative">
<%# Placeholder for thumbnails - Phase 1C will add actual thumbnails %>
<div class="absolute inset-0 flex items-center justify-center">
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0120 8.618m6.418 2.276L11 14.914M4.418 4.418a2 2 0 00-2.828 0l-4.418 4.418a2 2 0 002.828 0l4.418-4.418a2 2 0 012.828 0l4.418 4.418a2 2 0 012.828 0l4.418-4.418z" />
</svg>
</div>
<%# Badge for source type %>
<div class="absolute top-2 left-2 bg-gray-800 text-white text-xs px-2 py-1 rounded">
<%= video.storage_location.name %>
</div>
</div>
<div class="p-4">
<h3 class="font-semibold text-gray-900 truncate mb-2">
<%= link_to video.display_title, video_path(video), class: "hover:text-blue-600" %>
</h3>
<div class="text-sm text-gray-500 space-y-1">
<div>Duration: <%= video.formatted_duration %></div>
<div>Size: <%= video.formatted_file_size %></div>
<% if video.resolution_label.present? %>
<div>Resolution: <%= video.resolution_label %></div>
<% end %>
</div>
<div class="mt-3 flex justify-between items-center">
<div class="text-xs text-gray-400">
<% if video.processing_errors.present? %>
<span class="text-red-500">Failed</span>
<% elsif video.processed? %>
<span class="text-green-500">Processed</span>
<% else %>
<span class="text-yellow-500">Processing</span>
<% end %>
</div>
<%= link_to "Watch", video_path(video), class: "bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded text-xs" %>
</div>
</div>
</div>
<% end %>
</div>
<!-- Pagination with Pagy -->
<%== pagy_nav(@pagy) %>
<% else %>
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M9 16h6" />
</svg>
<h3 class="mt-2 text-sm font-semibold text-gray-900">No videos found</h3>
<p class="mt-1 text-sm text-gray-500">Get started by adding a storage location and scanning for videos.</p>
<%= link_to "Add Storage Location", new_admin_storage_location_path, class: "mt-4 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %>
</div>
<% end %>

View File

@@ -0,0 +1,106 @@
<% content_for :title, @video.display_title %>
<div class="max-w-4xl mx-auto">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="aspect-video bg-gray-200 relative">
<%# Placeholder for video player - Phase 1B will add Video.js %>
<div class="absolute inset-0 flex items-center justify-center">
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 0 0-5.656 0M9 10h.01M15 5.5c0 0 0-4.95-5.39 0-7.28 0-7.28 0A4 4 0 0 1 5.5 8.78a4 4 0 0 1 0 7.28 0 7.28a4 4 0 0 1-7.28 0c0 0-4.95 4.95-5.39 0-7.28 0A4 4 0 0 1 15.5 5.5c0 0 0 0 7.28 0 7.28a4 4 0 0 0 0-7.28 0" />
</svg>
</div>
</div>
<div class="p-6">
<div class="mb-4">
<h1 class="text-2xl font-bold text-gray-900"><%= @video.display_title %></h1>
<% if @video.work.present? %>
<p class="text-gray-600"><%= link_to @video.work.display_title, work_path(@video.work), class: "hover:text-blue-600" %></p>
<% end %>
</div>
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-2">Video Information</h3>
<dl class="space-y-2">
<div class="flex justify-between">
<dt class="text-sm font-medium text-gray-500">Duration:</dt>
<dd class="text-sm text-gray-900"><%= @video.formatted_duration %></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-gray-500">File Size:</dt>
<dd class="text-sm text-gray-900"><%= @video.formatted_file_size %></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-gray-500">Resolution:</dt>
<dd class="text-sm text-gray-900"><%= @video.resolution_label || "Unknown" %></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-gray-500">Format:</dt>
<dd class="text-sm text-gray-900"><%= @video.format || "Unknown" %></dd>
</div>
</dl>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-900 mb-2">Storage Information</h3>
<dl class="space-y-2">
<div class="flex justify-between">
<dt class="text-sm font-medium text-gray-500">Storage Location:</dt>
<dd class="text-sm text-gray-900"><%= @video.storage_location.name %></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-gray-500">Source Type:</dt>
<dd class="text-sm text-gray-900"><%= @video.source_type.humanize %></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-gray-500">File Path:</dt>
<dd class="text-sm text-gray-900 truncate"><%= @video.file_path %></dd>
</div>
</dl>
</div>
</div>
<% if @video.video_metadata.present? %>
<div class="mb-6">
<h3 class="text-sm font-semibold text-gray-900 mb-2">Technical Details</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm font-medium text-gray-500">Video Codec:</span>
<span class="text-sm text-gray-900"><%= @video.video_codec || "N/A" %></span>
</div>
<div class="flex justify-between">
<span class="text-sm font-medium text-gray-500">Audio Codec:</span>
<span class="text-sm text-gray-900"><%= @video.audio_codec || "N/A" %></span>
</div>
<div class="flex justify-between">
<span class="text-sm font-medium text-gray-500">Frame Rate:</span>
<span class="text-sm text-gray-900"><%= @video.frame_rate || "N/A" %> fps</span>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm font-medium text-gray-500">Bit Rate:</span>
<span class="text-sm text-gray-900"><%= @video.bit_rate ? "#{(@video.bit_rate / 1000).round(1)} kb/s" : "N/A" %></span>
</div>
<div class="flex justify-between">
<span class="text-sm font-medium text-500">Dimensions:</span>
<span class="text-sm text-gray-900">
<%= @video.width || "N/A" %> × <%= @video.height || "N/A" %>
</span>
</div>
</div>
</div>
</div>
<% end %>
<div class="flex gap-3">
<%= link_to "Back to Videos", videos_path, class: "bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-md text-sm font-medium" %>
<% if @video.streamable? %>
<%= link_to "Watch Video", watch_video_path(@video), class: "bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %>
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,4 @@
<div>
<h1 class="font-bold text-4xl">Works#index</h1>
<p>Find me in app/views/works/index.html.erb</p>
</div>

View File

@@ -0,0 +1,4 @@
<div>
<h1 class="font-bold text-4xl">Works#show</h1>
<p>Find me in app/views/works/show.html.erb</p>
</div>

View File

@@ -1,14 +1,39 @@
Rails.application.routes.draw do Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Health check
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check get "up" => "rails/health#show", as: :rails_health_check
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) # Root - Phase 1: Storage locations as main entry
# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest root "storage_locations#index"
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
# Defines the root path route ("/") # Phase 1: Storage locations focused routes
# root "posts#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
# Rails authentication routes
resource :session
resources :passwords, param: :token
end end

View File

@@ -0,0 +1,17 @@
class CreateWorks < ActiveRecord::Migration[8.1]
def change
create_table :works do |t|
t.string :title
t.integer :year
t.string :director
t.text :description
t.decimal :rating
t.boolean :organized
t.string :poster_path
t.string :backdrop_path
t.text :metadata
t.timestamps
end
end
end

View File

@@ -0,0 +1,11 @@
class CreateExternalIds < ActiveRecord::Migration[8.1]
def change
create_table :external_ids do |t|
t.references :work, null: false, foreign_key: true
t.integer :source
t.string :value
t.timestamps
end
end
end

View File

@@ -0,0 +1,17 @@
class CreateStorageLocations < ActiveRecord::Migration[8.1]
def change
create_table :storage_locations do |t|
t.string :name
t.string :path
t.integer :location_type
t.boolean :writable
t.boolean :enabled
t.boolean :scan_subdirectories
t.integer :priority
t.text :settings
t.datetime :last_scanned_at
t.timestamps
end
end
end

View File

@@ -0,0 +1,31 @@
class CreateVideos < ActiveRecord::Migration[8.1]
def change
create_table :videos do |t|
t.references :work, null: false, foreign_key: true
t.references :storage_location, null: false, foreign_key: true
t.string :title
t.string :file_path
t.string :file_hash
t.integer :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
t.string :version_type
t.integer :source_type
t.string :source_url
t.boolean :imported
t.boolean :processing_failed
t.text :error_message
t.text :metadata
t.timestamps
end
end
end

View File

@@ -0,0 +1,11 @@
class CreateVideoAssets < ActiveRecord::Migration[8.1]
def change
create_table :video_assets do |t|
t.references :video, null: false, foreign_key: true
t.integer :asset_type
t.text :metadata
t.timestamps
end
end
end

View File

@@ -0,0 +1,15 @@
class CreatePlaybackSessions < ActiveRecord::Migration[8.1]
def change
create_table :playback_sessions do |t|
t.references :video, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.float :position
t.float :duration_watched
t.datetime :last_watched_at
t.boolean :completed
t.integer :play_count
t.timestamps
end
end
end

View File

@@ -0,0 +1,11 @@
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
end

View File

@@ -0,0 +1,11 @@
class CreateSessions < ActiveRecord::Migration[8.1]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
end

View File

@@ -0,0 +1,29 @@
class UpdateVideosFromArchitecture < ActiveRecord::Migration[8.1]
def change
change_table :videos do |t|
# Make work reference optional (videos can exist without works initially)
t.change_null :work_id, true
# Add defaults for boolean fields
t.change_default :imported, false
t.change_default :processing_failed, false
t.change_default :has_subtitles, false
# Note: file_size is already integer, SQLite compatible with bigint
# Add source_type default and make required fields not null
t.change_default :source_type, 0
t.change_null :source_type, false
# Make file_path required
t.change_null :file_path, false
end
# Add indexes as specified in architecture
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

View File

@@ -0,0 +1,16 @@
class UpdateWorksFromArchitecture < ActiveRecord::Migration[8.1]
def change
change_table :works do |t|
# Add defaults for boolean fields
t.change_default :organized, false
# Make title required
t.change_null :title, false
end
# Add indexes as specified in architecture
add_index :works, :title
add_index :works, [:title, :year], unique: true
add_index :works, :organized
end
end

View File

@@ -0,0 +1,24 @@
class UpdateStorageLocationsFromArchitecture < ActiveRecord::Migration[8.1]
def change
change_table :storage_locations do |t|
# Add defaults for boolean fields
t.change_default :writable, false
t.change_default :enabled, true
t.change_default :scan_subdirectories, true
t.change_default :priority, 0
# Add location_type default and make required fields not null
t.change_default :location_type, 0
t.change_null :location_type, false
# Make name required
t.change_null :name, false
end
# Add indexes as specified in architecture
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

View File

@@ -0,0 +1,11 @@
class UpdateVideoAssetsFromArchitecture < ActiveRecord::Migration[8.1]
def change
change_table :video_assets do |t|
# Make asset_type required
t.change_null :asset_type, false
end
# Add indexes as specified in architecture
add_index :video_assets, [:video_id, :asset_type], unique: true
end
end

View File

@@ -0,0 +1,15 @@
class UpdatePlaybackSessionsFromArchitecture < ActiveRecord::Migration[8.1]
def change
change_table :playback_sessions do |t|
# Add defaults for fields
t.change_default :position, 0.0
t.change_default :duration_watched, 0.0
t.change_default :completed, false
t.change_default :play_count, 0
end
# Add indexes as specified in architecture
add_index :playback_sessions, [:video_id, :user_id], unique: true
add_index :playback_sessions, :last_watched_at
end
end

View File

@@ -0,0 +1,16 @@
class UpdateExternalIdsFromArchitecture < ActiveRecord::Migration[8.1]
def change
change_table :external_ids do |t|
# Make source and value required
t.change_null :source, false
t.change_null :value, false
end
# Add indexes as specified in architecture
# Ensure each source only appears once per work
add_index :external_ids, [:work_id, :source], unique: true
# Fast lookup by external ID (for "find work by IMDB ID" queries)
add_index :external_ids, [:source, :value], unique: true
end
end

View File

@@ -0,0 +1,7 @@
class AddJsonStoresToVideo < ActiveRecord::Migration[8.1]
def change
add_column :videos, :fingerprints, :text
add_column :videos, :video_metadata, :text
add_column :videos, :processing_info, :text
end
end

View File

@@ -0,0 +1,7 @@
class AddJsonStoresToWork < ActiveRecord::Migration[8.1]
def change
add_column :works, :tmdb_data, :text
add_column :works, :imdb_data, :text
add_column :works, :custom_fields, :text
end
end

View File

@@ -0,0 +1,8 @@
class AddPhase1FieldsToVideos < ActiveRecord::Migration[8.1]
def change
add_column :videos, :filename, :string
add_column :videos, :transcoded_path, :string
add_column :videos, :transcoded_permanently, :boolean
add_column :videos, :web_compatible, :boolean
end
end

View File

@@ -0,0 +1,5 @@
class AddProcessedToVideos < ActiveRecord::Migration[8.1]
def change
add_column :videos, :processed, :boolean
end
end

View File

@@ -0,0 +1,6 @@
class AddTypeToVideos < ActiveRecord::Migration[8.1]
def change
add_column :videos, :type, :string, default: 'Video', null: false
add_index :videos, :type
end
end

146
db/schema.rb generated
View File

@@ -10,5 +10,149 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 0) do ActiveRecord::Schema[8.1].define(version: 2025_10_31_022926) do
create_table "external_ids", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "source", null: false
t.datetime "updated_at", null: false
t.string "value", null: false
t.integer "work_id", null: false
t.index ["source", "value"], name: "index_external_ids_on_source_and_value", unique: true
t.index ["work_id", "source"], name: "index_external_ids_on_work_id_and_source", unique: true
t.index ["work_id"], name: "index_external_ids_on_work_id"
end
create_table "playback_sessions", force: :cascade do |t|
t.boolean "completed", default: false
t.datetime "created_at", null: false
t.float "duration_watched", default: 0.0
t.datetime "last_watched_at"
t.integer "play_count", default: 0
t.float "position", default: 0.0
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.integer "video_id", null: false
t.index ["last_watched_at"], name: "index_playback_sessions_on_last_watched_at"
t.index ["user_id"], name: "index_playback_sessions_on_user_id"
t.index ["video_id", "user_id"], name: "index_playback_sessions_on_video_id_and_user_id", unique: true
t.index ["video_id"], name: "index_playback_sessions_on_video_id"
end
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
t.datetime "updated_at", null: false
t.string "user_agent"
t.integer "user_id", null: false
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "storage_locations", force: :cascade do |t|
t.datetime "created_at", null: false
t.boolean "enabled", default: true
t.datetime "last_scanned_at"
t.integer "location_type", default: 0, null: false
t.string "name", null: false
t.string "path"
t.integer "priority", default: 0
t.boolean "scan_subdirectories", default: true
t.text "settings"
t.datetime "updated_at", null: false
t.boolean "writable", default: false
t.index ["enabled"], name: "index_storage_locations_on_enabled"
t.index ["location_type"], name: "index_storage_locations_on_location_type"
t.index ["name"], name: "index_storage_locations_on_name", unique: true
t.index ["priority"], name: "index_storage_locations_on_priority"
end
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email_address", null: false
t.string "password_digest", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
end
create_table "video_assets", force: :cascade do |t|
t.integer "asset_type", null: false
t.datetime "created_at", null: false
t.text "metadata"
t.datetime "updated_at", null: false
t.integer "video_id", null: false
t.index ["video_id", "asset_type"], name: "index_video_assets_on_video_id_and_asset_type", unique: true
t.index ["video_id"], name: "index_video_assets_on_video_id"
end
create_table "videos", force: :cascade do |t|
t.string "audio_codec"
t.integer "bit_rate"
t.datetime "created_at", null: false
t.float "duration"
t.text "error_message"
t.string "file_hash"
t.string "file_path", null: false
t.integer "file_size"
t.string "filename"
t.text "fingerprints"
t.string "format"
t.float "frame_rate"
t.boolean "has_subtitles", default: false
t.integer "height"
t.boolean "imported", default: false
t.text "metadata"
t.boolean "processed"
t.boolean "processing_failed", default: false
t.text "processing_info"
t.string "resolution_label"
t.integer "source_type", default: 0, null: false
t.string "source_url"
t.integer "storage_location_id", null: false
t.string "title"
t.string "transcoded_path"
t.boolean "transcoded_permanently"
t.string "type", default: "Video", null: false
t.datetime "updated_at", null: false
t.string "version_type"
t.string "video_codec"
t.text "video_metadata"
t.boolean "web_compatible"
t.integer "width"
t.integer "work_id"
t.index ["file_hash"], name: "index_videos_on_file_hash"
t.index ["imported"], name: "index_videos_on_imported"
t.index ["source_type"], name: "index_videos_on_source_type"
t.index ["storage_location_id", "file_path"], name: "index_videos_on_storage_location_id_and_file_path", unique: true
t.index ["storage_location_id"], name: "index_videos_on_storage_location_id"
t.index ["type"], name: "index_videos_on_type"
t.index ["work_id", "resolution_label"], name: "index_videos_on_work_id_and_resolution_label"
t.index ["work_id"], name: "index_videos_on_work_id"
end
create_table "works", force: :cascade do |t|
t.string "backdrop_path"
t.datetime "created_at", null: false
t.text "custom_fields"
t.text "description"
t.string "director"
t.text "imdb_data"
t.text "metadata"
t.boolean "organized", default: false
t.string "poster_path"
t.decimal "rating"
t.string "title", null: false
t.text "tmdb_data"
t.datetime "updated_at", null: false
t.integer "year"
t.index ["organized"], name: "index_works_on_organized"
t.index ["title", "year"], name: "index_works_on_title_and_year", unique: true
t.index ["title"], name: "index_works_on_title"
end
add_foreign_key "external_ids", "works"
add_foreign_key "playback_sessions", "users"
add_foreign_key "playback_sessions", "videos"
add_foreign_key "sessions", "users"
add_foreign_key "video_assets", "videos"
add_foreign_key "videos", "storage_locations"
add_foreign_key "videos", "works"
end end

File diff suppressed because it is too large Load Diff

548
docs/phases/phase_1.md Normal file
View File

@@ -0,0 +1,548 @@
# 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.

486
docs/phases/phase_2.md Normal file
View File

@@ -0,0 +1,486 @@
# Velour Phase 2: Authentication & Multi-User
Phase 2 adds user management, authentication, and multi-user support while maintaining the simplicity of the core application.
## Authentication Architecture
### Rails Authentication Generators + OIDC Extension
We use Rails' built-in authentication generators as the foundation, extended with OIDC support for enterprise environments.
### User Model
```ruby
class User < ApplicationRecord
# Include default devise modules or Rails authentication
# Devise modules: :database_authenticatable, :registerable,
# :recoverable, :rememberable, :validatable
has_many :playback_sessions, dependent: :destroy
has_many :user_preferences, dependent: :destroy
validates :email, presence: true, uniqueness: true
enum role: { user: 0, admin: 1 }
def admin?
role == "admin" || email == ENV.fetch("ADMIN_EMAIL", "").downcase
end
def can_manage_storage?
admin?
end
def can_manage_users?
admin?
end
end
```
### Authentication Setup
```bash
# Generate Rails authentication
rails generate authentication
# Add OIDC support
gem 'omniauth-openid-connect'
bundle install
```
### OIDC Configuration
```ruby
# 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],
response_type: :code,
client_options: {
identifier: ENV['OIDC_CLIENT_ID'],
secret: ENV['OIDC_CLIENT_SECRET'],
redirect_uri: "#{ENV['RAILS_HOST']}/auth/oidc/callback"
}
}
end
```
## First User Bootstrap Flow
### Initial Setup
```ruby
# db/seeds.rb
admin_email = ENV.fetch('ADMIN_EMAIL', 'admin@velour.local')
User.find_or_create_by!(email: admin_email) do |user|
user.password = SecureRandom.hex(16)
user.role = :admin
puts "Created admin user: #{admin_email}"
puts "Password: #{user.password}"
end
```
### First Login Controller
```ruby
class FirstSetupController < ApplicationController
before_action :ensure_no_users_exist
before_action :require_admin_setup, only: [:create_admin]
def show
@user = User.new
end
def create_admin
@user = User.new(user_params)
@user.role = :admin
if @user.save
session[:user_id] = @user.id
redirect_to root_path, notice: "Admin account created successfully!"
else
render :show, status: :unprocessable_entity
end
end
private
def ensure_no_users_exist
redirect_to root_path if User.exists?
end
def require_admin_setup
redirect_to first_setup_path unless ENV.key?('ADMIN_EMAIL')
end
def user_params
params.require(:user).permit(:email, :password, :password_confirmation)
end
end
```
## User Management
### User Preferences
```ruby
class UserPreference < ApplicationRecord
belongs_to :user
store :settings, coder: JSON, accessors: [
:default_video_quality,
:auto_play_next,
:subtitle_language,
:theme
]
validates :user_id, presence: true, uniqueness: true
end
```
### Per-User Playback Sessions
```ruby
class PlaybackSession < ApplicationRecord
belongs_to :user
belongs_to :video
scope :for_user, ->(user) { where(user: user) }
scope :recent, -> { order(updated_at: :desc) }
def self.resume_position_for(video, user)
for_user(user).where(video: video).last&.position || 0
end
end
```
## Authorization & Security
### Model-Level Authorization
```ruby
# app/models/concerns/authorizable.rb
module Authorizable
extend ActiveSupport::Concern
def viewable_by?(user)
true # All videos viewable by all users
end
def editable_by?(user)
user.admin?
end
end
# Include in Video and Work models
class Video < ApplicationRecord
include Authorizable
# ... rest of model
end
```
### Controller Authorization
```ruby
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :set_current_user
private
def set_current_user
Current.user = current_user
end
def require_admin
redirect_to root_path, alert: "Access denied" unless current_user&.admin?
end
end
class StorageLocationsController < ApplicationController
before_action :require_admin, except: [:index, :show]
# ... rest of controller
end
```
## Updated Controllers for Multi-User
### Videos Controller
```ruby
class VideosController < ApplicationController
before_action :set_video, only: [:show, :stream, :update_position]
before_action :authorize_video
def show
@work = @video.work
@last_position = PlaybackSession.resume_position_for(@video, current_user)
# Create playback session for tracking
@playback_session = current_user.playback_sessions.create!(
video: @video,
position: @last_position
)
end
def stream
unless @video.viewable_by?(current_user)
head :forbidden
return
end
send_file @video.web_stream_path,
type: "video/mp4",
disposition: "inline",
range: request.headers['Range']
end
def update_position
current_user.playback_sessions.where(video: @video).last&.update!(
position: params[:position],
completed: params[:completed] || false
)
head :ok
end
private
def set_video
@video = Video.find(params[:id])
end
def authorize_video
head :forbidden unless @video.viewable_by?(current_user)
end
end
```
### Storage Locations Controller (Admin Only)
```ruby
class StorageLocationsController < ApplicationController
before_action :require_admin, except: [:index, :show]
before_action :set_storage_location, only: [:show, :destroy, :scan]
def index
@storage_locations = StorageLocation.accessible.order(:name)
end
def show
@videos = @storage_location.videos.includes(:work).order(:filename)
end
def create
@storage_location = StorageLocation.new(storage_location_params)
if @storage_location.save
redirect_to @storage_location, notice: 'Storage location was successfully created.'
else
render :new, status: :unprocessable_entity
end
end
def destroy
if @storage_location.videos.exists?
redirect_to @storage_location, alert: 'Cannot delete storage location with videos.'
else
@storage_location.destroy
redirect_to storage_locations_path, notice: 'Storage location was successfully deleted.'
end
end
def scan
service = FileScannerService.new(@storage_location)
result = service.scan
if result[:success]
redirect_to @storage_location, notice: result[:message]
else
redirect_to @storage_location, alert: result[:message]
end
end
private
def set_storage_location
@storage_location = StorageLocation.find(params[:id])
end
def storage_location_params
params.require(:storage_location).permit(:name, :path, :storage_type)
end
end
```
## User Interface Updates
### User Navigation
```erb
<!-- app/views/layouts/application.html.erb -->
<nav class="bg-gray-800 text-white p-4">
<div class="container mx-auto flex justify-between items-center">
<%= link_to 'Velour', root_path, class: 'text-xl font-bold' %>
<div class="flex items-center space-x-4">
<%= link_to 'Library', works_path, class: 'hover:text-gray-300' %>
<%= link_to 'Storage', storage_locations_path, class: 'hover:text-gray-300' if current_user.admin? %>
<div class="relative">
<button data-action="click->dropdown#toggle" class="flex items-center">
<%= current_user.email %>
<svg class="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
<div data-dropdown-target="menu" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
<%= link_to 'Settings', edit_user_registration_path, class: 'block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100' %>
<%= link_to 'Admin Panel', admin_path, class: 'block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100' if current_user.admin? %>
<%= button_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100' %>
</div>
</div>
</div>
</div>
</nav>
```
### Admin Panel
```erb
<!-- app/views/admin/dashboard.html.erb -->
<div class="container mx-auto p-6">
<h1 class="text-3xl font-bold mb-6">Admin Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-white p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4">Users</h2>
<p class="text-3xl font-bold text-blue-600"><%= User.count %></p>
<p class="text-gray-600">Total users</p>
<%= link_to 'Manage Users', admin_users_path, class: 'mt-2 text-blue-500 hover:text-blue-700' %>
</div>
<div class="bg-white p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4">Videos</h2>
<p class="text-3xl font-bold text-green-600"><%= Video.count %></p>
<p class="text-gray-600">Total videos</p>
<%= link_to 'View Library', works_path, class: 'mt-2 text-green-500 hover:text-green-700' %>
</div>
<div class="bg-white p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4">Storage</h2>
<p class="text-3xl font-bold text-purple-600"><%= StorageLocation.count %></p>
<p class="text-gray-600">Storage locations</p>
<%= link_to 'Manage Storage', storage_locations_path, class: 'mt-2 text-purple-500 hover:text-purple-700' %>
</div>
</div>
<div class="mt-8 bg-white p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4">System Status</h2>
<div class="space-y-2">
<div class="flex justify-between">
<span>Background Jobs:</span>
<span class="font-mono"><%= SolidQueue::Job.count %></span>
</div>
<div class="flex justify-between">
<span>Processing Jobs:</span>
<span class="font-mono"><%= SolidQueue::Job.where(finished_at: nil).count %></span>
</div>
<div class="flex justify-between">
<span>Failed Jobs:</span>
<span class="font-mono"><%= SolidQueue::FailedExecution.count %></span>
</div>
</div>
</div>
</div>
```
## Environment Configuration
### New Environment Variables
```bash
# Authentication
ADMIN_EMAIL=admin@yourdomain.com
RAILS_HOST=https://your-velour-domain.com
# OIDC (optional)
OIDC_ISSUER=https://your-oidc-provider.com
OIDC_CLIENT_ID=your_client_id
OIDC_CLIENT_SECRET=your_client_secret
# Session management
RAILS_SESSION_COOKIE_SECURE=true
RAILS_SESSION_COOKIE_SAME_SITE=lax
```
## Testing for Phase 2
### Authentication Tests
```ruby
# test/integration/authentication_test.rb
class AuthenticationTest < ActionDispatch::IntegrationTest
test "first user can create admin account" do
User.delete_all
get first_setup_path
assert_response :success
post first_setup_path, params: {
user: {
email: "admin@example.com",
password: "password123",
password_confirmation: "password123"
}
}
assert_redirected_to root_path
assert_equal "admin@example.com", User.last.email
assert User.last.admin?
end
test "regular users cannot access admin features" do
user = users(:regular)
sign_in user
get storage_locations_path
assert_redirected_to root_path
post storage_locations_path, params: {
storage_location: {
name: "Test",
path: "/test",
storage_type: "local"
}
}
assert_redirected_to root_path
end
end
```
## Migration from Phase 1
### Database Migration
```ruby
class AddAuthenticationToUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :email, null: false, index: { unique: true }
t.string :encrypted_password, null: false
t.string :role, default: 'user', null: false
t.timestamps null: false
end
create_table :user_preferences do |t|
t.references :user, null: false, foreign_key: true
t.json :settings, default: {}
t.timestamps null: false
end
# Add user_id to existing playback_sessions
add_reference :playback_sessions, :user, null: false, foreign_key: true
# Create first admin if ADMIN_EMAIL is set
if ENV.key?('ADMIN_EMAIL')
User.create!(
email: ENV['ADMIN_EMAIL'],
password: SecureRandom.hex(16),
role: 'admin'
)
end
end
end
```
Phase 2 provides a complete multi-user system while maintaining the simplicity of Phase 1. Users can have personal playback history, and administrators can manage the system through a clean interface.

773
docs/phases/phase_3.md Normal file
View File

@@ -0,0 +1,773 @@
# Velour Phase 3: Remote Sources & Import
Phase 3 extends the video library to support remote storage sources like S3, JellyFin servers, and web directories. This allows users to access and import videos from multiple locations.
## Extended Storage Architecture
### New Storage Location Types
```ruby
class StorageLocation < ApplicationRecord
has_many :videos, dependent: :destroy
validates :name, presence: true
validates :storage_type, presence: true, inclusion: { in: %w[local s3 jellyfin web] }
store :configuration, accessors: [
# S3 configuration
:bucket, :region, :access_key_id, :secret_access_key, :endpoint,
# JellyFin configuration
:server_url, :api_key, :username,
# Web directory configuration
:base_url, :auth_type, :username, :password, :headers
], coder: JSON
# Storage-type specific validations
validate :validate_s3_configuration, if: -> { s3? }
validate :validate_jellyfin_configuration, if: -> { jellyfin? }
validate :validate_web_configuration, if: -> { web? }
enum storage_type: { local: 0, s3: 1, jellyfin: 2, web: 3 }
def accessible?
case storage_type
when 'local'
File.exist?(path) && File.readable?(path)
when 's3'
s3_client&.bucket(bucket)&.exists?
when 'jellyfin'
jellyfin_client&.ping?
when 'web'
web_accessible?
else
false
end
end
def scanner
case storage_type
when 'local'
LocalFileScanner.new(self)
when 's3'
S3Scanner.new(self)
when 'jellyfin'
JellyFinScanner.new(self)
when 'web'
WebDirectoryScanner.new(self)
end
end
def streamer
case storage_type
when 'local'
LocalStreamer.new(self)
when 's3'
S3Streamer.new(self)
when 'jellyfin'
JellyFinStreamer.new(self)
when 'web'
WebStreamer.new(self)
end
end
private
def validate_s3_configuration
%w[bucket region access_key_id secret_access_key].each do |field|
errors.add(:configuration, "#{field} is required for S3 storage") if send(field).blank?
end
end
def validate_jellyfin_configuration
%w[server_url api_key].each do |field|
errors.add(:configuration, "#{field} is required for JellyFin storage") if send(field).blank?
end
end
def validate_web_configuration
errors.add(:configuration, "base_url is required for web storage") if base_url.blank?
end
end
```
## S3 Storage Implementation
### S3 Scanner Service
```ruby
class S3Scanner
def initialize(storage_location)
@storage_location = storage_location
@client = s3_client
end
def scan
return failure_result("S3 bucket not accessible") unless @storage_location.accessible?
video_files = find_video_files_in_s3
new_videos = process_s3_files(video_files)
success_result(new_videos)
rescue Aws::Errors::ServiceError => e
failure_result("S3 error: #{e.message}")
end
private
def s3_client
@s3_client ||= Aws::S3::Client.new(
region: @storage_location.region,
access_key_id: @storage_location.access_key_id,
secret_access_key: @storage_location.secret_access_key,
endpoint: @storage_location.endpoint # Optional for S3-compatible services
)
end
def find_video_files_in_s3
bucket = Aws::S3::Bucket.new(@storage_location.bucket, client: s3_client)
video_extensions = %w[.mp4 .avi .mkv .mov .wmv .flv .webm .m4v]
bucket.objects(prefix: "")
.select { |obj| video_extensions.any? { |ext| obj.key.end_with?(ext) } }
.to_a
end
def process_s3_files(s3_objects)
new_videos = []
s3_objects.each do |s3_object|
filename = File.basename(s3_object.key)
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)),
file_size: s3_object.size,
video_metadata: {
remote_url: s3_object.key,
last_modified: s3_object.last_modified
}
)
new_videos << video
VideoProcessorJob.perform_later(video.id)
end
new_videos
end
def extract_title(filename)
File.basename(filename, ".*").gsub(/[\[\(].*?[\]\)]/, "").strip
end
def success_result(videos = [])
{ success: true, videos: videos, message: "Found #{videos.length} new videos in S3" }
end
def failure_result(message)
{ success: false, message: message }
end
end
```
### S3 Streamer
```ruby
class S3Streamer
def initialize(storage_location)
@storage_location = storage_location
@client = s3_client
end
def stream(video, range: nil)
s3_object = s3_object_for_video(video)
if range
# Handle byte-range requests for seeking
range_header = "bytes=#{range}"
resp = @client.get_object(
bucket: @storage_location.bucket,
key: video.video_metadata['remote_url'],
range: range_header
)
{
body: resp.body,
status: 206, # Partial content
headers: {
'Content-Range' => "bytes #{range}/#{s3_object.size}",
'Content-Length' => resp.content_length,
'Accept-Ranges' => 'bytes',
'Content-Type' => 'video/mp4'
}
}
else
resp = @client.get_object(
bucket: @storage_location.bucket,
key: video.video_metadata['remote_url']
)
{
body: resp.body,
status: 200,
headers: {
'Content-Length' => resp.content_length,
'Content-Type' => 'video/mp4'
}
}
end
end
def presigned_url(video, expires_in: 1.hour)
signer = Aws::S3::Presigner.new(client: @client)
signer.presigned_url(
:get_object,
bucket: @storage_location.bucket,
key: video.video_metadata['remote_url'],
expires_in: expires_in.to_i
)
end
private
def s3_client
@s3_client ||= Aws::S3::Client.new(
region: @storage_location.region,
access_key_id: @storage_location.access_key_id,
secret_access_key: @storage_location.secret_access_key,
endpoint: @storage_location.endpoint
)
end
def s3_object_for_video(video)
@client.get_object(
bucket: @storage_location.bucket,
key: video.video_metadata['remote_url']
)
end
end
```
## JellyFin Integration
### JellyFin Client
```ruby
class JellyFinClient
def initialize(server_url:, api_key:, username: nil)
@server_url = server_url.chomp('/')
@api_key = api_key
@username = username
@http = Faraday.new(url: @server_url) do |faraday|
faraday.headers['X-Emby-Token'] = @api_key
faraday.adapter Faraday.default_adapter
end
end
def ping?
response = @http.get('/System/Ping')
response.success?
rescue
false
end
def libraries
response = @http.get('/Library/VirtualFolders')
JSON.parse(response.body)
end
def movies(library_id = nil)
path = library_id ? "/Users/#{user_id}/Items?ParentId=#{library_id}&IncludeItemTypes=Movie&Recursive=true" : "/Users/#{user_id}/Items?IncludeItemTypes=Movie&Recursive=true"
response = @http.get(path)
JSON.parse(response.body)['Items']
end
def tv_shows(library_id = nil)
path = library_id ? "/Users/#{user_id}/Items?ParentId=#{library_id}&IncludeItemTypes=Series&Recursive=true" : "/Users/#{user_id}/Items?IncludeItemTypes=Series&Recursive=true"
response = @http.get(path)
JSON.parse(response.body)['Items']
end
def episodes(show_id)
response = @http.get("/Shows/#{show_id}/Episodes?UserId=#{user_id}")
JSON.parse(response.body)['Items']
end
def streaming_url(item_id)
"#{@server_url}/Videos/#{item_id}/stream?Static=true&MediaSourceId=#{item_id}&DeviceId=Velour&api_key=#{@api_key}"
end
def item_details(item_id)
response = @http.get("/Users/#{user_id}/Items/#{item_id}")
JSON.parse(response.body)
end
private
def user_id
@user_id ||= begin
response = @http.get('/Users')
users = JSON.parse(response.body)
if @username
user = users.find { |u| u['Name'] == @username }
user&.dig('Id') || users.first['Id']
else
users.first['Id']
end
end
end
end
```
### JellyFin Scanner
```ruby
class JellyFinScanner
def initialize(storage_location)
@storage_location = storage_location
@client = jellyfin_client
end
def scan
return failure_result("JellyFin server not accessible") unless @storage_location.accessible?
movies = @client.movies
shows = @client.tv_shows
episodes = []
shows.each do |show|
episodes.concat(@client.episodes(show['Id']))
end
all_items = movies + episodes
new_videos = process_jellyfin_items(all_items)
success_result(new_videos)
rescue => e
failure_result("JellyFin error: #{e.message}")
end
private
def jellyfin_client
@client ||= JellyFinClient.new(
server_url: @storage_location.server_url,
api_key: @storage_location.api_key,
username: @storage_location.username
)
end
def process_jellyfin_items(items)
new_videos = []
items.each do |item|
next unless item['MediaType'] == 'Video'
title = item['Name']
year = item['ProductionYear']
work_title = year ? "#{title} (#{year})" : title
work = Work.find_or_create_by(title: work_title) do |w|
w.year = year
w.description = item['Overview']
end
video = Video.find_or_initialize_by(
filename: item['Id'],
storage_location: @storage_location
)
if video.new_record?
video.update!(
work: work,
video_metadata: {
jellyfin_id: item['Id'],
media_type: item['Type'],
runtime: item['RunTimeTicks'] ? item['RunTimeTicks'] / 10_000_000 : nil,
premiere_date: item['PremiereDate'],
community_rating: item['CommunityRating'],
genres: item['Genres']
}
)
new_videos << video
VideoProcessorJob.perform_later(video.id)
end
end
new_videos
end
def success_result(videos = [])
{ success: true, videos: videos, message: "Found #{videos.length} new videos from JellyFin" }
end
def failure_result(message)
{ success: false, message: message }
end
end
```
### JellyFin Streamer
```ruby
class JellyFinStreamer
def initialize(storage_location)
@storage_location = storage_location
@client = jellyfin_client
end
def stream(video, range: nil)
jellyfin_id = video.video_metadata['jellyfin_id']
stream_url = @client.streaming_url(jellyfin_id)
# For JellyFin, we typically proxy the stream
if range
proxy_stream_with_range(stream_url, range)
else
proxy_stream(stream_url)
end
end
private
def jellyfin_client
@client ||= JellyFinClient.new(
server_url: @storage_location.server_url,
api_key: @storage_location.api_key,
username: @storage_location.username
)
end
def proxy_stream(url)
response = Faraday.get(url)
{
body: response.body,
status: response.status,
headers: response.headers
}
end
def proxy_stream_with_range(url, range)
response = Faraday.get(url, nil, { 'Range' => "bytes=#{range}" })
{
body: response.body,
status: response.status,
headers: response.headers
}
end
end
```
## Video Import System
### Import Job with Progress Tracking
```ruby
class VideoImportJob < ApplicationJob
include ActiveJob::Statuses
def perform(video_id, destination_storage_location_id)
video = Video.find(video_id)
destination = StorageLocation.find(destination_storage_location_id)
progress.update(stage: "download", total: 100, current: 0)
# Download file from source
downloaded_file = download_video(video, destination) do |current, total|
progress.update(current: (current.to_f / total * 50).to_i) # Download is 50% of progress
end
progress.update(stage: "process", total: 100, current: 50)
# Create new video record in destination
new_video = Video.create!(
filename: video.filename,
storage_location: destination,
work: video.work,
file_size: video.file_size
)
# Copy file to destination
destination_path = File.join(destination.path, video.filename)
FileUtils.cp(downloaded_file.path, destination_path)
# Process the new video
VideoProcessorJob.perform_later(new_video.id)
progress.update(stage: "complete", total: 100, current: 100)
# Clean up temp file
File.delete(downloaded_file.path)
end
private
def download_video(video, destination, &block)
case video.storage_location.storage_type
when 's3'
download_from_s3(video, &block)
when 'jellyfin'
download_from_jellyfin(video, &block)
when 'web'
download_from_web(video, &block)
else
raise "Unsupported import from #{video.storage_location.storage_type}"
end
end
def download_from_s3(video, &block)
temp_file = Tempfile.new(['video_import', File.extname(video.filename)])
s3_client = Aws::S3::Client.new(
region: video.storage_location.region,
access_key_id: video.storage_location.access_key_id,
secret_access_key: video.storage_location.secret_access_key
)
s3_client.get_object(
bucket: video.storage_location.bucket,
key: video.video_metadata['remote_url'],
response_target: temp_file.path
) do |chunk|
yield(chunk.bytes_written, chunk.size) if block_given?
end
temp_file
end
def download_from_jellyfin(video, &block)
temp_file = Tempfile.new(['video_import', File.extname(video.filename)])
jellyfin_id = video.video_metadata['jellyfin_id']
client = JellyFinClient.new(
server_url: video.storage_location.server_url,
api_key: video.storage_location.api_key
)
stream_url = client.streaming_url(jellyfin_id)
# Download with progress tracking
uri = URI(stream_url)
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
request = Net::HTTP::Get.new(uri)
http.request(request) do |response|
total_size = response['Content-Length'].to_i
downloaded = 0
response.read_body do |chunk|
temp_file.write(chunk)
downloaded += chunk.bytesize
yield(downloaded, total_size) if block_given?
end
end
end
temp_file
end
end
```
### Import UI
```erb
<!-- app/views/videos/_import_button.html.erb -->
<% if video.storage_location.remote? && current_user.admin? %>
<div data-controller="import-dialog">
<button
data-action="click->import-dialog#show"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Import to Local Storage
</button>
<div
data-import-dialog-target="dialog"
class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<h3 class="text-lg font-bold text-gray-900 mb-4">Import Video</h3>
<p class="text-gray-600 mb-4">
Import "<%= video.filename %>" to a local storage location for offline access and transcoding.
</p>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2">
Destination Storage:
</label>
<select
name="destination_storage_location_id"
data-import-dialog-target="destination"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight">
<% StorageLocation.local.accessible.each do |location| %>
<option value="<%= location.id %>"><%= location.name %></option>
<% end %>
</select>
</div>
<div class="flex justify-end space-x-2">
<button
data-action="click->import-dialog#hide"
class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
Cancel
</button>
<button
data-action="click->import-dialog#import"
data-video-id="<%= video.id %>"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Import
</button>
</div>
</div>
</div>
<!-- Progress display -->
<div
data-import-dialog-target="progress"
class="hidden mt-2">
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4">
<p class="font-bold">Importing video...</p>
<div class="mt-2">
<div class="bg-blue-200 rounded-full h-2">
<div
data-import-dialog-target="progressBar"
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: 0%"></div>
</div>
<p class="text-sm mt-1" data-import-dialog-target="progressText">Starting...</p>
</div>
</div>
</div>
</div>
<% end %>
```
### Import Stimulus Controller
```javascript
// app/javascript/controllers/import_dialog_controller.js
import { Controller } from "@hotwired/stimulus"
import { get } from "@rails/request.js"
export default class extends Controller {
static targets = ["dialog", "progress", "progressBar", "progressText", "destination"]
show() {
this.dialogTarget.classList.remove("hidden")
}
hide() {
this.dialogTarget.classList.add("hidden")
}
async import(event) {
const videoId = event.target.dataset.videoId
const destinationId = this.destinationTarget.value
this.hide()
this.progressTarget.classList.remove("hidden")
try {
// Start import job
const response = await post("/videos/import", {
body: JSON.stringify({
video_id: videoId,
destination_storage_location_id: destinationId
})
})
const { jobId } = await response.json
// Poll for progress
this.pollProgress(jobId)
} catch (error) {
console.error("Import failed:", error)
this.progressTarget.innerHTML = `
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4">
<p class="font-bold">Import failed</p>
<p class="text-sm">${error.message}</p>
</div>
`
}
}
async pollProgress(jobId) {
const updateProgress = async () => {
try {
const response = await get(`/jobs/${jobId}/progress`)
const progress = await response.json
this.progressBarTarget.style.width = `${progress.current}%`
this.progressTextTarget.textContent = `${progress.stage}: ${progress.current}%`
if (progress.stage === "complete") {
this.progressTarget.innerHTML = `
<div class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4">
<p class="font-bold">Import complete!</p>
</div>
`
setTimeout(() => {
window.location.reload()
}, 2000)
} else if (progress.stage === "failed") {
this.progressTarget.innerHTML = `
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4">
<p class="font-bold">Import failed</p>
<p class="text-sm">${progress.error || "Unknown error"}</p>
</div>
`
} else {
setTimeout(updateProgress, 1000)
}
} catch (error) {
console.error("Failed to get progress:", error)
setTimeout(updateProgress, 1000)
}
}
updateProgress()
}
}
```
## Environment Configuration
### New Environment Variables
```bash
# S3 Configuration
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_DEFAULT_REGION=us-east-1
# Import Settings
MAX_IMPORT_SIZE_GB=10
IMPORT_TIMEOUT_MINUTES=60
# Rate limiting
JELLYFIN_RATE_LIMIT_DELAY=1 # seconds between requests
```
### New Gems
```ruby
# Gemfile
gem 'aws-sdk-s3', '~> 1'
gem 'faraday', '~> 2.0'
gem 'httparty', '~> 0.21'
```
## Routes for Phase 3
```ruby
# Add to existing routes
resources :videos, only: [] do
member do
post :import
end
end
namespace :admin do
resources :storage_locations do
member do
post :test_connection
end
end
end
```
Phase 3 enables users to access video libraries from multiple remote sources while maintaining a unified interface. The import system allows bringing remote videos into local storage for offline access and transcoding.

636
docs/phases/phase_4.md Normal file
View File

@@ -0,0 +1,636 @@
# Velour Phase 4: Federation
Phase 4 enables federation between multiple Velour instances, allowing users to share and access video libraries across different servers while maintaining security and access controls.
## Federation Architecture
### Overview
Velour federation allows instances to:
- Share video libraries with other trusted instances
- Stream videos from remote servers
- Sync metadata and work information
- Maintain access control and authentication
### Federation Storage Location Type
```ruby
class StorageLocation < ApplicationRecord
# ... existing code ...
validates :storage_type, inclusion: { in: %w[local s3 jellyfin web velour] }
store :configuration, accessors: [
# Existing configurations...
# Velour federation configuration
:remote_instance_url, :api_key, :instance_name, :trusted_instances
], coder: JSON
enum storage_type: { local: 0, s3: 1, jellyfin: 2, web: 3, velour: 4 }
def federation_client
return nil unless velour?
@federation_client ||= VelourFederationClient.new(
instance_url: remote_instance_url,
api_key: api_key
)
end
def accessible?
case storage_type
# ... existing cases ...
when 'velour'
federation_client&.ping?
else
false
end
end
def scanner
case storage_type
# ... existing cases ...
when 'velour'
VelourFederationScanner.new(self)
else
super
end
end
def streamer
case storage_type
# ... existing cases ...
when 'velour'
VelourFederationStreamer.new(self)
else
super
end
end
end
```
## Federation API Authentication
### API Key Management
```ruby
class ApiKey < ApplicationRecord
belongs_to :user
has_many :federation_connections, dependent: :destroy
validates :key, presence: true, uniqueness: true
validates :name, presence: true
validates :permissions, presence: true
store :permissions, coder: JSON, accessors: [:can_read, :can_stream, :can_metadata]
before_validation :generate_key, on: :create
def self.generate_key
SecureRandom.hex(32)
end
private
def generate_key
self.key ||= self.class.generate_key
end
end
```
### Federation Connections
```ruby
class FederationConnection < ApplicationRecord
belongs_to :api_key
belongs_to :storage_location
validates :remote_instance_url, presence: true, uniqueness: { scope: :api_key_id }
validates :status, inclusion: { in: %w[pending active suspended rejected] }
enum status: { pending: 0, active: 1, suspended: 2, rejected: 3 }
def active?
status == "active"
end
end
```
## Federation Client
### Velour Federation Client
```ruby
class VelourFederationClient
def initialize(instance_url:, api_key:)
@instance_url = instance_url.chomp('/')
@api_key = api_key
@http = Faraday.new(url: @instance_url) do |faraday|
faraday.headers['Authorization'] = "Bearer #{@api_key}"
faraday.headers['User-Agent'] = "Velour/#{Velour::VERSION}"
faraday.request :json
faraday.response :json
faraday.adapter Faraday.default_adapter
end
end
def ping?
response = @http.get('/api/v1/ping')
response.success?
rescue
false
end
def instance_info
response = @http.get('/api/v1/instance')
response.success? ? response.body : nil
end
def works(page: 1, per_page: 50)
response = @http.get('/api/v1/works', params: {
page: page,
per_page: per_page
})
response.success? ? response.body : []
end
def work_details(work_id)
response = @http.get("/api/v1/works/#{work_id}")
response.success? ? response.body : nil
end
def video_stream_url(video_id)
"#{@instance_url}/api/v1/videos/#{video_id}/stream"
end
def video_metadata(video_id)
response = @http.get("/api/v1/videos/#{video_id}")
response.success? ? response.body : nil
end
def search_videos(query:, page: 1, per_page: 20)
response = @http.get('/api/v1/videos/search', params: {
query: query,
page: page,
per_page: per_page
})
response.success? ? response.body : []
end
end
```
## Federation Scanner
### Velour Federation Scanner
```ruby
class VelourFederationScanner
def initialize(storage_location)
@storage_location = storage_location
@client = @storage_location.federation_client
end
def scan
return failure_result("Remote Velour instance not accessible") unless @storage_location.accessible?
instance_info = @client.instance_info
return failure_result("Failed to get instance info") unless instance_info
works = @client.works(per_page: 100) # Start with first 100 works
new_videos = process_remote_works(works)
success_result(new_videos, instance_info)
rescue => e
failure_result("Federation error: #{e.message}")
end
private
def process_remote_works(works)
new_videos = []
works.each do |work_data|
# Create or find local work
work = Work.find_or_create_by(
title: work_data['title'],
year: work_data['year']
) do |w|
w.description = work_data['description']
w.director = work_data['director']
w.rating = work_data['rating']
end
# Process videos for this work
work_data['videos'].each do |video_data|
video = Video.find_or_initialize_by(
filename: video_data['id'], # Use remote ID as filename
storage_location: @storage_location
)
if video.new_record?
video.update!(
work: work,
file_size: video_data['file_size'],
web_compatible: video_data['web_compatible'],
video_metadata: {
remote_video_id: video_data['id'],
remote_instance_url: @storage_location.remote_instance_url,
duration: video_data['duration'],
width: video_data['width'],
height: video_data['height'],
video_codec: video_data['video_codec'],
audio_codec: video_data['audio_codec']
},
fingerprints: video_data['fingerprints']
)
new_videos << video
# Note: We don't process remote videos locally
# We just catalog them for streaming
end
end
end
new_videos
end
def success_result(videos = [], instance_info = {})
{
success: true,
videos: videos,
message: "Found #{videos.length} videos from #{@storage_location.instance_name}",
instance_info: instance_info
}
end
def failure_result(message)
{ success: false, message: message }
end
end
```
## Federation Streamer
### Velour Federation Streamer
```ruby
class VelourFederationStreamer
def initialize(storage_location)
@storage_location = storage_location
@client = @storage_location.federation_client
end
def stream(video, range: nil)
remote_video_id = video.video_metadata['remote_video_id']
stream_url = @client.video_stream_url(remote_video_id)
if range
proxy_stream_with_range(stream_url, range)
else
proxy_stream(stream_url)
end
end
def thumbnail_url(video)
remote_video_id = video.video_metadata['remote_video_id']
"#{@storage_location.remote_instance_url}/api/v1/videos/#{remote_video_id}/thumbnail"
end
private
def proxy_stream(url)
response = Faraday.get(url) do |req|
req.headers['Authorization'] = "Bearer #{@storage_location.api_key}"
end
{
body: response.body,
status: response.status,
headers: response.headers
}
end
def proxy_stream_with_range(url, range)
response = Faraday.get(url) do |req|
req.headers['Authorization'] = "Bearer #{@storage_location.api_key}"
req.headers['Range'] = "bytes=#{range}"
end
{
body: response.body,
status: response.status,
headers: response.headers
}
end
end
```
## Federation API Endpoints
### API Controllers
```ruby
# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ActionController::Base
before_action :authenticate_api_request
private
def authenticate_api_request
token = request.headers['Authorization']&.gsub(/^Bearer\s+/, '')
api_key = ApiKey.find_by(key: token)
if api_key&.active?
@current_api_key = api_key
@current_user = api_key.user
else
render json: { error: 'Unauthorized' }, status: :unauthorized
end
end
def authorize_federation
head :forbidden unless @current_api_key&.can_read
end
def authorize_streaming
head :forbidden unless @current_api_key&.can_stream
end
end
# app/controllers/api/v1/instance_controller.rb
class Api::V1::InstanceController < Api::V1::BaseController
before_action :authorize_federation
def ping
render json: { status: 'ok', timestamp: Time.current.iso8601 }
end
def show
render json: {
name: 'Velour Instance',
version: Velour::VERSION,
description: 'Video library federation node',
total_works: Work.count,
total_videos: Video.count,
total_storage: Video.sum(:file_size)
}
end
end
# app/controllers/api/v1/works_controller.rb
class Api::V1::WorksController < Api::V1::BaseController
before_action :authorize_federation
def index
works = Work.includes(:videos)
.order(:title)
.page(params[:page])
.per(params[:per_page] || 50)
render json: {
works: works.map { |work| serialize_work(work) },
pagination: {
current_page: works.current_page,
total_pages: works.total_pages,
total_count: works.total_count
}
}
end
def show
work = Work.includes(videos: :storage_location).find(params[:id])
render json: serialize_work(work)
end
private
def serialize_work(work)
{
id: work.id,
title: work.title,
year: work.year,
description: work.description,
director: work.director,
rating: work.rating,
organized: work.organized,
created_at: work.created_at.iso8601,
videos: work.videos.map { |video| serialize_video(video) }
}
end
def serialize_video(video)
{
id: video.id,
filename: video.filename,
file_size: video.file_size,
web_compatible: video.web_compatible?,
duration: video.duration,
width: video.width,
height: video.height,
video_codec: video.video_codec,
audio_codec: video.audio_codec,
fingerprints: video.fingerprints,
storage_type: video.storage_location.storage_type,
created_at: video.created_at.iso8601
}
end
end
# app/controllers/api/v1/videos_controller.rb
class Api::V1::VideosController < Api::V1::BaseController
before_action :set_video, only: [:show, :stream, :thumbnail]
before_action :authorize_federation, except: [:stream, :thumbnail]
before_action :authorize_streaming, only: [:stream, :thumbnail]
def show
render json: serialize_video(@video)
end
def stream
streamer = @video.storage_location.streamer
result = streamer.stream(@video, range: request.headers['Range'])
send_data result[:body],
type: result[:headers]['Content-Type'] || 'video/mp4',
disposition: 'inline',
status: result[:status],
headers: result[:headers].slice('Content-Range', 'Content-Length', 'Accept-Ranges')
end
def thumbnail
if @video.video_assets.where(asset_type: 'thumbnail').exists?
asset = @video.video_assets.where(asset_type: 'thumbnail').first
redirect_to rails_blob_url(asset.file, disposition: 'inline')
else
head :not_found
end
end
def search
query = params[:query]
return render json: [] if query.blank?
videos = Video.joins(:work)
.where('works.title ILIKE ?', "%#{query}%")
.includes(:work, :storage_location)
.page(params[:page])
.per(params[:per_page] || 20)
render json: {
videos: videos.map { |video| serialize_video(video) },
pagination: {
current_page: videos.current_page,
total_pages: videos.total_pages,
total_count: videos.total_count
}
}
end
private
def set_video
@video = Video.find(params[:id])
end
def serialize_video(video)
{
id: video.id,
filename: video.filename,
work_id: video.work_id,
work_title: video.work.title,
file_size: video.file_size,
web_compatible: video.web_compatible?,
duration: video.duration,
width: video.width,
height: video.height,
video_codec: video.video_codec,
audio_codec: video.audio_codec,
fingerprints: video.fingerprints,
storage_type: video.storage_location.storage_type,
created_at: video.created_at.iso8601
}
end
end
```
## Federation Management UI
### Federation Connections Management
```erb
<!-- app/views/admin/federation_connections/index.html.erb -->
<div class="container mx-auto p-6">
<h1 class="text-3xl font-bold mb-6">Federation Connections</h1>
<% if @api_keys.any? %>
<div class="mb-6">
<%= link_to 'Create New Connection', new_admin_federation_connection_path, class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-gray-200">
<% @connections.each do |connection| %>
<li class="px-6 py-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">
<%= connection.storage_location.name %>
</p>
<p class="text-sm text-gray-500">
<%= connection.remote_instance_url %>
</p>
<p class="text-sm text-gray-500">
API Key: <%= connection.api_key.name %>
</p>
</div>
<div class="flex items-center space-x-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
<%= case connection.status
when 'active' then 'bg-green-100 text-green-800'
when 'pending' then 'bg-yellow-100 text-yellow-800'
when 'suspended' then 'bg-red-100 text-red-800'
when 'rejected' then 'bg-gray-100 text-gray-800'
end %>">
<%= connection.status.titleize %>
</span>
<%= link_to 'Edit', edit_admin_federation_connection_path(connection), class: 'text-blue-600 hover:text-blue-900' %>
<%= link_to 'Test', test_admin_federation_connection_path(connection), method: :post, class: 'text-green-600 hover:text-green-900' %>
<%= link_to 'Delete', admin_federation_connection_path(connection), method: :delete,
data: { confirm: 'Are you sure?' }, class: 'text-red-600 hover:text-red-900' %>
</div>
</div>
</li>
<% end %>
</ul>
</div>
<% else %>
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
You need to create an API key before you can establish federation connections.
</p>
<div class="mt-2">
<%= link_to 'Create API Key', new_admin_api_key_path, class: 'text-yellow-700 underline hover:text-yellow-600' %>
</div>
</div>
</div>
</div>
<% end %>
</div>
```
## Security Considerations
### Federation Security Best Practices
1. **API Key Management**: Regular rotation and limited scope
2. **Access Control**: Minimum required permissions
3. **Rate Limiting**: Prevent abuse from federated instances
4. **Audit Logging**: Track all federated access
5. **Network Security**: HTTPS-only federation connections
### Rate Limiting
```ruby
# app/controllers/concerns/rate_limited.rb
module RateLimited
extend ActiveSupport::Concern
included do
before_action :check_rate_limit, only: [:index, :show, :stream]
end
private
def check_rate_limit
return unless @current_api_key
key = "federation_rate_limit:#{@current_api_key.id}"
count = Rails.cache.increment(key, 1, expires_in: 1.hour)
if count > rate_limit
Rails.logger.warn "Rate limit exceeded for API key #{@current_api_key.id}"
render json: { error: 'Rate limit exceeded' }, status: :too_many_requests
end
end
def rate_limit
case action_name
when 'stream'
1000 # requests per hour
else
5000 # requests per hour
end
end
end
```
## Environment Configuration
### Federation Configuration
```bash
# Federation settings
FEDERATION_ENABLED=true
MAX_FEDERATED_CONNECTIONS=10
FEDERATION_RATE_LIMIT_PER_HOUR=5000
# Federation security
FEDERATION_REQUIRE_HTTPS=true
FEDERATION_TOKEN_EXPIRY_HOURS=24
```
Phase 4 enables a distributed network of Velour instances that can share video libraries while maintaining security and access controls. This allows organizations to federate their video collections across multiple servers or locations.

324
docs/phases/phase_5.md Normal file
View File

@@ -0,0 +1,324 @@
# Velour Phase 5: Audio Support (Music & Audiobooks)
Phase 5 extends Velour from a video library to a comprehensive media library by adding support for music and audiobooks. This builds upon the extensible MediaFile architecture established in Phase 1.
## Technology Stack
### Audio Processing Components
- **FFmpeg** - Audio transcoding and metadata extraction (extends existing video processing)
- **Ruby Audio Gems** - ID3 tag parsing, waveform generation
- **Active Storage** - Album art and waveform visualization storage
- **MediaInfo** - Comprehensive audio metadata extraction
## Database Schema Extensions
### Audio Model (inherits from MediaFile)
```ruby
class Audio < MediaFile
# Audio-specific associations
has_many :audio_assets, dependent: :destroy # album art, waveforms
# Audio-specific metadata store
store :audio_metadata, accessors: [:sample_rate, :channels, :artist, :album, :track_number, :genre, :year]
# Audio-specific methods
def quality_label
return "Unknown" unless bit_rate
case bit_rate
when 0..128 then "128kbps"
when 129..192 then "192kbps"
when 193..256 then "256kbps"
when 257..320 then "320kbps"
else "Lossless"
end
end
def format_type
return "Unknown" unless format
case format&.downcase
when "mp3" then "MP3"
when "flac" then "FLAC"
when "wav" then "WAV"
when "aac", "m4a" then "AAC"
when "ogg" then "OGG Vorbis"
else format&.upcase
end
end
end
class AudioAsset < ApplicationRecord
belongs_to :audio
enum asset_type: { album_art: 0, waveform: 1, lyrics: 2 }
# Uses Active Storage for file storage
has_one_attached :file
end
```
### Extended Work Model
```ruby
class Work < ApplicationRecord
# Existing video associations
has_many :videos, dependent: :destroy
has_many :external_ids, dependent: :destroy
# New audio associations
has_many :audios, dependent: :destroy
# Enhanced primary media selection
def primary_media
(audios + videos).sort_by(&:created_at).last
end
def primary_video
videos.order(created_at: :desc).first
end
def primary_audio
audios.order(created_at: :desc).first
end
# Content type detection
def video_content?
videos.exists?
end
def audio_content?
audios.exists?
end
def mixed_content?
video_content? && audio_content?
end
end
```
## Audio Processing Pipeline
### AudioProcessorJob
```ruby
class AudioProcessorJob < ApplicationJob
queue_as :processing
def perform(audio_id)
audio = Audio.find(audio_id)
# Extract audio metadata
AudioMetadataExtractor.new(audio).extract!
# Generate album art if embedded
AlbumArtExtractor.new(audio).extract!
# Generate waveform visualization
WaveformGenerator.new(audio).generate!
# Check web compatibility and transcode if needed
unless AudioTranscoder.new(audio).web_compatible?
AudioTranscoderJob.perform_later(audio_id)
end
audio.update!(processed: true)
rescue => e
audio.update!(processing_error: e.message)
raise
end
end
```
### AudioTranscoderJob
```ruby
class AudioTranscoderJob < ApplicationJob
queue_as :transcoding
def perform(audio_id)
audio = Audio.find(audio_id)
AudioTranscoder.new(audio).transcode_for_web!
end
end
```
## File Discovery Extensions
### Enhanced FileScannerService
```ruby
class FileScannerService
AUDIO_EXTENSIONS = %w[mp3 flac wav aac m4a ogg wma].freeze
def scan_directory(storage_location)
# Existing video scanning logic
scan_videos(storage_location)
# New audio scanning logic
scan_audio(storage_location)
end
private
def scan_audio(storage_location)
AUDIO_EXTENSIONS.each do |ext|
Dir.glob(File.join(storage_location.path, "**", "*.#{ext}")).each do |file_path|
process_audio_file(file_path, storage_location)
end
end
end
def process_audio_file(file_path, storage_location)
filename = File.basename(file_path)
return if Audio.joins(:storage_location).exists?(filename: filename, storage_locations: { id: storage_location.id })
# Create Work based on filename parsing (album/track structure)
work = find_or_create_audio_work(filename, file_path)
# Create Audio record
Audio.create!(
work: work,
storage_location: storage_location,
filename: filename,
xxhash64: calculate_xxhash64(file_path)
)
AudioProcessorJob.perform_later(audio.id)
end
end
```
## User Interface Extensions
### Audio Player Integration
- **Video.js Audio Plugin** - Extend existing video player for audio
- **Waveform Visualization** - Interactive seeking with waveform display
- **Chapter Support** - Essential for audiobooks
- **Speed Control** - Variable playback speed for audiobooks
### Library Organization
- **Album View** - Grid layout with album art
- **Artist Pages** - Discography and album organization
- **Audiobook Progress** - Chapter tracking and resume functionality
- **Mixed Media Collections** - Works containing both video and audio content
### Audio-Specific Features
- **Playlist Creation** - Custom playlists for music
- **Shuffle Play** - Random playback for albums/artists
- **Gapless Playback** - Seamless track transitions
- **Lyrics Display** - Embedded or external lyrics support
## Implementation Timeline
### Phase 5A: Audio Foundation (Week 1-2)
- Create Audio model inheriting from MediaFile
- Implement AudioProcessorJob and audio metadata extraction
- Extend FileScannerService for audio formats
- Basic audio streaming endpoint
### Phase 5B: Audio Processing (Week 3)
- Album art extraction and storage
- Waveform generation
- Audio transcoding for web compatibility
- Quality optimization and format conversion
### Phase 5C: User Interface (Week 4)
- Audio player component (extends Video.js)
- Album and artist browsing interfaces
- Audio library management views
- Search and filtering for audio content
### Phase 5D: Advanced Features (Week 5)
- Chapter support for audiobooks
- Playlist creation and management
- Mixed media Works (video + audio)
- Audio-specific user preferences
## Migration Strategy
### Database Migrations
```ruby
# Extend videos table for STI (already done in Phase 1)
# Add audio-specific columns if needed
class AddAudioFeatures < ActiveRecord::Migration[8.1]
def change
create_table :audio_assets do |t|
t.references :audio, null: false, foreign_key: true
t.string :asset_type
t.timestamps
end
# Audio-specific indexes
add_index :audios, :artist if column_exists?(:audios, :artist)
add_index :audios, :album if column_exists?(:audios, :album)
end
end
```
### Backward Compatibility
- All existing video functionality remains unchanged
- Video URLs and routes continue to work identically
- Database migration is additive (type column only)
- No breaking changes to existing API
## Configuration
### Environment Variables
```bash
# Audio Processing (extends existing video processing)
FFMPEG_PATH=/usr/bin/ffmpeg
AUDIO_TRANSCODE_QUALITY=high
MAX_AUDIO_TRANSCODE_SIZE_GB=10
# Audio Features
ENABLE_AUDIO_SCANNING=true
ENABLE_WAVEFORM_GENERATION=true
AUDIO_THUMBNAIL_SIZE=300x300
```
### Storage Considerations
- Album art storage in Active Storage
- Waveform images (generated per track)
- Potential audio transcoding cache
- Audio-specific metadata storage
## Testing Strategy
### Model Tests
- Audio model validation and inheritance
- Work model mixed content handling
- Audio metadata extraction accuracy
### Integration Tests
- Audio processing pipeline end-to-end
- Audio streaming with seeking support
- File scanner audio discovery
### System Tests
- Audio player functionality
- Album/artist interface navigation
- Mixed media library browsing
## Performance Considerations
### Audio Processing
- Parallel audio metadata extraction
- Efficient album art extraction
- Optimized waveform generation
- Background transcoding queue management
### Storage Optimization
- Compressed waveform storage
- Album art caching and optimization
- Efficient audio streaming with range requests
### User Experience
- Fast audio library browsing
- Quick album art loading
- Responsive audio player controls
## Future Extensions
### Phase 5+ Possibilities
- **Podcast Support** - RSS feed integration and episode management
- **Radio Streaming** - Internet radio station integration
- **Music Discovery** - Similar artist recommendations
- **Audio Bookmarks** - Detailed note-taking for audiobooks
- **Social Features** - Sharing playlists and recommendations
This phase transforms Velour from a video library into a comprehensive personal media platform while maintaining the simplicity and robustness of the existing architecture.

View File

@@ -0,0 +1,67 @@
require "test_helper"
class PasswordsControllerTest < ActionDispatch::IntegrationTest
setup { @user = User.take }
test "new" do
get new_password_path
assert_response :success
end
test "create" do
post passwords_path, params: { email_address: @user.email_address }
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
assert_redirected_to new_session_path
follow_redirect!
assert_notice "reset instructions sent"
end
test "create for an unknown user redirects but sends no mail" do
post passwords_path, params: { email_address: "missing-user@example.com" }
assert_enqueued_emails 0
assert_redirected_to new_session_path
follow_redirect!
assert_notice "reset instructions sent"
end
test "edit" do
get edit_password_path(@user.password_reset_token)
assert_response :success
end
test "edit with invalid password reset token" do
get edit_password_path("invalid token")
assert_redirected_to new_password_path
follow_redirect!
assert_notice "reset link is invalid"
end
test "update" do
assert_changes -> { @user.reload.password_digest } do
put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" }
assert_redirected_to new_session_path
end
follow_redirect!
assert_notice "Password has been reset"
end
test "update with non matching passwords" do
token = @user.password_reset_token
assert_no_changes -> { @user.reload.password_digest } do
put password_path(token), params: { password: "no", password_confirmation: "match" }
assert_redirected_to edit_password_path(token)
end
follow_redirect!
assert_notice "Passwords did not match"
end
private
def assert_notice(text)
assert_select "div", /#{text}/
end
end

View File

@@ -0,0 +1,33 @@
require "test_helper"
class SessionsControllerTest < ActionDispatch::IntegrationTest
setup { @user = User.take }
test "new" do
get new_session_path
assert_response :success
end
test "create with valid credentials" do
post session_path, params: { email_address: @user.email_address, password: "password" }
assert_redirected_to root_path
assert cookies[:session_id]
end
test "create with invalid credentials" do
post session_path, params: { email_address: @user.email_address, password: "wrong" }
assert_redirected_to new_session_path
assert_nil cookies[:session_id]
end
test "destroy" do
sign_in_as(User.take)
delete session_path
assert_redirected_to new_session_path
assert_empty cookies[:session_id]
end
end

View File

@@ -0,0 +1,23 @@
require "test_helper"
class StorageLocationsControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get storage_locations_index_url
assert_response :success
end
test "should get show" do
get storage_locations_show_url
assert_response :success
end
test "should get create" do
get storage_locations_create_url
assert_response :success
end
test "should get destroy" do
get storage_locations_destroy_url
assert_response :success
end
end

View File

@@ -0,0 +1,13 @@
require "test_helper"
class VideosControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get videos_index_url
assert_response :success
end
test "should get show" do
get videos_show_url
assert_response :success
end
end

View File

@@ -0,0 +1,13 @@
require "test_helper"
class WorksControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get works_index_url
assert_response :success
end
test "should get show" do
get works_show_url
assert_response :success
end
end

11
test/fixtures/external_ids.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
work: one
source: 1
value: MyString
two:
work: two
source: 1
value: MyString

19
test/fixtures/playback_sessions.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
video: one
user: one
position: 1.5
duration_watched: 1.5
last_watched_at: 2025-10-29 22:39:57
completed: false
play_count: 1
two:
video: two
user: two
position: 1.5
duration_watched: 1.5
last_watched_at: 2025-10-29 22:39:57
completed: false
play_count: 1

23
test/fixtures/storage_locations.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: MyString
path: MyString
location_type: 1
writable: false
enabled: false
scan_subdirectories: false
priority: 1
settings: MyText
last_scanned_at: 2025-10-29 22:38:50
two:
name: MyString
path: MyString
location_type: 1
writable: false
enabled: false
scan_subdirectories: false
priority: 1
settings: MyText
last_scanned_at: 2025-10-29 22:38:50

9
test/fixtures/users.yml vendored Normal file
View File

@@ -0,0 +1,9 @@
<% password_digest = BCrypt::Password.create("password") %>
one:
email_address: one@example.com
password_digest: <%= password_digest %>
two:
email_address: two@example.com
password_digest: <%= password_digest %>

11
test/fixtures/video_assets.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
video: one
asset_type: 1
metadata: MyText
two:
video: two
asset_type: 1
metadata: MyText

51
test/fixtures/videos.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
work: one
storage_location: one
title: MyString
file_path: MyString
file_hash: MyString
file_size: 1
duration: 1.5
width: 1
height: 1
resolution_label: MyString
video_codec: MyString
audio_codec: MyString
bit_rate: 1
frame_rate: 1.5
format: MyString
has_subtitles: false
version_type: MyString
source_type: 1
source_url: MyString
imported: false
processing_failed: false
error_message: MyText
metadata: MyText
two:
work: two
storage_location: two
title: MyString
file_path: MyString
file_hash: MyString
file_size: 1
duration: 1.5
width: 1
height: 1
resolution_label: MyString
video_codec: MyString
audio_codec: MyString
bit_rate: 1
frame_rate: 1.5
format: MyString
has_subtitles: false
version_type: MyString
source_type: 1
source_url: MyString
imported: false
processing_failed: false
error_message: MyText
metadata: MyText

23
test/fixtures/works.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
title: MyString
year: 1
director: MyString
description: MyText
rating: 9.99
organized: false
poster_path: MyString
backdrop_path: MyString
metadata: MyText
two:
title: MyString
year: 1
director: MyString
description: MyText
rating: 9.99
organized: false
poster_path: MyString
backdrop_path: MyString
metadata: MyText

View File

@@ -0,0 +1,7 @@
# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer
class PasswordsMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset
def reset
PasswordsMailer.reset(User.take)
end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class ExternalIdTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class PlaybackSessionTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class StorageLocationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

8
test/models/user_test.rb Normal file
View File

@@ -0,0 +1,8 @@
require "test_helper"
class UserTest < ActiveSupport::TestCase
test "downcases and strips email_address" do
user = User.new(email_address: " DOWNCASED@EXAMPLE.COM ")
assert_equal("downcased@example.com", user.email_address)
end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class VideoAssetTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class VideoTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

7
test/models/work_test.rb Normal file
View File

@@ -0,0 +1,7 @@
require "test_helper"
class WorkTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -1,6 +1,7 @@
ENV["RAILS_ENV"] ||= "test" ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment" require_relative "../config/environment"
require "rails/test_help" require "rails/test_help"
require_relative "test_helpers/session_test_helper"
module ActiveSupport module ActiveSupport
class TestCase class TestCase

View File

@@ -0,0 +1,19 @@
module SessionTestHelper
def sign_in_as(user)
Current.session = user.sessions.create!
ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar|
cookie_jar.signed[:session_id] = Current.session.id
cookies["session_id"] = cookie_jar[:session_id]
end
end
def sign_out
Current.session&.destroy!
cookies.delete("session_id")
end
end
ActiveSupport.on_load(:action_dispatch_integration_test) do
include SessionTestHelper
end