Much base work started
This commit is contained in:
8
Gemfile
8
Gemfile
@@ -20,7 +20,7 @@ gem "tailwindcss-rails"
|
|||||||
gem "jbuilder"
|
gem "jbuilder"
|
||||||
|
|
||||||
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
|
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
|
||||||
# gem "bcrypt", "~> 3.1.7"
|
gem "bcrypt", "~> 3.1.7"
|
||||||
|
|
||||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
@@ -66,3 +66,9 @@ group :test do
|
|||||||
gem "capybara"
|
gem "capybara"
|
||||||
gem "selenium-webdriver"
|
gem "selenium-webdriver"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
gem "streamio-ffmpeg", "~> 3.0"
|
||||||
|
gem "pagy", "~> 9.4"
|
||||||
|
gem "aws-sdk-s3", "~> 1.202"
|
||||||
|
|
||||||
|
gem "xxhash", "~> 0.7.0"
|
||||||
|
|||||||
31
Gemfile.lock
31
Gemfile.lock
@@ -78,7 +78,27 @@ GEM
|
|||||||
addressable (2.8.7)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
|
aws-eventstream (1.4.0)
|
||||||
|
aws-partitions (1.1178.0)
|
||||||
|
aws-sdk-core (3.235.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
|
aws-sigv4 (~> 1.9)
|
||||||
|
base64
|
||||||
|
bigdecimal
|
||||||
|
jmespath (~> 1, >= 1.6.1)
|
||||||
|
logger
|
||||||
|
aws-sdk-kms (1.115.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.234.0)
|
||||||
|
aws-sigv4 (~> 1.5)
|
||||||
|
aws-sdk-s3 (1.202.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.234.0)
|
||||||
|
aws-sdk-kms (~> 1)
|
||||||
|
aws-sigv4 (~> 1.5)
|
||||||
|
aws-sigv4 (1.12.1)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
|
bcrypt (3.1.20)
|
||||||
bcrypt_pbkdf (1.1.1)
|
bcrypt_pbkdf (1.1.1)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (3.3.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
@@ -142,6 +162,7 @@ GEM
|
|||||||
jbuilder (2.14.1)
|
jbuilder (2.14.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
|
jmespath (1.6.2)
|
||||||
json (2.15.2)
|
json (2.15.2)
|
||||||
kamal (2.8.2)
|
kamal (2.8.2)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
@@ -173,6 +194,7 @@ GEM
|
|||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.26.0)
|
minitest (5.26.0)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
|
multi_json (1.17.0)
|
||||||
net-imap (0.5.12)
|
net-imap (0.5.12)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
@@ -203,6 +225,7 @@ GEM
|
|||||||
nokogiri (1.18.10-x86_64-linux-musl)
|
nokogiri (1.18.10-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
|
pagy (9.4.0)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.10.0)
|
parser (3.3.10.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
@@ -343,6 +366,8 @@ GEM
|
|||||||
ostruct
|
ostruct
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
|
streamio-ffmpeg (3.0.2)
|
||||||
|
multi_json (~> 1.8)
|
||||||
stringio (3.1.7)
|
stringio (3.1.7)
|
||||||
tailwindcss-rails (4.4.0)
|
tailwindcss-rails (4.4.0)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
@@ -382,6 +407,7 @@ GEM
|
|||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
|
xxhash (0.7.0)
|
||||||
zeitwerk (2.7.3)
|
zeitwerk (2.7.3)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
@@ -396,6 +422,8 @@ PLATFORMS
|
|||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
aws-sdk-s3 (~> 1.202)
|
||||||
|
bcrypt (~> 3.1.7)
|
||||||
bootsnap
|
bootsnap
|
||||||
brakeman
|
brakeman
|
||||||
bundler-audit
|
bundler-audit
|
||||||
@@ -405,6 +433,7 @@ DEPENDENCIES
|
|||||||
importmap-rails
|
importmap-rails
|
||||||
jbuilder
|
jbuilder
|
||||||
kamal
|
kamal
|
||||||
|
pagy (~> 9.4)
|
||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.1)
|
rails (~> 8.1.1)
|
||||||
@@ -415,11 +444,13 @@ DEPENDENCIES
|
|||||||
solid_queue
|
solid_queue
|
||||||
sqlite3 (>= 2.1)
|
sqlite3 (>= 2.1)
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
|
streamio-ffmpeg (~> 3.0)
|
||||||
tailwindcss-rails
|
tailwindcss-rails
|
||||||
thruster
|
thruster
|
||||||
turbo-rails
|
turbo-rails
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
web-console
|
web-console
|
||||||
|
xxhash (~> 0.7.0)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.7.2
|
2.7.2
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
web: bin/rails server
|
web: bin/rails server -b 0.0.0.0 -p 3057
|
||||||
css: bin/rails tailwindcss:watch
|
css: bin/rails tailwindcss:watch
|
||||||
|
|||||||
16
app/channels/application_cable/connection.rb
Normal file
16
app/channels/application_cable/connection.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module ApplicationCable
|
||||||
|
class Connection < ActionCable::Connection::Base
|
||||||
|
identified_by :current_user
|
||||||
|
|
||||||
|
def connect
|
||||||
|
set_current_user || reject_unauthorized_connection
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_current_user
|
||||||
|
if session = Session.find_by(id: cookies.signed[:session_id])
|
||||||
|
self.current_user = session.user
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
62
app/controllers/admin/storage_locations_controller.rb
Normal file
62
app/controllers/admin/storage_locations_controller.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
module Admin
|
||||||
|
class StorageLocationsController < ApplicationController
|
||||||
|
before_action :set_storage_location, only: [:show, :edit, :update, :destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@storage_locations = StorageLocation.all
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@storage_location = StorageLocation.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@storage_location = StorageLocation.new(storage_location_params)
|
||||||
|
|
||||||
|
if @storage_location.save
|
||||||
|
redirect_to [:admin, @storage_location], notice: "Storage location was successfully created."
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @storage_location.update(storage_location_params)
|
||||||
|
redirect_to [:admin, @storage_location], notice: "Storage location was successfully updated."
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@storage_location.destroy
|
||||||
|
redirect_to admin_storage_locations_url, notice: "Storage location was successfully destroyed."
|
||||||
|
end
|
||||||
|
|
||||||
|
def scan
|
||||||
|
# Placeholder for scan functionality
|
||||||
|
redirect_to [:admin, @storage_location], notice: "Scan functionality will be implemented."
|
||||||
|
end
|
||||||
|
|
||||||
|
def scan_status
|
||||||
|
# Placeholder for scan status
|
||||||
|
render json: { status: "idle" }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_storage_location
|
||||||
|
@storage_location = StorageLocation.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def storage_location_params
|
||||||
|
params.require(:storage_location).permit(:name, :path, :location_type, :writable, :enabled, :scan_subdirectories, :priority, :settings)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
|
include Authentication
|
||||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||||
allow_browser versions: :modern
|
allow_browser versions: :modern
|
||||||
|
|
||||||
|
|||||||
52
app/controllers/concerns/authentication.rb
Normal file
52
app/controllers/concerns/authentication.rb
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
module Authentication
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
before_action :require_authentication
|
||||||
|
helper_method :authenticated?
|
||||||
|
end
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
def allow_unauthenticated_access(**options)
|
||||||
|
skip_before_action :require_authentication, **options
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def authenticated?
|
||||||
|
resume_session
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_authentication
|
||||||
|
resume_session || request_authentication
|
||||||
|
end
|
||||||
|
|
||||||
|
def resume_session
|
||||||
|
Current.session ||= find_session_by_cookie
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_session_by_cookie
|
||||||
|
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def request_authentication
|
||||||
|
session[:return_to_after_authenticating] = request.url
|
||||||
|
redirect_to new_session_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def after_authentication_url
|
||||||
|
session.delete(:return_to_after_authenticating) || root_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_new_session_for(user)
|
||||||
|
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||||
|
Current.session = session
|
||||||
|
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminate_session
|
||||||
|
Current.session.destroy
|
||||||
|
cookies.delete(:session_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
35
app/controllers/passwords_controller.rb
Normal file
35
app/controllers/passwords_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
class PasswordsController < ApplicationController
|
||||||
|
allow_unauthenticated_access
|
||||||
|
before_action :set_user_by_token, only: %i[ edit update ]
|
||||||
|
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
||||||
|
|
||||||
|
def new
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
if user = User.find_by(email_address: params[:email_address])
|
||||||
|
PasswordsMailer.reset(user).deliver_later
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @user.update(params.permit(:password, :password_confirmation))
|
||||||
|
@user.sessions.destroy_all
|
||||||
|
redirect_to new_session_path, notice: "Password has been reset."
|
||||||
|
else
|
||||||
|
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_user_by_token
|
||||||
|
@user = User.find_by_password_reset_token!(params[:token])
|
||||||
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
|
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||||
|
end
|
||||||
|
end
|
||||||
21
app/controllers/sessions_controller.rb
Normal file
21
app/controllers/sessions_controller.rb
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
class SessionsController < ApplicationController
|
||||||
|
allow_unauthenticated_access only: %i[ new create ]
|
||||||
|
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
|
||||||
|
|
||||||
|
def new
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
if user = User.authenticate_by(params.permit(:email_address, :password))
|
||||||
|
start_new_session_for user
|
||||||
|
redirect_to after_authentication_url
|
||||||
|
else
|
||||||
|
redirect_to new_session_path, alert: "Try another email address or password."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
terminate_session
|
||||||
|
redirect_to new_session_path, status: :see_other
|
||||||
|
end
|
||||||
|
end
|
||||||
49
app/controllers/storage_locations_controller.rb
Normal file
49
app/controllers/storage_locations_controller.rb
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
class StorageLocationsController < ApplicationController
|
||||||
|
before_action :set_storage_location, only: [:show, :destroy, :scan]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@storage_locations = StorageLocation.all
|
||||||
|
# Auto-discover storage locations on index page load
|
||||||
|
StorageDiscoveryService.discover_and_create
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@videos = @storage_location.videos.includes(:work).recent
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@storage_location = StorageLocation.new(storage_location_params)
|
||||||
|
|
||||||
|
if @storage_location.save
|
||||||
|
redirect_to @storage_location, notice: 'Storage location was successfully created.'
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@storage_location.destroy
|
||||||
|
redirect_to storage_locations_url, notice: 'Storage location was successfully destroyed.'
|
||||||
|
end
|
||||||
|
|
||||||
|
def scan
|
||||||
|
scanner = FileScannerService.new(@storage_location)
|
||||||
|
result = scanner.scan
|
||||||
|
|
||||||
|
if result[:success]
|
||||||
|
redirect_to @storage_location, notice: result[:message]
|
||||||
|
else
|
||||||
|
redirect_to @storage_location, alert: result[:message]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_storage_location
|
||||||
|
@storage_location = StorageLocation.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def storage_location_params
|
||||||
|
params.require(:storage_location).permit(:name, :path, :storage_type)
|
||||||
|
end
|
||||||
|
end
|
||||||
58
app/controllers/videos_controller.rb
Normal file
58
app/controllers/videos_controller.rb
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
class VideosController < ApplicationController
|
||||||
|
before_action :set_video, only: [:show, :stream, :playback_position, :retry_processing]
|
||||||
|
|
||||||
|
def show
|
||||||
|
@work = @video.work
|
||||||
|
@last_position = get_last_playback_position
|
||||||
|
end
|
||||||
|
|
||||||
|
def stream
|
||||||
|
file_path = @video.web_stream_path
|
||||||
|
|
||||||
|
unless file_path && File.exist?(file_path)
|
||||||
|
head :not_found
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
send_file file_path,
|
||||||
|
filename: @video.filename,
|
||||||
|
type: 'video/mp4',
|
||||||
|
disposition: 'inline',
|
||||||
|
stream: true,
|
||||||
|
buffer_size: 4096
|
||||||
|
end
|
||||||
|
|
||||||
|
def playback_position
|
||||||
|
position = params[:position].to_i
|
||||||
|
session = get_or_create_playback_session
|
||||||
|
session.update!(position: position, last_played_at: Time.current)
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def retry_processing
|
||||||
|
VideoProcessorJob.perform_later(@video.id)
|
||||||
|
redirect_to @video, notice: 'Video processing has been queued.'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_video
|
||||||
|
@video = Video.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_last_playback_position
|
||||||
|
# Get from current user's session or cookie
|
||||||
|
session_key = "video_position_#{@video.id}"
|
||||||
|
session[session_key] || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_or_create_playback_session
|
||||||
|
# For Phase 1, we'll use a simple session-based approach
|
||||||
|
# Phase 2 will use proper user authentication
|
||||||
|
PlaybackSession.find_or_initialize_by(
|
||||||
|
video: @video,
|
||||||
|
session_id: session.id.to_s,
|
||||||
|
user_id: nil # Will be populated in Phase 2
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
18
app/controllers/works_controller.rb
Normal file
18
app/controllers/works_controller.rb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
class WorksController < ApplicationController
|
||||||
|
before_action :set_work, only: [:show]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@works = Work.includes(:videos).recent
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@videos = @work.videos.includes(:storage_location).recent
|
||||||
|
@primary_video = @work.primary_video
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_work
|
||||||
|
@work = Work.find(params[:id])
|
||||||
|
end
|
||||||
|
end
|
||||||
2
app/helpers/videos_helper.rb
Normal file
2
app/helpers/videos_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
module VideosHelper
|
||||||
|
end
|
||||||
63
app/jobs/video_processor_job.rb
Normal file
63
app/jobs/video_processor_job.rb
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
class VideoProcessorJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(video_id)
|
||||||
|
video = Video.find(video_id)
|
||||||
|
|
||||||
|
# Extract metadata
|
||||||
|
metadata = VideoMetadataExtractor.new(video.full_file_path).extract
|
||||||
|
video.update!(video_metadata: metadata)
|
||||||
|
|
||||||
|
# Check if web compatible
|
||||||
|
transcoder = VideoTranscoder.new
|
||||||
|
web_compatible = transcoder.web_compatible?(video.full_file_path)
|
||||||
|
video.update!(web_compatible: web_compatible)
|
||||||
|
|
||||||
|
# Generate thumbnail
|
||||||
|
generate_thumbnail(video)
|
||||||
|
|
||||||
|
# Transcode if needed
|
||||||
|
unless web_compatible
|
||||||
|
transcode_video(video, transcoder)
|
||||||
|
end
|
||||||
|
|
||||||
|
video.update!(processed: true)
|
||||||
|
rescue => e
|
||||||
|
video.update!(processing_errors: e.message)
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_thumbnail(video)
|
||||||
|
transcoder = VideoTranscoder.new
|
||||||
|
|
||||||
|
# Generate thumbnail at 10% of duration or 5 seconds if duration unknown
|
||||||
|
thumbnail_time = video.duration ? video.duration * 0.1 : 5
|
||||||
|
thumbnail_path = transcoder.extract_frame(video.full_file_path, thumbnail_time)
|
||||||
|
|
||||||
|
# Attach thumbnail as video asset
|
||||||
|
video.video_assets.create!(
|
||||||
|
asset_type: 'thumbnail',
|
||||||
|
file: File.open(thumbnail_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up temporary file
|
||||||
|
File.delete(thumbnail_path) if File.exist?(thumbnail_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def transcode_video(video, transcoder)
|
||||||
|
output_path = video.full_file_path.gsub(/\.[^.]+$/, '.web.mp4')
|
||||||
|
|
||||||
|
transcoder.transcode_for_web(
|
||||||
|
input_path: video.full_file_path,
|
||||||
|
output_path: output_path
|
||||||
|
)
|
||||||
|
|
||||||
|
video.update!(
|
||||||
|
transcoded_path: File.basename(output_path),
|
||||||
|
transcoded_permanently: true,
|
||||||
|
web_compatible: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
6
app/mailers/passwords_mailer.rb
Normal file
6
app/mailers/passwords_mailer.rb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class PasswordsMailer < ApplicationMailer
|
||||||
|
def reset(user)
|
||||||
|
@user = user
|
||||||
|
mail subject: "Reset your password", to: user.email_address
|
||||||
|
end
|
||||||
|
end
|
||||||
26
app/models/concerns/processable.rb
Normal file
26
app/models/concerns/processable.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
module Processable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
scope :processing_pending, -> { where("video_metadata->>'duration' IS NULL") }
|
||||||
|
end
|
||||||
|
|
||||||
|
def processed?
|
||||||
|
duration.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def processing_pending?
|
||||||
|
!processed? && processing_errors.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_processing_failed!(error_message)
|
||||||
|
self.processing_errors = [error_message]
|
||||||
|
save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def retry_processing!
|
||||||
|
self.processing_errors = []
|
||||||
|
save!
|
||||||
|
# VideoProcessorJob.perform_later(id) - will be implemented later
|
||||||
|
end
|
||||||
|
end
|
||||||
11
app/models/concerns/searchable.rb
Normal file
11
app/models/concerns/searchable.rb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module Searchable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
def search(query)
|
||||||
|
return all if query.blank?
|
||||||
|
|
||||||
|
where("title LIKE ?", "%#{sanitize_sql_like(query)}%")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
19
app/models/concerns/streamable.rb
Normal file
19
app/models/concerns/streamable.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module Streamable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def stream_url
|
||||||
|
storage_location.adapter.stream_url(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
def streamable?
|
||||||
|
duration.present? && storage_location.enabled? && storage_location.adapter.readable?
|
||||||
|
end
|
||||||
|
|
||||||
|
def stream_type
|
||||||
|
case source_type
|
||||||
|
when "s3" then :presigned
|
||||||
|
when "local" then :direct
|
||||||
|
else :proxy
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
4
app/models/current.rb
Normal file
4
app/models/current.rb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
class Current < ActiveSupport::CurrentAttributes
|
||||||
|
attribute :session
|
||||||
|
delegate :user, to: :session, allow_nil: true
|
||||||
|
end
|
||||||
3
app/models/external_id.rb
Normal file
3
app/models/external_id.rb
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
class ExternalId < ApplicationRecord
|
||||||
|
belongs_to :work
|
||||||
|
end
|
||||||
75
app/models/media_file.rb
Normal file
75
app/models/media_file.rb
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
class MediaFile < ApplicationRecord
|
||||||
|
# Base class for all media files (Video, Audio, etc.)
|
||||||
|
# Uses Single Table Inheritance (STI) via the 'type' column
|
||||||
|
self.table_name = 'videos'
|
||||||
|
self.abstract_class = true
|
||||||
|
|
||||||
|
include Streamable
|
||||||
|
include Processable
|
||||||
|
|
||||||
|
# Common JSON stores for flexible metadata
|
||||||
|
store :fingerprints, accessors: [:xxhash64, :md5, :oshash, :phash]
|
||||||
|
store :media_metadata, accessors: [:duration, :codec, :bit_rate, :format]
|
||||||
|
|
||||||
|
# Common associations
|
||||||
|
belongs_to :work
|
||||||
|
belongs_to :storage_location
|
||||||
|
has_many :playback_sessions, dependent: :destroy
|
||||||
|
|
||||||
|
# Common validations
|
||||||
|
validates :filename, presence: true
|
||||||
|
validates :xxhash64, presence: true, uniqueness: { scope: :storage_location_id }
|
||||||
|
|
||||||
|
# Common scopes
|
||||||
|
scope :web_compatible, -> { where(web_compatible: true) }
|
||||||
|
scope :needs_transcoding, -> { where(web_compatible: false) }
|
||||||
|
scope :recent, -> { order(created_at: :desc) }
|
||||||
|
|
||||||
|
# Common delegations
|
||||||
|
delegate :display_title, to: :work, prefix: true, allow_nil: true
|
||||||
|
|
||||||
|
# Common instance methods
|
||||||
|
def display_title
|
||||||
|
work&.display_title || filename
|
||||||
|
end
|
||||||
|
|
||||||
|
def full_file_path
|
||||||
|
File.join(storage_location.path, filename)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_duration
|
||||||
|
return "Unknown" unless duration
|
||||||
|
|
||||||
|
hours = (duration / 3600).to_i
|
||||||
|
minutes = ((duration % 3600) / 60).to_i
|
||||||
|
seconds = (duration % 60).to_i
|
||||||
|
|
||||||
|
if hours > 0
|
||||||
|
"%d:%02d:%02d" % [hours, minutes, seconds]
|
||||||
|
else
|
||||||
|
"%d:%02d" % [minutes, seconds]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Template method for subclasses to override
|
||||||
|
def web_stream_path
|
||||||
|
# Default implementation - subclasses can override for specific behavior
|
||||||
|
if transcoded_permanently? && transcoded_path && File.exist?(transcoded_full_path)
|
||||||
|
return transcoded_full_path
|
||||||
|
end
|
||||||
|
if transcoded_path && File.exist?(temp_transcoded_full_path)
|
||||||
|
return temp_transcoded_full_path
|
||||||
|
end
|
||||||
|
full_file_path if web_compatible?
|
||||||
|
end
|
||||||
|
|
||||||
|
def transcoded_full_path
|
||||||
|
return nil unless transcoded_path
|
||||||
|
File.join(storage_location.path, transcoded_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def temp_transcoded_full_path
|
||||||
|
return nil unless transcoded_path
|
||||||
|
File.join(Rails.root, 'tmp', 'transcodes', storage_location.id.to_s, transcoded_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
4
app/models/playback_session.rb
Normal file
4
app/models/playback_session.rb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
class PlaybackSession < ApplicationRecord
|
||||||
|
belongs_to :video
|
||||||
|
belongs_to :user
|
||||||
|
end
|
||||||
3
app/models/session.rb
Normal file
3
app/models/session.rb
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
class Session < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
end
|
||||||
27
app/models/storage_location.rb
Normal file
27
app/models/storage_location.rb
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
class StorageLocation < ApplicationRecord
|
||||||
|
has_many :videos, dependent: :destroy
|
||||||
|
|
||||||
|
validates :name, presence: true
|
||||||
|
validates :path, presence: true, uniqueness: true
|
||||||
|
validates :storage_type, presence: true, inclusion: { in: %w[local] }
|
||||||
|
|
||||||
|
validate :path_must_exist_and_be_readable
|
||||||
|
|
||||||
|
def accessible?
|
||||||
|
File.exist?(path) && File.readable?(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def video_count
|
||||||
|
videos.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_name
|
||||||
|
name
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def path_must_exist_and_be_readable
|
||||||
|
errors.add(:path, "must exist and be readable") unless accessible?
|
||||||
|
end
|
||||||
|
end
|
||||||
6
app/models/user.rb
Normal file
6
app/models/user.rb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class User < ApplicationRecord
|
||||||
|
has_secure_password
|
||||||
|
has_many :sessions, dependent: :destroy
|
||||||
|
|
||||||
|
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||||
|
end
|
||||||
20
app/models/video.rb
Normal file
20
app/models/video.rb
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
class Video < MediaFile
|
||||||
|
# Video-specific associations
|
||||||
|
has_many :video_assets, dependent: :destroy
|
||||||
|
|
||||||
|
# Video-specific metadata store
|
||||||
|
store :video_metadata, accessors: [:width, :height, :video_codec, :audio_codec, :frame_rate]
|
||||||
|
|
||||||
|
# Video-specific instance methods
|
||||||
|
def resolution_label
|
||||||
|
return "Unknown" unless height
|
||||||
|
case height
|
||||||
|
when 0..480 then "SD"
|
||||||
|
when 481..720 then "720p"
|
||||||
|
when 721..1080 then "1080p"
|
||||||
|
when 1081..1440 then "1440p"
|
||||||
|
when 1441..2160 then "4K"
|
||||||
|
else "8K+"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
3
app/models/video_asset.rb
Normal file
3
app/models/video_asset.rb
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
class VideoAsset < ApplicationRecord
|
||||||
|
belongs_to :video
|
||||||
|
end
|
||||||
100
app/models/work.rb
Normal file
100
app/models/work.rb
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
class Work < ApplicationRecord
|
||||||
|
# 1. Includes/Concerns
|
||||||
|
include Searchable
|
||||||
|
|
||||||
|
# 2. JSON Store for flexible metadata
|
||||||
|
store :metadata, accessors: [:tmdb_data, :imdb_data, :custom_fields]
|
||||||
|
store :tmdb_data, accessors: [:overview, :poster_path, :backdrop_path, :release_date, :genres]
|
||||||
|
store :imdb_data, accessors: [:plot, :rating, :votes, :runtime, :director]
|
||||||
|
store :custom_fields
|
||||||
|
|
||||||
|
# 3. Associations
|
||||||
|
has_many :videos, dependent: :nullify
|
||||||
|
has_many :external_ids, dependent: :destroy
|
||||||
|
has_one :primary_video, -> { order("(video_metadata->>'height')::int DESC") }, class_name: "Video"
|
||||||
|
|
||||||
|
# 4. Validations
|
||||||
|
validates :title, presence: true
|
||||||
|
validates :year, numericality: { only_integer: true, greater_than: 1800 }, allow_nil: true
|
||||||
|
validates :rating, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }, allow_nil: true
|
||||||
|
|
||||||
|
# 5. Scopes
|
||||||
|
scope :organized, -> { where(organized: true) }
|
||||||
|
scope :unorganized, -> { where(organized: false) }
|
||||||
|
scope :recent, -> { order(created_at: :desc) }
|
||||||
|
scope :by_title, -> { order(:title) }
|
||||||
|
scope :with_year, -> { where.not(year: nil) }
|
||||||
|
|
||||||
|
# 6. Delegations
|
||||||
|
delegate :resolution_label, :duration, to: :primary_video, prefix: true, allow_nil: true
|
||||||
|
|
||||||
|
# 7. Class methods
|
||||||
|
def self.search(query)
|
||||||
|
where("title LIKE ? OR director LIKE ?", "%#{sanitize_sql_like(query)}%", "%#{sanitize_sql_like(query)}%")
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.find_by_external_id(source, value)
|
||||||
|
joins(:external_ids).find_by(external_ids: { source: source, value: value })
|
||||||
|
end
|
||||||
|
|
||||||
|
# 8. Instance methods
|
||||||
|
def display_title
|
||||||
|
year ? "#{title} (#{year})" : title
|
||||||
|
end
|
||||||
|
|
||||||
|
def video_count
|
||||||
|
videos.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_duration
|
||||||
|
videos.sum("(video_metadata->>'duration')::float")
|
||||||
|
end
|
||||||
|
|
||||||
|
def available_versions
|
||||||
|
videos.group_by(&:resolution_label)
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_external_ids?
|
||||||
|
external_ids.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def poster_url
|
||||||
|
poster_path || tmdb_data['poster_path']
|
||||||
|
end
|
||||||
|
|
||||||
|
def backdrop_url
|
||||||
|
backdrop_path || tmdb_data['backdrop_path']
|
||||||
|
end
|
||||||
|
|
||||||
|
def description
|
||||||
|
return read_attribute(:description) if read_attribute(:description).present?
|
||||||
|
tmdb_data['overview'] || imdb_data['plot']
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_director
|
||||||
|
return read_attribute(:director) if read_attribute(:director).present?
|
||||||
|
imdb_data['director']
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_rating
|
||||||
|
return read_attribute(:rating) if read_attribute(:rating).present?
|
||||||
|
imdb_data['rating']&.to_f
|
||||||
|
end
|
||||||
|
|
||||||
|
# Convenience accessors for common external IDs
|
||||||
|
# Auto-generated for all sources (will be implemented when we add ExternalId model logic)
|
||||||
|
# ExternalId.sources.keys.each do |source_name|
|
||||||
|
# define_method("#{source_name}_id") do
|
||||||
|
# external_ids.find_by(source: source_name)&.value
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# define_method("#{source_name}_id=") do |value|
|
||||||
|
# return if value.blank?
|
||||||
|
# external_ids.find_or_initialize_by(source: source_name).update!(value: value)
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# define_method("#{source_name}_url") do
|
||||||
|
# external_ids.find_by(source: source_name)&.url
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
end
|
||||||
56
app/services/file_scanner_service.rb
Normal file
56
app/services/file_scanner_service.rb
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
class FileScannerService
|
||||||
|
def initialize(storage_location)
|
||||||
|
@storage_location = storage_location
|
||||||
|
end
|
||||||
|
|
||||||
|
def scan
|
||||||
|
return failure_result("Storage location not accessible") unless @storage_location.accessible?
|
||||||
|
|
||||||
|
video_files = find_video_files
|
||||||
|
new_videos = process_files(video_files)
|
||||||
|
|
||||||
|
success_result(new_videos)
|
||||||
|
rescue => e
|
||||||
|
failure_result(e.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def find_video_files
|
||||||
|
Dir.glob(File.join(@storage_location.path, "**", "*.{mp4,avi,mkv,mov,wmv,flv,webm,m4v}"))
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_files(file_paths)
|
||||||
|
new_videos = []
|
||||||
|
|
||||||
|
file_paths.each do |file_path|
|
||||||
|
filename = File.basename(file_path)
|
||||||
|
|
||||||
|
next if Video.exists?(filename: filename, storage_location: @storage_location)
|
||||||
|
|
||||||
|
video = Video.create!(
|
||||||
|
filename: filename,
|
||||||
|
storage_location: @storage_location,
|
||||||
|
work: Work.find_or_create_by(title: extract_title(filename))
|
||||||
|
)
|
||||||
|
|
||||||
|
new_videos << video
|
||||||
|
VideoProcessorJob.perform_later(video.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
new_videos
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_title(filename)
|
||||||
|
# Simple title extraction - can be enhanced
|
||||||
|
File.basename(filename, ".*").gsub(/[\[\(].*?[\]\)]/, "").strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def success_result(videos = [])
|
||||||
|
{ success: true, videos: videos, message: "Found #{videos.length} new videos" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure_result(message)
|
||||||
|
{ success: false, message: message }
|
||||||
|
end
|
||||||
|
end
|
||||||
35
app/services/result.rb
Normal file
35
app/services/result.rb
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
class Result
|
||||||
|
attr_reader :data, :error
|
||||||
|
|
||||||
|
def initialize(success:, data: {}, error: nil)
|
||||||
|
@success = success
|
||||||
|
@data = data
|
||||||
|
@error = error
|
||||||
|
end
|
||||||
|
|
||||||
|
def success?
|
||||||
|
@success
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure?
|
||||||
|
!@success
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.success(data = {})
|
||||||
|
new(success: true, data: data)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.failure(error)
|
||||||
|
new(success: false, error: error)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Allow accessing data as methods
|
||||||
|
def method_missing(method, *args)
|
||||||
|
return @data[method] if @data.key?(method)
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
def respond_to_missing?(method, include_private = false)
|
||||||
|
@data.key?(method) || super
|
||||||
|
end
|
||||||
|
end
|
||||||
46
app/services/storage_adapters/base_adapter.rb
Normal file
46
app/services/storage_adapters/base_adapter.rb
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
module StorageAdapters
|
||||||
|
class BaseAdapter
|
||||||
|
def initialize(storage_location)
|
||||||
|
@storage_location = storage_location
|
||||||
|
end
|
||||||
|
|
||||||
|
# Scan for video files and return array of relative paths
|
||||||
|
def scan
|
||||||
|
raise NotImplementedError, "#{self.class} must implement #scan"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate streaming URL for a video
|
||||||
|
def stream_url(video)
|
||||||
|
raise NotImplementedError, "#{self.class} must implement #stream_url"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if file exists at path
|
||||||
|
def exists?(file_path)
|
||||||
|
raise NotImplementedError, "#{self.class} must implement #exists?"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if storage can be read from
|
||||||
|
def readable?
|
||||||
|
raise NotImplementedError, "#{self.class} must implement #readable?"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if storage can be written to
|
||||||
|
def writable?
|
||||||
|
@storage_location.writable?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Write/copy file to storage
|
||||||
|
def write(source_path, dest_path)
|
||||||
|
raise NotImplementedError, "#{self.class} must implement #write"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Download file to local temp path (for processing)
|
||||||
|
def download_to_temp(video)
|
||||||
|
raise NotImplementedError, "#{self.class} must implement #download_to_temp"
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
attr_reader :storage_location
|
||||||
|
end
|
||||||
|
end
|
||||||
59
app/services/storage_adapters/local_adapter.rb
Normal file
59
app/services/storage_adapters/local_adapter.rb
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
module StorageAdapters
|
||||||
|
class LocalAdapter < BaseAdapter
|
||||||
|
VIDEO_EXTENSIONS = %w[.mp4 .mkv .avi .mov .wmv .flv .webm .m4v].freeze
|
||||||
|
|
||||||
|
def scan
|
||||||
|
return [] unless readable?
|
||||||
|
|
||||||
|
pattern = if storage_location.scan_subdirectories
|
||||||
|
File.join(storage_location.path, "**", "*{#{VIDEO_EXTENSIONS.join(',')}}")
|
||||||
|
else
|
||||||
|
File.join(storage_location.path, "*{#{VIDEO_EXTENSIONS.join(',')}}")
|
||||||
|
end
|
||||||
|
|
||||||
|
Dir.glob(pattern, File::FNM_CASEFOLD).map do |full_path|
|
||||||
|
full_path.sub(storage_location.path + "/", "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def stream_url(video)
|
||||||
|
full_path(video)
|
||||||
|
end
|
||||||
|
|
||||||
|
def exists?(file_path)
|
||||||
|
File.exist?(full_path_from_relative(file_path))
|
||||||
|
end
|
||||||
|
|
||||||
|
def readable?
|
||||||
|
return false unless storage_location.path.present?
|
||||||
|
|
||||||
|
File.directory?(storage_location.path) && File.readable?(storage_location.path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def writable?
|
||||||
|
super && File.writable?(storage_location.path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def write(source_path, dest_path)
|
||||||
|
dest_full_path = full_path_from_relative(dest_path)
|
||||||
|
FileUtils.mkdir_p(File.dirname(dest_full_path))
|
||||||
|
FileUtils.cp(source_path, dest_full_path)
|
||||||
|
dest_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_to_temp(video)
|
||||||
|
# Already local, return path
|
||||||
|
full_path(video)
|
||||||
|
end
|
||||||
|
|
||||||
|
def full_path(video)
|
||||||
|
full_path_from_relative(video.file_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def full_path_from_relative(file_path)
|
||||||
|
File.join(storage_location.path, file_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
46
app/services/storage_discovery_service.rb
Normal file
46
app/services/storage_discovery_service.rb
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
class StorageDiscoveryService
|
||||||
|
CATEGORIES = {
|
||||||
|
'movies' => 'Movies',
|
||||||
|
'tv' => 'TV Shows',
|
||||||
|
'tv_shows' => 'TV Shows',
|
||||||
|
'series' => 'TV Shows',
|
||||||
|
'docs' => 'Documentaries',
|
||||||
|
'documentaries' => 'Documentaries',
|
||||||
|
'anime' => 'Anime',
|
||||||
|
'cartoons' => 'Animation',
|
||||||
|
'animation' => 'Animation',
|
||||||
|
'sports' => 'Sports',
|
||||||
|
'music' => 'Music Videos',
|
||||||
|
'music_videos' => 'Music Videos',
|
||||||
|
'kids' => 'Kids Content',
|
||||||
|
'family' => 'Family Content'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def self.discover_and_create
|
||||||
|
base_path = '/videos'
|
||||||
|
return [] unless Dir.exist?(base_path)
|
||||||
|
|
||||||
|
discovered = []
|
||||||
|
|
||||||
|
Dir.children(base_path).each do |subdir|
|
||||||
|
dir_path = File.join(base_path, subdir)
|
||||||
|
next unless Dir.exist?(dir_path)
|
||||||
|
|
||||||
|
category = categorize_directory(subdir)
|
||||||
|
storage = StorageLocation.find_or_create_by!(
|
||||||
|
name: "#{category}: #{subdir.titleize}",
|
||||||
|
path: dir_path,
|
||||||
|
storage_type: 'local'
|
||||||
|
)
|
||||||
|
|
||||||
|
discovered << storage
|
||||||
|
end
|
||||||
|
|
||||||
|
discovered
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.categorize_directory(dirname)
|
||||||
|
downcase = dirname.downcase
|
||||||
|
CATEGORIES[downcase] || CATEGORIES.find { |key, _| downcase.include?(key) }&.last || 'Other'
|
||||||
|
end
|
||||||
|
end
|
||||||
12
app/services/video_metadata_extractor.rb
Normal file
12
app/services/video_metadata_extractor.rb
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
class VideoMetadataExtractor
|
||||||
|
def initialize(file_path)
|
||||||
|
@file_path = file_path
|
||||||
|
@transcoder = VideoTranscoder.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract
|
||||||
|
return {} unless File.exist?(@file_path)
|
||||||
|
|
||||||
|
@transcoder.extract_metadata(@file_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
75
app/services/video_transcoder.rb
Normal file
75
app/services/video_transcoder.rb
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
class VideoTranscoder
|
||||||
|
require 'streamio-ffmpeg'
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@ffmpeg_path = ENV['FFMPEG_PATH'] || 'ffmpeg'
|
||||||
|
@ffprobe_path = ENV['FFPROBE_PATH'] || 'ffprobe'
|
||||||
|
end
|
||||||
|
|
||||||
|
def transcode_for_web(input_path:, output_path:, on_progress: nil)
|
||||||
|
movie = FFMPEG::Movie.new(input_path)
|
||||||
|
|
||||||
|
# Calculate progress callback
|
||||||
|
progress_callback = ->(progress) {
|
||||||
|
on_progress&.call(progress, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Transcoding options for web compatibility
|
||||||
|
options = {
|
||||||
|
video_codec: 'libx264',
|
||||||
|
audio_codec: 'aac',
|
||||||
|
custom: [
|
||||||
|
'-pix_fmt yuv420p',
|
||||||
|
'-preset medium',
|
||||||
|
'-crf 23',
|
||||||
|
'-movflags +faststart',
|
||||||
|
'-tune fastdecode'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
movie.transcode(output_path, options, &progress_callback)
|
||||||
|
|
||||||
|
output_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_frame(input_path, seconds)
|
||||||
|
movie = FFMPEG::Movie.new(input_path)
|
||||||
|
output_path = "#{Rails.root}/tmp/thumbnail_#{SecureRandom.hex(8)}.jpg"
|
||||||
|
|
||||||
|
movie.screenshot(output_path, seek_time: seconds, resolution: '320x240')
|
||||||
|
output_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_metadata(input_path)
|
||||||
|
movie = FFMPEG::Movie.new(input_path)
|
||||||
|
|
||||||
|
{
|
||||||
|
width: movie.width,
|
||||||
|
height: movie.height,
|
||||||
|
duration: movie.duration,
|
||||||
|
video_codec: movie.video_codec,
|
||||||
|
audio_codec: movie.audio_codec,
|
||||||
|
bit_rate: movie.bitrate,
|
||||||
|
frame_rate: movie.frame_rate,
|
||||||
|
format: movie.container
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def web_compatible?(input_path)
|
||||||
|
movie = FFMPEG::Movie.new(input_path)
|
||||||
|
|
||||||
|
# Check if video is already web-compatible
|
||||||
|
return false unless movie.valid?
|
||||||
|
|
||||||
|
# Common web-compatible formats
|
||||||
|
web_formats = %w[mp4 webm]
|
||||||
|
web_video_codecs = %w[h264 av1 vp9]
|
||||||
|
web_audio_codecs = %w[aac opus]
|
||||||
|
|
||||||
|
format_compatible = web_formats.include?(movie.container.downcase)
|
||||||
|
video_compatible = web_video_codecs.include?(movie.video_codec&.downcase)
|
||||||
|
audio_compatible = movie.audio_codec.blank? || web_audio_codecs.include?(movie.audio_codec&.downcase)
|
||||||
|
|
||||||
|
format_compatible && video_compatible && audio_compatible
|
||||||
|
end
|
||||||
|
end
|
||||||
21
app/views/passwords/edit.html.erb
Normal file
21
app/views/passwords/edit.html.erb
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<div class="mx-auto md:w-2/3 w-full">
|
||||||
|
<% if alert = flash[:alert] %>
|
||||||
|
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h1 class="font-bold text-4xl">Update your password</h1>
|
||||||
|
|
||||||
|
<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
|
||||||
|
<div class="my-5">
|
||||||
|
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-5">
|
||||||
|
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline">
|
||||||
|
<%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
17
app/views/passwords/new.html.erb
Normal file
17
app/views/passwords/new.html.erb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<div class="mx-auto md:w-2/3 w-full">
|
||||||
|
<% if alert = flash[:alert] %>
|
||||||
|
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h1 class="font-bold text-4xl">Forgot your password?</h1>
|
||||||
|
|
||||||
|
<%= form_with url: passwords_path, class: "contents" do |form| %>
|
||||||
|
<div class="my-5">
|
||||||
|
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline">
|
||||||
|
<%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
6
app/views/passwords_mailer/reset.html.erb
Normal file
6
app/views/passwords_mailer/reset.html.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<p>
|
||||||
|
You can reset your password on
|
||||||
|
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
|
||||||
|
|
||||||
|
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
||||||
|
</p>
|
||||||
4
app/views/passwords_mailer/reset.text.erb
Normal file
4
app/views/passwords_mailer/reset.text.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
You can reset your password on
|
||||||
|
<%= edit_password_url(@user.password_reset_token) %>
|
||||||
|
|
||||||
|
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
||||||
31
app/views/sessions/new.html.erb
Normal file
31
app/views/sessions/new.html.erb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<div class="mx-auto md:w-2/3 w-full">
|
||||||
|
<% if alert = flash[:alert] %>
|
||||||
|
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if notice = flash[:notice] %>
|
||||||
|
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h1 class="font-bold text-4xl">Sign in</h1>
|
||||||
|
|
||||||
|
<%= form_with url: session_url, class: "contents" do |form| %>
|
||||||
|
<div class="my-5">
|
||||||
|
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-5">
|
||||||
|
<%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-6 sm:flex sm:items-center sm:gap-4">
|
||||||
|
<div class="inline">
|
||||||
|
<%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-sm text-gray-500 sm:mt-0">
|
||||||
|
<%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
4
app/views/storage_locations/create.html.erb
Normal file
4
app/views/storage_locations/create.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">StorageLocations#create</h1>
|
||||||
|
<p>Find me in app/views/storage_locations/create.html.erb</p>
|
||||||
|
</div>
|
||||||
4
app/views/storage_locations/destroy.html.erb
Normal file
4
app/views/storage_locations/destroy.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">StorageLocations#destroy</h1>
|
||||||
|
<p>Find me in app/views/storage_locations/destroy.html.erb</p>
|
||||||
|
</div>
|
||||||
81
app/views/storage_locations/index.html.erb
Normal file
81
app/views/storage_locations/index.html.erb
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Video Library</h1>
|
||||||
|
<% if @storage_locations.any? %>
|
||||||
|
<%= link_to "New Storage Location", new_storage_location_path,
|
||||||
|
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @storage_locations.empty? %>
|
||||||
|
<div class="text-center py-12 bg-white rounded-lg shadow">
|
||||||
|
<div class="text-gray-500 text-lg mb-4">No storage locations found</div>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
Storage locations are automatically discovered from directories mounted under <code class="bg-gray-100 px-2 py-1 rounded">/videos</code>
|
||||||
|
</p>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<p class="mb-2">Example Docker volume mounts:</p>
|
||||||
|
<code class="block bg-gray-100 p-3 rounded text-left">
|
||||||
|
/path/to/movies:/videos/movies:ro<br>
|
||||||
|
/path/to/tv_shows:/videos/tv:ro<br>
|
||||||
|
/path/to/documentaries:/videos/docs:ro
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<% @storage_locations.each do |storage_location| %>
|
||||||
|
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
<%= link_to storage_location.name, storage_location,
|
||||||
|
class: "hover:text-blue-600 transition-colors" %>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="text-gray-600 text-sm mb-4">
|
||||||
|
<p class="mb-1">
|
||||||
|
<span class="font-medium">Path:</span>
|
||||||
|
<code class="bg-gray-100 px-1 py-0.5 rounded text-xs"><%= storage_location.path %></code>
|
||||||
|
</p>
|
||||||
|
<p class="mb-1">
|
||||||
|
<span class="font-medium">Type:</span>
|
||||||
|
<%= storage_location.storage_type.titleize %>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="font-medium">Videos:</span>
|
||||||
|
<%= storage_location.video_count %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if storage_location.accessible? %>
|
||||||
|
<div class="flex items-center text-green-600 text-sm mb-4">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Accessible
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="flex items-center text-red-600 text-sm mb-4">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Not Accessible
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<%= link_to "View Videos", storage_location,
|
||||||
|
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-3 rounded text-sm transition-colors" %>
|
||||||
|
|
||||||
|
<%= form_with(url: scan_storage_location_path(storage_location), method: :post,
|
||||||
|
class: "inline-flex") do |form| %>
|
||||||
|
<%= form.submit "Scan",
|
||||||
|
class: "bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-3 rounded text-sm cursor-pointer transition-colors" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
118
app/views/storage_locations/show.html.erb
Normal file
118
app/views/storage_locations/show.html.erb
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900"><%= @storage_location.name %></h1>
|
||||||
|
<p class="text-gray-600 mt-2">
|
||||||
|
<span class="font-medium">Path:</span>
|
||||||
|
<code class="bg-gray-100 px-2 py-1 rounded"><%= @storage_location.path %></code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "← Back to Library", storage_locations_path,
|
||||||
|
class: "bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 rounded-lg transition-colors" %>
|
||||||
|
|
||||||
|
<%= form_with(url: scan_storage_location_path(@storage_location), method: :post,
|
||||||
|
class: "inline-flex") do |form| %>
|
||||||
|
<%= form.submit "Scan for Videos",
|
||||||
|
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg cursor-pointer transition-colors" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @videos.empty? %>
|
||||||
|
<div class="text-center py-12 bg-white rounded-lg shadow">
|
||||||
|
<div class="text-gray-500 text-lg mb-4">No videos found</div>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
This storage location doesn't contain any video files yet. Try scanning for videos to add them to your library.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-white rounded-lg shadow">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">
|
||||||
|
Videos (<%= @videos.count %>)
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-gray-200">
|
||||||
|
<% @videos.each do |video| %>
|
||||||
|
<div class="p-6 hover:bg-gray-50 transition-colors">
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<!-- Thumbnail placeholder -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<% if video.video_assets.where(asset_type: 'thumbnail').any? %>
|
||||||
|
<%= image_tag video.video_assets.where(asset_type: 'thumbnail').first.file,
|
||||||
|
class: "w-24 h-16 object-cover rounded", alt: video.display_title %>
|
||||||
|
<% else %>
|
||||||
|
<div class="w-24 h-16 bg-gray-200 rounded flex items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-1">
|
||||||
|
<%= link_to video.display_title, video,
|
||||||
|
class: "hover:text-blue-600 transition-colors" %>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 text-sm text-gray-600 mb-2">
|
||||||
|
<span>
|
||||||
|
<span class="font-medium">Duration:</span>
|
||||||
|
<%= video.format_duration %>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span class="font-medium">Resolution:</span>
|
||||||
|
<%= video.resolution_label %>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span class="font-medium">Size:</span>
|
||||||
|
<%= number_to_human_size(video.video_metadata['file_size']) rescue "Unknown" %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-3 text-sm">
|
||||||
|
<% if video.web_compatible? %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
Web Compatible
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
Needs Transcoding
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if video.processed? %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
Processed
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
Processing
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if video.work&.title && video.work.title != video.display_title %>
|
||||||
|
<span class="text-gray-500">
|
||||||
|
Part of: <%= link_to video.work.title, video.work,
|
||||||
|
class: "hover:text-blue-600 transition-colors" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<%= link_to "Watch", video,
|
||||||
|
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
76
app/views/videos/index.html.erb
Normal file
76
app/views/videos/index.html.erb
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<% content_for :title, "Videos" %>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Video Library</h1>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<% if @storage_locations.any? %>
|
||||||
|
<select class="rounded-md border-gray-300 border px-3 py-2 text-sm" id="storage-filter">
|
||||||
|
<option value="">All Sources</option>
|
||||||
|
<% @storage_locations.each do |location| %>
|
||||||
|
<option value="<%= location.id %>"><%= location.display_name %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to "New Storage Location", new_admin_storage_location_path, class: "bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @videos.any? %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
<% @videos.each do |video| %>
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
|
||||||
|
<div class="aspect-video bg-gray-200 relative">
|
||||||
|
<%# Placeholder for thumbnails - Phase 1C will add actual thumbnails %>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0120 8.618m6.418 2.276L11 14.914M4.418 4.418a2 2 0 00-2.828 0l-4.418 4.418a2 2 0 002.828 0l4.418-4.418a2 2 0 012.828 0l4.418 4.418a2 2 0 012.828 0l4.418-4.418z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<%# Badge for source type %>
|
||||||
|
<div class="absolute top-2 left-2 bg-gray-800 text-white text-xs px-2 py-1 rounded">
|
||||||
|
<%= video.storage_location.name %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 truncate mb-2">
|
||||||
|
<%= link_to video.display_title, video_path(video), class: "hover:text-blue-600" %>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-500 space-y-1">
|
||||||
|
<div>Duration: <%= video.formatted_duration %></div>
|
||||||
|
<div>Size: <%= video.formatted_file_size %></div>
|
||||||
|
<% if video.resolution_label.present? %>
|
||||||
|
<div>Resolution: <%= video.resolution_label %></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex justify-between items-center">
|
||||||
|
<div class="text-xs text-gray-400">
|
||||||
|
<% if video.processing_errors.present? %>
|
||||||
|
<span class="text-red-500">Failed</span>
|
||||||
|
<% elsif video.processed? %>
|
||||||
|
<span class="text-green-500">Processed</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-yellow-500">Processing</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<%= link_to "Watch", video_path(video), class: "bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded text-xs" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination with Pagy -->
|
||||||
|
<%== pagy_nav(@pagy) %>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M9 16h6" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-semibold text-gray-900">No videos found</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Get started by adding a storage location and scanning for videos.</p>
|
||||||
|
<%= link_to "Add Storage Location", new_admin_storage_location_path, class: "mt-4 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
106
app/views/videos/show.html.erb
Normal file
106
app/views/videos/show.html.erb
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<% content_for :title, @video.display_title %>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<div class="aspect-video bg-gray-200 relative">
|
||||||
|
<%# Placeholder for video player - Phase 1B will add Video.js %>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 0 0-5.656 0M9 10h.01M15 5.5c0 0 0-4.95-5.39 0-7.28 0-7.28 0A4 4 0 0 1 5.5 8.78a4 4 0 0 1 0 7.28 0 7.28a4 4 0 0 1-7.28 0c0 0-4.95 4.95-5.39 0-7.28 0A4 4 0 0 1 15.5 5.5c0 0 0 0 7.28 0 7.28a4 4 0 0 0 0-7.28 0" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900"><%= @video.display_title %></h1>
|
||||||
|
<% if @video.work.present? %>
|
||||||
|
<p class="text-gray-600"><%= link_to @video.work.display_title, work_path(@video.work), class: "hover:text-blue-600" %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 mb-2">Video Information</h3>
|
||||||
|
<dl class="space-y-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Duration:</dt>
|
||||||
|
<dd class="text-sm text-gray-900"><%= @video.formatted_duration %></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">File Size:</dt>
|
||||||
|
<dd class="text-sm text-gray-900"><%= @video.formatted_file_size %></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Resolution:</dt>
|
||||||
|
<dd class="text-sm text-gray-900"><%= @video.resolution_label || "Unknown" %></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Format:</dt>
|
||||||
|
<dd class="text-sm text-gray-900"><%= @video.format || "Unknown" %></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 mb-2">Storage Information</h3>
|
||||||
|
<dl class="space-y-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Storage Location:</dt>
|
||||||
|
<dd class="text-sm text-gray-900"><%= @video.storage_location.name %></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Source Type:</dt>
|
||||||
|
<dd class="text-sm text-gray-900"><%= @video.source_type.humanize %></dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-sm font-medium text-gray-500">File Path:</dt>
|
||||||
|
<dd class="text-sm text-gray-900 truncate"><%= @video.file_path %></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @video.video_metadata.present? %>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 mb-2">Technical Details</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm font-medium text-gray-500">Video Codec:</span>
|
||||||
|
<span class="text-sm text-gray-900"><%= @video.video_codec || "N/A" %></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm font-medium text-gray-500">Audio Codec:</span>
|
||||||
|
<span class="text-sm text-gray-900"><%= @video.audio_codec || "N/A" %></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm font-medium text-gray-500">Frame Rate:</span>
|
||||||
|
<span class="text-sm text-gray-900"><%= @video.frame_rate || "N/A" %> fps</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm font-medium text-gray-500">Bit Rate:</span>
|
||||||
|
<span class="text-sm text-gray-900"><%= @video.bit_rate ? "#{(@video.bit_rate / 1000).round(1)} kb/s" : "N/A" %></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm font-medium text-500">Dimensions:</span>
|
||||||
|
<span class="text-sm text-gray-900">
|
||||||
|
<%= @video.width || "N/A" %> × <%= @video.height || "N/A" %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<%= link_to "Back to Videos", videos_path, class: "bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-md text-sm font-medium" %>
|
||||||
|
<% if @video.streamable? %>
|
||||||
|
<%= link_to "Watch Video", watch_video_path(@video), class: "bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
4
app/views/works/index.html.erb
Normal file
4
app/views/works/index.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Works#index</h1>
|
||||||
|
<p>Find me in app/views/works/index.html.erb</p>
|
||||||
|
</div>
|
||||||
4
app/views/works/show.html.erb
Normal file
4
app/views/works/show.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div>
|
||||||
|
<h1 class="font-bold text-4xl">Works#show</h1>
|
||||||
|
<p>Find me in app/views/works/show.html.erb</p>
|
||||||
|
</div>
|
||||||
@@ -1,14 +1,39 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
# Health check
|
||||||
|
|
||||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
|
||||||
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
|
||||||
get "up" => "rails/health#show", as: :rails_health_check
|
get "up" => "rails/health#show", as: :rails_health_check
|
||||||
|
|
||||||
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
|
# Root - Phase 1: Storage locations as main entry
|
||||||
# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
|
root "storage_locations#index"
|
||||||
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
|
|
||||||
|
|
||||||
# Defines the root path route ("/")
|
# Phase 1: Storage locations focused routes
|
||||||
# root "posts#index"
|
resources :storage_locations, only: [:index, :show, :create, :destroy] do
|
||||||
|
member do
|
||||||
|
post :scan
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
resources :works, only: [:index, :show] do
|
||||||
|
resources :videos, only: [:show]
|
||||||
|
end
|
||||||
|
|
||||||
|
resources :videos, only: [] do
|
||||||
|
member do
|
||||||
|
get :stream
|
||||||
|
patch :playback_position
|
||||||
|
post :retry_processing
|
||||||
|
end
|
||||||
|
|
||||||
|
resources :playback_sessions, only: [:create]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Real-time job progress
|
||||||
|
resources :jobs, only: [:show] do
|
||||||
|
member do
|
||||||
|
get :progress
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Rails authentication routes
|
||||||
|
resource :session
|
||||||
|
resources :passwords, param: :token
|
||||||
end
|
end
|
||||||
|
|||||||
17
db/migrate/20251029113808_create_works.rb
Normal file
17
db/migrate/20251029113808_create_works.rb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
class CreateWorks < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :works do |t|
|
||||||
|
t.string :title
|
||||||
|
t.integer :year
|
||||||
|
t.string :director
|
||||||
|
t.text :description
|
||||||
|
t.decimal :rating
|
||||||
|
t.boolean :organized
|
||||||
|
t.string :poster_path
|
||||||
|
t.string :backdrop_path
|
||||||
|
t.text :metadata
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
11
db/migrate/20251029113830_create_external_ids.rb
Normal file
11
db/migrate/20251029113830_create_external_ids.rb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
class CreateExternalIds < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :external_ids do |t|
|
||||||
|
t.references :work, null: false, foreign_key: true
|
||||||
|
t.integer :source
|
||||||
|
t.string :value
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
17
db/migrate/20251029113850_create_storage_locations.rb
Normal file
17
db/migrate/20251029113850_create_storage_locations.rb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
class CreateStorageLocations < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :storage_locations do |t|
|
||||||
|
t.string :name
|
||||||
|
t.string :path
|
||||||
|
t.integer :location_type
|
||||||
|
t.boolean :writable
|
||||||
|
t.boolean :enabled
|
||||||
|
t.boolean :scan_subdirectories
|
||||||
|
t.integer :priority
|
||||||
|
t.text :settings
|
||||||
|
t.datetime :last_scanned_at
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
31
db/migrate/20251029113911_create_videos.rb
Normal file
31
db/migrate/20251029113911_create_videos.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
class CreateVideos < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :videos do |t|
|
||||||
|
t.references :work, null: false, foreign_key: true
|
||||||
|
t.references :storage_location, null: false, foreign_key: true
|
||||||
|
t.string :title
|
||||||
|
t.string :file_path
|
||||||
|
t.string :file_hash
|
||||||
|
t.integer :file_size
|
||||||
|
t.float :duration
|
||||||
|
t.integer :width
|
||||||
|
t.integer :height
|
||||||
|
t.string :resolution_label
|
||||||
|
t.string :video_codec
|
||||||
|
t.string :audio_codec
|
||||||
|
t.integer :bit_rate
|
||||||
|
t.float :frame_rate
|
||||||
|
t.string :format
|
||||||
|
t.boolean :has_subtitles
|
||||||
|
t.string :version_type
|
||||||
|
t.integer :source_type
|
||||||
|
t.string :source_url
|
||||||
|
t.boolean :imported
|
||||||
|
t.boolean :processing_failed
|
||||||
|
t.text :error_message
|
||||||
|
t.text :metadata
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
11
db/migrate/20251029113919_create_video_assets.rb
Normal file
11
db/migrate/20251029113919_create_video_assets.rb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
class CreateVideoAssets < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :video_assets do |t|
|
||||||
|
t.references :video, null: false, foreign_key: true
|
||||||
|
t.integer :asset_type
|
||||||
|
t.text :metadata
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
15
db/migrate/20251029113957_create_playback_sessions.rb
Normal file
15
db/migrate/20251029113957_create_playback_sessions.rb
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
class CreatePlaybackSessions < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :playback_sessions do |t|
|
||||||
|
t.references :video, null: false, foreign_key: true
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.float :position
|
||||||
|
t.float :duration_watched
|
||||||
|
t.datetime :last_watched_at
|
||||||
|
t.boolean :completed
|
||||||
|
t.integer :play_count
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
11
db/migrate/20251029120242_create_users.rb
Normal file
11
db/migrate/20251029120242_create_users.rb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
class CreateUsers < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :users do |t|
|
||||||
|
t.string :email_address, null: false
|
||||||
|
t.string :password_digest, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
add_index :users, :email_address, unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
11
db/migrate/20251029120243_create_sessions.rb
Normal file
11
db/migrate/20251029120243_create_sessions.rb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
class CreateSessions < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :sessions do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.string :ip_address
|
||||||
|
t.string :user_agent
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
29
db/migrate/20251029120404_update_videos_from_architecture.rb
Normal file
29
db/migrate/20251029120404_update_videos_from_architecture.rb
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
class UpdateVideosFromArchitecture < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
change_table :videos do |t|
|
||||||
|
# Make work reference optional (videos can exist without works initially)
|
||||||
|
t.change_null :work_id, true
|
||||||
|
|
||||||
|
# Add defaults for boolean fields
|
||||||
|
t.change_default :imported, false
|
||||||
|
t.change_default :processing_failed, false
|
||||||
|
t.change_default :has_subtitles, false
|
||||||
|
|
||||||
|
# Note: file_size is already integer, SQLite compatible with bigint
|
||||||
|
|
||||||
|
# Add source_type default and make required fields not null
|
||||||
|
t.change_default :source_type, 0
|
||||||
|
t.change_null :source_type, false
|
||||||
|
|
||||||
|
# Make file_path required
|
||||||
|
t.change_null :file_path, false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add indexes as specified in architecture
|
||||||
|
add_index :videos, [:storage_location_id, :file_path], unique: true
|
||||||
|
add_index :videos, :source_type
|
||||||
|
add_index :videos, :file_hash
|
||||||
|
add_index :videos, :imported
|
||||||
|
add_index :videos, [:work_id, :resolution_label]
|
||||||
|
end
|
||||||
|
end
|
||||||
16
db/migrate/20251029120428_update_works_from_architecture.rb
Normal file
16
db/migrate/20251029120428_update_works_from_architecture.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class UpdateWorksFromArchitecture < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
change_table :works do |t|
|
||||||
|
# Add defaults for boolean fields
|
||||||
|
t.change_default :organized, false
|
||||||
|
|
||||||
|
# Make title required
|
||||||
|
t.change_null :title, false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add indexes as specified in architecture
|
||||||
|
add_index :works, :title
|
||||||
|
add_index :works, [:title, :year], unique: true
|
||||||
|
add_index :works, :organized
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
7
db/migrate/20251029204614_add_json_stores_to_video.rb
Normal file
7
db/migrate/20251029204614_add_json_stores_to_video.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
class AddJsonStoresToVideo < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :videos, :fingerprints, :text
|
||||||
|
add_column :videos, :video_metadata, :text
|
||||||
|
add_column :videos, :processing_info, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
7
db/migrate/20251029204641_add_json_stores_to_work.rb
Normal file
7
db/migrate/20251029204641_add_json_stores_to_work.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
class AddJsonStoresToWork < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :works, :tmdb_data, :text
|
||||||
|
add_column :works, :imdb_data, :text
|
||||||
|
add_column :works, :custom_fields, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
8
db/migrate/20251029215501_add_phase1_fields_to_videos.rb
Normal file
8
db/migrate/20251029215501_add_phase1_fields_to_videos.rb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
class AddPhase1FieldsToVideos < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :videos, :filename, :string
|
||||||
|
add_column :videos, :transcoded_path, :string
|
||||||
|
add_column :videos, :transcoded_permanently, :boolean
|
||||||
|
add_column :videos, :web_compatible, :boolean
|
||||||
|
end
|
||||||
|
end
|
||||||
5
db/migrate/20251029215811_add_processed_to_videos.rb
Normal file
5
db/migrate/20251029215811_add_processed_to_videos.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class AddProcessedToVideos < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :videos, :processed, :boolean
|
||||||
|
end
|
||||||
|
end
|
||||||
6
db/migrate/20251031022926_add_type_to_videos.rb
Normal file
6
db/migrate/20251031022926_add_type_to_videos.rb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class AddTypeToVideos < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :videos, :type, :string, default: 'Video', null: false
|
||||||
|
add_index :videos, :type
|
||||||
|
end
|
||||||
|
end
|
||||||
146
db/schema.rb
generated
146
db/schema.rb
generated
@@ -10,5 +10,149 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 0) do
|
ActiveRecord::Schema[8.1].define(version: 2025_10_31_022926) do
|
||||||
|
create_table "external_ids", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.integer "source", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "value", null: false
|
||||||
|
t.integer "work_id", null: false
|
||||||
|
t.index ["source", "value"], name: "index_external_ids_on_source_and_value", unique: true
|
||||||
|
t.index ["work_id", "source"], name: "index_external_ids_on_work_id_and_source", unique: true
|
||||||
|
t.index ["work_id"], name: "index_external_ids_on_work_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "playback_sessions", force: :cascade do |t|
|
||||||
|
t.boolean "completed", default: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.float "duration_watched", default: 0.0
|
||||||
|
t.datetime "last_watched_at"
|
||||||
|
t.integer "play_count", default: 0
|
||||||
|
t.float "position", default: 0.0
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "user_id", null: false
|
||||||
|
t.integer "video_id", null: false
|
||||||
|
t.index ["last_watched_at"], name: "index_playback_sessions_on_last_watched_at"
|
||||||
|
t.index ["user_id"], name: "index_playback_sessions_on_user_id"
|
||||||
|
t.index ["video_id", "user_id"], name: "index_playback_sessions_on_video_id_and_user_id", unique: true
|
||||||
|
t.index ["video_id"], name: "index_playback_sessions_on_video_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "sessions", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "ip_address"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "user_agent"
|
||||||
|
t.integer "user_id", null: false
|
||||||
|
t.index ["user_id"], name: "index_sessions_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "storage_locations", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.boolean "enabled", default: true
|
||||||
|
t.datetime "last_scanned_at"
|
||||||
|
t.integer "location_type", default: 0, null: false
|
||||||
|
t.string "name", null: false
|
||||||
|
t.string "path"
|
||||||
|
t.integer "priority", default: 0
|
||||||
|
t.boolean "scan_subdirectories", default: true
|
||||||
|
t.text "settings"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.boolean "writable", default: false
|
||||||
|
t.index ["enabled"], name: "index_storage_locations_on_enabled"
|
||||||
|
t.index ["location_type"], name: "index_storage_locations_on_location_type"
|
||||||
|
t.index ["name"], name: "index_storage_locations_on_name", unique: true
|
||||||
|
t.index ["priority"], name: "index_storage_locations_on_priority"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "users", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "email_address", null: false
|
||||||
|
t.string "password_digest", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "video_assets", force: :cascade do |t|
|
||||||
|
t.integer "asset_type", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.text "metadata"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "video_id", null: false
|
||||||
|
t.index ["video_id", "asset_type"], name: "index_video_assets_on_video_id_and_asset_type", unique: true
|
||||||
|
t.index ["video_id"], name: "index_video_assets_on_video_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "videos", force: :cascade do |t|
|
||||||
|
t.string "audio_codec"
|
||||||
|
t.integer "bit_rate"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.float "duration"
|
||||||
|
t.text "error_message"
|
||||||
|
t.string "file_hash"
|
||||||
|
t.string "file_path", null: false
|
||||||
|
t.integer "file_size"
|
||||||
|
t.string "filename"
|
||||||
|
t.text "fingerprints"
|
||||||
|
t.string "format"
|
||||||
|
t.float "frame_rate"
|
||||||
|
t.boolean "has_subtitles", default: false
|
||||||
|
t.integer "height"
|
||||||
|
t.boolean "imported", default: false
|
||||||
|
t.text "metadata"
|
||||||
|
t.boolean "processed"
|
||||||
|
t.boolean "processing_failed", default: false
|
||||||
|
t.text "processing_info"
|
||||||
|
t.string "resolution_label"
|
||||||
|
t.integer "source_type", default: 0, null: false
|
||||||
|
t.string "source_url"
|
||||||
|
t.integer "storage_location_id", null: false
|
||||||
|
t.string "title"
|
||||||
|
t.string "transcoded_path"
|
||||||
|
t.boolean "transcoded_permanently"
|
||||||
|
t.string "type", default: "Video", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "version_type"
|
||||||
|
t.string "video_codec"
|
||||||
|
t.text "video_metadata"
|
||||||
|
t.boolean "web_compatible"
|
||||||
|
t.integer "width"
|
||||||
|
t.integer "work_id"
|
||||||
|
t.index ["file_hash"], name: "index_videos_on_file_hash"
|
||||||
|
t.index ["imported"], name: "index_videos_on_imported"
|
||||||
|
t.index ["source_type"], name: "index_videos_on_source_type"
|
||||||
|
t.index ["storage_location_id", "file_path"], name: "index_videos_on_storage_location_id_and_file_path", unique: true
|
||||||
|
t.index ["storage_location_id"], name: "index_videos_on_storage_location_id"
|
||||||
|
t.index ["type"], name: "index_videos_on_type"
|
||||||
|
t.index ["work_id", "resolution_label"], name: "index_videos_on_work_id_and_resolution_label"
|
||||||
|
t.index ["work_id"], name: "index_videos_on_work_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "works", force: :cascade do |t|
|
||||||
|
t.string "backdrop_path"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.text "custom_fields"
|
||||||
|
t.text "description"
|
||||||
|
t.string "director"
|
||||||
|
t.text "imdb_data"
|
||||||
|
t.text "metadata"
|
||||||
|
t.boolean "organized", default: false
|
||||||
|
t.string "poster_path"
|
||||||
|
t.decimal "rating"
|
||||||
|
t.string "title", null: false
|
||||||
|
t.text "tmdb_data"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "year"
|
||||||
|
t.index ["organized"], name: "index_works_on_organized"
|
||||||
|
t.index ["title", "year"], name: "index_works_on_title_and_year", unique: true
|
||||||
|
t.index ["title"], name: "index_works_on_title"
|
||||||
|
end
|
||||||
|
|
||||||
|
add_foreign_key "external_ids", "works"
|
||||||
|
add_foreign_key "playback_sessions", "users"
|
||||||
|
add_foreign_key "playback_sessions", "videos"
|
||||||
|
add_foreign_key "sessions", "users"
|
||||||
|
add_foreign_key "video_assets", "videos"
|
||||||
|
add_foreign_key "videos", "storage_locations"
|
||||||
|
add_foreign_key "videos", "works"
|
||||||
end
|
end
|
||||||
|
|||||||
2978
docs/architecture.md
2978
docs/architecture.md
File diff suppressed because it is too large
Load Diff
548
docs/phases/phase_1.md
Normal file
548
docs/phases/phase_1.md
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
# Velour Phase 1: MVP (Local Filesystem)
|
||||||
|
|
||||||
|
Phase 1 delivers a complete video library application for local files with grouping, transcoding, and playback. This is the foundation that provides immediate value.
|
||||||
|
|
||||||
|
**Architecture Note**: This phase implements a extensible MediaFile architecture using Single Table Inheritance (STI). Video inherits from MediaFile, preparing the system for future audio support in Phase 5.
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
- **Ruby on Rails 8.x** with SQLite3
|
||||||
|
- **Hotwire** (Turbo + Stimulus) for frontend
|
||||||
|
- **Solid Queue** for background jobs
|
||||||
|
- **Video.js** for video playback
|
||||||
|
- **FFmpeg** for video processing
|
||||||
|
- **Active Storage** for thumbnails/assets
|
||||||
|
- **TailwindCSS** for styling
|
||||||
|
|
||||||
|
## Database Schema (Core Models)
|
||||||
|
|
||||||
|
### Work Model
|
||||||
|
```ruby
|
||||||
|
class Work < ApplicationRecord
|
||||||
|
validates :title, presence: true
|
||||||
|
|
||||||
|
has_many :videos, dependent: :destroy
|
||||||
|
has_many :external_ids, dependent: :destroy
|
||||||
|
|
||||||
|
scope :organized, -> { where(organized: true) }
|
||||||
|
scope :unorganized, -> { where(organized: false) }
|
||||||
|
|
||||||
|
def primary_video
|
||||||
|
videos.order(created_at: :desc).first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### MediaFile Model (Base Class)
|
||||||
|
```ruby
|
||||||
|
class MediaFile < ApplicationRecord
|
||||||
|
# Base class for all media files using STI
|
||||||
|
include Streamable, Processable
|
||||||
|
|
||||||
|
# Common associations
|
||||||
|
belongs_to :work
|
||||||
|
belongs_to :storage_location
|
||||||
|
has_many :playback_sessions, dependent: :destroy
|
||||||
|
|
||||||
|
# Common metadata stores
|
||||||
|
store :fingerprints, accessors: [:xxhash64, :md5, :oshash, :phash]
|
||||||
|
store :media_metadata, accessors: [:duration, :codec, :bit_rate, :format]
|
||||||
|
|
||||||
|
# Common validations and methods
|
||||||
|
validates :filename, presence: true
|
||||||
|
validates :xxhash64, presence: true, uniqueness: { scope: :storage_location_id }
|
||||||
|
|
||||||
|
scope :web_compatible, -> { where(web_compatible: true) }
|
||||||
|
scope :needs_transcoding, -> { where(web_compatible: false) }
|
||||||
|
|
||||||
|
def display_title
|
||||||
|
work&.display_title || filename
|
||||||
|
end
|
||||||
|
|
||||||
|
def full_file_path
|
||||||
|
File.join(storage_location.path, filename)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_duration
|
||||||
|
# Duration formatting logic
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Video Model (Inherits from MediaFile)
|
||||||
|
```ruby
|
||||||
|
class Video < MediaFile
|
||||||
|
# Video-specific associations
|
||||||
|
has_many :video_assets, dependent: :destroy
|
||||||
|
|
||||||
|
# Video-specific metadata
|
||||||
|
store :video_metadata, accessors: [:width, :height, :video_codec, :audio_codec, :frame_rate]
|
||||||
|
|
||||||
|
# Video-specific methods
|
||||||
|
def resolution_label
|
||||||
|
# Resolution-based quality labeling (SD, 720p, 1080p, 4K, etc.)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### StorageLocation Model
|
||||||
|
```ruby
|
||||||
|
class StorageLocation < ApplicationRecord
|
||||||
|
has_many :videos, dependent: :destroy
|
||||||
|
|
||||||
|
validates :name, presence: true
|
||||||
|
validates :path, presence: true, uniqueness: true
|
||||||
|
validates :storage_type, presence: true, inclusion: { in: %w[local] }
|
||||||
|
|
||||||
|
validate :path_must_exist_and_be_readable
|
||||||
|
|
||||||
|
def accessible?
|
||||||
|
File.exist?(path) && File.readable?(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def path_must_exist_and_be_readable
|
||||||
|
errors.add(:path, "must exist and be readable") unless accessible?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage Architecture (Local Only)
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
```bash
|
||||||
|
# User's original media directories (read-only references)
|
||||||
|
/movies/action/Die Hard (1988).mkv
|
||||||
|
/movies/anime/Your Name (2016).webm
|
||||||
|
/movies/scifi/The Matrix (1999).avi
|
||||||
|
|
||||||
|
# Velour transcoded files (same directories, web-compatible)
|
||||||
|
/movies/action/Die Hard (1988).web.mp4
|
||||||
|
/movies/anime/Your Name (2016).web.mp4
|
||||||
|
/movies/scifi/The Matrix (1999).web.mp4
|
||||||
|
|
||||||
|
# Velour managed directories
|
||||||
|
./velour_data/
|
||||||
|
├── assets/ # Active Storage for generated content
|
||||||
|
│ └── thumbnails/ # Video screenshots
|
||||||
|
└── tmp/ # Temporary processing files
|
||||||
|
└── transcodes/ # Temporary transcode storage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Volume Mounting with Heuristic Discovery
|
||||||
|
|
||||||
|
Users mount their video directories under `/videos` and Velour automatically discovers and categorizes them:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
volumes:
|
||||||
|
- /path/to/user/movies:/videos/movies:ro
|
||||||
|
- /path/to/user/tv_shows:/videos/tv:ro
|
||||||
|
- /path/to/user/documentaries:/videos/docs:ro
|
||||||
|
- /path/to/user/anime:/videos/anime:ro
|
||||||
|
- ./velour_data:/app/velour_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatic Storage Location Discovery
|
||||||
|
```ruby
|
||||||
|
# app/services/storage_discovery_service.rb
|
||||||
|
class StorageDiscoveryService
|
||||||
|
CATEGORIES = {
|
||||||
|
'movies' => 'Movies',
|
||||||
|
'tv' => 'TV Shows',
|
||||||
|
'tv_shows' => 'TV Shows',
|
||||||
|
'series' => 'TV Shows',
|
||||||
|
'docs' => 'Documentaries',
|
||||||
|
'documentaries' => 'Documentaries',
|
||||||
|
'anime' => 'Anime',
|
||||||
|
'cartoons' => 'Animation',
|
||||||
|
'animation' => 'Animation',
|
||||||
|
'sports' => 'Sports',
|
||||||
|
'music' => 'Music Videos',
|
||||||
|
'music_videos' => 'Music Videos',
|
||||||
|
'kids' => 'Kids Content',
|
||||||
|
'family' => 'Family Content'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def self.discover_and_create
|
||||||
|
base_path = '/videos'
|
||||||
|
return [] unless Dir.exist?(base_path)
|
||||||
|
|
||||||
|
discovered = []
|
||||||
|
|
||||||
|
Dir.children(base_path).each do |subdir|
|
||||||
|
dir_path = File.join(base_path, subdir)
|
||||||
|
next unless Dir.exist?(dir_path)
|
||||||
|
|
||||||
|
category = categorize_directory(subdir)
|
||||||
|
storage = StorageLocation.find_or_create_by!(
|
||||||
|
name: "#{category}: #{subdir.titleize}",
|
||||||
|
path: dir_path,
|
||||||
|
storage_type: 'local'
|
||||||
|
)
|
||||||
|
|
||||||
|
discovered << storage
|
||||||
|
end
|
||||||
|
|
||||||
|
discovered
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.categorize_directory(dirname)
|
||||||
|
downcase = dirname.downcase
|
||||||
|
CATEGORIES[downcase] || CATEGORIES.find { |key, _| downcase.include?(key) }&.last || 'Other'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Video Processing Pipeline
|
||||||
|
|
||||||
|
### Background Job with ActiveJob 8.1 Continuations
|
||||||
|
```ruby
|
||||||
|
class VideoProcessorJob < ApplicationJob
|
||||||
|
include ActiveJob::Statuses
|
||||||
|
|
||||||
|
def perform(video_id)
|
||||||
|
video = Video.find(video_id)
|
||||||
|
|
||||||
|
progress.update(stage: "metadata", total: 100, current: 0)
|
||||||
|
metadata = VideoMetadataExtractor.new(video.full_file_path).extract
|
||||||
|
video.update!(video_metadata: metadata)
|
||||||
|
progress.update(stage: "metadata", total: 100, current: 100)
|
||||||
|
|
||||||
|
progress.update(stage: "thumbnail", total: 100, current: 0)
|
||||||
|
generate_thumbnail(video)
|
||||||
|
progress.update(stage: "thumbnail", total: 100, current: 100)
|
||||||
|
|
||||||
|
unless video.web_compatible?
|
||||||
|
progress.update(stage: "transcode", total: 100, current: 0)
|
||||||
|
transcode_video(video)
|
||||||
|
progress.update(stage: "transcode", total: 100, current: 100)
|
||||||
|
end
|
||||||
|
|
||||||
|
progress.update(stage: "complete", total: 100, current: 100)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_thumbnail(video)
|
||||||
|
# Generate thumbnail at 10% of duration
|
||||||
|
thumbnail_path = VideoTranscoder.new.extract_frame(video.full_file_path, video.duration * 0.1)
|
||||||
|
video.video_assets.create!(asset_type: "thumbnail", file: File.open(thumbnail_path))
|
||||||
|
end
|
||||||
|
|
||||||
|
def transcode_video(video)
|
||||||
|
transcoder = VideoTranscoder.new
|
||||||
|
output_path = video.full_file_path.gsub(/\.[^.]+$/, '.web.mp4')
|
||||||
|
|
||||||
|
transcoder.transcode_for_web(
|
||||||
|
input_path: video.full_file_path,
|
||||||
|
output_path: output_path,
|
||||||
|
on_progress: ->(current, total) { progress.update(current: current) }
|
||||||
|
)
|
||||||
|
|
||||||
|
video.update!(
|
||||||
|
transcoded_path: File.basename(output_path),
|
||||||
|
transcoded_permanently: true,
|
||||||
|
web_compatible: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Architecture
|
||||||
|
|
||||||
|
### Uninterrupted Video Playback
|
||||||
|
```erb
|
||||||
|
<!-- videos/show.html.erb -->
|
||||||
|
<turbo-frame id="video-player-frame" data-turbo-permanent>
|
||||||
|
<div class="video-container">
|
||||||
|
<video
|
||||||
|
id="video-player"
|
||||||
|
data-controller="video-player"
|
||||||
|
data-video-player-video-id-value="<%= @video.id %>"
|
||||||
|
data-video-player-start-position-value="<%= @last_position %>"
|
||||||
|
class="w-full"
|
||||||
|
controls>
|
||||||
|
<source src="<%= stream_video_path(@video) %>" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</turbo-frame>
|
||||||
|
|
||||||
|
<turbo-frame id="video-info">
|
||||||
|
<h1><%= @work.title %></h1>
|
||||||
|
<p>Duration: <%= format_duration(@video.duration) %></p>
|
||||||
|
</turbo-frame>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Video Player Stimulus Controller
|
||||||
|
```javascript
|
||||||
|
// app/javascript/controllers/video_player_controller.js
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { videoId: Number, startPosition: Number }
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.player = videojs(this.element, {
|
||||||
|
controls: true,
|
||||||
|
responsive: true,
|
||||||
|
fluid: true,
|
||||||
|
playbackRates: [0.5, 1, 1.25, 1.5, 2]
|
||||||
|
})
|
||||||
|
|
||||||
|
this.player.ready(() => {
|
||||||
|
if (this.startPositionValue > 0) {
|
||||||
|
this.player.currentTime(this.startPositionValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save position every 10 seconds
|
||||||
|
this.interval = setInterval(() => {
|
||||||
|
this.savePosition()
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
// Save on pause
|
||||||
|
this.player.on("pause", () => this.savePosition())
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
clearInterval(this.interval)
|
||||||
|
this.player.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
savePosition() {
|
||||||
|
const position = Math.floor(this.player.currentTime())
|
||||||
|
|
||||||
|
fetch(`/videos/${this.videoIdValue}/playback-position`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ position })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service Objects
|
||||||
|
|
||||||
|
### File Scanner Service
|
||||||
|
```ruby
|
||||||
|
class FileScannerService
|
||||||
|
def initialize(storage_location)
|
||||||
|
@storage_location = storage_location
|
||||||
|
end
|
||||||
|
|
||||||
|
def scan
|
||||||
|
return failure_result("Storage location not accessible") unless @storage_location.accessible?
|
||||||
|
|
||||||
|
video_files = find_video_files
|
||||||
|
new_videos = process_files(video_files)
|
||||||
|
|
||||||
|
success_result(new_videos)
|
||||||
|
rescue => e
|
||||||
|
failure_result(e.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def find_video_files
|
||||||
|
Dir.glob(File.join(@storage_location.path, "**", "*.{mp4,avi,mkv,mov,wmv,flv,webm,m4v}"))
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_files(file_paths)
|
||||||
|
new_videos = []
|
||||||
|
|
||||||
|
file_paths.each do |file_path|
|
||||||
|
filename = File.basename(file_path)
|
||||||
|
|
||||||
|
next if Video.exists?(filename: filename, storage_location: @storage_location)
|
||||||
|
|
||||||
|
video = Video.create!(
|
||||||
|
filename: filename,
|
||||||
|
storage_location: @storage_location,
|
||||||
|
work: Work.find_or_create_by(title: extract_title(filename))
|
||||||
|
)
|
||||||
|
|
||||||
|
new_videos << video
|
||||||
|
VideoProcessorJob.perform_later(video.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
new_videos
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_title(filename)
|
||||||
|
# Simple title extraction - can be enhanced
|
||||||
|
File.basename(filename, ".*").gsub(/[\[\(].*?[\]\)]/, "").strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def success_result(videos = [])
|
||||||
|
{ success: true, videos: videos, message: "Found #{videos.length} new videos" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure_result(message)
|
||||||
|
{ success: false, message: message }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Video Transcoder Service
|
||||||
|
```ruby
|
||||||
|
class VideoTranscoder
|
||||||
|
def transcode_for_web(input_path:, output_path:, on_progress: nil)
|
||||||
|
movie = FFMPEG::Movie.new(input_path)
|
||||||
|
|
||||||
|
# Calculate progress callback
|
||||||
|
progress_callback = ->(progress) {
|
||||||
|
on_progress&.call(progress, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
movie.transcode(output_path, {
|
||||||
|
video_codec: "libx264",
|
||||||
|
audio_codec: "aac",
|
||||||
|
custom: %w[
|
||||||
|
-pix_fmt yuv420p
|
||||||
|
-preset medium
|
||||||
|
-crf 23
|
||||||
|
-movflags +faststart
|
||||||
|
-tune fastdecode
|
||||||
|
]
|
||||||
|
}, &progress_callback)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_frame(input_path, seconds)
|
||||||
|
movie = FFMPEG::Movie.new(input_path)
|
||||||
|
output_path = "#{Rails.root}/tmp/thumbnail_#{SecureRandom.hex(8)}.jpg"
|
||||||
|
|
||||||
|
movie.screenshot(output_path, seek_time: seconds, resolution: "320x240")
|
||||||
|
output_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
Rails.application.routes.draw do
|
||||||
|
root "storage_locations#index"
|
||||||
|
|
||||||
|
resources :storage_locations, only: [:index, :show, :create, :destroy] do
|
||||||
|
member do
|
||||||
|
post :scan
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
resources :works, only: [:index, :show] do
|
||||||
|
resources :videos, only: [:show]
|
||||||
|
end
|
||||||
|
|
||||||
|
resources :videos, only: [] do
|
||||||
|
member do
|
||||||
|
get :stream
|
||||||
|
patch :playback_position
|
||||||
|
post :retry_processing
|
||||||
|
end
|
||||||
|
|
||||||
|
resources :playback_sessions, only: [:create]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Real-time job progress
|
||||||
|
resources :jobs, only: [:show] do
|
||||||
|
member do
|
||||||
|
get :progress
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Sub-Phases
|
||||||
|
|
||||||
|
### Phase 1A: Core Foundation (Week 1-2)
|
||||||
|
1. Generate models with migrations
|
||||||
|
2. Implement model validations and associations
|
||||||
|
3. Create storage adapter pattern
|
||||||
|
4. Create FileScannerService
|
||||||
|
5. Basic UI for storage locations and video listing
|
||||||
|
|
||||||
|
### Phase 1B: Video Playback (Week 3)
|
||||||
|
1. Video streaming controller with byte-range support
|
||||||
|
2. Video.js integration with Stimulus controller
|
||||||
|
3. Playback session tracking
|
||||||
|
4. Resume functionality
|
||||||
|
|
||||||
|
### Phase 1C: Processing Pipeline (Week 4)
|
||||||
|
1. Video metadata extraction with FFmpeg
|
||||||
|
2. Background job processing with progress tracking
|
||||||
|
3. Thumbnail generation and storage
|
||||||
|
4. Processing UI with status indicators
|
||||||
|
|
||||||
|
### Phase 1D: Works & Grouping (Week 5)
|
||||||
|
1. Duplicate detection by file hash
|
||||||
|
2. Manual grouping interface
|
||||||
|
3. Works display with version selection
|
||||||
|
4. Search, filtering, and pagination
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
RAILS_ENV=development
|
||||||
|
RAILS_MASTER_KEY=your_master_key
|
||||||
|
|
||||||
|
# Video processing
|
||||||
|
FFMPEG_PATH=/usr/bin/ffmpeg
|
||||||
|
FFPROBE_PATH=/usr/bin/ffprobe
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
VIDEOS_PATH=./velour_data/videos
|
||||||
|
MAX_TRANSCODE_SIZE_GB=50
|
||||||
|
|
||||||
|
# Background jobs (SolidQueue runs with defaults)
|
||||||
|
SOLID_QUEUE_PROCESSES="*:2" # 2 workers for all queues
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Configuration
|
||||||
|
```dockerfile
|
||||||
|
FROM ruby:3.3-alpine
|
||||||
|
|
||||||
|
# Install FFmpeg and other dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ffmpeg \
|
||||||
|
imagemagick \
|
||||||
|
sqlite-dev \
|
||||||
|
nodejs \
|
||||||
|
npm
|
||||||
|
|
||||||
|
# Install Rails and other gems
|
||||||
|
COPY Gemfile* /app/
|
||||||
|
RUN bundle install
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . /app/
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
RUN mkdir -p /app/velour_data/{assets,tmp}
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["rails", "server", "-b", "0.0.0.0"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Model Tests
|
||||||
|
- Model validations and associations
|
||||||
|
- Scopes and class methods
|
||||||
|
- JSON store accessor functionality
|
||||||
|
|
||||||
|
### Service Tests
|
||||||
|
- FileScannerService with sample directory
|
||||||
|
- VideoTranscoder service methods
|
||||||
|
- Background job processing
|
||||||
|
|
||||||
|
### System Tests
|
||||||
|
- Video playback functionality
|
||||||
|
- File scanning workflow
|
||||||
|
- Work grouping interface
|
||||||
|
|
||||||
|
This Phase 1 delivers a complete, useful video library application that provides immediate value while establishing the foundation for future enhancements.
|
||||||
486
docs/phases/phase_2.md
Normal file
486
docs/phases/phase_2.md
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
# Velour Phase 2: Authentication & Multi-User
|
||||||
|
|
||||||
|
Phase 2 adds user management, authentication, and multi-user support while maintaining the simplicity of the core application.
|
||||||
|
|
||||||
|
## Authentication Architecture
|
||||||
|
|
||||||
|
### Rails Authentication Generators + OIDC Extension
|
||||||
|
We use Rails' built-in authentication generators as the foundation, extended with OIDC support for enterprise environments.
|
||||||
|
|
||||||
|
### User Model
|
||||||
|
```ruby
|
||||||
|
class User < ApplicationRecord
|
||||||
|
# Include default devise modules or Rails authentication
|
||||||
|
# Devise modules: :database_authenticatable, :registerable,
|
||||||
|
# :recoverable, :rememberable, :validatable
|
||||||
|
|
||||||
|
has_many :playback_sessions, dependent: :destroy
|
||||||
|
has_many :user_preferences, dependent: :destroy
|
||||||
|
|
||||||
|
validates :email, presence: true, uniqueness: true
|
||||||
|
|
||||||
|
enum role: { user: 0, admin: 1 }
|
||||||
|
|
||||||
|
def admin?
|
||||||
|
role == "admin" || email == ENV.fetch("ADMIN_EMAIL", "").downcase
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_manage_storage?
|
||||||
|
admin?
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_manage_users?
|
||||||
|
admin?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Setup
|
||||||
|
```bash
|
||||||
|
# Generate Rails authentication
|
||||||
|
rails generate authentication
|
||||||
|
|
||||||
|
# Add OIDC support
|
||||||
|
gem 'omniauth-openid-connect'
|
||||||
|
bundle install
|
||||||
|
```
|
||||||
|
|
||||||
|
### OIDC Configuration
|
||||||
|
```ruby
|
||||||
|
# config/initializers/omniauth.rb
|
||||||
|
Rails.application.config.middleware.use OmniAuth::Builder do
|
||||||
|
provider :openid_connect, {
|
||||||
|
name: :oidc,
|
||||||
|
issuer: ENV['OIDC_ISSUER'],
|
||||||
|
client_id: ENV['OIDC_CLIENT_ID'],
|
||||||
|
client_secret: ENV['OIDC_CLIENT_SECRET'],
|
||||||
|
scope: [:openid, :email, :profile],
|
||||||
|
response_type: :code,
|
||||||
|
client_options: {
|
||||||
|
identifier: ENV['OIDC_CLIENT_ID'],
|
||||||
|
secret: ENV['OIDC_CLIENT_SECRET'],
|
||||||
|
redirect_uri: "#{ENV['RAILS_HOST']}/auth/oidc/callback"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## First User Bootstrap Flow
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
```ruby
|
||||||
|
# db/seeds.rb
|
||||||
|
admin_email = ENV.fetch('ADMIN_EMAIL', 'admin@velour.local')
|
||||||
|
User.find_or_create_by!(email: admin_email) do |user|
|
||||||
|
user.password = SecureRandom.hex(16)
|
||||||
|
user.role = :admin
|
||||||
|
puts "Created admin user: #{admin_email}"
|
||||||
|
puts "Password: #{user.password}"
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### First Login Controller
|
||||||
|
```ruby
|
||||||
|
class FirstSetupController < ApplicationController
|
||||||
|
before_action :ensure_no_users_exist
|
||||||
|
before_action :require_admin_setup, only: [:create_admin]
|
||||||
|
|
||||||
|
def show
|
||||||
|
@user = User.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_admin
|
||||||
|
@user = User.new(user_params)
|
||||||
|
@user.role = :admin
|
||||||
|
|
||||||
|
if @user.save
|
||||||
|
session[:user_id] = @user.id
|
||||||
|
redirect_to root_path, notice: "Admin account created successfully!"
|
||||||
|
else
|
||||||
|
render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_no_users_exist
|
||||||
|
redirect_to root_path if User.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_admin_setup
|
||||||
|
redirect_to first_setup_path unless ENV.key?('ADMIN_EMAIL')
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_params
|
||||||
|
params.require(:user).permit(:email, :password, :password_confirmation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Management
|
||||||
|
|
||||||
|
### User Preferences
|
||||||
|
```ruby
|
||||||
|
class UserPreference < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
store :settings, coder: JSON, accessors: [
|
||||||
|
:default_video_quality,
|
||||||
|
:auto_play_next,
|
||||||
|
:subtitle_language,
|
||||||
|
:theme
|
||||||
|
]
|
||||||
|
|
||||||
|
validates :user_id, presence: true, uniqueness: true
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-User Playback Sessions
|
||||||
|
```ruby
|
||||||
|
class PlaybackSession < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :video
|
||||||
|
|
||||||
|
scope :for_user, ->(user) { where(user: user) }
|
||||||
|
scope :recent, -> { order(updated_at: :desc) }
|
||||||
|
|
||||||
|
def self.resume_position_for(video, user)
|
||||||
|
for_user(user).where(video: video).last&.position || 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authorization & Security
|
||||||
|
|
||||||
|
### Model-Level Authorization
|
||||||
|
```ruby
|
||||||
|
# app/models/concerns/authorizable.rb
|
||||||
|
module Authorizable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def viewable_by?(user)
|
||||||
|
true # All videos viewable by all users
|
||||||
|
end
|
||||||
|
|
||||||
|
def editable_by?(user)
|
||||||
|
user.admin?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Include in Video and Work models
|
||||||
|
class Video < ApplicationRecord
|
||||||
|
include Authorizable
|
||||||
|
# ... rest of model
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controller Authorization
|
||||||
|
```ruby
|
||||||
|
class ApplicationController < ActionController::Base
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :set_current_user
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_current_user
|
||||||
|
Current.user = current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_admin
|
||||||
|
redirect_to root_path, alert: "Access denied" unless current_user&.admin?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class StorageLocationsController < ApplicationController
|
||||||
|
before_action :require_admin, except: [:index, :show]
|
||||||
|
|
||||||
|
# ... rest of controller
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updated Controllers for Multi-User
|
||||||
|
|
||||||
|
### Videos Controller
|
||||||
|
```ruby
|
||||||
|
class VideosController < ApplicationController
|
||||||
|
before_action :set_video, only: [:show, :stream, :update_position]
|
||||||
|
before_action :authorize_video
|
||||||
|
|
||||||
|
def show
|
||||||
|
@work = @video.work
|
||||||
|
@last_position = PlaybackSession.resume_position_for(@video, current_user)
|
||||||
|
|
||||||
|
# Create playback session for tracking
|
||||||
|
@playback_session = current_user.playback_sessions.create!(
|
||||||
|
video: @video,
|
||||||
|
position: @last_position
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def stream
|
||||||
|
unless @video.viewable_by?(current_user)
|
||||||
|
head :forbidden
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
send_file @video.web_stream_path,
|
||||||
|
type: "video/mp4",
|
||||||
|
disposition: "inline",
|
||||||
|
range: request.headers['Range']
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_position
|
||||||
|
current_user.playback_sessions.where(video: @video).last&.update!(
|
||||||
|
position: params[:position],
|
||||||
|
completed: params[:completed] || false
|
||||||
|
)
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_video
|
||||||
|
@video = Video.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_video
|
||||||
|
head :forbidden unless @video.viewable_by?(current_user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage Locations Controller (Admin Only)
|
||||||
|
```ruby
|
||||||
|
class StorageLocationsController < ApplicationController
|
||||||
|
before_action :require_admin, except: [:index, :show]
|
||||||
|
before_action :set_storage_location, only: [:show, :destroy, :scan]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@storage_locations = StorageLocation.accessible.order(:name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@videos = @storage_location.videos.includes(:work).order(:filename)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@storage_location = StorageLocation.new(storage_location_params)
|
||||||
|
|
||||||
|
if @storage_location.save
|
||||||
|
redirect_to @storage_location, notice: 'Storage location was successfully created.'
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
if @storage_location.videos.exists?
|
||||||
|
redirect_to @storage_location, alert: 'Cannot delete storage location with videos.'
|
||||||
|
else
|
||||||
|
@storage_location.destroy
|
||||||
|
redirect_to storage_locations_path, notice: 'Storage location was successfully deleted.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def scan
|
||||||
|
service = FileScannerService.new(@storage_location)
|
||||||
|
result = service.scan
|
||||||
|
|
||||||
|
if result[:success]
|
||||||
|
redirect_to @storage_location, notice: result[:message]
|
||||||
|
else
|
||||||
|
redirect_to @storage_location, alert: result[:message]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_storage_location
|
||||||
|
@storage_location = StorageLocation.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def storage_location_params
|
||||||
|
params.require(:storage_location).permit(:name, :path, :storage_type)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Interface Updates
|
||||||
|
|
||||||
|
### User Navigation
|
||||||
|
```erb
|
||||||
|
<!-- app/views/layouts/application.html.erb -->
|
||||||
|
<nav class="bg-gray-800 text-white p-4">
|
||||||
|
<div class="container mx-auto flex justify-between items-center">
|
||||||
|
<%= link_to 'Velour', root_path, class: 'text-xl font-bold' %>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<%= link_to 'Library', works_path, class: 'hover:text-gray-300' %>
|
||||||
|
<%= link_to 'Storage', storage_locations_path, class: 'hover:text-gray-300' if current_user.admin? %>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<button data-action="click->dropdown#toggle" class="flex items-center">
|
||||||
|
<%= current_user.email %>
|
||||||
|
<svg class="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div data-dropdown-target="menu" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
|
||||||
|
<%= link_to 'Settings', edit_user_registration_path, class: 'block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100' %>
|
||||||
|
<%= link_to 'Admin Panel', admin_path, class: 'block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100' if current_user.admin? %>
|
||||||
|
<%= button_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Panel
|
||||||
|
```erb
|
||||||
|
<!-- app/views/admin/dashboard.html.erb -->
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Admin Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Users</h2>
|
||||||
|
<p class="text-3xl font-bold text-blue-600"><%= User.count %></p>
|
||||||
|
<p class="text-gray-600">Total users</p>
|
||||||
|
<%= link_to 'Manage Users', admin_users_path, class: 'mt-2 text-blue-500 hover:text-blue-700' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Videos</h2>
|
||||||
|
<p class="text-3xl font-bold text-green-600"><%= Video.count %></p>
|
||||||
|
<p class="text-gray-600">Total videos</p>
|
||||||
|
<%= link_to 'View Library', works_path, class: 'mt-2 text-green-500 hover:text-green-700' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Storage</h2>
|
||||||
|
<p class="text-3xl font-bold text-purple-600"><%= StorageLocation.count %></p>
|
||||||
|
<p class="text-gray-600">Storage locations</p>
|
||||||
|
<%= link_to 'Manage Storage', storage_locations_path, class: 'mt-2 text-purple-500 hover:text-purple-700' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">System Status</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Background Jobs:</span>
|
||||||
|
<span class="font-mono"><%= SolidQueue::Job.count %></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Processing Jobs:</span>
|
||||||
|
<span class="font-mono"><%= SolidQueue::Job.where(finished_at: nil).count %></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Failed Jobs:</span>
|
||||||
|
<span class="font-mono"><%= SolidQueue::FailedExecution.count %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### New Environment Variables
|
||||||
|
```bash
|
||||||
|
# Authentication
|
||||||
|
ADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
RAILS_HOST=https://your-velour-domain.com
|
||||||
|
|
||||||
|
# OIDC (optional)
|
||||||
|
OIDC_ISSUER=https://your-oidc-provider.com
|
||||||
|
OIDC_CLIENT_ID=your_client_id
|
||||||
|
OIDC_CLIENT_SECRET=your_client_secret
|
||||||
|
|
||||||
|
# Session management
|
||||||
|
RAILS_SESSION_COOKIE_SECURE=true
|
||||||
|
RAILS_SESSION_COOKIE_SAME_SITE=lax
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing for Phase 2
|
||||||
|
|
||||||
|
### Authentication Tests
|
||||||
|
```ruby
|
||||||
|
# test/integration/authentication_test.rb
|
||||||
|
class AuthenticationTest < ActionDispatch::IntegrationTest
|
||||||
|
test "first user can create admin account" do
|
||||||
|
User.delete_all
|
||||||
|
|
||||||
|
get first_setup_path
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
post first_setup_path, params: {
|
||||||
|
user: {
|
||||||
|
email: "admin@example.com",
|
||||||
|
password: "password123",
|
||||||
|
password_confirmation: "password123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to root_path
|
||||||
|
assert_equal "admin@example.com", User.last.email
|
||||||
|
assert User.last.admin?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "regular users cannot access admin features" do
|
||||||
|
user = users(:regular)
|
||||||
|
|
||||||
|
sign_in user
|
||||||
|
get storage_locations_path
|
||||||
|
assert_redirected_to root_path
|
||||||
|
|
||||||
|
post storage_locations_path, params: {
|
||||||
|
storage_location: {
|
||||||
|
name: "Test",
|
||||||
|
path: "/test",
|
||||||
|
storage_type: "local"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_redirected_to root_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration from Phase 1
|
||||||
|
|
||||||
|
### Database Migration
|
||||||
|
```ruby
|
||||||
|
class AddAuthenticationToUsers < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
create_table :users do |t|
|
||||||
|
t.string :email, null: false, index: { unique: true }
|
||||||
|
t.string :encrypted_password, null: false
|
||||||
|
t.string :role, default: 'user', null: false
|
||||||
|
t.timestamps null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :user_preferences do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.json :settings, default: {}
|
||||||
|
t.timestamps null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add user_id to existing playback_sessions
|
||||||
|
add_reference :playback_sessions, :user, null: false, foreign_key: true
|
||||||
|
|
||||||
|
# Create first admin if ADMIN_EMAIL is set
|
||||||
|
if ENV.key?('ADMIN_EMAIL')
|
||||||
|
User.create!(
|
||||||
|
email: ENV['ADMIN_EMAIL'],
|
||||||
|
password: SecureRandom.hex(16),
|
||||||
|
role: 'admin'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 2 provides a complete multi-user system while maintaining the simplicity of Phase 1. Users can have personal playback history, and administrators can manage the system through a clean interface.
|
||||||
773
docs/phases/phase_3.md
Normal file
773
docs/phases/phase_3.md
Normal file
@@ -0,0 +1,773 @@
|
|||||||
|
# Velour Phase 3: Remote Sources & Import
|
||||||
|
|
||||||
|
Phase 3 extends the video library to support remote storage sources like S3, JellyFin servers, and web directories. This allows users to access and import videos from multiple locations.
|
||||||
|
|
||||||
|
## Extended Storage Architecture
|
||||||
|
|
||||||
|
### New Storage Location Types
|
||||||
|
```ruby
|
||||||
|
class StorageLocation < ApplicationRecord
|
||||||
|
has_many :videos, dependent: :destroy
|
||||||
|
|
||||||
|
validates :name, presence: true
|
||||||
|
validates :storage_type, presence: true, inclusion: { in: %w[local s3 jellyfin web] }
|
||||||
|
|
||||||
|
store :configuration, accessors: [
|
||||||
|
# S3 configuration
|
||||||
|
:bucket, :region, :access_key_id, :secret_access_key, :endpoint,
|
||||||
|
# JellyFin configuration
|
||||||
|
:server_url, :api_key, :username,
|
||||||
|
# Web directory configuration
|
||||||
|
:base_url, :auth_type, :username, :password, :headers
|
||||||
|
], coder: JSON
|
||||||
|
|
||||||
|
# Storage-type specific validations
|
||||||
|
validate :validate_s3_configuration, if: -> { s3? }
|
||||||
|
validate :validate_jellyfin_configuration, if: -> { jellyfin? }
|
||||||
|
validate :validate_web_configuration, if: -> { web? }
|
||||||
|
|
||||||
|
enum storage_type: { local: 0, s3: 1, jellyfin: 2, web: 3 }
|
||||||
|
|
||||||
|
def accessible?
|
||||||
|
case storage_type
|
||||||
|
when 'local'
|
||||||
|
File.exist?(path) && File.readable?(path)
|
||||||
|
when 's3'
|
||||||
|
s3_client&.bucket(bucket)&.exists?
|
||||||
|
when 'jellyfin'
|
||||||
|
jellyfin_client&.ping?
|
||||||
|
when 'web'
|
||||||
|
web_accessible?
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def scanner
|
||||||
|
case storage_type
|
||||||
|
when 'local'
|
||||||
|
LocalFileScanner.new(self)
|
||||||
|
when 's3'
|
||||||
|
S3Scanner.new(self)
|
||||||
|
when 'jellyfin'
|
||||||
|
JellyFinScanner.new(self)
|
||||||
|
when 'web'
|
||||||
|
WebDirectoryScanner.new(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def streamer
|
||||||
|
case storage_type
|
||||||
|
when 'local'
|
||||||
|
LocalStreamer.new(self)
|
||||||
|
when 's3'
|
||||||
|
S3Streamer.new(self)
|
||||||
|
when 'jellyfin'
|
||||||
|
JellyFinStreamer.new(self)
|
||||||
|
when 'web'
|
||||||
|
WebStreamer.new(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_s3_configuration
|
||||||
|
%w[bucket region access_key_id secret_access_key].each do |field|
|
||||||
|
errors.add(:configuration, "#{field} is required for S3 storage") if send(field).blank?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_jellyfin_configuration
|
||||||
|
%w[server_url api_key].each do |field|
|
||||||
|
errors.add(:configuration, "#{field} is required for JellyFin storage") if send(field).blank?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_web_configuration
|
||||||
|
errors.add(:configuration, "base_url is required for web storage") if base_url.blank?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## S3 Storage Implementation
|
||||||
|
|
||||||
|
### S3 Scanner Service
|
||||||
|
```ruby
|
||||||
|
class S3Scanner
|
||||||
|
def initialize(storage_location)
|
||||||
|
@storage_location = storage_location
|
||||||
|
@client = s3_client
|
||||||
|
end
|
||||||
|
|
||||||
|
def scan
|
||||||
|
return failure_result("S3 bucket not accessible") unless @storage_location.accessible?
|
||||||
|
|
||||||
|
video_files = find_video_files_in_s3
|
||||||
|
new_videos = process_s3_files(video_files)
|
||||||
|
|
||||||
|
success_result(new_videos)
|
||||||
|
rescue Aws::Errors::ServiceError => e
|
||||||
|
failure_result("S3 error: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def s3_client
|
||||||
|
@s3_client ||= Aws::S3::Client.new(
|
||||||
|
region: @storage_location.region,
|
||||||
|
access_key_id: @storage_location.access_key_id,
|
||||||
|
secret_access_key: @storage_location.secret_access_key,
|
||||||
|
endpoint: @storage_location.endpoint # Optional for S3-compatible services
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_video_files_in_s3
|
||||||
|
bucket = Aws::S3::Bucket.new(@storage_location.bucket, client: s3_client)
|
||||||
|
|
||||||
|
video_extensions = %w[.mp4 .avi .mkv .mov .wmv .flv .webm .m4v]
|
||||||
|
|
||||||
|
bucket.objects(prefix: "")
|
||||||
|
.select { |obj| video_extensions.any? { |ext| obj.key.end_with?(ext) } }
|
||||||
|
.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_s3_files(s3_objects)
|
||||||
|
new_videos = []
|
||||||
|
|
||||||
|
s3_objects.each do |s3_object|
|
||||||
|
filename = File.basename(s3_object.key)
|
||||||
|
|
||||||
|
next if Video.exists?(filename: filename, storage_location: @storage_location)
|
||||||
|
|
||||||
|
video = Video.create!(
|
||||||
|
filename: filename,
|
||||||
|
storage_location: @storage_location,
|
||||||
|
work: Work.find_or_create_by(title: extract_title(filename)),
|
||||||
|
file_size: s3_object.size,
|
||||||
|
video_metadata: {
|
||||||
|
remote_url: s3_object.key,
|
||||||
|
last_modified: s3_object.last_modified
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
new_videos << video
|
||||||
|
VideoProcessorJob.perform_later(video.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
new_videos
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_title(filename)
|
||||||
|
File.basename(filename, ".*").gsub(/[\[\(].*?[\]\)]/, "").strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def success_result(videos = [])
|
||||||
|
{ success: true, videos: videos, message: "Found #{videos.length} new videos in S3" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure_result(message)
|
||||||
|
{ success: false, message: message }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### S3 Streamer
|
||||||
|
```ruby
|
||||||
|
class S3Streamer
|
||||||
|
def initialize(storage_location)
|
||||||
|
@storage_location = storage_location
|
||||||
|
@client = s3_client
|
||||||
|
end
|
||||||
|
|
||||||
|
def stream(video, range: nil)
|
||||||
|
s3_object = s3_object_for_video(video)
|
||||||
|
|
||||||
|
if range
|
||||||
|
# Handle byte-range requests for seeking
|
||||||
|
range_header = "bytes=#{range}"
|
||||||
|
resp = @client.get_object(
|
||||||
|
bucket: @storage_location.bucket,
|
||||||
|
key: video.video_metadata['remote_url'],
|
||||||
|
range: range_header
|
||||||
|
)
|
||||||
|
|
||||||
|
{
|
||||||
|
body: resp.body,
|
||||||
|
status: 206, # Partial content
|
||||||
|
headers: {
|
||||||
|
'Content-Range' => "bytes #{range}/#{s3_object.size}",
|
||||||
|
'Content-Length' => resp.content_length,
|
||||||
|
'Accept-Ranges' => 'bytes',
|
||||||
|
'Content-Type' => 'video/mp4'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
resp = @client.get_object(
|
||||||
|
bucket: @storage_location.bucket,
|
||||||
|
key: video.video_metadata['remote_url']
|
||||||
|
)
|
||||||
|
|
||||||
|
{
|
||||||
|
body: resp.body,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Length' => resp.content_length,
|
||||||
|
'Content-Type' => 'video/mp4'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def presigned_url(video, expires_in: 1.hour)
|
||||||
|
signer = Aws::S3::Presigner.new(client: @client)
|
||||||
|
|
||||||
|
signer.presigned_url(
|
||||||
|
:get_object,
|
||||||
|
bucket: @storage_location.bucket,
|
||||||
|
key: video.video_metadata['remote_url'],
|
||||||
|
expires_in: expires_in.to_i
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def s3_client
|
||||||
|
@s3_client ||= Aws::S3::Client.new(
|
||||||
|
region: @storage_location.region,
|
||||||
|
access_key_id: @storage_location.access_key_id,
|
||||||
|
secret_access_key: @storage_location.secret_access_key,
|
||||||
|
endpoint: @storage_location.endpoint
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def s3_object_for_video(video)
|
||||||
|
@client.get_object(
|
||||||
|
bucket: @storage_location.bucket,
|
||||||
|
key: video.video_metadata['remote_url']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## JellyFin Integration
|
||||||
|
|
||||||
|
### JellyFin Client
|
||||||
|
```ruby
|
||||||
|
class JellyFinClient
|
||||||
|
def initialize(server_url:, api_key:, username: nil)
|
||||||
|
@server_url = server_url.chomp('/')
|
||||||
|
@api_key = api_key
|
||||||
|
@username = username
|
||||||
|
@http = Faraday.new(url: @server_url) do |faraday|
|
||||||
|
faraday.headers['X-Emby-Token'] = @api_key
|
||||||
|
faraday.adapter Faraday.default_adapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def ping?
|
||||||
|
response = @http.get('/System/Ping')
|
||||||
|
response.success?
|
||||||
|
rescue
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def libraries
|
||||||
|
response = @http.get('/Library/VirtualFolders')
|
||||||
|
JSON.parse(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
def movies(library_id = nil)
|
||||||
|
path = library_id ? "/Users/#{user_id}/Items?ParentId=#{library_id}&IncludeItemTypes=Movie&Recursive=true" : "/Users/#{user_id}/Items?IncludeItemTypes=Movie&Recursive=true"
|
||||||
|
response = @http.get(path)
|
||||||
|
JSON.parse(response.body)['Items']
|
||||||
|
end
|
||||||
|
|
||||||
|
def tv_shows(library_id = nil)
|
||||||
|
path = library_id ? "/Users/#{user_id}/Items?ParentId=#{library_id}&IncludeItemTypes=Series&Recursive=true" : "/Users/#{user_id}/Items?IncludeItemTypes=Series&Recursive=true"
|
||||||
|
response = @http.get(path)
|
||||||
|
JSON.parse(response.body)['Items']
|
||||||
|
end
|
||||||
|
|
||||||
|
def episodes(show_id)
|
||||||
|
response = @http.get("/Shows/#{show_id}/Episodes?UserId=#{user_id}")
|
||||||
|
JSON.parse(response.body)['Items']
|
||||||
|
end
|
||||||
|
|
||||||
|
def streaming_url(item_id)
|
||||||
|
"#{@server_url}/Videos/#{item_id}/stream?Static=true&MediaSourceId=#{item_id}&DeviceId=Velour&api_key=#{@api_key}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def item_details(item_id)
|
||||||
|
response = @http.get("/Users/#{user_id}/Items/#{item_id}")
|
||||||
|
JSON.parse(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def user_id
|
||||||
|
@user_id ||= begin
|
||||||
|
response = @http.get('/Users')
|
||||||
|
users = JSON.parse(response.body)
|
||||||
|
|
||||||
|
if @username
|
||||||
|
user = users.find { |u| u['Name'] == @username }
|
||||||
|
user&.dig('Id') || users.first['Id']
|
||||||
|
else
|
||||||
|
users.first['Id']
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### JellyFin Scanner
|
||||||
|
```ruby
|
||||||
|
class JellyFinScanner
|
||||||
|
def initialize(storage_location)
|
||||||
|
@storage_location = storage_location
|
||||||
|
@client = jellyfin_client
|
||||||
|
end
|
||||||
|
|
||||||
|
def scan
|
||||||
|
return failure_result("JellyFin server not accessible") unless @storage_location.accessible?
|
||||||
|
|
||||||
|
movies = @client.movies
|
||||||
|
shows = @client.tv_shows
|
||||||
|
episodes = []
|
||||||
|
|
||||||
|
shows.each do |show|
|
||||||
|
episodes.concat(@client.episodes(show['Id']))
|
||||||
|
end
|
||||||
|
|
||||||
|
all_items = movies + episodes
|
||||||
|
new_videos = process_jellyfin_items(all_items)
|
||||||
|
|
||||||
|
success_result(new_videos)
|
||||||
|
rescue => e
|
||||||
|
failure_result("JellyFin error: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def jellyfin_client
|
||||||
|
@client ||= JellyFinClient.new(
|
||||||
|
server_url: @storage_location.server_url,
|
||||||
|
api_key: @storage_location.api_key,
|
||||||
|
username: @storage_location.username
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_jellyfin_items(items)
|
||||||
|
new_videos = []
|
||||||
|
|
||||||
|
items.each do |item|
|
||||||
|
next unless item['MediaType'] == 'Video'
|
||||||
|
|
||||||
|
title = item['Name']
|
||||||
|
year = item['ProductionYear']
|
||||||
|
work_title = year ? "#{title} (#{year})" : title
|
||||||
|
|
||||||
|
work = Work.find_or_create_by(title: work_title) do |w|
|
||||||
|
w.year = year
|
||||||
|
w.description = item['Overview']
|
||||||
|
end
|
||||||
|
|
||||||
|
video = Video.find_or_initialize_by(
|
||||||
|
filename: item['Id'],
|
||||||
|
storage_location: @storage_location
|
||||||
|
)
|
||||||
|
|
||||||
|
if video.new_record?
|
||||||
|
video.update!(
|
||||||
|
work: work,
|
||||||
|
video_metadata: {
|
||||||
|
jellyfin_id: item['Id'],
|
||||||
|
media_type: item['Type'],
|
||||||
|
runtime: item['RunTimeTicks'] ? item['RunTimeTicks'] / 10_000_000 : nil,
|
||||||
|
premiere_date: item['PremiereDate'],
|
||||||
|
community_rating: item['CommunityRating'],
|
||||||
|
genres: item['Genres']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
new_videos << video
|
||||||
|
VideoProcessorJob.perform_later(video.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
new_videos
|
||||||
|
end
|
||||||
|
|
||||||
|
def success_result(videos = [])
|
||||||
|
{ success: true, videos: videos, message: "Found #{videos.length} new videos from JellyFin" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure_result(message)
|
||||||
|
{ success: false, message: message }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### JellyFin Streamer
|
||||||
|
```ruby
|
||||||
|
class JellyFinStreamer
|
||||||
|
def initialize(storage_location)
|
||||||
|
@storage_location = storage_location
|
||||||
|
@client = jellyfin_client
|
||||||
|
end
|
||||||
|
|
||||||
|
def stream(video, range: nil)
|
||||||
|
jellyfin_id = video.video_metadata['jellyfin_id']
|
||||||
|
stream_url = @client.streaming_url(jellyfin_id)
|
||||||
|
|
||||||
|
# For JellyFin, we typically proxy the stream
|
||||||
|
if range
|
||||||
|
proxy_stream_with_range(stream_url, range)
|
||||||
|
else
|
||||||
|
proxy_stream(stream_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def jellyfin_client
|
||||||
|
@client ||= JellyFinClient.new(
|
||||||
|
server_url: @storage_location.server_url,
|
||||||
|
api_key: @storage_location.api_key,
|
||||||
|
username: @storage_location.username
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def proxy_stream(url)
|
||||||
|
response = Faraday.get(url)
|
||||||
|
|
||||||
|
{
|
||||||
|
body: response.body,
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def proxy_stream_with_range(url, range)
|
||||||
|
response = Faraday.get(url, nil, { 'Range' => "bytes=#{range}" })
|
||||||
|
|
||||||
|
{
|
||||||
|
body: response.body,
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Video Import System
|
||||||
|
|
||||||
|
### Import Job with Progress Tracking
|
||||||
|
```ruby
|
||||||
|
class VideoImportJob < ApplicationJob
|
||||||
|
include ActiveJob::Statuses
|
||||||
|
|
||||||
|
def perform(video_id, destination_storage_location_id)
|
||||||
|
video = Video.find(video_id)
|
||||||
|
destination = StorageLocation.find(destination_storage_location_id)
|
||||||
|
|
||||||
|
progress.update(stage: "download", total: 100, current: 0)
|
||||||
|
|
||||||
|
# Download file from source
|
||||||
|
downloaded_file = download_video(video, destination) do |current, total|
|
||||||
|
progress.update(current: (current.to_f / total * 50).to_i) # Download is 50% of progress
|
||||||
|
end
|
||||||
|
|
||||||
|
progress.update(stage: "process", total: 100, current: 50)
|
||||||
|
|
||||||
|
# Create new video record in destination
|
||||||
|
new_video = Video.create!(
|
||||||
|
filename: video.filename,
|
||||||
|
storage_location: destination,
|
||||||
|
work: video.work,
|
||||||
|
file_size: video.file_size
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy file to destination
|
||||||
|
destination_path = File.join(destination.path, video.filename)
|
||||||
|
FileUtils.cp(downloaded_file.path, destination_path)
|
||||||
|
|
||||||
|
# Process the new video
|
||||||
|
VideoProcessorJob.perform_later(new_video.id)
|
||||||
|
|
||||||
|
progress.update(stage: "complete", total: 100, current: 100)
|
||||||
|
|
||||||
|
# Clean up temp file
|
||||||
|
File.delete(downloaded_file.path)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def download_video(video, destination, &block)
|
||||||
|
case video.storage_location.storage_type
|
||||||
|
when 's3'
|
||||||
|
download_from_s3(video, &block)
|
||||||
|
when 'jellyfin'
|
||||||
|
download_from_jellyfin(video, &block)
|
||||||
|
when 'web'
|
||||||
|
download_from_web(video, &block)
|
||||||
|
else
|
||||||
|
raise "Unsupported import from #{video.storage_location.storage_type}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_from_s3(video, &block)
|
||||||
|
temp_file = Tempfile.new(['video_import', File.extname(video.filename)])
|
||||||
|
|
||||||
|
s3_client = Aws::S3::Client.new(
|
||||||
|
region: video.storage_location.region,
|
||||||
|
access_key_id: video.storage_location.access_key_id,
|
||||||
|
secret_access_key: video.storage_location.secret_access_key
|
||||||
|
)
|
||||||
|
|
||||||
|
s3_client.get_object(
|
||||||
|
bucket: video.storage_location.bucket,
|
||||||
|
key: video.video_metadata['remote_url'],
|
||||||
|
response_target: temp_file.path
|
||||||
|
) do |chunk|
|
||||||
|
yield(chunk.bytes_written, chunk.size) if block_given?
|
||||||
|
end
|
||||||
|
|
||||||
|
temp_file
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_from_jellyfin(video, &block)
|
||||||
|
temp_file = Tempfile.new(['video_import', File.extname(video.filename)])
|
||||||
|
|
||||||
|
jellyfin_id = video.video_metadata['jellyfin_id']
|
||||||
|
client = JellyFinClient.new(
|
||||||
|
server_url: video.storage_location.server_url,
|
||||||
|
api_key: video.storage_location.api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
stream_url = client.streaming_url(jellyfin_id)
|
||||||
|
|
||||||
|
# Download with progress tracking
|
||||||
|
uri = URI(stream_url)
|
||||||
|
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
||||||
|
request = Net::HTTP::Get.new(uri)
|
||||||
|
http.request(request) do |response|
|
||||||
|
total_size = response['Content-Length'].to_i
|
||||||
|
downloaded = 0
|
||||||
|
|
||||||
|
response.read_body do |chunk|
|
||||||
|
temp_file.write(chunk)
|
||||||
|
downloaded += chunk.bytesize
|
||||||
|
yield(downloaded, total_size) if block_given?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
temp_file
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import UI
|
||||||
|
```erb
|
||||||
|
<!-- app/views/videos/_import_button.html.erb -->
|
||||||
|
<% if video.storage_location.remote? && current_user.admin? %>
|
||||||
|
<div data-controller="import-dialog">
|
||||||
|
<button
|
||||||
|
data-action="click->import-dialog#show"
|
||||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Import to Local Storage
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-import-dialog-target="dialog"
|
||||||
|
class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 mb-4">Import Video</h3>
|
||||||
|
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Import "<%= video.filename %>" to a local storage location for offline access and transcoding.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-gray-700 text-sm font-bold mb-2">
|
||||||
|
Destination Storage:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="destination_storage_location_id"
|
||||||
|
data-import-dialog-target="destination"
|
||||||
|
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight">
|
||||||
|
<% StorageLocation.local.accessible.each do |location| %>
|
||||||
|
<option value="<%= location.id %>"><%= location.name %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
data-action="click->import-dialog#hide"
|
||||||
|
class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-action="click->import-dialog#import"
|
||||||
|
data-video-id="<%= video.id %>"
|
||||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress display -->
|
||||||
|
<div
|
||||||
|
data-import-dialog-target="progress"
|
||||||
|
class="hidden mt-2">
|
||||||
|
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4">
|
||||||
|
<p class="font-bold">Importing video...</p>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="bg-blue-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
data-import-dialog-target="progressBar"
|
||||||
|
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm mt-1" data-import-dialog-target="progressText">Starting...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Stimulus Controller
|
||||||
|
```javascript
|
||||||
|
// app/javascript/controllers/import_dialog_controller.js
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import { get } from "@rails/request.js"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["dialog", "progress", "progressBar", "progressText", "destination"]
|
||||||
|
|
||||||
|
show() {
|
||||||
|
this.dialogTarget.classList.remove("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.dialogTarget.classList.add("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
async import(event) {
|
||||||
|
const videoId = event.target.dataset.videoId
|
||||||
|
const destinationId = this.destinationTarget.value
|
||||||
|
|
||||||
|
this.hide()
|
||||||
|
this.progressTarget.classList.remove("hidden")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start import job
|
||||||
|
const response = await post("/videos/import", {
|
||||||
|
body: JSON.stringify({
|
||||||
|
video_id: videoId,
|
||||||
|
destination_storage_location_id: destinationId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { jobId } = await response.json
|
||||||
|
|
||||||
|
// Poll for progress
|
||||||
|
this.pollProgress(jobId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Import failed:", error)
|
||||||
|
this.progressTarget.innerHTML = `
|
||||||
|
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4">
|
||||||
|
<p class="font-bold">Import failed</p>
|
||||||
|
<p class="text-sm">${error.message}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pollProgress(jobId) {
|
||||||
|
const updateProgress = async () => {
|
||||||
|
try {
|
||||||
|
const response = await get(`/jobs/${jobId}/progress`)
|
||||||
|
const progress = await response.json
|
||||||
|
|
||||||
|
this.progressBarTarget.style.width = `${progress.current}%`
|
||||||
|
this.progressTextTarget.textContent = `${progress.stage}: ${progress.current}%`
|
||||||
|
|
||||||
|
if (progress.stage === "complete") {
|
||||||
|
this.progressTarget.innerHTML = `
|
||||||
|
<div class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4">
|
||||||
|
<p class="font-bold">Import complete!</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload()
|
||||||
|
}, 2000)
|
||||||
|
} else if (progress.stage === "failed") {
|
||||||
|
this.progressTarget.innerHTML = `
|
||||||
|
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4">
|
||||||
|
<p class="font-bold">Import failed</p>
|
||||||
|
<p class="text-sm">${progress.error || "Unknown error"}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
} else {
|
||||||
|
setTimeout(updateProgress, 1000)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get progress:", error)
|
||||||
|
setTimeout(updateProgress, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### New Environment Variables
|
||||||
|
```bash
|
||||||
|
# S3 Configuration
|
||||||
|
AWS_ACCESS_KEY_ID=your_access_key
|
||||||
|
AWS_SECRET_ACCESS_KEY=your_secret_key
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
|
||||||
|
# Import Settings
|
||||||
|
MAX_IMPORT_SIZE_GB=10
|
||||||
|
IMPORT_TIMEOUT_MINUTES=60
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
JELLYFIN_RATE_LIMIT_DELAY=1 # seconds between requests
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Gems
|
||||||
|
```ruby
|
||||||
|
# Gemfile
|
||||||
|
gem 'aws-sdk-s3', '~> 1'
|
||||||
|
gem 'faraday', '~> 2.0'
|
||||||
|
gem 'httparty', '~> 0.21'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routes for Phase 3
|
||||||
|
```ruby
|
||||||
|
# Add to existing routes
|
||||||
|
resources :videos, only: [] do
|
||||||
|
member do
|
||||||
|
post :import
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
namespace :admin do
|
||||||
|
resources :storage_locations do
|
||||||
|
member do
|
||||||
|
post :test_connection
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 3 enables users to access video libraries from multiple remote sources while maintaining a unified interface. The import system allows bringing remote videos into local storage for offline access and transcoding.
|
||||||
636
docs/phases/phase_4.md
Normal file
636
docs/phases/phase_4.md
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
# Velour Phase 4: Federation
|
||||||
|
|
||||||
|
Phase 4 enables federation between multiple Velour instances, allowing users to share and access video libraries across different servers while maintaining security and access controls.
|
||||||
|
|
||||||
|
## Federation Architecture
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
Velour federation allows instances to:
|
||||||
|
- Share video libraries with other trusted instances
|
||||||
|
- Stream videos from remote servers
|
||||||
|
- Sync metadata and work information
|
||||||
|
- Maintain access control and authentication
|
||||||
|
|
||||||
|
### Federation Storage Location Type
|
||||||
|
```ruby
|
||||||
|
class StorageLocation < ApplicationRecord
|
||||||
|
# ... existing code ...
|
||||||
|
|
||||||
|
validates :storage_type, inclusion: { in: %w[local s3 jellyfin web velour] }
|
||||||
|
|
||||||
|
store :configuration, accessors: [
|
||||||
|
# Existing configurations...
|
||||||
|
# Velour federation configuration
|
||||||
|
:remote_instance_url, :api_key, :instance_name, :trusted_instances
|
||||||
|
], coder: JSON
|
||||||
|
|
||||||
|
enum storage_type: { local: 0, s3: 1, jellyfin: 2, web: 3, velour: 4 }
|
||||||
|
|
||||||
|
def federation_client
|
||||||
|
return nil unless velour?
|
||||||
|
@federation_client ||= VelourFederationClient.new(
|
||||||
|
instance_url: remote_instance_url,
|
||||||
|
api_key: api_key
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def accessible?
|
||||||
|
case storage_type
|
||||||
|
# ... existing cases ...
|
||||||
|
when 'velour'
|
||||||
|
federation_client&.ping?
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def scanner
|
||||||
|
case storage_type
|
||||||
|
# ... existing cases ...
|
||||||
|
when 'velour'
|
||||||
|
VelourFederationScanner.new(self)
|
||||||
|
else
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def streamer
|
||||||
|
case storage_type
|
||||||
|
# ... existing cases ...
|
||||||
|
when 'velour'
|
||||||
|
VelourFederationStreamer.new(self)
|
||||||
|
else
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Federation API Authentication
|
||||||
|
|
||||||
|
### API Key Management
|
||||||
|
```ruby
|
||||||
|
class ApiKey < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
has_many :federation_connections, dependent: :destroy
|
||||||
|
|
||||||
|
validates :key, presence: true, uniqueness: true
|
||||||
|
validates :name, presence: true
|
||||||
|
validates :permissions, presence: true
|
||||||
|
|
||||||
|
store :permissions, coder: JSON, accessors: [:can_read, :can_stream, :can_metadata]
|
||||||
|
|
||||||
|
before_validation :generate_key, on: :create
|
||||||
|
|
||||||
|
def self.generate_key
|
||||||
|
SecureRandom.hex(32)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_key
|
||||||
|
self.key ||= self.class.generate_key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Federation Connections
|
||||||
|
```ruby
|
||||||
|
class FederationConnection < ApplicationRecord
|
||||||
|
belongs_to :api_key
|
||||||
|
belongs_to :storage_location
|
||||||
|
|
||||||
|
validates :remote_instance_url, presence: true, uniqueness: { scope: :api_key_id }
|
||||||
|
validates :status, inclusion: { in: %w[pending active suspended rejected] }
|
||||||
|
|
||||||
|
enum status: { pending: 0, active: 1, suspended: 2, rejected: 3 }
|
||||||
|
|
||||||
|
def active?
|
||||||
|
status == "active"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Federation Client
|
||||||
|
|
||||||
|
### Velour Federation Client
|
||||||
|
```ruby
|
||||||
|
class VelourFederationClient
|
||||||
|
def initialize(instance_url:, api_key:)
|
||||||
|
@instance_url = instance_url.chomp('/')
|
||||||
|
@api_key = api_key
|
||||||
|
@http = Faraday.new(url: @instance_url) do |faraday|
|
||||||
|
faraday.headers['Authorization'] = "Bearer #{@api_key}"
|
||||||
|
faraday.headers['User-Agent'] = "Velour/#{Velour::VERSION}"
|
||||||
|
faraday.request :json
|
||||||
|
faraday.response :json
|
||||||
|
faraday.adapter Faraday.default_adapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def ping?
|
||||||
|
response = @http.get('/api/v1/ping')
|
||||||
|
response.success?
|
||||||
|
rescue
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def instance_info
|
||||||
|
response = @http.get('/api/v1/instance')
|
||||||
|
response.success? ? response.body : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def works(page: 1, per_page: 50)
|
||||||
|
response = @http.get('/api/v1/works', params: {
|
||||||
|
page: page,
|
||||||
|
per_page: per_page
|
||||||
|
})
|
||||||
|
response.success? ? response.body : []
|
||||||
|
end
|
||||||
|
|
||||||
|
def work_details(work_id)
|
||||||
|
response = @http.get("/api/v1/works/#{work_id}")
|
||||||
|
response.success? ? response.body : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def video_stream_url(video_id)
|
||||||
|
"#{@instance_url}/api/v1/videos/#{video_id}/stream"
|
||||||
|
end
|
||||||
|
|
||||||
|
def video_metadata(video_id)
|
||||||
|
response = @http.get("/api/v1/videos/#{video_id}")
|
||||||
|
response.success? ? response.body : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_videos(query:, page: 1, per_page: 20)
|
||||||
|
response = @http.get('/api/v1/videos/search', params: {
|
||||||
|
query: query,
|
||||||
|
page: page,
|
||||||
|
per_page: per_page
|
||||||
|
})
|
||||||
|
response.success? ? response.body : []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Federation Scanner
|
||||||
|
|
||||||
|
### Velour Federation Scanner
|
||||||
|
```ruby
|
||||||
|
class VelourFederationScanner
|
||||||
|
def initialize(storage_location)
|
||||||
|
@storage_location = storage_location
|
||||||
|
@client = @storage_location.federation_client
|
||||||
|
end
|
||||||
|
|
||||||
|
def scan
|
||||||
|
return failure_result("Remote Velour instance not accessible") unless @storage_location.accessible?
|
||||||
|
|
||||||
|
instance_info = @client.instance_info
|
||||||
|
return failure_result("Failed to get instance info") unless instance_info
|
||||||
|
|
||||||
|
works = @client.works(per_page: 100) # Start with first 100 works
|
||||||
|
new_videos = process_remote_works(works)
|
||||||
|
|
||||||
|
success_result(new_videos, instance_info)
|
||||||
|
rescue => e
|
||||||
|
failure_result("Federation error: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_remote_works(works)
|
||||||
|
new_videos = []
|
||||||
|
|
||||||
|
works.each do |work_data|
|
||||||
|
# Create or find local work
|
||||||
|
work = Work.find_or_create_by(
|
||||||
|
title: work_data['title'],
|
||||||
|
year: work_data['year']
|
||||||
|
) do |w|
|
||||||
|
w.description = work_data['description']
|
||||||
|
w.director = work_data['director']
|
||||||
|
w.rating = work_data['rating']
|
||||||
|
end
|
||||||
|
|
||||||
|
# Process videos for this work
|
||||||
|
work_data['videos'].each do |video_data|
|
||||||
|
video = Video.find_or_initialize_by(
|
||||||
|
filename: video_data['id'], # Use remote ID as filename
|
||||||
|
storage_location: @storage_location
|
||||||
|
)
|
||||||
|
|
||||||
|
if video.new_record?
|
||||||
|
video.update!(
|
||||||
|
work: work,
|
||||||
|
file_size: video_data['file_size'],
|
||||||
|
web_compatible: video_data['web_compatible'],
|
||||||
|
video_metadata: {
|
||||||
|
remote_video_id: video_data['id'],
|
||||||
|
remote_instance_url: @storage_location.remote_instance_url,
|
||||||
|
duration: video_data['duration'],
|
||||||
|
width: video_data['width'],
|
||||||
|
height: video_data['height'],
|
||||||
|
video_codec: video_data['video_codec'],
|
||||||
|
audio_codec: video_data['audio_codec']
|
||||||
|
},
|
||||||
|
fingerprints: video_data['fingerprints']
|
||||||
|
)
|
||||||
|
|
||||||
|
new_videos << video
|
||||||
|
# Note: We don't process remote videos locally
|
||||||
|
# We just catalog them for streaming
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
new_videos
|
||||||
|
end
|
||||||
|
|
||||||
|
def success_result(videos = [], instance_info = {})
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
videos: videos,
|
||||||
|
message: "Found #{videos.length} videos from #{@storage_location.instance_name}",
|
||||||
|
instance_info: instance_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure_result(message)
|
||||||
|
{ success: false, message: message }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Federation Streamer
|
||||||
|
|
||||||
|
### Velour Federation Streamer
|
||||||
|
```ruby
|
||||||
|
class VelourFederationStreamer
|
||||||
|
def initialize(storage_location)
|
||||||
|
@storage_location = storage_location
|
||||||
|
@client = @storage_location.federation_client
|
||||||
|
end
|
||||||
|
|
||||||
|
def stream(video, range: nil)
|
||||||
|
remote_video_id = video.video_metadata['remote_video_id']
|
||||||
|
stream_url = @client.video_stream_url(remote_video_id)
|
||||||
|
|
||||||
|
if range
|
||||||
|
proxy_stream_with_range(stream_url, range)
|
||||||
|
else
|
||||||
|
proxy_stream(stream_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def thumbnail_url(video)
|
||||||
|
remote_video_id = video.video_metadata['remote_video_id']
|
||||||
|
"#{@storage_location.remote_instance_url}/api/v1/videos/#{remote_video_id}/thumbnail"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def proxy_stream(url)
|
||||||
|
response = Faraday.get(url) do |req|
|
||||||
|
req.headers['Authorization'] = "Bearer #{@storage_location.api_key}"
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
body: response.body,
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def proxy_stream_with_range(url, range)
|
||||||
|
response = Faraday.get(url) do |req|
|
||||||
|
req.headers['Authorization'] = "Bearer #{@storage_location.api_key}"
|
||||||
|
req.headers['Range'] = "bytes=#{range}"
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
body: response.body,
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Federation API Endpoints
|
||||||
|
|
||||||
|
### API Controllers
|
||||||
|
```ruby
|
||||||
|
# app/controllers/api/v1/base_controller.rb
|
||||||
|
class Api::V1::BaseController < ActionController::Base
|
||||||
|
before_action :authenticate_api_request
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def authenticate_api_request
|
||||||
|
token = request.headers['Authorization']&.gsub(/^Bearer\s+/, '')
|
||||||
|
api_key = ApiKey.find_by(key: token)
|
||||||
|
|
||||||
|
if api_key&.active?
|
||||||
|
@current_api_key = api_key
|
||||||
|
@current_user = api_key.user
|
||||||
|
else
|
||||||
|
render json: { error: 'Unauthorized' }, status: :unauthorized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_federation
|
||||||
|
head :forbidden unless @current_api_key&.can_read
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_streaming
|
||||||
|
head :forbidden unless @current_api_key&.can_stream
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# app/controllers/api/v1/instance_controller.rb
|
||||||
|
class Api::V1::InstanceController < Api::V1::BaseController
|
||||||
|
before_action :authorize_federation
|
||||||
|
|
||||||
|
def ping
|
||||||
|
render json: { status: 'ok', timestamp: Time.current.iso8601 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: {
|
||||||
|
name: 'Velour Instance',
|
||||||
|
version: Velour::VERSION,
|
||||||
|
description: 'Video library federation node',
|
||||||
|
total_works: Work.count,
|
||||||
|
total_videos: Video.count,
|
||||||
|
total_storage: Video.sum(:file_size)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# app/controllers/api/v1/works_controller.rb
|
||||||
|
class Api::V1::WorksController < Api::V1::BaseController
|
||||||
|
before_action :authorize_federation
|
||||||
|
|
||||||
|
def index
|
||||||
|
works = Work.includes(:videos)
|
||||||
|
.order(:title)
|
||||||
|
.page(params[:page])
|
||||||
|
.per(params[:per_page] || 50)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
works: works.map { |work| serialize_work(work) },
|
||||||
|
pagination: {
|
||||||
|
current_page: works.current_page,
|
||||||
|
total_pages: works.total_pages,
|
||||||
|
total_count: works.total_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
work = Work.includes(videos: :storage_location).find(params[:id])
|
||||||
|
render json: serialize_work(work)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def serialize_work(work)
|
||||||
|
{
|
||||||
|
id: work.id,
|
||||||
|
title: work.title,
|
||||||
|
year: work.year,
|
||||||
|
description: work.description,
|
||||||
|
director: work.director,
|
||||||
|
rating: work.rating,
|
||||||
|
organized: work.organized,
|
||||||
|
created_at: work.created_at.iso8601,
|
||||||
|
videos: work.videos.map { |video| serialize_video(video) }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialize_video(video)
|
||||||
|
{
|
||||||
|
id: video.id,
|
||||||
|
filename: video.filename,
|
||||||
|
file_size: video.file_size,
|
||||||
|
web_compatible: video.web_compatible?,
|
||||||
|
duration: video.duration,
|
||||||
|
width: video.width,
|
||||||
|
height: video.height,
|
||||||
|
video_codec: video.video_codec,
|
||||||
|
audio_codec: video.audio_codec,
|
||||||
|
fingerprints: video.fingerprints,
|
||||||
|
storage_type: video.storage_location.storage_type,
|
||||||
|
created_at: video.created_at.iso8601
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# app/controllers/api/v1/videos_controller.rb
|
||||||
|
class Api::V1::VideosController < Api::V1::BaseController
|
||||||
|
before_action :set_video, only: [:show, :stream, :thumbnail]
|
||||||
|
before_action :authorize_federation, except: [:stream, :thumbnail]
|
||||||
|
before_action :authorize_streaming, only: [:stream, :thumbnail]
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: serialize_video(@video)
|
||||||
|
end
|
||||||
|
|
||||||
|
def stream
|
||||||
|
streamer = @video.storage_location.streamer
|
||||||
|
result = streamer.stream(@video, range: request.headers['Range'])
|
||||||
|
|
||||||
|
send_data result[:body],
|
||||||
|
type: result[:headers]['Content-Type'] || 'video/mp4',
|
||||||
|
disposition: 'inline',
|
||||||
|
status: result[:status],
|
||||||
|
headers: result[:headers].slice('Content-Range', 'Content-Length', 'Accept-Ranges')
|
||||||
|
end
|
||||||
|
|
||||||
|
def thumbnail
|
||||||
|
if @video.video_assets.where(asset_type: 'thumbnail').exists?
|
||||||
|
asset = @video.video_assets.where(asset_type: 'thumbnail').first
|
||||||
|
redirect_to rails_blob_url(asset.file, disposition: 'inline')
|
||||||
|
else
|
||||||
|
head :not_found
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def search
|
||||||
|
query = params[:query]
|
||||||
|
return render json: [] if query.blank?
|
||||||
|
|
||||||
|
videos = Video.joins(:work)
|
||||||
|
.where('works.title ILIKE ?', "%#{query}%")
|
||||||
|
.includes(:work, :storage_location)
|
||||||
|
.page(params[:page])
|
||||||
|
.per(params[:per_page] || 20)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
videos: videos.map { |video| serialize_video(video) },
|
||||||
|
pagination: {
|
||||||
|
current_page: videos.current_page,
|
||||||
|
total_pages: videos.total_pages,
|
||||||
|
total_count: videos.total_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_video
|
||||||
|
@video = Video.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialize_video(video)
|
||||||
|
{
|
||||||
|
id: video.id,
|
||||||
|
filename: video.filename,
|
||||||
|
work_id: video.work_id,
|
||||||
|
work_title: video.work.title,
|
||||||
|
file_size: video.file_size,
|
||||||
|
web_compatible: video.web_compatible?,
|
||||||
|
duration: video.duration,
|
||||||
|
width: video.width,
|
||||||
|
height: video.height,
|
||||||
|
video_codec: video.video_codec,
|
||||||
|
audio_codec: video.audio_codec,
|
||||||
|
fingerprints: video.fingerprints,
|
||||||
|
storage_type: video.storage_location.storage_type,
|
||||||
|
created_at: video.created_at.iso8601
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Federation Management UI
|
||||||
|
|
||||||
|
### Federation Connections Management
|
||||||
|
```erb
|
||||||
|
<!-- app/views/admin/federation_connections/index.html.erb -->
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Federation Connections</h1>
|
||||||
|
|
||||||
|
<% if @api_keys.any? %>
|
||||||
|
<div class="mb-6">
|
||||||
|
<%= link_to 'Create New Connection', new_admin_federation_connection_path, class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
|
<ul class="divide-y divide-gray-200">
|
||||||
|
<% @connections.each do |connection| %>
|
||||||
|
<li class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
<%= connection.storage_location.name %>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
<%= connection.remote_instance_url %>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
API Key: <%= connection.api_key.name %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
|
<%= case connection.status
|
||||||
|
when 'active' then 'bg-green-100 text-green-800'
|
||||||
|
when 'pending' then 'bg-yellow-100 text-yellow-800'
|
||||||
|
when 'suspended' then 'bg-red-100 text-red-800'
|
||||||
|
when 'rejected' then 'bg-gray-100 text-gray-800'
|
||||||
|
end %>">
|
||||||
|
<%= connection.status.titleize %>
|
||||||
|
</span>
|
||||||
|
<%= link_to 'Edit', edit_admin_federation_connection_path(connection), class: 'text-blue-600 hover:text-blue-900' %>
|
||||||
|
<%= link_to 'Test', test_admin_federation_connection_path(connection), method: :post, class: 'text-green-600 hover:text-green-900' %>
|
||||||
|
<%= link_to 'Delete', admin_federation_connection_path(connection), method: :delete,
|
||||||
|
data: { confirm: 'Are you sure?' }, class: 'text-red-600 hover:text-red-900' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-yellow-700">
|
||||||
|
You need to create an API key before you can establish federation connections.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= link_to 'Create API Key', new_admin_api_key_path, class: 'text-yellow-700 underline hover:text-yellow-600' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Federation Security Best Practices
|
||||||
|
1. **API Key Management**: Regular rotation and limited scope
|
||||||
|
2. **Access Control**: Minimum required permissions
|
||||||
|
3. **Rate Limiting**: Prevent abuse from federated instances
|
||||||
|
4. **Audit Logging**: Track all federated access
|
||||||
|
5. **Network Security**: HTTPS-only federation connections
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
```ruby
|
||||||
|
# app/controllers/concerns/rate_limited.rb
|
||||||
|
module RateLimited
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
before_action :check_rate_limit, only: [:index, :show, :stream]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_rate_limit
|
||||||
|
return unless @current_api_key
|
||||||
|
|
||||||
|
key = "federation_rate_limit:#{@current_api_key.id}"
|
||||||
|
count = Rails.cache.increment(key, 1, expires_in: 1.hour)
|
||||||
|
|
||||||
|
if count > rate_limit
|
||||||
|
Rails.logger.warn "Rate limit exceeded for API key #{@current_api_key.id}"
|
||||||
|
render json: { error: 'Rate limit exceeded' }, status: :too_many_requests
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def rate_limit
|
||||||
|
case action_name
|
||||||
|
when 'stream'
|
||||||
|
1000 # requests per hour
|
||||||
|
else
|
||||||
|
5000 # requests per hour
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Federation Configuration
|
||||||
|
```bash
|
||||||
|
# Federation settings
|
||||||
|
FEDERATION_ENABLED=true
|
||||||
|
MAX_FEDERATED_CONNECTIONS=10
|
||||||
|
FEDERATION_RATE_LIMIT_PER_HOUR=5000
|
||||||
|
|
||||||
|
# Federation security
|
||||||
|
FEDERATION_REQUIRE_HTTPS=true
|
||||||
|
FEDERATION_TOKEN_EXPIRY_HOURS=24
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 4 enables a distributed network of Velour instances that can share video libraries while maintaining security and access controls. This allows organizations to federate their video collections across multiple servers or locations.
|
||||||
324
docs/phases/phase_5.md
Normal file
324
docs/phases/phase_5.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# Velour Phase 5: Audio Support (Music & Audiobooks)
|
||||||
|
|
||||||
|
Phase 5 extends Velour from a video library to a comprehensive media library by adding support for music and audiobooks. This builds upon the extensible MediaFile architecture established in Phase 1.
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
### Audio Processing Components
|
||||||
|
- **FFmpeg** - Audio transcoding and metadata extraction (extends existing video processing)
|
||||||
|
- **Ruby Audio Gems** - ID3 tag parsing, waveform generation
|
||||||
|
- **Active Storage** - Album art and waveform visualization storage
|
||||||
|
- **MediaInfo** - Comprehensive audio metadata extraction
|
||||||
|
|
||||||
|
## Database Schema Extensions
|
||||||
|
|
||||||
|
### Audio Model (inherits from MediaFile)
|
||||||
|
```ruby
|
||||||
|
class Audio < MediaFile
|
||||||
|
# Audio-specific associations
|
||||||
|
has_many :audio_assets, dependent: :destroy # album art, waveforms
|
||||||
|
|
||||||
|
# Audio-specific metadata store
|
||||||
|
store :audio_metadata, accessors: [:sample_rate, :channels, :artist, :album, :track_number, :genre, :year]
|
||||||
|
|
||||||
|
# Audio-specific methods
|
||||||
|
def quality_label
|
||||||
|
return "Unknown" unless bit_rate
|
||||||
|
case bit_rate
|
||||||
|
when 0..128 then "128kbps"
|
||||||
|
when 129..192 then "192kbps"
|
||||||
|
when 193..256 then "256kbps"
|
||||||
|
when 257..320 then "320kbps"
|
||||||
|
else "Lossless"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_type
|
||||||
|
return "Unknown" unless format
|
||||||
|
case format&.downcase
|
||||||
|
when "mp3" then "MP3"
|
||||||
|
when "flac" then "FLAC"
|
||||||
|
when "wav" then "WAV"
|
||||||
|
when "aac", "m4a" then "AAC"
|
||||||
|
when "ogg" then "OGG Vorbis"
|
||||||
|
else format&.upcase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class AudioAsset < ApplicationRecord
|
||||||
|
belongs_to :audio
|
||||||
|
|
||||||
|
enum asset_type: { album_art: 0, waveform: 1, lyrics: 2 }
|
||||||
|
|
||||||
|
# Uses Active Storage for file storage
|
||||||
|
has_one_attached :file
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extended Work Model
|
||||||
|
```ruby
|
||||||
|
class Work < ApplicationRecord
|
||||||
|
# Existing video associations
|
||||||
|
has_many :videos, dependent: :destroy
|
||||||
|
has_many :external_ids, dependent: :destroy
|
||||||
|
|
||||||
|
# New audio associations
|
||||||
|
has_many :audios, dependent: :destroy
|
||||||
|
|
||||||
|
# Enhanced primary media selection
|
||||||
|
def primary_media
|
||||||
|
(audios + videos).sort_by(&:created_at).last
|
||||||
|
end
|
||||||
|
|
||||||
|
def primary_video
|
||||||
|
videos.order(created_at: :desc).first
|
||||||
|
end
|
||||||
|
|
||||||
|
def primary_audio
|
||||||
|
audios.order(created_at: :desc).first
|
||||||
|
end
|
||||||
|
|
||||||
|
# Content type detection
|
||||||
|
def video_content?
|
||||||
|
videos.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def audio_content?
|
||||||
|
audios.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def mixed_content?
|
||||||
|
video_content? && audio_content?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Processing Pipeline
|
||||||
|
|
||||||
|
### AudioProcessorJob
|
||||||
|
```ruby
|
||||||
|
class AudioProcessorJob < ApplicationJob
|
||||||
|
queue_as :processing
|
||||||
|
|
||||||
|
def perform(audio_id)
|
||||||
|
audio = Audio.find(audio_id)
|
||||||
|
|
||||||
|
# Extract audio metadata
|
||||||
|
AudioMetadataExtractor.new(audio).extract!
|
||||||
|
|
||||||
|
# Generate album art if embedded
|
||||||
|
AlbumArtExtractor.new(audio).extract!
|
||||||
|
|
||||||
|
# Generate waveform visualization
|
||||||
|
WaveformGenerator.new(audio).generate!
|
||||||
|
|
||||||
|
# Check web compatibility and transcode if needed
|
||||||
|
unless AudioTranscoder.new(audio).web_compatible?
|
||||||
|
AudioTranscoderJob.perform_later(audio_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
audio.update!(processed: true)
|
||||||
|
rescue => e
|
||||||
|
audio.update!(processing_error: e.message)
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### AudioTranscoderJob
|
||||||
|
```ruby
|
||||||
|
class AudioTranscoderJob < ApplicationJob
|
||||||
|
queue_as :transcoding
|
||||||
|
|
||||||
|
def perform(audio_id)
|
||||||
|
audio = Audio.find(audio_id)
|
||||||
|
AudioTranscoder.new(audio).transcode_for_web!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Discovery Extensions
|
||||||
|
|
||||||
|
### Enhanced FileScannerService
|
||||||
|
```ruby
|
||||||
|
class FileScannerService
|
||||||
|
AUDIO_EXTENSIONS = %w[mp3 flac wav aac m4a ogg wma].freeze
|
||||||
|
|
||||||
|
def scan_directory(storage_location)
|
||||||
|
# Existing video scanning logic
|
||||||
|
scan_videos(storage_location)
|
||||||
|
|
||||||
|
# New audio scanning logic
|
||||||
|
scan_audio(storage_location)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def scan_audio(storage_location)
|
||||||
|
AUDIO_EXTENSIONS.each do |ext|
|
||||||
|
Dir.glob(File.join(storage_location.path, "**", "*.#{ext}")).each do |file_path|
|
||||||
|
process_audio_file(file_path, storage_location)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_audio_file(file_path, storage_location)
|
||||||
|
filename = File.basename(file_path)
|
||||||
|
return if Audio.joins(:storage_location).exists?(filename: filename, storage_locations: { id: storage_location.id })
|
||||||
|
|
||||||
|
# Create Work based on filename parsing (album/track structure)
|
||||||
|
work = find_or_create_audio_work(filename, file_path)
|
||||||
|
|
||||||
|
# Create Audio record
|
||||||
|
Audio.create!(
|
||||||
|
work: work,
|
||||||
|
storage_location: storage_location,
|
||||||
|
filename: filename,
|
||||||
|
xxhash64: calculate_xxhash64(file_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
AudioProcessorJob.perform_later(audio.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Interface Extensions
|
||||||
|
|
||||||
|
### Audio Player Integration
|
||||||
|
- **Video.js Audio Plugin** - Extend existing video player for audio
|
||||||
|
- **Waveform Visualization** - Interactive seeking with waveform display
|
||||||
|
- **Chapter Support** - Essential for audiobooks
|
||||||
|
- **Speed Control** - Variable playback speed for audiobooks
|
||||||
|
|
||||||
|
### Library Organization
|
||||||
|
- **Album View** - Grid layout with album art
|
||||||
|
- **Artist Pages** - Discography and album organization
|
||||||
|
- **Audiobook Progress** - Chapter tracking and resume functionality
|
||||||
|
- **Mixed Media Collections** - Works containing both video and audio content
|
||||||
|
|
||||||
|
### Audio-Specific Features
|
||||||
|
- **Playlist Creation** - Custom playlists for music
|
||||||
|
- **Shuffle Play** - Random playback for albums/artists
|
||||||
|
- **Gapless Playback** - Seamless track transitions
|
||||||
|
- **Lyrics Display** - Embedded or external lyrics support
|
||||||
|
|
||||||
|
## Implementation Timeline
|
||||||
|
|
||||||
|
### Phase 5A: Audio Foundation (Week 1-2)
|
||||||
|
- Create Audio model inheriting from MediaFile
|
||||||
|
- Implement AudioProcessorJob and audio metadata extraction
|
||||||
|
- Extend FileScannerService for audio formats
|
||||||
|
- Basic audio streaming endpoint
|
||||||
|
|
||||||
|
### Phase 5B: Audio Processing (Week 3)
|
||||||
|
- Album art extraction and storage
|
||||||
|
- Waveform generation
|
||||||
|
- Audio transcoding for web compatibility
|
||||||
|
- Quality optimization and format conversion
|
||||||
|
|
||||||
|
### Phase 5C: User Interface (Week 4)
|
||||||
|
- Audio player component (extends Video.js)
|
||||||
|
- Album and artist browsing interfaces
|
||||||
|
- Audio library management views
|
||||||
|
- Search and filtering for audio content
|
||||||
|
|
||||||
|
### Phase 5D: Advanced Features (Week 5)
|
||||||
|
- Chapter support for audiobooks
|
||||||
|
- Playlist creation and management
|
||||||
|
- Mixed media Works (video + audio)
|
||||||
|
- Audio-specific user preferences
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
```ruby
|
||||||
|
# Extend videos table for STI (already done in Phase 1)
|
||||||
|
# Add audio-specific columns if needed
|
||||||
|
class AddAudioFeatures < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :audio_assets do |t|
|
||||||
|
t.references :audio, null: false, foreign_key: true
|
||||||
|
t.string :asset_type
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# Audio-specific indexes
|
||||||
|
add_index :audios, :artist if column_exists?(:audios, :artist)
|
||||||
|
add_index :audios, :album if column_exists?(:audios, :album)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
- All existing video functionality remains unchanged
|
||||||
|
- Video URLs and routes continue to work identically
|
||||||
|
- Database migration is additive (type column only)
|
||||||
|
- No breaking changes to existing API
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```bash
|
||||||
|
# Audio Processing (extends existing video processing)
|
||||||
|
FFMPEG_PATH=/usr/bin/ffmpeg
|
||||||
|
AUDIO_TRANSCODE_QUALITY=high
|
||||||
|
MAX_AUDIO_TRANSCODE_SIZE_GB=10
|
||||||
|
|
||||||
|
# Audio Features
|
||||||
|
ENABLE_AUDIO_SCANNING=true
|
||||||
|
ENABLE_WAVEFORM_GENERATION=true
|
||||||
|
AUDIO_THUMBNAIL_SIZE=300x300
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage Considerations
|
||||||
|
- Album art storage in Active Storage
|
||||||
|
- Waveform images (generated per track)
|
||||||
|
- Potential audio transcoding cache
|
||||||
|
- Audio-specific metadata storage
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Model Tests
|
||||||
|
- Audio model validation and inheritance
|
||||||
|
- Work model mixed content handling
|
||||||
|
- Audio metadata extraction accuracy
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Audio processing pipeline end-to-end
|
||||||
|
- Audio streaming with seeking support
|
||||||
|
- File scanner audio discovery
|
||||||
|
|
||||||
|
### System Tests
|
||||||
|
- Audio player functionality
|
||||||
|
- Album/artist interface navigation
|
||||||
|
- Mixed media library browsing
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Audio Processing
|
||||||
|
- Parallel audio metadata extraction
|
||||||
|
- Efficient album art extraction
|
||||||
|
- Optimized waveform generation
|
||||||
|
- Background transcoding queue management
|
||||||
|
|
||||||
|
### Storage Optimization
|
||||||
|
- Compressed waveform storage
|
||||||
|
- Album art caching and optimization
|
||||||
|
- Efficient audio streaming with range requests
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- Fast audio library browsing
|
||||||
|
- Quick album art loading
|
||||||
|
- Responsive audio player controls
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
### Phase 5+ Possibilities
|
||||||
|
- **Podcast Support** - RSS feed integration and episode management
|
||||||
|
- **Radio Streaming** - Internet radio station integration
|
||||||
|
- **Music Discovery** - Similar artist recommendations
|
||||||
|
- **Audio Bookmarks** - Detailed note-taking for audiobooks
|
||||||
|
- **Social Features** - Sharing playlists and recommendations
|
||||||
|
|
||||||
|
This phase transforms Velour from a video library into a comprehensive personal media platform while maintaining the simplicity and robustness of the existing architecture.
|
||||||
67
test/controllers/passwords_controller_test.rb
Normal file
67
test/controllers/passwords_controller_test.rb
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup { @user = User.take }
|
||||||
|
|
||||||
|
test "new" do
|
||||||
|
get new_password_path
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create" do
|
||||||
|
post passwords_path, params: { email_address: @user.email_address }
|
||||||
|
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
|
||||||
|
assert_redirected_to new_session_path
|
||||||
|
|
||||||
|
follow_redirect!
|
||||||
|
assert_notice "reset instructions sent"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create for an unknown user redirects but sends no mail" do
|
||||||
|
post passwords_path, params: { email_address: "missing-user@example.com" }
|
||||||
|
assert_enqueued_emails 0
|
||||||
|
assert_redirected_to new_session_path
|
||||||
|
|
||||||
|
follow_redirect!
|
||||||
|
assert_notice "reset instructions sent"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edit" do
|
||||||
|
get edit_password_path(@user.password_reset_token)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edit with invalid password reset token" do
|
||||||
|
get edit_password_path("invalid token")
|
||||||
|
assert_redirected_to new_password_path
|
||||||
|
|
||||||
|
follow_redirect!
|
||||||
|
assert_notice "reset link is invalid"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update" do
|
||||||
|
assert_changes -> { @user.reload.password_digest } do
|
||||||
|
put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" }
|
||||||
|
assert_redirected_to new_session_path
|
||||||
|
end
|
||||||
|
|
||||||
|
follow_redirect!
|
||||||
|
assert_notice "Password has been reset"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update with non matching passwords" do
|
||||||
|
token = @user.password_reset_token
|
||||||
|
assert_no_changes -> { @user.reload.password_digest } do
|
||||||
|
put password_path(token), params: { password: "no", password_confirmation: "match" }
|
||||||
|
assert_redirected_to edit_password_path(token)
|
||||||
|
end
|
||||||
|
|
||||||
|
follow_redirect!
|
||||||
|
assert_notice "Passwords did not match"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def assert_notice(text)
|
||||||
|
assert_select "div", /#{text}/
|
||||||
|
end
|
||||||
|
end
|
||||||
33
test/controllers/sessions_controller_test.rb
Normal file
33
test/controllers/sessions_controller_test.rb
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup { @user = User.take }
|
||||||
|
|
||||||
|
test "new" do
|
||||||
|
get new_session_path
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create with valid credentials" do
|
||||||
|
post session_path, params: { email_address: @user.email_address, password: "password" }
|
||||||
|
|
||||||
|
assert_redirected_to root_path
|
||||||
|
assert cookies[:session_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create with invalid credentials" do
|
||||||
|
post session_path, params: { email_address: @user.email_address, password: "wrong" }
|
||||||
|
|
||||||
|
assert_redirected_to new_session_path
|
||||||
|
assert_nil cookies[:session_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "destroy" do
|
||||||
|
sign_in_as(User.take)
|
||||||
|
|
||||||
|
delete session_path
|
||||||
|
|
||||||
|
assert_redirected_to new_session_path
|
||||||
|
assert_empty cookies[:session_id]
|
||||||
|
end
|
||||||
|
end
|
||||||
23
test/controllers/storage_locations_controller_test.rb
Normal file
23
test/controllers/storage_locations_controller_test.rb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class StorageLocationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
test "should get index" do
|
||||||
|
get storage_locations_index_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get show" do
|
||||||
|
get storage_locations_show_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get create" do
|
||||||
|
get storage_locations_create_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get destroy" do
|
||||||
|
get storage_locations_destroy_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
end
|
||||||
13
test/controllers/videos_controller_test.rb
Normal file
13
test/controllers/videos_controller_test.rb
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class VideosControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
test "should get index" do
|
||||||
|
get videos_index_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get show" do
|
||||||
|
get videos_show_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
end
|
||||||
13
test/controllers/works_controller_test.rb
Normal file
13
test/controllers/works_controller_test.rb
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class WorksControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
test "should get index" do
|
||||||
|
get works_index_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get show" do
|
||||||
|
get works_show_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
end
|
||||||
11
test/fixtures/external_ids.yml
vendored
Normal file
11
test/fixtures/external_ids.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
work: one
|
||||||
|
source: 1
|
||||||
|
value: MyString
|
||||||
|
|
||||||
|
two:
|
||||||
|
work: two
|
||||||
|
source: 1
|
||||||
|
value: MyString
|
||||||
19
test/fixtures/playback_sessions.yml
vendored
Normal file
19
test/fixtures/playback_sessions.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
video: one
|
||||||
|
user: one
|
||||||
|
position: 1.5
|
||||||
|
duration_watched: 1.5
|
||||||
|
last_watched_at: 2025-10-29 22:39:57
|
||||||
|
completed: false
|
||||||
|
play_count: 1
|
||||||
|
|
||||||
|
two:
|
||||||
|
video: two
|
||||||
|
user: two
|
||||||
|
position: 1.5
|
||||||
|
duration_watched: 1.5
|
||||||
|
last_watched_at: 2025-10-29 22:39:57
|
||||||
|
completed: false
|
||||||
|
play_count: 1
|
||||||
23
test/fixtures/storage_locations.yml
vendored
Normal file
23
test/fixtures/storage_locations.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
name: MyString
|
||||||
|
path: MyString
|
||||||
|
location_type: 1
|
||||||
|
writable: false
|
||||||
|
enabled: false
|
||||||
|
scan_subdirectories: false
|
||||||
|
priority: 1
|
||||||
|
settings: MyText
|
||||||
|
last_scanned_at: 2025-10-29 22:38:50
|
||||||
|
|
||||||
|
two:
|
||||||
|
name: MyString
|
||||||
|
path: MyString
|
||||||
|
location_type: 1
|
||||||
|
writable: false
|
||||||
|
enabled: false
|
||||||
|
scan_subdirectories: false
|
||||||
|
priority: 1
|
||||||
|
settings: MyText
|
||||||
|
last_scanned_at: 2025-10-29 22:38:50
|
||||||
9
test/fixtures/users.yml
vendored
Normal file
9
test/fixtures/users.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<% password_digest = BCrypt::Password.create("password") %>
|
||||||
|
|
||||||
|
one:
|
||||||
|
email_address: one@example.com
|
||||||
|
password_digest: <%= password_digest %>
|
||||||
|
|
||||||
|
two:
|
||||||
|
email_address: two@example.com
|
||||||
|
password_digest: <%= password_digest %>
|
||||||
11
test/fixtures/video_assets.yml
vendored
Normal file
11
test/fixtures/video_assets.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
video: one
|
||||||
|
asset_type: 1
|
||||||
|
metadata: MyText
|
||||||
|
|
||||||
|
two:
|
||||||
|
video: two
|
||||||
|
asset_type: 1
|
||||||
|
metadata: MyText
|
||||||
51
test/fixtures/videos.yml
vendored
Normal file
51
test/fixtures/videos.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
work: one
|
||||||
|
storage_location: one
|
||||||
|
title: MyString
|
||||||
|
file_path: MyString
|
||||||
|
file_hash: MyString
|
||||||
|
file_size: 1
|
||||||
|
duration: 1.5
|
||||||
|
width: 1
|
||||||
|
height: 1
|
||||||
|
resolution_label: MyString
|
||||||
|
video_codec: MyString
|
||||||
|
audio_codec: MyString
|
||||||
|
bit_rate: 1
|
||||||
|
frame_rate: 1.5
|
||||||
|
format: MyString
|
||||||
|
has_subtitles: false
|
||||||
|
version_type: MyString
|
||||||
|
source_type: 1
|
||||||
|
source_url: MyString
|
||||||
|
imported: false
|
||||||
|
processing_failed: false
|
||||||
|
error_message: MyText
|
||||||
|
metadata: MyText
|
||||||
|
|
||||||
|
two:
|
||||||
|
work: two
|
||||||
|
storage_location: two
|
||||||
|
title: MyString
|
||||||
|
file_path: MyString
|
||||||
|
file_hash: MyString
|
||||||
|
file_size: 1
|
||||||
|
duration: 1.5
|
||||||
|
width: 1
|
||||||
|
height: 1
|
||||||
|
resolution_label: MyString
|
||||||
|
video_codec: MyString
|
||||||
|
audio_codec: MyString
|
||||||
|
bit_rate: 1
|
||||||
|
frame_rate: 1.5
|
||||||
|
format: MyString
|
||||||
|
has_subtitles: false
|
||||||
|
version_type: MyString
|
||||||
|
source_type: 1
|
||||||
|
source_url: MyString
|
||||||
|
imported: false
|
||||||
|
processing_failed: false
|
||||||
|
error_message: MyText
|
||||||
|
metadata: MyText
|
||||||
23
test/fixtures/works.yml
vendored
Normal file
23
test/fixtures/works.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
title: MyString
|
||||||
|
year: 1
|
||||||
|
director: MyString
|
||||||
|
description: MyText
|
||||||
|
rating: 9.99
|
||||||
|
organized: false
|
||||||
|
poster_path: MyString
|
||||||
|
backdrop_path: MyString
|
||||||
|
metadata: MyText
|
||||||
|
|
||||||
|
two:
|
||||||
|
title: MyString
|
||||||
|
year: 1
|
||||||
|
director: MyString
|
||||||
|
description: MyText
|
||||||
|
rating: 9.99
|
||||||
|
organized: false
|
||||||
|
poster_path: MyString
|
||||||
|
backdrop_path: MyString
|
||||||
|
metadata: MyText
|
||||||
7
test/mailers/previews/passwords_mailer_preview.rb
Normal file
7
test/mailers/previews/passwords_mailer_preview.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer
|
||||||
|
class PasswordsMailerPreview < ActionMailer::Preview
|
||||||
|
# Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset
|
||||||
|
def reset
|
||||||
|
PasswordsMailer.reset(User.take)
|
||||||
|
end
|
||||||
|
end
|
||||||
7
test/models/external_id_test.rb
Normal file
7
test/models/external_id_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ExternalIdTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/models/playback_session_test.rb
Normal file
7
test/models/playback_session_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class PlaybackSessionTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/models/storage_location_test.rb
Normal file
7
test/models/storage_location_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class StorageLocationTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
8
test/models/user_test.rb
Normal file
8
test/models/user_test.rb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class UserTest < ActiveSupport::TestCase
|
||||||
|
test "downcases and strips email_address" do
|
||||||
|
user = User.new(email_address: " DOWNCASED@EXAMPLE.COM ")
|
||||||
|
assert_equal("downcased@example.com", user.email_address)
|
||||||
|
end
|
||||||
|
end
|
||||||
7
test/models/video_asset_test.rb
Normal file
7
test/models/video_asset_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class VideoAssetTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/models/video_test.rb
Normal file
7
test/models/video_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class VideoTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/models/work_test.rb
Normal file
7
test/models/work_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class WorkTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
ENV["RAILS_ENV"] ||= "test"
|
ENV["RAILS_ENV"] ||= "test"
|
||||||
require_relative "../config/environment"
|
require_relative "../config/environment"
|
||||||
require "rails/test_help"
|
require "rails/test_help"
|
||||||
|
require_relative "test_helpers/session_test_helper"
|
||||||
|
|
||||||
module ActiveSupport
|
module ActiveSupport
|
||||||
class TestCase
|
class TestCase
|
||||||
|
|||||||
19
test/test_helpers/session_test_helper.rb
Normal file
19
test/test_helpers/session_test_helper.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module SessionTestHelper
|
||||||
|
def sign_in_as(user)
|
||||||
|
Current.session = user.sessions.create!
|
||||||
|
|
||||||
|
ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar|
|
||||||
|
cookie_jar.signed[:session_id] = Current.session.id
|
||||||
|
cookies["session_id"] = cookie_jar[:session_id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sign_out
|
||||||
|
Current.session&.destroy!
|
||||||
|
cookies.delete("session_id")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ActiveSupport.on_load(:action_dispatch_integration_test) do
|
||||||
|
include SessionTestHelper
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user