diff --git a/Gemfile b/Gemfile
index d2334c4..94e4e86 100644
--- a/Gemfile
+++ b/Gemfile
@@ -20,7 +20,7 @@ gem "tailwindcss-rails"
gem "jbuilder"
# 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
gem "tzinfo-data", platforms: %i[ windows jruby ]
@@ -66,3 +66,9 @@ group :test do
gem "capybara"
gem "selenium-webdriver"
end
+
+gem "streamio-ffmpeg", "~> 3.0"
+gem "pagy", "~> 9.4"
+gem "aws-sdk-s3", "~> 1.202"
+
+gem "xxhash", "~> 0.7.0"
diff --git a/Gemfile.lock b/Gemfile.lock
index a8ac1ae..646559a 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -78,7 +78,27 @@ GEM
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
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)
+ bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
bigdecimal (3.3.1)
bindex (0.8.1)
@@ -142,6 +162,7 @@ GEM
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
+ jmespath (1.6.2)
json (2.15.2)
kamal (2.8.2)
activesupport (>= 7.0)
@@ -173,6 +194,7 @@ GEM
mini_mime (1.1.5)
minitest (5.26.0)
msgpack (1.8.0)
+ multi_json (1.17.0)
net-imap (0.5.12)
date
net-protocol
@@ -203,6 +225,7 @@ GEM
nokogiri (1.18.10-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.3)
+ pagy (9.4.0)
parallel (1.27.0)
parser (3.3.10.0)
ast (~> 2.4.1)
@@ -343,6 +366,8 @@ GEM
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
+ streamio-ffmpeg (3.0.2)
+ multi_json (~> 1.8)
stringio (3.1.7)
tailwindcss-rails (4.4.0)
railties (>= 7.0.0)
@@ -382,6 +407,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
+ xxhash (0.7.0)
zeitwerk (2.7.3)
PLATFORMS
@@ -396,6 +422,8 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
+ aws-sdk-s3 (~> 1.202)
+ bcrypt (~> 3.1.7)
bootsnap
brakeman
bundler-audit
@@ -405,6 +433,7 @@ DEPENDENCIES
importmap-rails
jbuilder
kamal
+ pagy (~> 9.4)
propshaft
puma (>= 5.0)
rails (~> 8.1.1)
@@ -415,11 +444,13 @@ DEPENDENCIES
solid_queue
sqlite3 (>= 2.1)
stimulus-rails
+ streamio-ffmpeg (~> 3.0)
tailwindcss-rails
thruster
turbo-rails
tzinfo-data
web-console
+ xxhash (~> 0.7.0)
BUNDLED WITH
2.7.2
diff --git a/Procfile.dev b/Procfile.dev
index da151fe..8a30c19 100644
--- a/Procfile.dev
+++ b/Procfile.dev
@@ -1,2 +1,2 @@
-web: bin/rails server
+web: bin/rails server -b 0.0.0.0 -p 3057
css: bin/rails tailwindcss:watch
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
new file mode 100644
index 0000000..4264c74
--- /dev/null
+++ b/app/channels/application_cable/connection.rb
@@ -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
diff --git a/app/controllers/admin/storage_locations_controller.rb b/app/controllers/admin/storage_locations_controller.rb
new file mode 100644
index 0000000..8e30baa
--- /dev/null
+++ b/app/controllers/admin/storage_locations_controller.rb
@@ -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
\ No newline at end of file
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index c353756..5f38f02 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,4 +1,5 @@
class ApplicationController < ActionController::Base
+ include Authentication
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
new file mode 100644
index 0000000..3538f48
--- /dev/null
+++ b/app/controllers/concerns/authentication.rb
@@ -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
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
new file mode 100644
index 0000000..f95ec78
--- /dev/null
+++ b/app/controllers/passwords_controller.rb
@@ -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
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
new file mode 100644
index 0000000..cf7fccd
--- /dev/null
+++ b/app/controllers/sessions_controller.rb
@@ -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
diff --git a/app/controllers/storage_locations_controller.rb b/app/controllers/storage_locations_controller.rb
new file mode 100644
index 0000000..89ad4bb
--- /dev/null
+++ b/app/controllers/storage_locations_controller.rb
@@ -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
diff --git a/app/controllers/videos_controller.rb b/app/controllers/videos_controller.rb
new file mode 100644
index 0000000..fe1e872
--- /dev/null
+++ b/app/controllers/videos_controller.rb
@@ -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
diff --git a/app/controllers/works_controller.rb b/app/controllers/works_controller.rb
new file mode 100644
index 0000000..3b3a1aa
--- /dev/null
+++ b/app/controllers/works_controller.rb
@@ -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
diff --git a/app/helpers/videos_helper.rb b/app/helpers/videos_helper.rb
new file mode 100644
index 0000000..ee6c155
--- /dev/null
+++ b/app/helpers/videos_helper.rb
@@ -0,0 +1,2 @@
+module VideosHelper
+end
diff --git a/app/jobs/video_processor_job.rb b/app/jobs/video_processor_job.rb
new file mode 100644
index 0000000..e28b4e0
--- /dev/null
+++ b/app/jobs/video_processor_job.rb
@@ -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
\ No newline at end of file
diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb
new file mode 100644
index 0000000..4f0ac7f
--- /dev/null
+++ b/app/mailers/passwords_mailer.rb
@@ -0,0 +1,6 @@
+class PasswordsMailer < ApplicationMailer
+ def reset(user)
+ @user = user
+ mail subject: "Reset your password", to: user.email_address
+ end
+end
diff --git a/app/models/concerns/processable.rb b/app/models/concerns/processable.rb
new file mode 100644
index 0000000..28a7e46
--- /dev/null
+++ b/app/models/concerns/processable.rb
@@ -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
\ No newline at end of file
diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb
new file mode 100644
index 0000000..4639abc
--- /dev/null
+++ b/app/models/concerns/searchable.rb
@@ -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
\ No newline at end of file
diff --git a/app/models/concerns/streamable.rb b/app/models/concerns/streamable.rb
new file mode 100644
index 0000000..c76ef33
--- /dev/null
+++ b/app/models/concerns/streamable.rb
@@ -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
\ No newline at end of file
diff --git a/app/models/current.rb b/app/models/current.rb
new file mode 100644
index 0000000..2bef56d
--- /dev/null
+++ b/app/models/current.rb
@@ -0,0 +1,4 @@
+class Current < ActiveSupport::CurrentAttributes
+ attribute :session
+ delegate :user, to: :session, allow_nil: true
+end
diff --git a/app/models/external_id.rb b/app/models/external_id.rb
new file mode 100644
index 0000000..ebe9d6b
--- /dev/null
+++ b/app/models/external_id.rb
@@ -0,0 +1,3 @@
+class ExternalId < ApplicationRecord
+ belongs_to :work
+end
diff --git a/app/models/media_file.rb b/app/models/media_file.rb
new file mode 100644
index 0000000..f1424a3
--- /dev/null
+++ b/app/models/media_file.rb
@@ -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
\ No newline at end of file
diff --git a/app/models/playback_session.rb b/app/models/playback_session.rb
new file mode 100644
index 0000000..3693aea
--- /dev/null
+++ b/app/models/playback_session.rb
@@ -0,0 +1,4 @@
+class PlaybackSession < ApplicationRecord
+ belongs_to :video
+ belongs_to :user
+end
diff --git a/app/models/session.rb b/app/models/session.rb
new file mode 100644
index 0000000..cf376fb
--- /dev/null
+++ b/app/models/session.rb
@@ -0,0 +1,3 @@
+class Session < ApplicationRecord
+ belongs_to :user
+end
diff --git a/app/models/storage_location.rb b/app/models/storage_location.rb
new file mode 100644
index 0000000..29baea4
--- /dev/null
+++ b/app/models/storage_location.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..c88d5b0
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,6 @@
+class User < ApplicationRecord
+ has_secure_password
+ has_many :sessions, dependent: :destroy
+
+ normalizes :email_address, with: ->(e) { e.strip.downcase }
+end
diff --git a/app/models/video.rb b/app/models/video.rb
new file mode 100644
index 0000000..56fcfc6
--- /dev/null
+++ b/app/models/video.rb
@@ -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
diff --git a/app/models/video_asset.rb b/app/models/video_asset.rb
new file mode 100644
index 0000000..cd3bd00
--- /dev/null
+++ b/app/models/video_asset.rb
@@ -0,0 +1,3 @@
+class VideoAsset < ApplicationRecord
+ belongs_to :video
+end
diff --git a/app/models/work.rb b/app/models/work.rb
new file mode 100644
index 0000000..6b5b532
--- /dev/null
+++ b/app/models/work.rb
@@ -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
diff --git a/app/services/file_scanner_service.rb b/app/services/file_scanner_service.rb
new file mode 100644
index 0000000..0294b15
--- /dev/null
+++ b/app/services/file_scanner_service.rb
@@ -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
\ No newline at end of file
diff --git a/app/services/result.rb b/app/services/result.rb
new file mode 100644
index 0000000..55f7586
--- /dev/null
+++ b/app/services/result.rb
@@ -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
\ No newline at end of file
diff --git a/app/services/storage_adapters/base_adapter.rb b/app/services/storage_adapters/base_adapter.rb
new file mode 100644
index 0000000..fe51cdc
--- /dev/null
+++ b/app/services/storage_adapters/base_adapter.rb
@@ -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
\ No newline at end of file
diff --git a/app/services/storage_adapters/local_adapter.rb b/app/services/storage_adapters/local_adapter.rb
new file mode 100644
index 0000000..3e8439b
--- /dev/null
+++ b/app/services/storage_adapters/local_adapter.rb
@@ -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
\ No newline at end of file
diff --git a/app/services/storage_discovery_service.rb b/app/services/storage_discovery_service.rb
new file mode 100644
index 0000000..3a2b943
--- /dev/null
+++ b/app/services/storage_discovery_service.rb
@@ -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
\ No newline at end of file
diff --git a/app/services/video_metadata_extractor.rb b/app/services/video_metadata_extractor.rb
new file mode 100644
index 0000000..c82c81a
--- /dev/null
+++ b/app/services/video_metadata_extractor.rb
@@ -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
\ No newline at end of file
diff --git a/app/services/video_transcoder.rb b/app/services/video_transcoder.rb
new file mode 100644
index 0000000..4936acb
--- /dev/null
+++ b/app/services/video_transcoder.rb
@@ -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
\ No newline at end of file
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb
new file mode 100644
index 0000000..65798f8
--- /dev/null
+++ b/app/views/passwords/edit.html.erb
@@ -0,0 +1,21 @@
+
+ <% if alert = flash[:alert] %>
+
<%= alert %>
+ <% end %>
+
+
Update your password
+
+ <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
+
+ <%= 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" %>
+
+
+
+ <%= 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" %>
+
+
+
+ <%= 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" %>
+
+ <% end %>
+
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb
new file mode 100644
index 0000000..8360e02
--- /dev/null
+++ b/app/views/passwords/new.html.erb
@@ -0,0 +1,17 @@
+
+ <% if alert = flash[:alert] %>
+
<%= alert %>
+ <% end %>
+
+
Forgot your password?
+
+ <%= form_with url: passwords_path, class: "contents" do |form| %>
+
+ <%= 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" %>
+
+
+
+ <%= 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" %>
+
+ <% end %>
+
diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb
new file mode 100644
index 0000000..1b09154
--- /dev/null
+++ b/app/views/passwords_mailer/reset.html.erb
@@ -0,0 +1,6 @@
+
+ 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) %>.
+
diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb
new file mode 100644
index 0000000..aecee82
--- /dev/null
+++ b/app/views/passwords_mailer/reset.text.erb
@@ -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) %>.
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
new file mode 100644
index 0000000..308b04b
--- /dev/null
+++ b/app/views/sessions/new.html.erb
@@ -0,0 +1,31 @@
+
+ <% if alert = flash[:alert] %>
+
<%= alert %>
+ <% end %>
+
+ <% if notice = flash[:notice] %>
+
<%= notice %>
+ <% end %>
+
+
Sign in
+
+ <%= form_with url: session_url, class: "contents" do |form| %>
+
+ <%= 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" %>
+
+
+
+ <%= 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" %>
+
+
+
+
+ <%= 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" %>
+
+
+
+ <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %>
+
+
+ <% end %>
+
diff --git a/app/views/storage_locations/create.html.erb b/app/views/storage_locations/create.html.erb
new file mode 100644
index 0000000..b1bdf30
--- /dev/null
+++ b/app/views/storage_locations/create.html.erb
@@ -0,0 +1,4 @@
+
+
StorageLocations#create
+
Find me in app/views/storage_locations/create.html.erb
+
diff --git a/app/views/storage_locations/destroy.html.erb b/app/views/storage_locations/destroy.html.erb
new file mode 100644
index 0000000..5bffba7
--- /dev/null
+++ b/app/views/storage_locations/destroy.html.erb
@@ -0,0 +1,4 @@
+
+
StorageLocations#destroy
+
Find me in app/views/storage_locations/destroy.html.erb
+
diff --git a/app/views/storage_locations/index.html.erb b/app/views/storage_locations/index.html.erb
new file mode 100644
index 0000000..cbbccb0
--- /dev/null
+++ b/app/views/storage_locations/index.html.erb
@@ -0,0 +1,81 @@
+
+
+
Video Library
+ <% 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 %>
+
+
+ <% if @storage_locations.empty? %>
+
+
No storage locations found
+
+ Storage locations are automatically discovered from directories mounted under /videos
+
+
+
Example Docker volume mounts:
+
+ /path/to/movies:/videos/movies:ro
+ /path/to/tv_shows:/videos/tv:ro
+ /path/to/documentaries:/videos/docs:ro
+
+
+
+ <% else %>
+
+ <% @storage_locations.each do |storage_location| %>
+
+
+
+ <%= link_to storage_location.name, storage_location,
+ class: "hover:text-blue-600 transition-colors" %>
+
+
+
+
+ Path:
+ <%= storage_location.path %>
+
+
+ Type:
+ <%= storage_location.storage_type.titleize %>
+
+
+ Videos:
+ <%= storage_location.video_count %>
+
+
+
+ <% if storage_location.accessible? %>
+
+ <% else %>
+
+ <% end %>
+
+
+ <%= 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 %>
+
+
+
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/storage_locations/show.html.erb b/app/views/storage_locations/show.html.erb
new file mode 100644
index 0000000..6f9d29b
--- /dev/null
+++ b/app/views/storage_locations/show.html.erb
@@ -0,0 +1,118 @@
+
+
+
+
<%= @storage_location.name %>
+
+ Path:
+ <%= @storage_location.path %>
+
+
+
+ <%= 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 %>
+
+
+
+ <% if @videos.empty? %>
+
+
No videos found
+
+ This storage location doesn't contain any video files yet. Try scanning for videos to add them to your library.
+
+
+ <% else %>
+
+
+
+ Videos (<%= @videos.count %>)
+
+
+
+
+ <% @videos.each do |video| %>
+
+
+
+
+ <% 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 %>
+
+ <% end %>
+
+
+
+
+
+ <%= link_to video.display_title, video,
+ class: "hover:text-blue-600 transition-colors" %>
+
+
+
+
+ Duration:
+ <%= video.format_duration %>
+
+
+ Resolution:
+ <%= video.resolution_label %>
+
+
+ Size:
+ <%= number_to_human_size(video.video_metadata['file_size']) rescue "Unknown" %>
+
+
+
+
+ <% if video.web_compatible? %>
+
+ Web Compatible
+
+ <% else %>
+
+ Needs Transcoding
+
+ <% end %>
+
+ <% if video.processed? %>
+
+ Processed
+
+ <% else %>
+
+ Processing
+
+ <% end %>
+
+ <% if video.work&.title && video.work.title != video.display_title %>
+
+ Part of: <%= link_to video.work.title, video.work,
+ class: "hover:text-blue-600 transition-colors" %>
+
+ <% end %>
+
+
+
+
+
+ <%= link_to "Watch", video,
+ class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition-colors" %>
+
+
+
+ <% end %>
+
+
+ <% end %>
+
diff --git a/app/views/videos/index.html.erb b/app/views/videos/index.html.erb
new file mode 100644
index 0000000..481611f
--- /dev/null
+++ b/app/views/videos/index.html.erb
@@ -0,0 +1,76 @@
+<% content_for :title, "Videos" %>
+
+
+
Video Library
+
+ <% if @storage_locations.any? %>
+
+ All Sources
+ <% @storage_locations.each do |location| %>
+ <%= location.display_name %>
+ <% end %>
+
+ <% 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" %>
+
+
+
+<% if @videos.any? %>
+
+ <% @videos.each do |video| %>
+
+
+ <%# Placeholder for thumbnails - Phase 1C will add actual thumbnails %>
+
+ <%# Badge for source type %>
+
+ <%= video.storage_location.name %>
+
+
+
+
+
+ <%= link_to video.display_title, video_path(video), class: "hover:text-blue-600" %>
+
+
+
+
Duration: <%= video.formatted_duration %>
+
Size: <%= video.formatted_file_size %>
+ <% if video.resolution_label.present? %>
+
Resolution: <%= video.resolution_label %>
+ <% end %>
+
+
+
+
+ <% if video.processing_errors.present? %>
+ Failed
+ <% elsif video.processed? %>
+ Processed
+ <% else %>
+ Processing
+ <% end %>
+
+ <%= link_to "Watch", video_path(video), class: "bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded text-xs" %>
+
+
+
+ <% end %>
+
+
+
+ <%== pagy_nav(@pagy) %>
+<% else %>
+
+
+
+
+
No videos found
+
Get started by adding a storage location and scanning for videos.
+ <%= 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" %>
+
+<% end %>
diff --git a/app/views/videos/show.html.erb b/app/views/videos/show.html.erb
new file mode 100644
index 0000000..fab2ae3
--- /dev/null
+++ b/app/views/videos/show.html.erb
@@ -0,0 +1,106 @@
+<% content_for :title, @video.display_title %>
+
+
+
+
+ <%# Placeholder for video player - Phase 1B will add Video.js %>
+
+
+
+
+
+
<%= @video.display_title %>
+ <% if @video.work.present? %>
+
<%= link_to @video.work.display_title, work_path(@video.work), class: "hover:text-blue-600" %>
+ <% end %>
+
+
+
+
+
Video Information
+
+
+
Duration:
+ <%= @video.formatted_duration %>
+
+
+
File Size:
+ <%= @video.formatted_file_size %>
+
+
+
Resolution:
+ <%= @video.resolution_label || "Unknown" %>
+
+
+
Format:
+ <%= @video.format || "Unknown" %>
+
+
+
+
+
+
Storage Information
+
+
+
Storage Location:
+ <%= @video.storage_location.name %>
+
+
+
Source Type:
+ <%= @video.source_type.humanize %>
+
+
+
File Path:
+ <%= @video.file_path %>
+
+
+
+
+
+ <% if @video.video_metadata.present? %>
+
+
Technical Details
+
+
+
+ Video Codec:
+ <%= @video.video_codec || "N/A" %>
+
+
+ Audio Codec:
+ <%= @video.audio_codec || "N/A" %>
+
+
+ Frame Rate:
+ <%= @video.frame_rate || "N/A" %> fps
+
+
+
+
+ Bit Rate:
+ <%= @video.bit_rate ? "#{(@video.bit_rate / 1000).round(1)} kb/s" : "N/A" %>
+
+
+ Dimensions:
+
+ <%= @video.width || "N/A" %> × <%= @video.height || "N/A" %>
+
+
+
+
+
+ <% end %>
+
+
+ <%= 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 %>
+
+
+
+
diff --git a/app/views/works/index.html.erb b/app/views/works/index.html.erb
new file mode 100644
index 0000000..a0ee49d
--- /dev/null
+++ b/app/views/works/index.html.erb
@@ -0,0 +1,4 @@
+
+
Works#index
+
Find me in app/views/works/index.html.erb
+
diff --git a/app/views/works/show.html.erb b/app/views/works/show.html.erb
new file mode 100644
index 0000000..7f1bee5
--- /dev/null
+++ b/app/views/works/show.html.erb
@@ -0,0 +1,4 @@
+
+
Works#show
+
Find me in app/views/works/show.html.erb
+
diff --git a/config/routes.rb b/config/routes.rb
index 48254e8..0526fb0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,14 +1,39 @@
Rails.application.routes.draw do
- # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
-
- # 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.
+ # 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)
- # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
- # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
+ # Root - Phase 1: Storage locations as main entry
+ root "storage_locations#index"
- # Defines the root path route ("/")
- # root "posts#index"
+ # Phase 1: Storage locations focused routes
+ 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
diff --git a/db/migrate/20251029113808_create_works.rb b/db/migrate/20251029113808_create_works.rb
new file mode 100644
index 0000000..591321e
--- /dev/null
+++ b/db/migrate/20251029113808_create_works.rb
@@ -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
diff --git a/db/migrate/20251029113830_create_external_ids.rb b/db/migrate/20251029113830_create_external_ids.rb
new file mode 100644
index 0000000..7f207a7
--- /dev/null
+++ b/db/migrate/20251029113830_create_external_ids.rb
@@ -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
diff --git a/db/migrate/20251029113850_create_storage_locations.rb b/db/migrate/20251029113850_create_storage_locations.rb
new file mode 100644
index 0000000..d017ebf
--- /dev/null
+++ b/db/migrate/20251029113850_create_storage_locations.rb
@@ -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
diff --git a/db/migrate/20251029113911_create_videos.rb b/db/migrate/20251029113911_create_videos.rb
new file mode 100644
index 0000000..1f0f82b
--- /dev/null
+++ b/db/migrate/20251029113911_create_videos.rb
@@ -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
diff --git a/db/migrate/20251029113919_create_video_assets.rb b/db/migrate/20251029113919_create_video_assets.rb
new file mode 100644
index 0000000..8d764ca
--- /dev/null
+++ b/db/migrate/20251029113919_create_video_assets.rb
@@ -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
diff --git a/db/migrate/20251029113957_create_playback_sessions.rb b/db/migrate/20251029113957_create_playback_sessions.rb
new file mode 100644
index 0000000..03e6b91
--- /dev/null
+++ b/db/migrate/20251029113957_create_playback_sessions.rb
@@ -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
diff --git a/db/migrate/20251029120242_create_users.rb b/db/migrate/20251029120242_create_users.rb
new file mode 100644
index 0000000..71f2ff1
--- /dev/null
+++ b/db/migrate/20251029120242_create_users.rb
@@ -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
diff --git a/db/migrate/20251029120243_create_sessions.rb b/db/migrate/20251029120243_create_sessions.rb
new file mode 100644
index 0000000..ec9efdb
--- /dev/null
+++ b/db/migrate/20251029120243_create_sessions.rb
@@ -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
diff --git a/db/migrate/20251029120404_update_videos_from_architecture.rb b/db/migrate/20251029120404_update_videos_from_architecture.rb
new file mode 100644
index 0000000..071d869
--- /dev/null
+++ b/db/migrate/20251029120404_update_videos_from_architecture.rb
@@ -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
diff --git a/db/migrate/20251029120428_update_works_from_architecture.rb b/db/migrate/20251029120428_update_works_from_architecture.rb
new file mode 100644
index 0000000..a0ec313
--- /dev/null
+++ b/db/migrate/20251029120428_update_works_from_architecture.rb
@@ -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
diff --git a/db/migrate/20251029120434_update_storage_locations_from_architecture.rb b/db/migrate/20251029120434_update_storage_locations_from_architecture.rb
new file mode 100644
index 0000000..b43c953
--- /dev/null
+++ b/db/migrate/20251029120434_update_storage_locations_from_architecture.rb
@@ -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
diff --git a/db/migrate/20251029120452_update_video_assets_from_architecture.rb b/db/migrate/20251029120452_update_video_assets_from_architecture.rb
new file mode 100644
index 0000000..7e7e86e
--- /dev/null
+++ b/db/migrate/20251029120452_update_video_assets_from_architecture.rb
@@ -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
diff --git a/db/migrate/20251029120500_update_playback_sessions_from_architecture.rb b/db/migrate/20251029120500_update_playback_sessions_from_architecture.rb
new file mode 100644
index 0000000..6658ced
--- /dev/null
+++ b/db/migrate/20251029120500_update_playback_sessions_from_architecture.rb
@@ -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
diff --git a/db/migrate/20251029120528_update_external_ids_from_architecture.rb b/db/migrate/20251029120528_update_external_ids_from_architecture.rb
new file mode 100644
index 0000000..4673f99
--- /dev/null
+++ b/db/migrate/20251029120528_update_external_ids_from_architecture.rb
@@ -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
diff --git a/db/migrate/20251029204614_add_json_stores_to_video.rb b/db/migrate/20251029204614_add_json_stores_to_video.rb
new file mode 100644
index 0000000..707de3c
--- /dev/null
+++ b/db/migrate/20251029204614_add_json_stores_to_video.rb
@@ -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
diff --git a/db/migrate/20251029204641_add_json_stores_to_work.rb b/db/migrate/20251029204641_add_json_stores_to_work.rb
new file mode 100644
index 0000000..faf6944
--- /dev/null
+++ b/db/migrate/20251029204641_add_json_stores_to_work.rb
@@ -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
diff --git a/db/migrate/20251029215501_add_phase1_fields_to_videos.rb b/db/migrate/20251029215501_add_phase1_fields_to_videos.rb
new file mode 100644
index 0000000..0f3dc68
--- /dev/null
+++ b/db/migrate/20251029215501_add_phase1_fields_to_videos.rb
@@ -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
diff --git a/db/migrate/20251029215811_add_processed_to_videos.rb b/db/migrate/20251029215811_add_processed_to_videos.rb
new file mode 100644
index 0000000..5cf4774
--- /dev/null
+++ b/db/migrate/20251029215811_add_processed_to_videos.rb
@@ -0,0 +1,5 @@
+class AddProcessedToVideos < ActiveRecord::Migration[8.1]
+ def change
+ add_column :videos, :processed, :boolean
+ end
+end
diff --git a/db/migrate/20251031022926_add_type_to_videos.rb b/db/migrate/20251031022926_add_type_to_videos.rb
new file mode 100644
index 0000000..df443a8
--- /dev/null
+++ b/db/migrate/20251031022926_add_type_to_videos.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 03e7368..8ea649c 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,5 +10,149 @@
#
# 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
diff --git a/docs/architecture.md b/docs/architecture.md
index 0745116..47f4f5b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,2803 +1,259 @@
-# Velour - Video Library Application Architecture Plan
+# Velour - Video Library Application Architecture
+
+Velour is a self-hosted video library application built with Rails 8 and Hotwire, designed for personal video collections with optional multi-user support and federation capabilities.
+
+## Quick Start
+
+### Phase 1 (MVP) - Local Filesystem
+- **Goal**: Complete local video library with grouping and transcoding
+- **Timeline**: 5 weeks
+- **Documentation**: [Phase 1 Details](./phases/phase_1.md)
+
+### Phase 2 - Authentication & Multi-User
+- **Goal**: User management and per-user playback history
+- **Documentation**: [Phase 2 Details](./phases/phase_2.md)
+
+### Phase 3 - Remote Sources & Import
+- **Goal**: S3, JellyFin, and web directory integration
+- **Documentation**: [Phase 3 Details](./phases/phase_3.md)
+
+### Phase 4 - Federation
+- **Goal**: Share libraries between Velour instances
+- **Documentation**: [Phase 4 Details](./phases/phase_4.md)
+
+### Phase 5 - Audio Support
+- **Goal**: Add music and audiobook support using extensible MediaFile architecture
+- **Documentation**: [Phase 5 Details](./phases/phase_5.md)
## Technology Stack
-### Backend
-- **Framework:** Ruby on Rails 8.x
-- **Database:** SQLite3 (with potential migration path to PostgreSQL later)
-- **Background Jobs:** Solid Queue (Rails 8 default)
-- **Caching:** Solid Cache (Rails 8 default)
-- **File Storage:**
- - Active Storage (thumbnails/sprites/previews only)
- - Direct filesystem paths for video files
- - S3 SDK (aws-sdk-s3 gem) for remote video storage
-- **Video Processing:** FFmpeg via streamio-ffmpeg gem
+### Core Technologies
+- **Ruby on Rails 8.x** - Modern Rails with Solid Queue and Solid Cache
+- **SQLite3** - Single-user database (PostgreSQL path available)
+- **Hotwire** - Turbo + Stimulus for reactive frontend
+- **Video.js** - HTML5 video player with plugins
+- **FFmpeg** - Video transcoding and metadata extraction
+- **Active Storage** - File management for thumbnails/assets
+- **TailwindCSS** - Utility-first CSS framework
-### Frontend
-- **Framework:** Hotwire (Turbo + Stimulus)
-- **Video Player:** Video.js 8.x with custom plugins
-- **Asset Pipeline:** Importmap-rails or esbuild
-- **Styling:** TailwindCSS
+### Key Rails 8 Features
+- **Solid Queue** - Background job processing
+- **ActiveJob 8.1 Continuations** - Progress tracking
+- **Structured Event Reporting** - Real-time job updates
+- **TurboFrame Permanent Attributes** - Uninterrupted video playback
-### Authentication (Phase 2)
-- **OIDC:** omniauth-openid-connect gem
-- **Session Management:** Rails sessions with encrypted cookies
+## Architecture Overview
----
+### Video Processing Pipeline
+1. **File Discovery** - Automatic scanning of mounted directories
+2. **Metadata Extraction** - FFmpeg analysis with xxhash64 deduplication
+3. **Thumbnail Generation** - Preview images at 10% mark
+4. **Transcoding** - Web-compatible MP4 creation for incompatible formats
+5. **Storage** - Files remain in original locations with managed assets
-## Database Schema
+### Storage Flexibility
+- **Local Files** - Primary storage, original files untouched
+- **Heuristic Discovery** - Automatic categorization of mounted directories
+- **Remote Sources** (Phase 3) - S3, JellyFin, web directories
+- **Federation** (Phase 4) - Cross-instance sharing
-### Core Models
-
-**Works** (canonical representation)
-- Represents the conceptual "work" (e.g., "Batman [1989]")
-- Has many Videos (different versions/qualities)
+### Data Model
```ruby
-- title (string, required)
-- year (integer)
-- director (string)
-- description (text)
-- rating (decimal)
-- organized (boolean, default: false)
-- poster_path (string)
-- backdrop_path (string)
-- metadata (jsonb)
-```
-
-**Videos** (instances of works)
-- Physical video files across all sources
-- Belongs to a Work
-```ruby
-- work_id (references works)
-- storage_location_id (references storage_locations)
-- title (string)
-- file_path (string, required) # relative path or S3 key
-- file_hash (string, indexed)
-- file_size (bigint)
-- duration (float)
-- width (integer)
-- height (integer)
-- resolution_label (string)
-- video_codec (string)
-- audio_codec (string)
-- bit_rate (integer)
-- frame_rate (float)
-- format (string)
-- has_subtitles (boolean)
-- version_type (string)
-- source_type (string) # "local", "s3", "jellyfin", "web", "velour"
-- source_url (string) # full URL for remote sources
-- imported (boolean, default: false) # copied to writable storage?
-- metadata (jsonb)
-```
-
-**StorageLocations**
-- All video sources (readable and writable)
-```ruby
-- name (string, required)
-- path (string) # local path or S3 bucket name
-- location_type (string) # "local", "s3", "jellyfin", "web", "velour"
-- writable (boolean, default: false) # can we import videos here?
-- enabled (boolean, default: true)
-- scan_subdirectories (boolean, default: true)
-- priority (integer, default: 0) # for unified view ordering
-- settings (jsonb) # config per type:
- # S3: {region, access_key_id, secret_access_key, endpoint}
- # JellyFin: {api_url, api_key, user_id}
- # Web: {base_url, username, password, auth_type}
- # Velour: {api_url, api_key}
-- last_scanned_at (datetime)
-```
-
-**VideoAssets**
-- Generated assets (stored via Active Storage)
-```ruby
-- video_id (references videos)
-- asset_type (string) # "thumbnail", "preview", "sprite", "vtt"
-- metadata (jsonb)
-# Active Storage attachments
-```
-
-**PlaybackSessions**
-```ruby
-- video_id (references videos)
-- user_id (references users, nullable)
-- position (float)
-- duration_watched (float)
-- last_watched_at (datetime)
-- completed (boolean)
-- play_count (integer, default: 0)
-```
-
-**ImportJobs** (Phase 3)
-- Track video imports from remote sources
-```ruby
-- video_id (references videos) # source video
-- destination_location_id (references storage_locations)
-- destination_path (string)
-- status (string) # "pending", "downloading", "completed", "failed"
-- progress (float) # 0-100%
-- bytes_transferred (bigint)
-- error_message (text)
-- started_at (datetime)
-- completed_at (datetime)
-```
-
-**Users** (Phase 2)
-```ruby
-- email (string, required, unique)
-- name (string)
-- role (integer, default: 0) # enum: member, admin
-- provider (string)
-- uid (string)
-```
-
-### Database Schema Implementation Notes
-
-**SQLite Limitations:**
-- SQLite does NOT support `jsonb` type - must use `text` with `serialize :metadata, coder: JSON`
-- SQLite does NOT support `enum` types - use integers with Rails enums
-- Consider PostgreSQL migration path for production deployments
-
-**Rails Migration Best Practices:**
-
-```ruby
-# db/migrate/20240101000001_create_works.rb
-class CreateWorks < ActiveRecord::Migration[8.1]
- def change
- create_table :works do |t|
- t.string :title, null: false
- t.integer :year
- t.string :director
- t.text :description
- t.decimal :rating, precision: 3, scale: 1
- t.boolean :organized, default: false, null: false
- t.string :poster_path
- t.string :backdrop_path
- t.text :metadata # SQLite: use text, serialize in model
-
- t.timestamps
- end
-
- add_index :works, :title
- add_index :works, [:title, :year], unique: true
- add_index :works, :organized
- end
-end
-
-# db/migrate/20240101000002_create_storage_locations.rb
-class CreateStorageLocations < ActiveRecord::Migration[8.1]
- def change
- create_table :storage_locations do |t|
- t.string :name, null: false
- t.string :path
- t.integer :location_type, null: false, default: 0 # enum
- t.boolean :writable, default: false, null: false
- t.boolean :enabled, default: true, null: false
- t.boolean :scan_subdirectories, default: true, null: false
- t.integer :priority, default: 0, null: false
- t.text :settings # SQLite: encrypted text column
- t.datetime :last_scanned_at
-
- t.timestamps
- end
-
- add_index :storage_locations, :name, unique: true
- add_index :storage_locations, :location_type
- add_index :storage_locations, :enabled
- add_index :storage_locations, :priority
- end
-end
-
-# db/migrate/20240101000003_create_videos.rb
-class CreateVideos < ActiveRecord::Migration[8.1]
- def change
- create_table :videos do |t|
- t.references :work, null: true, foreign_key: true, index: true
- t.references :storage_location, null: false, foreign_key: true, index: true
- t.string :title
- t.string :file_path, null: false
- t.string :file_hash, index: true
- t.bigint :file_size
- t.float :duration
- t.integer :width
- t.integer :height
- t.string :resolution_label
- t.string :video_codec
- t.string :audio_codec
- t.integer :bit_rate
- t.float :frame_rate
- t.string :format
- t.boolean :has_subtitles, default: false
- t.string :version_type
- t.integer :source_type, null: false, default: 0 # enum
- t.string :source_url
- t.boolean :imported, default: false, null: false
- t.boolean :processing_failed, default: false
- t.text :error_message
- t.text :metadata
-
- t.timestamps
- end
-
- add_index :videos, [:storage_location_id, :file_path], unique: true
- add_index :videos, :source_type
- add_index :videos, :file_hash
- add_index :videos, :imported
- add_index :videos, [:work_id, :resolution_label]
- end
-end
-
-# db/migrate/20240101000004_create_video_assets.rb
-class CreateVideoAssets < ActiveRecord::Migration[8.1]
- def change
- create_table :video_assets do |t|
- t.references :video, null: false, foreign_key: true, index: true
- t.integer :asset_type, null: false # enum
- t.text :metadata
-
- t.timestamps
- end
-
- add_index :video_assets, [:video_id, :asset_type], unique: true
- end
-end
-
-# db/migrate/20240101000005_create_playback_sessions.rb
-class CreatePlaybackSessions < ActiveRecord::Migration[8.1]
- def change
- create_table :playback_sessions do |t|
- t.references :video, null: false, foreign_key: true, index: true
- t.references :user, null: true, foreign_key: true, index: true
- t.float :position, default: 0.0
- t.float :duration_watched, default: 0.0
- t.datetime :last_watched_at
- t.boolean :completed, default: false, null: false
- t.integer :play_count, default: 0, null: false
-
- t.timestamps
- end
-
- add_index :playback_sessions, [:video_id, :user_id], unique: true
- add_index :playback_sessions, :last_watched_at
- end
-end
-
-# db/migrate/20240101000006_create_import_jobs.rb
-class CreateImportJobs < ActiveRecord::Migration[8.1]
- def change
- create_table :import_jobs do |t|
- t.references :video, null: false, foreign_key: true, index: true
- t.references :destination_location, null: false, foreign_key: { to_table: :storage_locations }
- t.string :destination_path
- t.integer :status, null: false, default: 0 # enum
- t.float :progress, default: 0.0
- t.bigint :bytes_transferred, default: 0
- t.text :error_message
- t.datetime :started_at
- t.datetime :completed_at
-
- t.timestamps
- end
-
- add_index :import_jobs, :status
- add_index :import_jobs, [:video_id, :status]
- end
-end
-
-# db/migrate/20240101000007_create_users.rb (Phase 2)
-class CreateUsers < ActiveRecord::Migration[8.1]
- def change
- create_table :users do |t|
- t.string :email, null: false
- t.string :name
- t.integer :role, default: 0, null: false # enum
- t.string :provider
- t.string :uid
-
- t.timestamps
- end
-
- add_index :users, :email, unique: true
- add_index :users, [:provider, :uid], unique: true
- add_index :users, :role
- end
-end
-```
-
-**Enum Definitions:**
-
-```ruby
-# app/models/video.rb
-class Video < ApplicationRecord
- enum source_type: {
- local: 0,
- s3: 1,
- jellyfin: 2,
- web: 3,
- velour: 4
- }
-end
-
-# app/models/storage_location.rb
-class StorageLocation < ApplicationRecord
- enum location_type: {
- local: 0,
- s3: 1,
- jellyfin: 2,
- web: 3,
- velour: 4
- }
-end
-
-# app/models/video_asset.rb
-class VideoAsset < ApplicationRecord
- enum asset_type: {
- thumbnail: 0,
- preview: 1,
- sprite: 2,
- vtt: 3
- }
-end
-
-# app/models/import_job.rb
-class ImportJob < ApplicationRecord
- enum status: {
- pending: 0,
- downloading: 1,
- processing: 2,
- completed: 3,
- failed: 4,
- cancelled: 5
- }
-end
-
-# app/models/user.rb (Phase 2)
-class User < ApplicationRecord
- enum role: {
- member: 0,
- admin: 1
- }
-end
-```
-
----
-
-## Storage Architecture
-
-### Storage Location Types
-
-**1. Local Filesystem** (Readable + Writable)
-```ruby
-settings: {}
-path: "/path/to/videos"
-writable: true
-```
-
-**2. S3 Compatible Storage** (Readable + Writable)
-```ruby
-settings: {
- region: "us-east-1",
- access_key_id: "...",
- secret_access_key: "...",
- endpoint: "https://s3.amazonaws.com" # or Wasabi, Backblaze, etc.
-}
-path: "bucket-name"
-writable: true
-```
-
-**3. JellyFin Server** (Readable only)
-```ruby
-settings: {
- api_url: "https://jellyfin.example.com",
- api_key: "...",
- user_id: "..."
-}
-path: null
-writable: false
-```
-
-**4. Web Directory** (Readable only)
-```ruby
-settings: {
- base_url: "https://videos.example.com",
- auth_type: "basic", # or "bearer", "none"
- username: "...",
- password: "..."
-}
-path: null
-writable: false
-```
-
-**5. Velour Instance** (Readable only - Phase 4)
-```ruby
-settings: {
- api_url: "https://velour.example.com",
- api_key: "..."
-}
-path: null
-writable: false
-```
-
-### Unified View Strategy
-
-**Library Aggregation:**
-- Videos table contains entries from ALL sources
-- `storage_location_id` links each video to its source
-- Unified `/videos` view queries across all enabled locations
-- Filter/group by `storage_location` to see source breakdown
-
-**Video Streaming Strategy:**
-- Local: `send_file` with byte-range support
-- S3: Generate presigned URLs (configurable expiry)
-- JellyFin: Proxy through Rails or redirect to JellyFin stream
-- Web: Proxy through Rails with auth forwarding
-- Velour: Proxy or redirect to federated instance
-
-### Storage Adapter Pattern
-
-**Architecture:** Strategy pattern for pluggable storage backends.
-
-**Base Adapter Interface:**
-
-```ruby
-# app/services/storage_adapters/base_adapter.rb
-module StorageAdapters
- class BaseAdapter
- def initialize(storage_location)
- @storage_location = storage_location
- end
-
- # Scan for video files and return array of relative paths
- def scan
- raise NotImplementedError, "#{self.class} must implement #scan"
- end
-
- # Generate streaming URL for a video
- def stream_url(video)
- raise NotImplementedError, "#{self.class} must implement #stream_url"
- end
-
- # Check if file exists at path
- def exists?(file_path)
- raise NotImplementedError, "#{self.class} must implement #exists?"
- end
-
- # Check if storage can be read from
- def readable?
- raise NotImplementedError, "#{self.class} must implement #readable?"
- end
-
- # Check if storage can be written to
- def writable?
- @storage_location.writable?
- end
-
- # Write/copy file to storage
- def write(source_path, dest_path)
- raise NotImplementedError, "#{self.class} must implement #write"
- end
-
- # Download file to local temp path (for processing)
- def download_to_temp(video)
- raise NotImplementedError, "#{self.class} must implement #download_to_temp"
- end
- end
-end
-```
-
-**Example: Local Adapter**
-
-```ruby
-# app/services/storage_adapters/local_adapter.rb
-module StorageAdapters
- class LocalAdapter < BaseAdapter
- VIDEO_EXTENSIONS = %w[.mp4 .mkv .avi .mov .wmv .flv .webm .m4v].freeze
-
- def scan
- return [] unless readable?
-
- pattern = if @storage_location.scan_subdirectories
- File.join(@storage_location.path, "**", "*{#{VIDEO_EXTENSIONS.join(',')}}")
- else
- File.join(@storage_location.path, "*{#{VIDEO_EXTENSIONS.join(',')}}")
- end
-
- Dir.glob(pattern, File::FNM_CASEFOLD).map do |full_path|
- full_path.sub(@storage_location.path + "/", "")
- end
- end
-
- def stream_url(video)
- full_path(video)
- end
-
- def exists?(file_path)
- File.exist?(full_path_from_relative(file_path))
- end
-
- def readable?
- File.directory?(@storage_location.path) && File.readable?(@storage_location.path)
- end
-
- def writable?
- super && File.writable?(@storage_location.path)
- end
-
- def write(source_path, dest_path)
- dest_full_path = full_path_from_relative(dest_path)
- FileUtils.mkdir_p(File.dirname(dest_full_path))
- FileUtils.cp(source_path, dest_full_path)
- dest_path
- end
-
- def download_to_temp(video)
- # Already local, return path
- full_path(video)
- end
-
- private
- def full_path(video)
- full_path_from_relative(video.file_path)
- end
-
- def full_path_from_relative(file_path)
- File.join(@storage_location.path, file_path)
- end
- end
-end
-```
-
-**Example: S3 Adapter**
-
-```ruby
-# app/services/storage_adapters/s3_adapter.rb
-require 'aws-sdk-s3'
-
-module StorageAdapters
- class S3Adapter < BaseAdapter
- VIDEO_EXTENSIONS = %w[.mp4 .mkv .avi .mov .wmv .flv .webm .m4v].freeze
-
- def scan
- return [] unless readable?
-
- prefix = @storage_location.scan_subdirectories ? "" : nil
-
- s3_client.list_objects_v2(
- bucket: bucket_name,
- prefix: prefix
- ).contents.select do |obj|
- VIDEO_EXTENSIONS.any? { |ext| obj.key.downcase.end_with?(ext) }
- end.map(&:key)
- end
-
- def stream_url(video)
- s3_client.presigned_url(
- :get_object,
- bucket: bucket_name,
- key: video.file_path,
- expires_in: 3600 # 1 hour
- )
- end
-
- def exists?(file_path)
- s3_client.head_object(bucket: bucket_name, key: file_path)
- true
- rescue Aws::S3::Errors::NotFound
- false
- end
-
- def readable?
- s3_client.head_bucket(bucket: bucket_name)
- true
- rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::Forbidden
- false
- end
-
- def write(source_path, dest_path)
- File.open(source_path, 'rb') do |file|
- s3_client.put_object(
- bucket: bucket_name,
- key: dest_path,
- body: file
- )
- end
- dest_path
- end
-
- def download_to_temp(video)
- temp_file = Tempfile.new(['velour-video', File.extname(video.file_path)])
- s3_client.get_object(
- bucket: bucket_name,
- key: video.file_path,
- response_target: temp_file.path
- )
- temp_file.path
- end
-
- private
- def s3_client
- @s3_client ||= Aws::S3::Client.new(
- region: settings['region'],
- access_key_id: settings['access_key_id'],
- secret_access_key: settings['secret_access_key'],
- endpoint: settings['endpoint']
- )
- end
-
- def bucket_name
- @storage_location.path
- end
-
- def settings
- @storage_location.settings
- end
- end
-end
-```
-
-**Model Integration:**
-
-```ruby
-# app/models/storage_location.rb
-class StorageLocation < ApplicationRecord
- encrypts :settings
- serialize :settings, coder: JSON
-
- enum location_type: {
- local: 0,
- s3: 1,
- jellyfin: 2,
- web: 3,
- velour: 4
- }
-
- def adapter
- @adapter ||= adapter_class.new(self)
- end
-
- private
- def adapter_class
- "StorageAdapters::#{location_type.classify}Adapter".constantize
- end
-end
-```
-
-**File Organization:**
-
-```
-app/
-└── services/
- └── storage_adapters/
- ├── base_adapter.rb
- ├── local_adapter.rb
- ├── s3_adapter.rb
- ├── jellyfin_adapter.rb # Phase 3
- ├── web_adapter.rb # Phase 3
- └── velour_adapter.rb # Phase 4
-```
-
----
-
-## Video Processing Pipeline
-
-### Asset Generation Jobs
-
-**VideoProcessorJob**
-- Only runs for videos we can access (local, S3, or importable)
-- Downloads temp copy if remote
-- Generates:
- 1. Thumbnail (1920x1080 JPEG at 10% mark)
- 2. Preview clip (30s MP4 at 720p)
- 3. VTT sprite sheet (160x90 tiles, 5s intervals)
- 4. Metadata extraction (FFprobe)
-- Stores assets via Active Storage (S3 or local)
-- Cleans up temp files
-
-**VideoImportJob** (Phase 3)
-- Downloads video from remote source
-- Shows progress (bytes transferred, %)
-- Saves to writable storage location
-- Creates new Video record for imported copy
-- Links to same Work as source
-- Triggers VideoProcessorJob on completion
-
-### Service Objects Architecture
-
-Service objects encapsulate complex business logic that doesn't belong in models or controllers. They follow the single responsibility principle and make code more testable.
-
-**1. FileScannerService**
-
-Orchestrates scanning a storage location for video files.
-
-```ruby
-# app/services/file_scanner_service.rb
-class FileScannerService
- def initialize(storage_location)
- @storage_location = storage_location
- @adapter = storage_location.adapter
- end
-
- def call
- return Result.failure("Storage location not readable") unless @adapter.readable?
-
- @storage_location.update!(last_scanned_at: Time.current)
-
- file_paths = @adapter.scan
- new_videos = []
-
- file_paths.each do |file_path|
- video = find_or_create_video(file_path)
- new_videos << video if video.previously_new_record?
-
- # Queue processing for new or unprocessed videos
- VideoProcessorJob.perform_later(video.id) if video.duration.nil?
- end
-
- Result.success(videos_found: file_paths.size, new_videos: new_videos.size)
- end
-
- private
- def find_or_create_video(file_path)
- Video.find_or_create_by!(
- storage_location: @storage_location,
- file_path: file_path
- ) do |video|
- video.title = extract_title_from_path(file_path)
- video.source_type = @storage_location.location_type
- end
- end
-
- def extract_title_from_path(file_path)
- File.basename(file_path, ".*")
- .gsub(/[\._]/, " ")
- .gsub(/\[.*?\]/, "")
- .strip
- end
-end
-
-# Usage:
-# result = FileScannerService.new(@storage_location).call
-# if result.success?
-# flash[:notice] = "Found #{result.videos_found} videos (#{result.new_videos} new)"
-# end
-```
-
-**2. VideoMetadataExtractor**
-
-Extracts video metadata using FFprobe.
-
-```ruby
-# app/services/video_metadata_extractor.rb
-require 'streamio-ffmpeg'
-
-class VideoMetadataExtractor
- def initialize(video)
- @video = video
- end
-
- def call
- file_path = @video.storage_location.adapter.download_to_temp(@video)
- movie = FFMPEG::Movie.new(file_path)
-
- @video.update!(
- duration: movie.duration,
- width: movie.width,
- height: movie.height,
- video_codec: movie.video_codec,
- audio_codec: movie.audio_codec,
- bit_rate: movie.bitrate,
- frame_rate: movie.frame_rate,
- format: movie.container,
- file_size: movie.size,
- resolution_label: calculate_resolution_label(movie.height),
- file_hash: calculate_file_hash(file_path)
- )
-
- Result.success
- rescue FFMPEG::Error => e
- @video.update!(processing_failed: true, error_message: e.message)
- Result.failure(e.message)
- ensure
- # Clean up temp file if it was downloaded
- File.delete(file_path) if file_path && File.exist?(file_path) && file_path.include?('tmp')
- end
-
- private
- def calculate_resolution_label(height)
- case height
- when 0..480 then "SD"
- when 481..720 then "720p"
- when 721..1080 then "1080p"
- when 1081..1440 then "1440p"
- when 1441..2160 then "4K"
- else "8K+"
- end
- end
-
- def calculate_file_hash(file_path)
- # Hash first and last 64KB for speed (like Plex/Emby)
- Digest::MD5.file(file_path).hexdigest
- end
-end
-```
-
-**3. DuplicateDetectorService**
-
-Finds potential duplicate videos based on file hash and title similarity.
-
-```ruby
-# app/services/duplicate_detector_service.rb
-class DuplicateDetectorService
- def initialize(video = nil)
- @video = video
- end
-
- def call
- # Find all videos without a work assigned
- unorganized_videos = Video.where(work_id: nil)
-
- # Group by similar titles and file hashes
- potential_groups = []
-
- unorganized_videos.group_by(&:file_hash).each do |hash, videos|
- next if videos.size < 2
-
- potential_groups << {
- type: :exact_duplicate,
- videos: videos,
- confidence: :high
- }
- end
-
- # Find similar titles using Levenshtein distance or fuzzy matching
- unorganized_videos.find_each do |video|
- similar = find_similar_titles(video, unorganized_videos)
- if similar.any?
- potential_groups << {
- type: :similar_title,
- videos: [video] + similar,
- confidence: :medium
- }
- end
- end
-
- Result.success(groups: potential_groups.uniq)
- end
-
- private
- def find_similar_titles(video, candidates)
- return [] unless video.title
-
- candidates.select do |candidate|
- next false if candidate.id == video.id
- next false unless candidate.title
-
- similarity_score(video.title, candidate.title) > 0.8
- end
- end
-
- def similarity_score(str1, str2)
- # Simple implementation - could use gems like 'fuzzy_match' or 'levenshtein'
- str1_clean = normalize_title(str1)
- str2_clean = normalize_title(str2)
-
- return 1.0 if str1_clean == str2_clean
-
- # Jaccard similarity on words
- words1 = str1_clean.split
- words2 = str2_clean.split
-
- intersection = (words1 & words2).size
- union = (words1 | words2).size
-
- intersection.to_f / union
- end
-
- def normalize_title(title)
- title.downcase
- .gsub(/\[.*?\]/, "") # Remove brackets
- .gsub(/\(.*?\)/, "") # Remove parentheses
- .gsub(/[^\w\s]/, "") # Remove special chars
- .strip
- end
-end
-```
-
-**4. WorkGrouperService**
-
-Groups videos into a work.
-
-```ruby
-# app/services/work_grouper_service.rb
-class WorkGrouperService
- def initialize(video_ids, work_attributes = {})
- @video_ids = video_ids
- @work_attributes = work_attributes
- end
-
- def call
- videos = Video.where(id: @video_ids).includes(:work)
-
- return Result.failure("No videos found") if videos.empty?
-
- # Use existing work if all videos belong to the same one
- existing_work = videos.first.work if videos.all? { |v| v.work_id == videos.first.work_id }
-
- ActiveRecord::Base.transaction do
- work = existing_work || create_work_from_videos(videos)
-
- videos.each do |video|
- video.update!(work: work)
- end
-
- Result.success(work: work)
- end
- end
-
- private
- def create_work_from_videos(videos)
- representative = videos.max_by(&:height) || videos.first
-
- Work.create!(
- title: @work_attributes[:title] || extract_base_title(representative.title),
- year: @work_attributes[:year],
- organized: false
- )
- end
-
- def extract_base_title(title)
- # Extract base title by removing resolution, format markers, etc.
- title.gsub(/\d{3,4}p/, "")
- .gsub(/\b(BluRay|WEB-?DL|HDRip|DVDRip|4K|UHD)\b/i, "")
- .gsub(/\[.*?\]/, "")
- .strip
- end
-end
-```
-
-**5. VideoImporterService** (Phase 3)
-
-Handles downloading a video from remote source to writable storage.
-
-```ruby
-# app/services/video_importer_service.rb
-class VideoImporterService
- def initialize(video, destination_location, destination_path = nil)
- @source_video = video
- @destination_location = destination_location
- @destination_path = destination_path || video.file_path
- @import_job = nil
- end
-
- def call
- return Result.failure("Destination is not writable") unless @destination_location.writable?
- return Result.failure("Source is not readable") unless @source_video.storage_location.adapter.readable?
-
- # Create import job
- @import_job = ImportJob.create!(
- video: @source_video,
- destination_location: @destination_location,
- destination_path: @destination_path,
- status: :pending
- )
-
- # Queue background job
- VideoImportJob.perform_later(@import_job.id)
-
- Result.success(import_job: @import_job)
- end
-end
-```
-
-**File Organization:**
-
-```
-app/
-└── services/
- ├── storage_adapters/ # Storage backends
- ├── file_scanner_service.rb
- ├── video_metadata_extractor.rb
- ├── duplicate_detector_service.rb
- ├── work_grouper_service.rb
- ├── video_importer_service.rb # Phase 3
- └── result.rb # Simple result object
-```
-
-**Result Object Pattern:**
-
-```ruby
-# app/services/result.rb
-class Result
- attr_reader :data, :error
-
- def initialize(success:, data: {}, error: nil)
- @success = success
- @data = data
- @error = error
- end
-
- def success?
- @success
- end
-
- def failure?
- !@success
- end
-
- def self.success(data = {})
- new(success: true, data: data)
- end
-
- def self.failure(error)
- new(success: false, error: error)
- end
-
- # Allow accessing data as methods
- def method_missing(method, *args)
- return @data[method] if @data.key?(method)
- super
- end
-
- def respond_to_missing?(method, include_private = false)
- @data.key?(method) || super
- end
-end
-```
-
----
-
-## Model Organization
-
-Rails models follow a specific organization pattern for readability and maintainability.
-
-### Complete Model Examples
-
-**Work Model:**
-
-```ruby
-# app/models/work.rb
-class Work < ApplicationRecord
- # 1. Includes/Concerns
- include Searchable
-
- # 2. Serialization
- serialize :metadata, coder: JSON
-
- # 3. Associations
- has_many :videos, dependent: :nullify
- has_one :primary_video, -> { order(height: :desc) }, class_name: "Video"
-
- # 4. Validations
- validates :title, presence: true
- validates :year, numericality: { only_integer: true, greater_than: 1800 }, allow_nil: true
- validates :rating, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }, allow_nil: true
-
- # 5. Scopes
- scope :organized, -> { where(organized: true) }
- scope :unorganized, -> { where(organized: false) }
- scope :recent, -> { order(created_at: :desc) }
- scope :by_title, -> { order(:title) }
- scope :with_year, -> { where.not(year: nil) }
-
- # 6. Delegations
- delegate :resolution_label, :duration, to: :primary_video, prefix: true, allow_nil: true
-
- # 7. Class methods
- def self.search(query)
- where("title LIKE ? OR director LIKE ?", "%#{query}%", "%#{query}%")
- end
-
- # 8. Instance methods
- def display_title
- year ? "#{title} (#{year})" : title
- end
-
- def video_count
- videos.count
- end
-
- def total_duration
- videos.sum(:duration)
- end
-
- def available_versions
- videos.group_by(&:resolution_label)
- end
-end
-```
-
-**Video Model:**
-
-```ruby
-# app/models/video.rb
-class Video < ApplicationRecord
- # 1. Includes/Concerns
- include Streamable
- include Processable
-
- # 2. Serialization
- serialize :metadata, coder: JSON
-
- # 3. Enums
- enum source_type: {
- local: 0,
- s3: 1,
- jellyfin: 2,
- web: 3,
- velour: 4
- }
-
- # 4. Associations
- belongs_to :work, optional: true, touch: true
- belongs_to :storage_location
- has_many :video_assets, dependent: :destroy
- has_many :playback_sessions, dependent: :destroy
- has_one :import_job, dependent: :nullify
-
- has_one_attached :thumbnail
- has_one_attached :preview_video
-
- # 5. Validations
- validates :title, presence: true
- validates :file_path, presence: true
- validates :file_hash, presence: true
- validates :source_type, presence: true
- validate :file_exists_on_storage, on: :create
-
- # 6. Callbacks
- before_save :normalize_title
- after_create :queue_processing
-
- # 7. Scopes
- scope :unprocessed, -> { where(duration: nil, processing_failed: false) }
- scope :processed, -> { where.not(duration: nil) }
- scope :failed, -> { where(processing_failed: true) }
- scope :by_source, ->(type) { where(source_type: type) }
- scope :imported, -> { where(imported: true) }
- scope :recent, -> { order(created_at: :desc) }
- scope :by_resolution, -> { order(height: :desc) }
- scope :with_work, -> { where.not(work_id: nil) }
- scope :without_work, -> { where(work_id: nil) }
-
- # 8. Delegations
- delegate :name, :location_type, :adapter, to: :storage_location, prefix: true
- delegate :display_title, to: :work, prefix: true, allow_nil: true
-
- # 9. Class methods
- def self.search(query)
- left_joins(:work)
- .where("videos.title LIKE ? OR works.title LIKE ?", "%#{query}%", "%#{query}%")
- .distinct
- end
-
- def self.by_duration(min: nil, max: nil)
- scope = all
- scope = scope.where("duration >= ?", min) if min
- scope = scope.where("duration <= ?", max) if max
- scope
- end
-
- # 10. Instance methods
- def display_title
- work_display_title || title || filename
- end
-
- def filename
- File.basename(file_path)
- end
-
- def file_extension
- File.extname(file_path).downcase
- end
-
- def formatted_duration
- return "Unknown" unless duration
-
- hours = (duration / 3600).to_i
- minutes = ((duration % 3600) / 60).to_i
- seconds = (duration % 60).to_i
-
- if hours > 0
- "%d:%02d:%02d" % [hours, minutes, seconds]
- else
- "%d:%02d" % [minutes, seconds]
- end
- end
-
- def formatted_file_size
- return "Unknown" unless file_size
-
- units = ['B', 'KB', 'MB', 'GB', 'TB']
- size = file_size.to_f
- unit_index = 0
-
- while size >= 1024 && unit_index < units.length - 1
- size /= 1024.0
- unit_index += 1
- end
-
- "%.2f %s" % [size, units[unit_index]]
- end
-
- def processable?
- !processing_failed && storage_location_adapter.readable?
- end
-
- def streamable?
- duration.present? && storage_location.enabled? && storage_location_adapter.readable?
- end
-
- private
- def normalize_title
- self.title = title.strip if title.present?
- end
-
- def queue_processing
- VideoProcessorJob.perform_later(id) if processable?
- end
-
- def file_exists_on_storage
- return if storage_location_adapter.exists?(file_path)
- errors.add(:file_path, "does not exist on storage")
- end
-end
-```
-
-**StorageLocation Model:**
-
-```ruby
-# app/models/storage_location.rb
-class StorageLocation < ApplicationRecord
- # 1. Encryption & Serialization
- encrypts :settings
- serialize :settings, coder: JSON
-
- # 2. Enums
- enum location_type: {
- local: 0,
- s3: 1,
- jellyfin: 2,
- web: 3,
- velour: 4
- }
-
- # 3. Associations
- has_many :videos, dependent: :restrict_with_error
- has_many :destination_imports, class_name: "ImportJob", foreign_key: :destination_location_id
-
- # 4. Validations
- validates :name, presence: true, uniqueness: true
- validates :location_type, presence: true
- validates :path, presence: true, if: -> { local? || s3? }
- validate :adapter_is_valid
-
- # 5. Scopes
- scope :enabled, -> { where(enabled: true) }
- scope :disabled, -> { where(enabled: false) }
- scope :writable, -> { where(writable: true) }
- scope :readable, -> { enabled }
- scope :by_priority, -> { order(priority: :desc) }
- scope :by_type, ->(type) { where(location_type: type) }
-
- # 6. Instance methods
- def adapter
- @adapter ||= adapter_class.new(self)
- end
-
- def scan!
- FileScannerService.new(self).call
- end
-
- def accessible?
- adapter.readable?
- rescue StandardError
- false
- end
-
- def video_count
- videos.count
- end
-
- def last_scan_ago
- return "Never" unless last_scanned_at
-
- distance_of_time_in_words(last_scanned_at, Time.current)
- end
-
- private
- def adapter_class
- "StorageAdapters::#{location_type.classify}Adapter".constantize
- end
-
- def adapter_is_valid
- adapter.readable?
- rescue StandardError => e
- errors.add(:base, "Cannot access storage: #{e.message}")
- end
-end
-```
-
-**PlaybackSession Model:**
-
-```ruby
-# app/models/playback_session.rb
-class PlaybackSession < ApplicationRecord
- # 1. Associations
- belongs_to :video
- belongs_to :user, optional: true
-
- # 2. Validations
- validates :video, presence: true
- validates :position, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
- validates :duration_watched, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
-
- # 3. Callbacks
- before_save :check_completion
-
- # 4. Scopes
- scope :recent, -> { order(last_watched_at: :desc) }
- scope :completed, -> { where(completed: true) }
- scope :in_progress, -> { where(completed: false).where.not(position: 0) }
- scope :for_user, ->(user) { where(user: user) }
-
- # 5. Class methods
- def self.update_position(video, user, position, duration_watched = 0)
- session = find_or_initialize_by(video: video, user: user)
- session.position = position
- session.duration_watched = (session.duration_watched || 0) + duration_watched
- session.last_watched_at = Time.current
- session.play_count += 1 if session.position.zero?
- session.save!
- end
-
- # 6. Instance methods
- def progress_percentage
- return 0 unless video.duration && position
-
- ((position / video.duration) * 100).round(1)
- end
-
- def resume_position
- completed? ? 0 : position
- end
-
- private
- def check_completion
- if video.duration && position
- self.completed = (position / video.duration) > 0.9
- end
- end
-end
-```
-
-### Model Concerns
-
-**Streamable Concern:**
-
-```ruby
-# app/models/concerns/streamable.rb
-module Streamable
- extend ActiveSupport::Concern
-
- included do
- # Add any class-level includes here
- end
-
- def stream_url
- storage_location.adapter.stream_url(self)
- end
-
- def streamable?
- duration.present? && storage_location.enabled? && storage_location.adapter.readable?
- end
-
- def stream_type
- case source_type
- when "s3" then :presigned
- when "local" then :direct
- else :proxy
- end
- end
-end
-```
-
-**Processable Concern:**
-
-```ruby
-# app/models/concerns/processable.rb
-module Processable
- extend ActiveSupport::Concern
-
- included do
- scope :processing_pending, -> { where(duration: nil, processing_failed: false) }
- end
-
- def processed?
- duration.present?
- end
-
- def processing_pending?
- !processed? && !processing_failed?
- end
-
- def mark_processing_failed!(error_message)
- update!(processing_failed: true, error_message: error_message)
- end
-
- def retry_processing!
- update!(processing_failed: false, error_message: nil)
- VideoProcessorJob.perform_later(id)
- end
-end
-```
-
-**Searchable Concern:**
-
-```ruby
-# app/models/concerns/searchable.rb
-module Searchable
- extend ActiveSupport::Concern
-
- class_methods do
- def search(query)
- return all if query.blank?
-
- where("title LIKE ?", "%#{sanitize_sql_like(query)}%")
- end
- end
-end
-```
-
----
-
-## Frontend Architecture
-
-### Main Views (Hotwire)
-
-**Library Views:**
-- `videos/index` - Unified library grid with filters by source
-- `videos/show` - Video player page
-- `works/index` - Works library (grouped by work)
-- `works/show` - Work details with all versions/sources
-
-**Admin Views (Phase 2+):**
-- `storage_locations/index` - Manage all sources
-- `storage_locations/new` - Add new source (local/S3/JellyFin/etc)
-- `import_jobs/index` - Monitor import progress
-- `videos/:id/import` - Import modal/form
-
-### Stimulus Controllers
-
-**VideoPlayerController**
-- Initialize Video.js with source detection
-- Handle different streaming strategies (local/S3/proxy)
-- Track playback position
-- Quality switching for multiple versions
-
-**LibraryScanController**
-- Trigger scans per storage location
-- Real-time progress via Turbo Streams
-- Show scan results and new videos found
-
-**VideoImportController**
-- Select destination storage location
-- Show import progress
-- Cancel import jobs
-
-**WorkMergeController**
-- Group videos into works
-- Drag-and-drop UI
-- Show all versions/sources for a work
-
-### Video.js Custom Plugins
-
-1. **resume-plugin** - Auto-resume from saved position
-2. **track-plugin** - Send playback stats to Rails API
-3. **quality-selector** - Switch between versions (same work, different sources/resolutions)
-4. **thumbnails-plugin** - VTT sprite preview on seek
-
----
-
-## Authorization & Security
-
-### Phase 1: No Authentication (MVP)
-
-For MVP, all features are accessible without authentication. However, the authorization structure is designed to be auth-ready.
-
-**Current Pattern (Request Context):**
-
-```ruby
-# app/models/current.rb
-class Current < ActiveSupport::CurrentAttributes
- attribute :user
- attribute :request_id
-end
-
-# app/controllers/application_controller.rb
-class ApplicationController < ActionController::Base
- before_action :set_current_attributes
-
- private
- def set_current_attributes
- Current.user = current_user if respond_to?(:current_user)
- Current.request_id = request.uuid
- end
-end
-```
-
-### Phase 2: OIDC Authentication
-
-**User Model with Authorization:**
-
-```ruby
-# app/models/user.rb
-class User < ApplicationRecord
- enum role: { member: 0, admin: 1 }
-
- validates :email, presence: true, uniqueness: true
-
- def admin?
- role == "admin"
- end
-
- def self.admin_from_env
- admin_email = ENV['ADMIN_EMAIL']
- return nil unless admin_email
-
- find_by(email: admin_email)
- end
-end
-```
-
-**OIDC Configuration:**
-
-```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]
-end
-```
-
-**Sessions Controller:**
-
-```ruby
-# app/controllers/sessions_controller.rb
-class SessionsController < ApplicationController
- skip_before_action :authenticate_user!, only: [:create]
-
- def create
- auth_hash = request.env['omniauth.auth']
- user = User.find_or_create_from_omniauth(auth_hash)
-
- session[:user_id] = user.id
- redirect_to root_path, notice: "Signed in successfully"
- end
-
- def destroy
- session[:user_id] = nil
- redirect_to root_path, notice: "Signed out successfully"
- end
-end
-```
-
-### Model-Level Authorization
-
-```ruby
-# app/models/storage_location.rb
-class StorageLocation < ApplicationRecord
- def editable_by?(user)
- return true if user.nil? # Phase 1: no auth
- user.admin? # Phase 2+: only admins
- end
-
- def deletable_by?(user)
- return true if user.nil?
- user.admin? && videos.count.zero?
- end
-end
-
-# app/models/work.rb
-class Work < ApplicationRecord
- def editable_by?(user)
- return true if user.nil?
- user.present? # Any authenticated user
- end
-end
-```
-
-**Controller-Level Guards:**
-
-```ruby
-# app/controllers/admin/base_controller.rb
-module Admin
- class BaseController < ApplicationController
- before_action :require_admin!
-
- private
- def require_admin!
- return if Current.user&.admin?
-
- redirect_to root_path, alert: "Access denied"
- end
- end
-end
-
-# app/controllers/admin/storage_locations_controller.rb
-module Admin
- class StorageLocationsController < Admin::BaseController
- before_action :set_storage_location, only: [:edit, :update, :destroy]
- before_action :authorize_storage_location, only: [:edit, :update, :destroy]
-
- def index
- @storage_locations = StorageLocation.all
- end
-
- def update
- if @storage_location.update(storage_location_params)
- redirect_to admin_storage_locations_path, notice: "Updated successfully"
- else
- render :edit, status: :unprocessable_entity
- end
- end
-
- private
- def authorize_storage_location
- head :forbidden unless @storage_location.editable_by?(Current.user)
- end
- end
-end
-```
-
-### Credentials Management
-
-**Encrypted Settings:**
-
-```ruby
-# app/models/storage_location.rb
-class StorageLocation < ApplicationRecord
- encrypts :settings # Rails 7+ built-in encryption
- serialize :settings, coder: JSON
-end
-
-# Generate encryption key:
-# bin/rails db:encryption:init
-# Add to config/credentials.yml.enc
-```
-
-**Rails Credentials:**
-
-```bash
-# Edit credentials
-bin/rails credentials:edit
-
-# Add:
-# aws:
-# access_key_id: <%= ENV["AWS_ACCESS_KEY_ID"] %>
-# secret_access_key: <%= ENV["AWS_SECRET_ACCESS_KEY"] %>
-```
-
-### API Authentication (Phase 4 - Federation)
-
-**API Key Authentication:**
-
-```ruby
-# app/controllers/api/federation/base_controller.rb
-module Api
- module Federation
- class BaseController < ActionController::API
- include ActionController::HttpAuthentication::Token::ControllerMethods
-
- before_action :authenticate_api_key!
-
- private
- def authenticate_api_key!
- return if Rails.env.development?
- return unless ENV['ALLOW_FEDERATION'] == 'true'
-
- authenticate_or_request_with_http_token do |token, options|
- ActiveSupport::SecurityUtils.secure_compare(
- token,
- ENV.fetch('VELOUR_API_KEY')
- )
- end
- end
- end
- end
-end
-```
-
-**Rate Limiting:**
-
-```ruby
-# config/initializers/rack_attack.rb (optional, recommended)
-class Rack::Attack
- throttle('api/ip', limit: 300, period: 5.minutes) do |req|
- req.ip if req.path.start_with?('/api/')
- end
-
- throttle('federation/api_key', limit: 1000, period: 1.hour) do |req|
- req.env['HTTP_AUTHORIZATION'] if req.path.start_with?('/api/federation/')
- end
-end
-```
-
----
-
-## API Controller Architecture
-
-### Controller Organization
-
-```
-app/
-└── controllers/
- ├── application_controller.rb
- ├── videos_controller.rb # HTML views
- ├── works_controller.rb
- ├── admin/
- │ ├── base_controller.rb
- │ ├── storage_locations_controller.rb
- │ └── import_jobs_controller.rb
- └── api/
- ├── base_controller.rb
- └── v1/
- ├── videos_controller.rb
- ├── works_controller.rb
- ├── playback_sessions_controller.rb
- └── storage_locations_controller.rb
- └── federation/ # Phase 4
- ├── base_controller.rb
- ├── videos_controller.rb
- └── works_controller.rb
-```
-
-### API Base Controllers
-
-**Internal API Base:**
-
-```ruby
-# app/controllers/api/base_controller.rb
-module Api
- class BaseController < ActionController::API
- include ActionController::Cookies
-
- rescue_from ActiveRecord::RecordNotFound, with: :not_found
- rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
-
- private
- def not_found
- render json: { error: "Not found" }, status: :not_found
- end
-
- def unprocessable_entity(exception)
- render json: {
- error: "Validation failed",
- details: exception.record.errors.full_messages
- }, status: :unprocessable_entity
- end
- end
-end
-```
-
-**Example API Controller:**
-
-```ruby
-# app/controllers/api/v1/videos_controller.rb
-module Api
- module V1
- class VideosController < Api::BaseController
- before_action :set_video, only: [:show, :stream]
-
- def index
- @videos = Video.includes(:work, :storage_location)
- .order(created_at: :desc)
- .page(params[:page])
- .per(params[:per] || 50)
-
- render json: {
- videos: @videos.as_json(include: [:work, :storage_location]),
- meta: pagination_meta(@videos)
- }
- end
-
- def show
- render json: @video.as_json(
- include: {
- work: { only: [:id, :title, :year] },
- storage_location: { only: [:id, :name, :location_type] }
- },
- methods: [:stream_url, :formatted_duration]
- )
- end
-
- def stream
- # Route to appropriate streaming strategy
- case @video.stream_type
- when :presigned
- render json: { stream_url: @video.stream_url }
- when :direct
- send_file @video.stream_url,
- type: @video.format,
- disposition: 'inline',
- stream: true
- when :proxy
- # Implement proxy logic
- redirect_to @video.stream_url, allow_other_host: true
- end
- end
-
- private
- def set_video
- @video = Video.find(params[:id])
- end
-
- def pagination_meta(collection)
- {
- current_page: collection.current_page,
- next_page: collection.next_page,
- prev_page: collection.prev_page,
- total_pages: collection.total_pages,
- total_count: collection.total_count
- }
- end
- end
- end
-end
-```
-
-**Playback Tracking API:**
-
-```ruby
-# app/controllers/api/v1/playback_sessions_controller.rb
-module Api
- module V1
- class PlaybackSessionsController < Api::BaseController
- def update
- video = Video.find(params[:video_id])
- user = Current.user # nil in Phase 1, actual user in Phase 2
-
- session = PlaybackSession.update_position(
- video,
- user,
- params[:position].to_f,
- params[:duration_watched].to_f
- )
-
- render json: { success: true, session: session }
- rescue ActiveRecord::RecordNotFound
- render json: { error: "Video not found" }, status: :not_found
- end
- end
- end
-end
-```
-
----
-
-## Turbo Streams & Real-Time Updates
-
-### Scan Progress Broadcasting
-
-**Job with Turbo Streams:**
-
-```ruby
-# app/jobs/file_scanner_job.rb
-class FileScannerJob < ApplicationJob
- queue_as :default
-
- def perform(storage_location_id)
- @storage_location = StorageLocation.find(storage_location_id)
-
- broadcast_update(status: "started", progress: 0)
-
- result = FileScannerService.new(@storage_location).call
-
- if result.success?
- broadcast_update(
- status: "completed",
- progress: 100,
- videos_found: result.videos_found,
- new_videos: result.new_videos
- )
- else
- broadcast_update(status: "failed", error: result.error)
- end
- end
-
- private
- def broadcast_update(**data)
- Turbo::StreamsChannel.broadcast_replace_to(
- "storage_location_#{@storage_location.id}",
- target: "scan_status",
- partial: "admin/storage_locations/scan_status",
- locals: { storage_location: @storage_location, **data }
- )
- end
-end
-```
-
-**View with Turbo Stream:**
-
-```erb
-<%# app/views/admin/storage_locations/show.html.erb %>
-<%= turbo_stream_from "storage_location_#{@storage_location.id}" %>
-
-
- <%= render "scan_status", storage_location: @storage_location, status: "idle" %>
-
-
-<%= button_to "Scan Now", scan_admin_storage_location_path(@storage_location),
- method: :post,
- data: { turbo_frame: "_top" },
- class: "btn btn-primary" %>
-```
-
-**Partial:**
-
-```erb
-<%# app/views/admin/storage_locations/_scan_status.html.erb %>
-
- <% case status %>
- <% when "started" %>
-
-
Scanning... <%= progress %>%
-
- <% when "completed" %>
-
-
Scan completed! Found <%= videos_found %> videos (<%= new_videos %> new)
-
- <% when "failed" %>
-
-
Scan failed: <%= error %>
-
- <% else %>
-
Ready to scan
- <% end %>
-
-```
-
-### Import Progress Updates
-
-Similar pattern for VideoImportJob with progress broadcasting.
-
----
-
-## Testing Strategy
-
-### Test Organization
-
-```
-test/
-├── models/
-│ ├── video_test.rb
-│ ├── work_test.rb
-│ ├── storage_location_test.rb
-│ ├── playback_session_test.rb
-│ └── concerns/
-│ ├── streamable_test.rb
-│ └── processable_test.rb
-├── services/
-│ ├── file_scanner_service_test.rb
-│ ├── video_metadata_extractor_test.rb
-│ ├── duplicate_detector_service_test.rb
-│ └── storage_adapters/
-│ ├── local_adapter_test.rb
-│ └── s3_adapter_test.rb
-├── jobs/
-│ ├── video_processor_job_test.rb
-│ └── file_scanner_job_test.rb
-├── controllers/
-│ ├── videos_controller_test.rb
-│ ├── works_controller_test.rb
-│ └── api/
-│ └── v1/
-│ └── videos_controller_test.rb
-└── system/
- ├── video_playback_test.rb
- ├── library_browsing_test.rb
- └── work_grouping_test.rb
-```
-
-### Example Tests
-
-**Model Test:**
-
-```ruby
-# test/models/video_test.rb
-require "test_helper"
-
-class VideoTest < ActiveSupport::TestCase
- test "belongs to storage location" do
- video = videos(:one)
- assert_instance_of StorageLocation, video.storage_location
- end
-
- test "validates presence of required fields" do
- video = Video.new
- assert_not video.valid?
- assert_includes video.errors[:title], "can't be blank"
- assert_includes video.errors[:file_path], "can't be blank"
- end
-
- test "formatted_duration returns correct format" do
- video = videos(:one)
- video.duration = 3665 # 1 hour, 1 minute, 5 seconds
- assert_equal "1:01:05", video.formatted_duration
- end
-
- test "streamable? returns true when video is processed and storage is enabled" do
- video = videos(:one)
- video.duration = 100
- video.storage_location.update!(enabled: true)
-
- assert video.streamable?
- end
-end
-```
-
-**Service Test:**
-
-```ruby
-# test/services/file_scanner_service_test.rb
-require "test_helper"
-
-class FileScannerServiceTest < ActiveSupport::TestCase
- setup do
- @storage_location = storage_locations(:local_movies)
- end
-
- test "scans directory and creates video records" do
- # Stub adapter scan method
- @storage_location.adapter.stub :scan, ["movie1.mp4", "movie2.mkv"] do
- result = FileScannerService.new(@storage_location).call
-
- assert result.success?
- assert_equal 2, result.videos_found
- end
- end
-
- test "updates last_scanned_at timestamp" do
- @storage_location.adapter.stub :scan, [] do
- FileScannerService.new(@storage_location).call
-
- @storage_location.reload
- assert_not_nil @storage_location.last_scanned_at
- end
- end
-end
-```
-
-**System Test:**
-
-```ruby
-# test/system/video_playback_test.rb
-require "application_system_test_case"
-
-class VideoPlaybackTest < ApplicationSystemTestCase
- test "playing a video updates playback position" do
- video = videos(:one)
-
- visit video_path(video)
- assert_selector "video#video-player"
-
- # Simulate video playback (would need JS execution)
- # assert_changes -> { video.playback_sessions.first&.position }
- end
-
- test "resume functionality loads saved position" do
- video = videos(:one)
- PlaybackSession.create!(video: video, position: 30.0)
-
- visit video_path(video)
-
- # Assert player starts at saved position
- # (implementation depends on Video.js setup)
- end
-end
-```
-
----
-
-## API Design (RESTful)
-
-### Video Playback API (Internal)
-```
-GET /api/v1/videos/:id/stream # Stream video (route to appropriate source)
-GET /api/v1/videos/:id/presigned # Get presigned S3 URL
-GET /api/v1/videos/:id/metadata # Get video metadata
-POST /api/v1/videos/:id/playback # Update playback position
-GET /api/v1/videos/:id/assets # Get thumbnails, sprites
-```
-
-### Library Management API
-```
-GET /api/v1/videos # List all videos (unified view)
-GET /api/v1/works # List works with grouped videos
-POST /api/v1/works/:id/merge # Merge videos into work
-GET /api/v1/storage_locations # List all sources
-POST /api/v1/storage_locations # Add new source
-POST /api/v1/storage_locations/:id/scan # Trigger scan
-GET /api/v1/storage_locations/:id/scan_status
-```
-
-### Import API (Phase 3)
-```
-POST /api/v1/videos/:id/import # Import video to writable storage
-GET /api/v1/import_jobs # List import jobs
-GET /api/v1/import_jobs/:id # Get import status
-DELETE /api/v1/import_jobs/:id # Cancel import
-```
-
-### Federation API (Phase 4) - Public to other Velour instances
-```
-GET /api/v1/federation/videos # List available videos
-GET /api/v1/federation/videos/:id # Get video details
-GET /api/v1/federation/videos/:id/stream # Stream video (with API key auth)
-GET /api/v1/federation/works # List works
-```
-
----
-
-## Required Gems
-
-Add these to the Gemfile:
-
-```ruby
-# Gemfile
-
-# Core gems (already present in Rails 8)
-gem "rails", "~> 8.1.1"
-gem "sqlite3", ">= 2.1"
-gem "puma", ">= 5.0"
-gem "importmap-rails"
-gem "turbo-rails"
-gem "stimulus-rails"
-gem "tailwindcss-rails"
-gem "solid_cache"
-gem "solid_queue"
-gem "solid_cable"
-gem "image_processing", "~> 1.2"
-
-# Video processing
-gem "streamio-ffmpeg" # FFmpeg wrapper for metadata extraction
-
-# Pagination
-gem "pagy" # Fast, lightweight pagination
-
-# AWS SDK for S3 support (Phase 3, but add early)
-gem "aws-sdk-s3"
-
-# Phase 2: Authentication
-# gem "omniauth-openid-connect"
-# gem "omniauth-rails_csrf_protection"
-
-# Phase 3: Remote sources
-# gem "httparty" # For JellyFin/Web APIs
-# gem "down" # For downloading remote files
-
-# Development & Test
-group :development, :test do
- gem "debug", platforms: %i[mri windows]
- gem "bundler-audit"
- gem "brakeman"
- gem "rubocop-rails-omakase"
-end
-
-group :development do
- gem "web-console"
- gem "bullet" # N+1 query detection
- gem "strong_migrations" # Catch unsafe migrations
-end
-
-group :test do
- gem "capybara"
- gem "selenium-webdriver"
- gem "mocha" # Stubbing/mocking
-end
-
-# Optional but recommended
-# gem "rack-attack" # Rate limiting (Phase 4)
-```
-
----
-
-## Route Structure
-
-```ruby
-# config/routes.rb
-Rails.application.routes.draw do
- # Health check
- get "up" => "rails/health#show", as: :rails_health_check
-
- # Root
- root "videos#index"
-
- # Main UI routes
- resources :videos, only: [:index, :show] do
- member do
- get :watch # Player page
- post :import # Phase 3: Import to writable storage
- end
- end
-
- resources :works, only: [:index, :show] do
- member do
- post :merge # Merge videos into this work
- end
- end
-
- # Admin routes (Phase 2+)
- namespace :admin do
- root "dashboard#index"
-
- resources :storage_locations do
- member do
- post :scan
- get :scan_status
- end
- end
-
- resources :import_jobs, only: [:index, :show, :destroy] do
- member do
- post :cancel
- end
- end
-
- resources :users, only: [:index, :edit, :update] # Phase 2
- end
-
- # Internal API (for JS/Stimulus)
- namespace :api do
- namespace :v1 do
- resources :videos, only: [:index, :show] do
- member do
- get :stream
- get :presigned # For S3 presigned URLs
- end
- end
-
- resources :works, only: [:index, :show]
-
- resources :playback_sessions, only: [] do
- collection do
- post :update # POST /api/v1/playback_sessions/update
- end
- end
-
- resources :storage_locations, only: [:index]
- end
-
- # Federation API (Phase 4)
- namespace :federation do
- resources :videos, only: [:index, :show] do
- member do
- get :stream
- end
- end
-
- resources :works, only: [:index, :show]
- end
- end
-
- # Authentication routes (Phase 2)
- # get '/auth/:provider/callback', to: 'sessions#create'
- # delete '/sign_out', to: 'sessions#destroy', as: :sign_out
-end
-```
-
----
+Work (canonical content) - has_many -> Videos (different files/qualities)
+Video (inherits from MediaFile) - belongs_to -> StorageLocation (where file lives)
+Video - has_many -> VideoAssets (thumbnails, previews)
+Video - has_many -> PlaybackSessions (user viewing history)
+Work - has_many -> ExternalIds (IMDb, TMDb references)
+
+**Extensible Architecture**: MediaFile base class supports future Audio model
+MediaFile - common functionality (streaming, processing, metadata)
+Video - video-specific functionality (resolution, codecs)
+Audio (Phase 5) - audio-specific functionality (bitrate, sample rate, album art)
+```
+
+## Key Features
+
+### Phase 1 (MVP)
+- ✅ Local video library scanning and organization
+- ✅ Video streaming with seeking support
+- ✅ Automatic transcoding to web-compatible formats
+- ✅ Duplicate detection and grouping into "works"
+- ✅ Uninterrupted playback with TurboFrame
+- ✅ Real-time processing progress with Turbo Streams
+
+### Phase 2 (Multi-User)
+- ✅ Rails authentication with OIDC extension
+- ✅ Admin user bootstrap flow
+- ✅ Per-user playback history and preferences
+- ✅ Storage location management (admin only)
+
+### Phase 3 (Remote Sources)
+- ✅ S3 storage location support
+- ✅ JellyFin server integration
+- ✅ Web directory browsing
+- ✅ Video import system with progress tracking
+
+### Phase 4 (Federation)
+- ✅ Cross-instance API with authentication
+- ✅ Remote video streaming
+- ✅ Federated library discovery
+- ✅ Security and rate limiting
+
+### Phase 5 (Audio Support)
+- ✅ Audio model inheriting from MediaFile
+- ✅ Music library with album/artist organization
+- ✅ Audiobook support with chapter tracking
+- ✅ Audio processing and transcoding pipeline
## Development Phases
-### Phase 1: MVP (Local Filesystem)
+### Phase 1A: Core Foundation (Week 1-2)
+- Models and migrations
+- Storage adapter pattern
+- File scanning service
+- Basic UI
-Phase 1 is broken into 4 sub-phases for manageable milestones:
+### Phase 1B: Video Playback (Week 3)
+- Video streaming with byte-range support
+- Video.js integration
+- Playback session tracking
-#### Phase 1A: Core Foundation (Week 1-2)
+### Phase 1C: Processing Pipeline (Week 4)
+- FFmpeg integration
+- Background job processing
+- Thumbnail generation
-**Goal:** Basic models and database setup
+### Phase 1D: Works & Grouping (Week 5)
+- Duplicate detection
+- Manual grouping interface
+- Search and filtering
-1. **Generate models with migrations:**
- - Works
- - Videos
- - StorageLocations
- - PlaybackSessions (basic structure)
+## Quick Setup
-2. **Implement models:**
- - Add validations, scopes, associations
- - Add concerns (Streamable, Processable, Searchable)
- - Serialize metadata fields
-
-3. **Create storage adapter pattern:**
- - BaseAdapter interface
- - LocalAdapter implementation
- - Test adapter with sample directory
-
-4. **Create basic services:**
- - FileScannerService (scan local directory)
- - Result object pattern
-
-5. **Simple UI:**
- - Videos index page (list only, no thumbnails yet)
- - Basic TailwindCSS styling
- - Storage locations admin page
-
-**Deliverable:** Can scan a local directory and see videos in database
-
-#### Phase 1B: Video Playback (Week 3)
-
-**Goal:** Working video player with streaming
-
-1. **Video streaming:**
- - Videos controller with show action
- - Stream action for serving video files
- - Byte-range support for seeking
-
-2. **Video.js integration:**
- - Add Video.js via Importmap
- - Create VideoPlayerController (Stimulus)
- - Basic player UI on videos#show page
-
-3. **Playback tracking:**
- - Implement PlaybackSession.update_position
- - Create API endpoint for position updates
- - Basic resume functionality (load last position)
-
-4. **Playback tracking plugin:**
- - Custom Video.js plugin to track position
- - Send updates to Rails API every 10 seconds
- - Save position on pause/stop
-
-**Deliverable:** Can watch videos with resume functionality
-
-#### Phase 1C: Processing Pipeline (Week 4)
-
-**Goal:** Video metadata extraction and asset generation
-
-1. **Video metadata extraction:**
- - VideoMetadataExtractor service
- - FFmpeg integration via streamio-ffmpeg
- - Extract duration, resolution, codecs, file hash
-
-2. **Background processing:**
- - VideoProcessorJob
- - Queue processing for new videos
- - Error handling and retry logic
-
-3. **Thumbnail generation:**
- - Generate thumbnail at 10% mark
- - Store via Active Storage
- - Display thumbnails on index page
-
-4. **Video assets:**
- - VideoAssets model
- - Store thumbnails, previews (phase 1C: thumbnails only)
- - VTT sprites (defer to Phase 1D if time-constrained)
-
-5. **Processing UI:**
- - Show processing status on video cards
- - Processing failed indicator
- - Retry processing action
-
-**Deliverable:** Videos automatically processed with thumbnails
-
-#### Phase 1D: Works & Grouping (Week 5)
-
-**Goal:** Group duplicate videos into works
-
-1. **Works functionality:**
- - Works model fully implemented
- - Works index/show pages
- - Display videos grouped by work
-
-2. **Duplicate detection:**
- - DuplicateDetectorService
- - Find videos with same file hash
- - Find videos with similar titles
-
-3. **Work grouping UI:**
- - WorkGrouperService
- - Manual grouping interface
- - Drag-and-drop or checkbox selection
- - Create work from selected videos
-
-4. **Works display:**
- - Works index with thumbnails
- - Works show with all versions
- - Version selector (resolution, format)
-
-5. **Polish:**
- - Search functionality
- - Filtering by source, resolution
- - Sorting options
- - Pagination with Pagy
-
-**Deliverable:** Full MVP with work grouping and polished UI
-
-### Phase 2: Authentication & Multi-User
-1. User model and OIDC integration
-2. Admin role management (ENV: ADMIN_EMAIL)
-3. Per-user playback history
-4. User management UI
-5. Storage location management UI
-
-### Phase 3: Remote Sources & Import
-1. **S3 Storage Location:**
- - S3 scanner (list bucket objects)
- - Presigned URL streaming
- - Import to/from S3
-2. **JellyFin Integration:**
- - JellyFin API client
- - Sync metadata
- - Proxy streaming
-3. **Web Directories:**
- - HTTP directory parser
- - Auth support (basic, bearer)
-4. **Import System:**
- - VideoImportJob with progress tracking
- - Import UI with destination selection
- - Background download with resume support
-5. **Unified Library View:**
- - Filter by source
- - Show source badges on videos
- - Multi-source search
-
-### Phase 4: Federation
-1. Public API for other Velour instances
-2. API key authentication
-3. Velour storage location type
-4. Federated video discovery
-5. Cross-instance streaming
-
----
-
-## File Organization
-
-### Local Storage Structure
+### Docker Compose
+```yaml
+version: '3.8'
+services:
+ velour:
+ build: .
+ ports:
+ - "3000:3000"
+ volumes:
+ - /path/to/movies:/videos/movies:ro
+ - /path/to/tv:/videos/tv:ro
+ - ./velour_data:/app/velour_data
+ environment:
+ - RAILS_ENV=production
+ - ADMIN_EMAIL=admin@yourdomain.com
```
-storage/
-├── assets/ # Active Storage (thumbnails, previews, sprites)
-│ └── [active_storage_blobs]
-└── tmp/ # Temporary processing files
-```
-
-### Video Files
-- **Local:** Direct filesystem paths (not copied/moved)
-- **S3:** Stored in configured bucket
-- **Remote:** Referenced by URL, optionally imported to local/S3
-
----
-
-## Key Implementation Decisions
-
-### Storage Flexibility
-- Videos are NOT managed by Active Storage
-- Local videos: store absolute/relative paths
-- S3 videos: store bucket + key
-- Remote videos: store source URL + metadata
-- Active Storage ONLY for generated assets (thumbnails, etc.)
-
-### Import Strategy (Phase 3)
-1. User selects video from remote source
-2. User selects destination (writable storage location)
-3. VideoImportJob starts download
-4. Progress tracked via Turbo Streams
-5. On completion:
- - New Video record created (imported: true)
- - Linked to same Work as source
- - Assets generated
- - Original remote video remains linked
-
-### Unified View
-- Single `/videos` index shows all sources
-- Filter dropdown: "All Sources", "Local", "S3", "JellyFin", etc.
-- Source badge on each video card
-- Search across all sources
-- Sort by: title, date added, last watched, etc.
-
-### Streaming Strategy by Source
-```ruby
-# Local filesystem
-send_file video.full_path, type: video.mime_type, disposition: 'inline'
-
-# S3
-redirect_to s3_client.presigned_url(:get_object, bucket: ..., key: ..., expires_in: 3600)
-
-# JellyFin
-redirect_to "#{jellyfin_url}/Videos/#{video.source_id}/stream?api_key=..."
-
-# Web directory
-# Proxy through Rails with auth headers
-
-# Velour federation
-redirect_to "#{velour_url}/api/v1/federation/videos/#{video.source_id}/stream?api_key=..."
-```
-
-### Performance Considerations
-- Lazy asset generation (only when video viewed)
-- S3 presigned URLs (no proxy for large files)
-- Caching of metadata and thumbnails
-- Cursor-based pagination for large libraries
-- Background scanning with incremental updates
-
----
-
-## Configuration (ENV)
-
-```bash
-# Admin
-ADMIN_EMAIL=admin@example.com
-
-# OIDC (Phase 2)
-OIDC_ISSUER=https://auth.example.com
-OIDC_CLIENT_ID=velour
-OIDC_CLIENT_SECRET=secret
-
-# Default Storage
-DEFAULT_SCAN_PATH=/path/to/videos
-
-# S3 (optional default)
-AWS_REGION=us-east-1
-AWS_ACCESS_KEY_ID=...
-AWS_SECRET_ACCESS_KEY=...
-AWS_S3_BUCKET=velour-videos
-
-# Processing
-FFMPEG_THREADS=4
-THUMBNAIL_SIZE=1920x1080
-PREVIEW_DURATION=30
-SPRITE_INTERVAL=5
-
-# Federation (Phase 4)
-VELOUR_API_KEY=secret-key-for-federation
-ALLOW_FEDERATION=true
-```
-
----
-
-## Video.js Implementation (Inspiration from Stash)
-
-Based on analysis of the Stash application, we'll use:
-
-- **Video.js v8.x** as the core player
-- **Custom plugins** for:
- - Resume functionality
- - Playback tracking
- - Quality/version selector
- - VTT thumbnails on seek bar
-- **Streaming format support:**
- - Direct MP4/MKV streaming with byte-range
- - DASH/HLS for adaptive streaming (future)
- - Multi-quality source selection
-
-### Key Features from Stash Worth Implementing:
-1. Scene markers on timeline (our "chapters" equivalent)
-2. Thumbnail sprite preview on hover
-3. Keyboard shortcuts
-4. Mobile-optimized controls
-5. Resume from last position
-6. Play duration and count tracking
-7. AirPlay/Chromecast support (future)
-
----
-
-## Error Handling & Job Configuration
-
-### Job Retry Strategy
-
-```ruby
-# app/jobs/video_processor_job.rb
-class VideoProcessorJob < ApplicationJob
- queue_as :default
-
- # Retry with exponential backoff
- retry_on StandardError, wait: :polynomially_longer, attempts: 3
-
- # Don't retry if video not found
- discard_on ActiveRecord::RecordNotFound do |job, error|
- Rails.logger.warn "Video not found for processing: #{error.message}"
- end
-
- def perform(video_id)
- video = Video.find(video_id)
-
- # Extract metadata
- result = VideoMetadataExtractor.new(video).call
-
- return unless result.success?
-
- # Generate thumbnail (Phase 1C)
- ThumbnailGeneratorService.new(video).call
- rescue FFMPEG::Error => e
- video.mark_processing_failed!(e.message)
- raise # Will retry
- end
-end
-```
-
-### Background Job Monitoring
-
-```ruby
-# app/controllers/admin/jobs_controller.rb (optional)
-module Admin
- class JobsController < Admin::BaseController
- def index
- @running_jobs = SolidQueue::Job.where(finished_at: nil).limit(50)
- @failed_jobs = SolidQueue::Job.where.not(error: nil).limit(50)
- end
-
- def retry
- job = SolidQueue::Job.find(params[:id])
- job.retry!
- redirect_to admin_jobs_path, notice: "Job queued for retry"
- end
- end
-end
-```
-
----
-
-## Configuration Summary
### Environment Variables
-
```bash
-# .env (Phase 1)
-DEFAULT_SCAN_PATH=/path/to/your/videos
-FFMPEG_THREADS=4
-THUMBNAIL_SIZE=1920x1080
+# Required
+RAILS_MASTER_KEY=your_master_key
+ADMIN_EMAIL=admin@yourdomain.com
-# .env (Phase 2)
-ADMIN_EMAIL=admin@example.com
-OIDC_ISSUER=https://auth.example.com
-OIDC_CLIENT_ID=velour
-OIDC_CLIENT_SECRET=your-secret
+# Video Processing
+FFMPEG_PATH=/usr/bin/ffmpeg
+VIDEOS_PATH=./velour_data/videos
+MAX_TRANSCODE_SIZE_GB=50
-# .env (Phase 3)
-AWS_REGION=us-east-1
-AWS_ACCESS_KEY_ID=your-key
-AWS_SECRET_ACCESS_KEY=your-secret
-AWS_S3_BUCKET=velour-videos
+# Background jobs (SolidQueue)
+SOLID_QUEUE_PROCESSES="*:2" # 2 workers for all queues
-# .env (Phase 4)
-VELOUR_API_KEY=your-api-key
-ALLOW_FEDERATION=true
+# Optional (Phase 2+)
+OIDC_ISSUER=https://your-provider.com
+OIDC_CLIENT_ID=your_client_id
+
+# Optional (Phase 3+)
+AWS_ACCESS_KEY_ID=your_key
+AWS_SECRET_ACCESS_KEY=your_secret
```
-### Database Encryption
+## File Structure
-```bash
-# Generate encryption keys
-bin/rails db:encryption:init
-
-# Add output to config/credentials.yml.enc:
-# active_record_encryption:
-# primary_key: ...
-# deterministic_key: ...
-# key_derivation_salt: ...
+```
+/app
+├── app/
+│ ├── models/ # ActiveRecord models
+│ ├── services/ # Business logic objects
+│ ├── jobs/ # Background jobs
+│ └── controllers/ # Web controllers
+├── lib/
+│ └── osmoviehash.rb # Video fingerprinting
+├── config/
+│ └── routes.rb # Application routes
+└── docs/
+ ├── architecture.md # This file
+ └── phases/ # Detailed phase documentation
+ ├── phase_1.md
+ ├── phase_2.md
+ ├── phase_3.md
+ └── phase_4.md
```
----
+## Design Decisions
-## Implementation Checklist
+### Why Rails + Hotwire?
+- **Convention over Configuration** - Faster development
+- **Integrated System** - Single framework for frontend and backend
+- **Real-time Capabilities** - Turbo Streams for job progress
+- **Video Focus** - Uninterrupted playback with TurboFrame
-### Phase 1A: Core Foundation
+### Why SQLite First?
+- **Single User Focus** - Simpler deployment and maintenance
+- **Migration Path** - Easy upgrade to PostgreSQL if needed
+- **Performance** - Excellent for personal video libraries
-- [ ] Install required gems (`streamio-ffmpeg`, `pagy`)
-- [ ] Generate models with proper migrations
-- [ ] Implement model validations and associations
-- [ ] Create model concerns (Streamable, Processable, Searchable)
-- [ ] Build storage adapter pattern (BaseAdapter, LocalAdapter)
-- [ ] Implement FileScannerService
-- [ ] Create Result object pattern
-- [ ] Build videos index page with TailwindCSS
-- [ ] Create admin storage locations CRUD
-- [ ] Write tests for models and adapters
+### Why Separate Transcoding?
+- **Original Preservation** - Never modify source files
+- **Quality Choice** - Users keep original quality when available
+- **Storage Efficiency** - Transcode only when needed
+- **Browser Compatibility** - Web-friendly formats for streaming
-### Phase 1B: Video Playback
+## Getting Started
-- [ ] Create videos#show action with byte-range support
-- [ ] Add Video.js via Importmap
-- [ ] Build VideoPlayerController (Stimulus)
-- [ ] Implement PlaybackSession.update_position
-- [ ] Create API endpoint for position tracking
-- [ ] Build custom Video.js tracking plugin
-- [ ] Implement resume functionality
-- [ ] Write system tests for playback
-
-### Phase 1C: Processing Pipeline
-
-- [ ] Implement VideoMetadataExtractor service
-- [ ] Create VideoProcessorJob with retry logic
-- [ ] Build ThumbnailGeneratorService
-- [ ] Set up Active Storage for thumbnails
-- [ ] Display thumbnails on index page
-- [ ] Add processing status indicators
-- [ ] Implement retry processing action
-- [ ] Write tests for processing services
-
-### Phase 1D: Works & Grouping
-
-- [ ] Fully implement Works model
-- [ ] Create Works index/show pages
-- [ ] Implement DuplicateDetectorService
-- [ ] Build WorkGrouperService
-- [ ] Create work grouping UI
-- [ ] Add version selector on work pages
-- [ ] Implement search functionality
-- [ ] Add filtering and sorting
-- [ ] Integrate Pagy pagination
-- [ ] Polish UI with TailwindCSS
-
----
-
-## Next Steps
-
-### To Begin Implementation:
-
-1. **Review this architecture document** with team/stakeholders
-2. **Set up development environment:**
- - Install FFmpeg (`brew install ffmpeg` on macOS)
- - Verify Rails 8.1.1+ installed
- - Create new Rails app (already done in `/Users/dkam/Development/velour`)
-
-3. **Start Phase 1A:**
+1. **Clone and setup**:
```bash
- # Add gems
- bundle add streamio-ffmpeg pagy aws-sdk-s3
-
- # Generate models
- rails generate model Work title:string year:integer director:string description:text rating:decimal organized:boolean poster_path:string backdrop_path:string metadata:text
- rails generate model StorageLocation name:string path:string location_type:integer writable:boolean enabled:boolean scan_subdirectories:boolean priority:integer settings:text last_scanned_at:datetime
- rails generate model Video work:references storage_location:references title:string file_path:string file_hash:string file_size:bigint duration:float width:integer height:integer resolution_label:string video_codec:string audio_codec:string bit_rate:integer frame_rate:float format:string has_subtitles:boolean version_type:string source_type:integer source_url:string imported:boolean processing_failed:boolean error_message:text metadata:text
- rails generate model VideoAsset video:references asset_type:integer metadata:text
- rails generate model PlaybackSession video:references user:references position:float duration_watched:float last_watched_at:datetime completed:boolean play_count:integer
-
- # Run migrations
- rails db:migrate
-
- # Start server
- bin/dev
+ git clone
+ cd velour
+ bundle install
+ rails db:setup
```
-4. **Follow the Phase 1A checklist** (see above)
+2. **Configure storage**:
+ ```bash
+ # Edit docker-compose.yml
+ volumes:
+ - /path/to/your/movies:/videos/movies:ro
+ - /path/to/your/tv:/videos/tv:ro
+ ```
-5. **Iterate through Phase 1B, 1C, 1D**
+3. **Start the application**:
+ ```bash
+ docker-compose up
+ # or
+ rails server
+ ```
+
+4. **Visit http://localhost:3000** and follow the first-user setup
+
+## Contributing
+
+When adding features:
+
+1. **Follow Rails Conventions** - Use generators and standard patterns
+2. **Maintain Phase Structure** - Add features to appropriate phase
+3. **Test Thoroughly** - Include model, service, and system tests
+4. **Update Documentation** - Keep architecture docs current
+
+## Resources
+
+- [Rails Guides](https://guides.rubyonrails.org/)
+- [Hotwire Documentation](https://hotwired.dev/)
+- [Video.js Documentation](https://videojs.com/)
+- [FFmpeg Documentation](https://ffmpeg.org/ffmpeg.html)
---
-## Architecture Decision Records
-
-Key architectural decisions made:
-
-1. **SQLite for MVP** - Simple, file-based, perfect for single-user. Migration path to PostgreSQL documented.
-2. **Storage Adapter Pattern** - Pluggable backends allow adding S3, JellyFin, etc. without changing core logic.
-3. **Service Objects** - Complex business logic extracted from controllers/models for testability.
-4. **Hotwire over React** - Server-rendered HTML with Turbo Streams for real-time updates. Less JS complexity.
-5. **Video.js** - Proven, extensible, well-documented player with broad format support.
-6. **Rails Enums (integers)** - SQLite-compatible, performant, database-friendly.
-7. **Active Storage for assets only** - Videos managed by storage adapters, not Active Storage.
-8. **Pagy over Kaminari** - Faster, simpler pagination with smaller footprint.
-9. **Model-level authorization** - Simple for MVP, easy upgrade path to Pundit/Action Policy.
-10. **Phase 1 broken into 4 sub-phases** - Manageable milestones with clear deliverables.
-
----
-
-## Support & Resources
-
-- **Rails Guides:** https://guides.rubyonrails.org
-- **Hotwire Docs:** https://hotwired.dev
-- **Video.js Docs:** https://docs.videojs.com
-- **FFmpeg Docs:** https://ffmpeg.org/documentation.html
-- **TailwindCSS:** https://tailwindcss.com/docs
-
----
-
-**Document Version:** 1.0
-**Last Updated:** <%= Time.current.strftime("%Y-%m-%d") %>
-**Status:** Ready for Phase 1A Implementation
+**Next Steps**: Start with [Phase 1 (MVP)](./phases/phase_1.md) for complete implementation details.
\ No newline at end of file
diff --git a/docs/phases/phase_1.md b/docs/phases/phase_1.md
new file mode 100644
index 0000000..0194c0a
--- /dev/null
+++ b/docs/phases/phase_1.md
@@ -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
+
+
+
+
+
+
+
+
+
+
+ <%= @work.title %>
+ Duration: <%= format_duration(@video.duration) %>
+
+```
+
+### Video Player Stimulus Controller
+```javascript
+// app/javascript/controllers/video_player_controller.js
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = { videoId: Number, startPosition: Number }
+
+ connect() {
+ this.player = videojs(this.element, {
+ controls: true,
+ responsive: true,
+ fluid: true,
+ playbackRates: [0.5, 1, 1.25, 1.5, 2]
+ })
+
+ this.player.ready(() => {
+ if (this.startPositionValue > 0) {
+ this.player.currentTime(this.startPositionValue)
+ }
+ })
+
+ // Save position every 10 seconds
+ this.interval = setInterval(() => {
+ this.savePosition()
+ }, 10000)
+
+ // Save on pause
+ this.player.on("pause", () => this.savePosition())
+ }
+
+ disconnect() {
+ clearInterval(this.interval)
+ this.player.dispose()
+ }
+
+ savePosition() {
+ const position = Math.floor(this.player.currentTime())
+
+ fetch(`/videos/${this.videoIdValue}/playback-position`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ position })
+ })
+ }
+}
+```
+
+## Service Objects
+
+### File Scanner Service
+```ruby
+class FileScannerService
+ def initialize(storage_location)
+ @storage_location = storage_location
+ end
+
+ def scan
+ return failure_result("Storage location not accessible") unless @storage_location.accessible?
+
+ video_files = find_video_files
+ new_videos = process_files(video_files)
+
+ success_result(new_videos)
+ rescue => e
+ failure_result(e.message)
+ end
+
+ private
+
+ def find_video_files
+ Dir.glob(File.join(@storage_location.path, "**", "*.{mp4,avi,mkv,mov,wmv,flv,webm,m4v}"))
+ end
+
+ def process_files(file_paths)
+ new_videos = []
+
+ file_paths.each do |file_path|
+ filename = File.basename(file_path)
+
+ next if Video.exists?(filename: filename, storage_location: @storage_location)
+
+ video = Video.create!(
+ filename: filename,
+ storage_location: @storage_location,
+ work: Work.find_or_create_by(title: extract_title(filename))
+ )
+
+ new_videos << video
+ VideoProcessorJob.perform_later(video.id)
+ end
+
+ new_videos
+ end
+
+ def extract_title(filename)
+ # Simple title extraction - can be enhanced
+ File.basename(filename, ".*").gsub(/[\[\(].*?[\]\)]/, "").strip
+ end
+
+ def success_result(videos = [])
+ { success: true, videos: videos, message: "Found #{videos.length} new videos" }
+ end
+
+ def failure_result(message)
+ { success: false, message: message }
+ end
+end
+```
+
+### Video Transcoder Service
+```ruby
+class VideoTranscoder
+ def transcode_for_web(input_path:, output_path:, on_progress: nil)
+ movie = FFMPEG::Movie.new(input_path)
+
+ # Calculate progress callback
+ progress_callback = ->(progress) {
+ on_progress&.call(progress, 100)
+ }
+
+ movie.transcode(output_path, {
+ video_codec: "libx264",
+ audio_codec: "aac",
+ custom: %w[
+ -pix_fmt yuv420p
+ -preset medium
+ -crf 23
+ -movflags +faststart
+ -tune fastdecode
+ ]
+ }, &progress_callback)
+ end
+
+ def extract_frame(input_path, seconds)
+ movie = FFMPEG::Movie.new(input_path)
+ output_path = "#{Rails.root}/tmp/thumbnail_#{SecureRandom.hex(8)}.jpg"
+
+ movie.screenshot(output_path, seek_time: seconds, resolution: "320x240")
+ output_path
+ end
+end
+```
+
+## Routes
+
+```ruby
+Rails.application.routes.draw do
+ root "storage_locations#index"
+
+ resources :storage_locations, only: [:index, :show, :create, :destroy] do
+ member do
+ post :scan
+ end
+ end
+
+ resources :works, only: [:index, :show] do
+ resources :videos, only: [:show]
+ end
+
+ resources :videos, only: [] do
+ member do
+ get :stream
+ patch :playback_position
+ post :retry_processing
+ end
+
+ resources :playback_sessions, only: [:create]
+ end
+
+ # Real-time job progress
+ resources :jobs, only: [:show] do
+ member do
+ get :progress
+ end
+ end
+end
+```
+
+## Implementation Sub-Phases
+
+### Phase 1A: Core Foundation (Week 1-2)
+1. Generate models with migrations
+2. Implement model validations and associations
+3. Create storage adapter pattern
+4. Create FileScannerService
+5. Basic UI for storage locations and video listing
+
+### Phase 1B: Video Playback (Week 3)
+1. Video streaming controller with byte-range support
+2. Video.js integration with Stimulus controller
+3. Playback session tracking
+4. Resume functionality
+
+### Phase 1C: Processing Pipeline (Week 4)
+1. Video metadata extraction with FFmpeg
+2. Background job processing with progress tracking
+3. Thumbnail generation and storage
+4. Processing UI with status indicators
+
+### Phase 1D: Works & Grouping (Week 5)
+1. Duplicate detection by file hash
+2. Manual grouping interface
+3. Works display with version selection
+4. Search, filtering, and pagination
+
+## Configuration
+
+### Environment Variables
+```bash
+# Required
+RAILS_ENV=development
+RAILS_MASTER_KEY=your_master_key
+
+# Video processing
+FFMPEG_PATH=/usr/bin/ffmpeg
+FFPROBE_PATH=/usr/bin/ffprobe
+
+# Storage
+VIDEOS_PATH=./velour_data/videos
+MAX_TRANSCODE_SIZE_GB=50
+
+# Background jobs (SolidQueue runs with defaults)
+SOLID_QUEUE_PROCESSES="*:2" # 2 workers for all queues
+```
+
+### Docker Configuration
+```dockerfile
+FROM ruby:3.3-alpine
+
+# Install FFmpeg and other dependencies
+RUN apk add --no-cache \
+ ffmpeg \
+ imagemagick \
+ sqlite-dev \
+ nodejs \
+ npm
+
+# Install Rails and other gems
+COPY Gemfile* /app/
+RUN bundle install
+
+# Copy application code
+COPY . /app/
+
+# Create directories
+RUN mkdir -p /app/velour_data/{assets,tmp}
+
+EXPOSE 3000
+CMD ["rails", "server", "-b", "0.0.0.0"]
+```
+
+## Testing Strategy
+
+### Model Tests
+- Model validations and associations
+- Scopes and class methods
+- JSON store accessor functionality
+
+### Service Tests
+- FileScannerService with sample directory
+- VideoTranscoder service methods
+- Background job processing
+
+### System Tests
+- Video playback functionality
+- File scanning workflow
+- Work grouping interface
+
+This Phase 1 delivers a complete, useful video library application that provides immediate value while establishing the foundation for future enhancements.
\ No newline at end of file
diff --git a/docs/phases/phase_2.md b/docs/phases/phase_2.md
new file mode 100644
index 0000000..d27ca8a
--- /dev/null
+++ b/docs/phases/phase_2.md
@@ -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
+
+
+
+ <%= link_to 'Velour', root_path, class: 'text-xl font-bold' %>
+
+
+ <%= 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? %>
+
+
+
+ <%= current_user.email %>
+
+
+
+
+
+
+ <%= 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' %>
+
+
+
+
+
+```
+
+### Admin Panel
+```erb
+
+
+
Admin Dashboard
+
+
+
+
Users
+
<%= User.count %>
+
Total users
+ <%= link_to 'Manage Users', admin_users_path, class: 'mt-2 text-blue-500 hover:text-blue-700' %>
+
+
+
+
Videos
+
<%= Video.count %>
+
Total videos
+ <%= link_to 'View Library', works_path, class: 'mt-2 text-green-500 hover:text-green-700' %>
+
+
+
+
Storage
+
<%= StorageLocation.count %>
+
Storage locations
+ <%= link_to 'Manage Storage', storage_locations_path, class: 'mt-2 text-purple-500 hover:text-purple-700' %>
+
+
+
+
+
System Status
+
+
+ Background Jobs:
+ <%= SolidQueue::Job.count %>
+
+
+ Processing Jobs:
+ <%= SolidQueue::Job.where(finished_at: nil).count %>
+
+
+ Failed Jobs:
+ <%= SolidQueue::FailedExecution.count %>
+
+
+
+
+```
+
+## 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.
\ No newline at end of file
diff --git a/docs/phases/phase_3.md b/docs/phases/phase_3.md
new file mode 100644
index 0000000..5dbbefe
--- /dev/null
+++ b/docs/phases/phase_3.md
@@ -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
+
+<% if video.storage_location.remote? && current_user.admin? %>
+
+
+ Import to Local Storage
+
+
+
+
+
Import Video
+
+
+ Import "<%= video.filename %>" to a local storage location for offline access and transcoding.
+
+
+
+
+ Destination Storage:
+
+
+ <% StorageLocation.local.accessible.each do |location| %>
+ <%= location.name %>
+ <% end %>
+
+
+
+
+
+ Cancel
+
+
+ Import
+
+
+
+
+
+
+
+
+<% 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 = `
+
+
Import failed
+
${error.message}
+
+ `
+ }
+ }
+
+ 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 = `
+
+ `
+ setTimeout(() => {
+ window.location.reload()
+ }, 2000)
+ } else if (progress.stage === "failed") {
+ this.progressTarget.innerHTML = `
+
+
Import failed
+
${progress.error || "Unknown error"}
+
+ `
+ } 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.
\ No newline at end of file
diff --git a/docs/phases/phase_4.md b/docs/phases/phase_4.md
new file mode 100644
index 0000000..3b79dc2
--- /dev/null
+++ b/docs/phases/phase_4.md
@@ -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
+
+
+
Federation Connections
+
+ <% if @api_keys.any? %>
+
+ <%= 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' %>
+
+
+
+
+ <% @connections.each do |connection| %>
+
+
+
+
+ <%= connection.storage_location.name %>
+
+
+ <%= connection.remote_instance_url %>
+
+
+ API Key: <%= connection.api_key.name %>
+
+
+
+
+ <%= connection.status.titleize %>
+
+ <%= 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' %>
+
+
+
+ <% end %>
+
+
+ <% else %>
+
+
+
+
+
+ You need to create an API key before you can establish federation connections.
+
+
+ <%= link_to 'Create API Key', new_admin_api_key_path, class: 'text-yellow-700 underline hover:text-yellow-600' %>
+
+
+
+
+ <% end %>
+
+```
+
+## 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.
\ No newline at end of file
diff --git a/docs/phases/phase_5.md b/docs/phases/phase_5.md
new file mode 100644
index 0000000..9dc5fe1
--- /dev/null
+++ b/docs/phases/phase_5.md
@@ -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.
\ No newline at end of file
diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb
new file mode 100644
index 0000000..e1a1b03
--- /dev/null
+++ b/test/controllers/passwords_controller_test.rb
@@ -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
diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb
new file mode 100644
index 0000000..07d72ef
--- /dev/null
+++ b/test/controllers/sessions_controller_test.rb
@@ -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
diff --git a/test/controllers/storage_locations_controller_test.rb b/test/controllers/storage_locations_controller_test.rb
new file mode 100644
index 0000000..e2b1456
--- /dev/null
+++ b/test/controllers/storage_locations_controller_test.rb
@@ -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
diff --git a/test/controllers/videos_controller_test.rb b/test/controllers/videos_controller_test.rb
new file mode 100644
index 0000000..d498b0f
--- /dev/null
+++ b/test/controllers/videos_controller_test.rb
@@ -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
diff --git a/test/controllers/works_controller_test.rb b/test/controllers/works_controller_test.rb
new file mode 100644
index 0000000..58c058b
--- /dev/null
+++ b/test/controllers/works_controller_test.rb
@@ -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
diff --git a/test/fixtures/external_ids.yml b/test/fixtures/external_ids.yml
new file mode 100644
index 0000000..c4cbd05
--- /dev/null
+++ b/test/fixtures/external_ids.yml
@@ -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
diff --git a/test/fixtures/playback_sessions.yml b/test/fixtures/playback_sessions.yml
new file mode 100644
index 0000000..18458de
--- /dev/null
+++ b/test/fixtures/playback_sessions.yml
@@ -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
diff --git a/test/fixtures/storage_locations.yml b/test/fixtures/storage_locations.yml
new file mode 100644
index 0000000..c927bf9
--- /dev/null
+++ b/test/fixtures/storage_locations.yml
@@ -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
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
new file mode 100644
index 0000000..0951563
--- /dev/null
+++ b/test/fixtures/users.yml
@@ -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 %>
diff --git a/test/fixtures/video_assets.yml b/test/fixtures/video_assets.yml
new file mode 100644
index 0000000..53d4f4e
--- /dev/null
+++ b/test/fixtures/video_assets.yml
@@ -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
diff --git a/test/fixtures/videos.yml b/test/fixtures/videos.yml
new file mode 100644
index 0000000..d88c09e
--- /dev/null
+++ b/test/fixtures/videos.yml
@@ -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
diff --git a/test/fixtures/works.yml b/test/fixtures/works.yml
new file mode 100644
index 0000000..259a8f6
--- /dev/null
+++ b/test/fixtures/works.yml
@@ -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
diff --git a/test/mailers/previews/passwords_mailer_preview.rb b/test/mailers/previews/passwords_mailer_preview.rb
new file mode 100644
index 0000000..01d07ec
--- /dev/null
+++ b/test/mailers/previews/passwords_mailer_preview.rb
@@ -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
diff --git a/test/models/external_id_test.rb b/test/models/external_id_test.rb
new file mode 100644
index 0000000..8d53e08
--- /dev/null
+++ b/test/models/external_id_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class ExternalIdTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/playback_session_test.rb b/test/models/playback_session_test.rb
new file mode 100644
index 0000000..f9c36ec
--- /dev/null
+++ b/test/models/playback_session_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class PlaybackSessionTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/storage_location_test.rb b/test/models/storage_location_test.rb
new file mode 100644
index 0000000..f3c2bed
--- /dev/null
+++ b/test/models/storage_location_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class StorageLocationTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
new file mode 100644
index 0000000..83445c4
--- /dev/null
+++ b/test/models/user_test.rb
@@ -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
diff --git a/test/models/video_asset_test.rb b/test/models/video_asset_test.rb
new file mode 100644
index 0000000..5a4139e
--- /dev/null
+++ b/test/models/video_asset_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class VideoAssetTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/video_test.rb b/test/models/video_test.rb
new file mode 100644
index 0000000..d3ed367
--- /dev/null
+++ b/test/models/video_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class VideoTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/work_test.rb b/test/models/work_test.rb
new file mode 100644
index 0000000..b9306b8
--- /dev/null
+++ b/test/models/work_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class WorkTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 0c22470..85c54c6 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,6 +1,7 @@
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
+require_relative "test_helpers/session_test_helper"
module ActiveSupport
class TestCase
diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb
new file mode 100644
index 0000000..0686378
--- /dev/null
+++ b/test/test_helpers/session_test_helper.rb
@@ -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