From 88a906064f0c32a44724407c795e1f0388c91140 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Fri, 31 Oct 2025 14:36:14 +1100 Subject: [PATCH] Much base work started --- Gemfile | 8 +- Gemfile.lock | 31 + Procfile.dev | 2 +- app/channels/application_cable/connection.rb | 16 + .../admin/storage_locations_controller.rb | 62 + app/controllers/application_controller.rb | 1 + app/controllers/concerns/authentication.rb | 52 + app/controllers/passwords_controller.rb | 35 + app/controllers/sessions_controller.rb | 21 + .../storage_locations_controller.rb | 49 + app/controllers/videos_controller.rb | 58 + app/controllers/works_controller.rb | 18 + app/helpers/videos_helper.rb | 2 + app/jobs/video_processor_job.rb | 63 + app/mailers/passwords_mailer.rb | 6 + app/models/concerns/processable.rb | 26 + app/models/concerns/searchable.rb | 11 + app/models/concerns/streamable.rb | 19 + app/models/current.rb | 4 + app/models/external_id.rb | 3 + app/models/media_file.rb | 75 + app/models/playback_session.rb | 4 + app/models/session.rb | 3 + app/models/storage_location.rb | 27 + app/models/user.rb | 6 + app/models/video.rb | 20 + app/models/video_asset.rb | 3 + app/models/work.rb | 100 + app/services/file_scanner_service.rb | 56 + app/services/result.rb | 35 + app/services/storage_adapters/base_adapter.rb | 46 + .../storage_adapters/local_adapter.rb | 59 + app/services/storage_discovery_service.rb | 46 + app/services/video_metadata_extractor.rb | 12 + app/services/video_transcoder.rb | 75 + app/views/passwords/edit.html.erb | 21 + app/views/passwords/new.html.erb | 17 + app/views/passwords_mailer/reset.html.erb | 6 + app/views/passwords_mailer/reset.text.erb | 4 + app/views/sessions/new.html.erb | 31 + app/views/storage_locations/create.html.erb | 4 + app/views/storage_locations/destroy.html.erb | 4 + app/views/storage_locations/index.html.erb | 81 + app/views/storage_locations/show.html.erb | 118 + app/views/videos/index.html.erb | 76 + app/views/videos/show.html.erb | 106 + app/views/works/index.html.erb | 4 + app/views/works/show.html.erb | 4 + config/routes.rb | 43 +- db/migrate/20251029113808_create_works.rb | 17 + .../20251029113830_create_external_ids.rb | 11 + ...20251029113850_create_storage_locations.rb | 17 + db/migrate/20251029113911_create_videos.rb | 31 + .../20251029113919_create_video_assets.rb | 11 + ...20251029113957_create_playback_sessions.rb | 15 + db/migrate/20251029120242_create_users.rb | 11 + db/migrate/20251029120243_create_sessions.rb | 11 + ...9120404_update_videos_from_architecture.rb | 29 + ...29120428_update_works_from_architecture.rb | 16 + ...ate_storage_locations_from_architecture.rb | 24 + ...2_update_video_assets_from_architecture.rb | 11 + ...ate_playback_sessions_from_architecture.rb | 15 + ...8_update_external_ids_from_architecture.rb | 16 + ...20251029204614_add_json_stores_to_video.rb | 7 + .../20251029204641_add_json_stores_to_work.rb | 7 + ...51029215501_add_phase1_fields_to_videos.rb | 8 + .../20251029215811_add_processed_to_videos.rb | 5 + .../20251031022926_add_type_to_videos.rb | 6 + db/schema.rb | 146 +- docs/architecture.md | 2980 ++--------------- docs/phases/phase_1.md | 548 +++ docs/phases/phase_2.md | 486 +++ docs/phases/phase_3.md | 773 +++++ docs/phases/phase_4.md | 636 ++++ docs/phases/phase_5.md | 324 ++ test/controllers/passwords_controller_test.rb | 67 + test/controllers/sessions_controller_test.rb | 33 + .../storage_locations_controller_test.rb | 23 + test/controllers/videos_controller_test.rb | 13 + test/controllers/works_controller_test.rb | 13 + test/fixtures/external_ids.yml | 11 + test/fixtures/playback_sessions.yml | 19 + test/fixtures/storage_locations.yml | 23 + test/fixtures/users.yml | 9 + test/fixtures/video_assets.yml | 11 + test/fixtures/videos.yml | 51 + test/fixtures/works.yml | 23 + .../previews/passwords_mailer_preview.rb | 7 + test/models/external_id_test.rb | 7 + test/models/playback_session_test.rb | 7 + test/models/storage_location_test.rb | 7 + test/models/user_test.rb | 8 + test/models/video_asset_test.rb | 7 + test/models/video_test.rb | 7 + test/models/work_test.rb | 7 + test/test_helper.rb | 1 + test/test_helpers/session_test_helper.rb | 19 + 97 files changed, 5333 insertions(+), 2774 deletions(-) create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/controllers/admin/storage_locations_controller.rb create mode 100644 app/controllers/concerns/authentication.rb create mode 100644 app/controllers/passwords_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/controllers/storage_locations_controller.rb create mode 100644 app/controllers/videos_controller.rb create mode 100644 app/controllers/works_controller.rb create mode 100644 app/helpers/videos_helper.rb create mode 100644 app/jobs/video_processor_job.rb create mode 100644 app/mailers/passwords_mailer.rb create mode 100644 app/models/concerns/processable.rb create mode 100644 app/models/concerns/searchable.rb create mode 100644 app/models/concerns/streamable.rb create mode 100644 app/models/current.rb create mode 100644 app/models/external_id.rb create mode 100644 app/models/media_file.rb create mode 100644 app/models/playback_session.rb create mode 100644 app/models/session.rb create mode 100644 app/models/storage_location.rb create mode 100644 app/models/user.rb create mode 100644 app/models/video.rb create mode 100644 app/models/video_asset.rb create mode 100644 app/models/work.rb create mode 100644 app/services/file_scanner_service.rb create mode 100644 app/services/result.rb create mode 100644 app/services/storage_adapters/base_adapter.rb create mode 100644 app/services/storage_adapters/local_adapter.rb create mode 100644 app/services/storage_discovery_service.rb create mode 100644 app/services/video_metadata_extractor.rb create mode 100644 app/services/video_transcoder.rb create mode 100644 app/views/passwords/edit.html.erb create mode 100644 app/views/passwords/new.html.erb create mode 100644 app/views/passwords_mailer/reset.html.erb create mode 100644 app/views/passwords_mailer/reset.text.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 app/views/storage_locations/create.html.erb create mode 100644 app/views/storage_locations/destroy.html.erb create mode 100644 app/views/storage_locations/index.html.erb create mode 100644 app/views/storage_locations/show.html.erb create mode 100644 app/views/videos/index.html.erb create mode 100644 app/views/videos/show.html.erb create mode 100644 app/views/works/index.html.erb create mode 100644 app/views/works/show.html.erb create mode 100644 db/migrate/20251029113808_create_works.rb create mode 100644 db/migrate/20251029113830_create_external_ids.rb create mode 100644 db/migrate/20251029113850_create_storage_locations.rb create mode 100644 db/migrate/20251029113911_create_videos.rb create mode 100644 db/migrate/20251029113919_create_video_assets.rb create mode 100644 db/migrate/20251029113957_create_playback_sessions.rb create mode 100644 db/migrate/20251029120242_create_users.rb create mode 100644 db/migrate/20251029120243_create_sessions.rb create mode 100644 db/migrate/20251029120404_update_videos_from_architecture.rb create mode 100644 db/migrate/20251029120428_update_works_from_architecture.rb create mode 100644 db/migrate/20251029120434_update_storage_locations_from_architecture.rb create mode 100644 db/migrate/20251029120452_update_video_assets_from_architecture.rb create mode 100644 db/migrate/20251029120500_update_playback_sessions_from_architecture.rb create mode 100644 db/migrate/20251029120528_update_external_ids_from_architecture.rb create mode 100644 db/migrate/20251029204614_add_json_stores_to_video.rb create mode 100644 db/migrate/20251029204641_add_json_stores_to_work.rb create mode 100644 db/migrate/20251029215501_add_phase1_fields_to_videos.rb create mode 100644 db/migrate/20251029215811_add_processed_to_videos.rb create mode 100644 db/migrate/20251031022926_add_type_to_videos.rb create mode 100644 docs/phases/phase_1.md create mode 100644 docs/phases/phase_2.md create mode 100644 docs/phases/phase_3.md create mode 100644 docs/phases/phase_4.md create mode 100644 docs/phases/phase_5.md create mode 100644 test/controllers/passwords_controller_test.rb create mode 100644 test/controllers/sessions_controller_test.rb create mode 100644 test/controllers/storage_locations_controller_test.rb create mode 100644 test/controllers/videos_controller_test.rb create mode 100644 test/controllers/works_controller_test.rb create mode 100644 test/fixtures/external_ids.yml create mode 100644 test/fixtures/playback_sessions.yml create mode 100644 test/fixtures/storage_locations.yml create mode 100644 test/fixtures/users.yml create mode 100644 test/fixtures/video_assets.yml create mode 100644 test/fixtures/videos.yml create mode 100644 test/fixtures/works.yml create mode 100644 test/mailers/previews/passwords_mailer_preview.rb create mode 100644 test/models/external_id_test.rb create mode 100644 test/models/playback_session_test.rb create mode 100644 test/models/storage_location_test.rb create mode 100644 test/models/user_test.rb create mode 100644 test/models/video_asset_test.rb create mode 100644 test/models/video_test.rb create mode 100644 test/models/work_test.rb create mode 100644 test/test_helpers/session_test_helper.rb diff --git a/Gemfile b/Gemfile index d2334c4..94e4e86 100644 --- a/Gemfile +++ b/Gemfile @@ -20,7 +20,7 @@ gem "tailwindcss-rails" gem "jbuilder" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" +gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] @@ -66,3 +66,9 @@ group :test do gem "capybara" gem "selenium-webdriver" end + +gem "streamio-ffmpeg", "~> 3.0" +gem "pagy", "~> 9.4" +gem "aws-sdk-s3", "~> 1.202" + +gem "xxhash", "~> 0.7.0" diff --git a/Gemfile.lock b/Gemfile.lock index a8ac1ae..646559a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -78,7 +78,27 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) + aws-eventstream (1.4.0) + aws-partitions (1.1178.0) + aws-sdk-core (3.235.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.115.0) + aws-sdk-core (~> 3, >= 3.234.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.202.0) + aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) + bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) bigdecimal (3.3.1) bindex (0.8.1) @@ -142,6 +162,7 @@ GEM jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) + jmespath (1.6.2) json (2.15.2) kamal (2.8.2) activesupport (>= 7.0) @@ -173,6 +194,7 @@ GEM mini_mime (1.1.5) minitest (5.26.0) msgpack (1.8.0) + multi_json (1.17.0) net-imap (0.5.12) date net-protocol @@ -203,6 +225,7 @@ GEM nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) ostruct (0.6.3) + pagy (9.4.0) parallel (1.27.0) parser (3.3.10.0) ast (~> 2.4.1) @@ -343,6 +366,8 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) + streamio-ffmpeg (3.0.2) + multi_json (~> 1.8) stringio (3.1.7) tailwindcss-rails (4.4.0) railties (>= 7.0.0) @@ -382,6 +407,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) + xxhash (0.7.0) zeitwerk (2.7.3) PLATFORMS @@ -396,6 +422,8 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + aws-sdk-s3 (~> 1.202) + bcrypt (~> 3.1.7) bootsnap brakeman bundler-audit @@ -405,6 +433,7 @@ DEPENDENCIES importmap-rails jbuilder kamal + pagy (~> 9.4) propshaft puma (>= 5.0) rails (~> 8.1.1) @@ -415,11 +444,13 @@ DEPENDENCIES solid_queue sqlite3 (>= 2.1) stimulus-rails + streamio-ffmpeg (~> 3.0) tailwindcss-rails thruster turbo-rails tzinfo-data web-console + xxhash (~> 0.7.0) BUNDLED WITH 2.7.2 diff --git a/Procfile.dev b/Procfile.dev index da151fe..8a30c19 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,2 @@ -web: bin/rails server +web: bin/rails server -b 0.0.0.0 -p 3057 css: bin/rails tailwindcss:watch diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..4264c74 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,16 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + set_current_user || reject_unauthorized_connection + end + + private + def set_current_user + if session = Session.find_by(id: cookies.signed[:session_id]) + self.current_user = session.user + end + end + end +end diff --git a/app/controllers/admin/storage_locations_controller.rb b/app/controllers/admin/storage_locations_controller.rb new file mode 100644 index 0000000..8e30baa --- /dev/null +++ b/app/controllers/admin/storage_locations_controller.rb @@ -0,0 +1,62 @@ +module Admin + class StorageLocationsController < ApplicationController + before_action :set_storage_location, only: [:show, :edit, :update, :destroy] + + def index + @storage_locations = StorageLocation.all + end + + def show + end + + def new + @storage_location = StorageLocation.new + end + + def create + @storage_location = StorageLocation.new(storage_location_params) + + if @storage_location.save + redirect_to [:admin, @storage_location], notice: "Storage location was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @storage_location.update(storage_location_params) + redirect_to [:admin, @storage_location], notice: "Storage location was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @storage_location.destroy + redirect_to admin_storage_locations_url, notice: "Storage location was successfully destroyed." + end + + def scan + # Placeholder for scan functionality + redirect_to [:admin, @storage_location], notice: "Scan functionality will be implemented." + end + + def scan_status + # Placeholder for scan status + render json: { status: "idle" } + end + + private + + def set_storage_location + @storage_location = StorageLocation.find(params[:id]) + end + + def storage_location_params + params.require(:storage_location).permit(:name, :path, :location_type, :writable, :enabled, :scan_subdirectories, :priority, :settings) + end + end +end \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c353756..5f38f02 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,5 @@ class ApplicationController < ActionController::Base + include Authentication # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 0000000..3538f48 --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,52 @@ +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated? + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + end + end + + private + def authenticated? + resume_session + end + + def require_authentication + resume_session || request_authentication + end + + def resume_session + Current.session ||= find_session_by_cookie + end + + def find_session_by_cookie + Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + end + + def request_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_session_path + end + + def after_authentication_url + session.delete(:return_to_after_authenticating) || root_url + end + + def start_new_session_for(user) + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + Current.session = session + cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + end + end + + def terminate_session + Current.session.destroy + cookies.delete(:session_id) + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 0000000..f95ec78 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,35 @@ +class PasswordsController < ApplicationController + allow_unauthenticated_access + before_action :set_user_by_token, only: %i[ edit update ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } + + def new + end + + def create + if user = User.find_by(email_address: params[:email_address]) + PasswordsMailer.reset(user).deliver_later + end + + redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." + end + + def edit + end + + def update + if @user.update(params.permit(:password, :password_confirmation)) + @user.sessions.destroy_all + redirect_to new_session_path, notice: "Password has been reset." + else + redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." + end + end + + private + def set_user_by_token + @user = User.find_by_password_reset_token!(params[:token]) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_password_path, alert: "Password reset link is invalid or has expired." + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..cf7fccd --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,21 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[ new create ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + + def new + end + + def create + if user = User.authenticate_by(params.permit(:email_address, :password)) + start_new_session_for user + redirect_to after_authentication_url + else + redirect_to new_session_path, alert: "Try another email address or password." + end + end + + def destroy + terminate_session + redirect_to new_session_path, status: :see_other + end +end diff --git a/app/controllers/storage_locations_controller.rb b/app/controllers/storage_locations_controller.rb new file mode 100644 index 0000000..89ad4bb --- /dev/null +++ b/app/controllers/storage_locations_controller.rb @@ -0,0 +1,49 @@ +class StorageLocationsController < ApplicationController + before_action :set_storage_location, only: [:show, :destroy, :scan] + + def index + @storage_locations = StorageLocation.all + # Auto-discover storage locations on index page load + StorageDiscoveryService.discover_and_create + end + + def show + @videos = @storage_location.videos.includes(:work).recent + end + + def create + @storage_location = StorageLocation.new(storage_location_params) + + if @storage_location.save + redirect_to @storage_location, notice: 'Storage location was successfully created.' + else + render :new, status: :unprocessable_entity + end + end + + def destroy + @storage_location.destroy + redirect_to storage_locations_url, notice: 'Storage location was successfully destroyed.' + end + + def scan + scanner = FileScannerService.new(@storage_location) + result = scanner.scan + + if result[:success] + redirect_to @storage_location, notice: result[:message] + else + redirect_to @storage_location, alert: result[:message] + end + end + + private + + def set_storage_location + @storage_location = StorageLocation.find(params[:id]) + end + + def storage_location_params + params.require(:storage_location).permit(:name, :path, :storage_type) + end +end diff --git a/app/controllers/videos_controller.rb b/app/controllers/videos_controller.rb new file mode 100644 index 0000000..fe1e872 --- /dev/null +++ b/app/controllers/videos_controller.rb @@ -0,0 +1,58 @@ +class VideosController < ApplicationController + before_action :set_video, only: [:show, :stream, :playback_position, :retry_processing] + + def show + @work = @video.work + @last_position = get_last_playback_position + end + + def stream + file_path = @video.web_stream_path + + unless file_path && File.exist?(file_path) + head :not_found + return + end + + send_file file_path, + filename: @video.filename, + type: 'video/mp4', + disposition: 'inline', + stream: true, + buffer_size: 4096 + end + + def playback_position + position = params[:position].to_i + session = get_or_create_playback_session + session.update!(position: position, last_played_at: Time.current) + head :ok + end + + def retry_processing + VideoProcessorJob.perform_later(@video.id) + redirect_to @video, notice: 'Video processing has been queued.' + end + + private + + def set_video + @video = Video.find(params[:id]) + end + + def get_last_playback_position + # Get from current user's session or cookie + session_key = "video_position_#{@video.id}" + session[session_key] || 0 + end + + def get_or_create_playback_session + # For Phase 1, we'll use a simple session-based approach + # Phase 2 will use proper user authentication + PlaybackSession.find_or_initialize_by( + video: @video, + session_id: session.id.to_s, + user_id: nil # Will be populated in Phase 2 + ) + end +end diff --git a/app/controllers/works_controller.rb b/app/controllers/works_controller.rb new file mode 100644 index 0000000..3b3a1aa --- /dev/null +++ b/app/controllers/works_controller.rb @@ -0,0 +1,18 @@ +class WorksController < ApplicationController + before_action :set_work, only: [:show] + + def index + @works = Work.includes(:videos).recent + end + + def show + @videos = @work.videos.includes(:storage_location).recent + @primary_video = @work.primary_video + end + + private + + def set_work + @work = Work.find(params[:id]) + end +end diff --git a/app/helpers/videos_helper.rb b/app/helpers/videos_helper.rb new file mode 100644 index 0000000..ee6c155 --- /dev/null +++ b/app/helpers/videos_helper.rb @@ -0,0 +1,2 @@ +module VideosHelper +end diff --git a/app/jobs/video_processor_job.rb b/app/jobs/video_processor_job.rb new file mode 100644 index 0000000..e28b4e0 --- /dev/null +++ b/app/jobs/video_processor_job.rb @@ -0,0 +1,63 @@ +class VideoProcessorJob < ApplicationJob + queue_as :default + + def perform(video_id) + video = Video.find(video_id) + + # Extract metadata + metadata = VideoMetadataExtractor.new(video.full_file_path).extract + video.update!(video_metadata: metadata) + + # Check if web compatible + transcoder = VideoTranscoder.new + web_compatible = transcoder.web_compatible?(video.full_file_path) + video.update!(web_compatible: web_compatible) + + # Generate thumbnail + generate_thumbnail(video) + + # Transcode if needed + unless web_compatible + transcode_video(video, transcoder) + end + + video.update!(processed: true) + rescue => e + video.update!(processing_errors: e.message) + raise + end + + private + + def generate_thumbnail(video) + transcoder = VideoTranscoder.new + + # Generate thumbnail at 10% of duration or 5 seconds if duration unknown + thumbnail_time = video.duration ? video.duration * 0.1 : 5 + thumbnail_path = transcoder.extract_frame(video.full_file_path, thumbnail_time) + + # Attach thumbnail as video asset + video.video_assets.create!( + asset_type: 'thumbnail', + file: File.open(thumbnail_path) + ) + + # Clean up temporary file + File.delete(thumbnail_path) if File.exist?(thumbnail_path) + end + + def transcode_video(video, transcoder) + output_path = video.full_file_path.gsub(/\.[^.]+$/, '.web.mp4') + + transcoder.transcode_for_web( + input_path: video.full_file_path, + output_path: output_path + ) + + video.update!( + transcoded_path: File.basename(output_path), + transcoded_permanently: true, + web_compatible: true + ) + end +end \ No newline at end of file diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb new file mode 100644 index 0000000..4f0ac7f --- /dev/null +++ b/app/mailers/passwords_mailer.rb @@ -0,0 +1,6 @@ +class PasswordsMailer < ApplicationMailer + def reset(user) + @user = user + mail subject: "Reset your password", to: user.email_address + end +end diff --git a/app/models/concerns/processable.rb b/app/models/concerns/processable.rb new file mode 100644 index 0000000..28a7e46 --- /dev/null +++ b/app/models/concerns/processable.rb @@ -0,0 +1,26 @@ +module Processable + extend ActiveSupport::Concern + + included do + scope :processing_pending, -> { where("video_metadata->>'duration' IS NULL") } + end + + def processed? + duration.present? + end + + def processing_pending? + !processed? && processing_errors.blank? + end + + def mark_processing_failed!(error_message) + self.processing_errors = [error_message] + save! + end + + def retry_processing! + self.processing_errors = [] + save! + # VideoProcessorJob.perform_later(id) - will be implemented later + end +end \ No newline at end of file diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb new file mode 100644 index 0000000..4639abc --- /dev/null +++ b/app/models/concerns/searchable.rb @@ -0,0 +1,11 @@ +module Searchable + extend ActiveSupport::Concern + + class_methods do + def search(query) + return all if query.blank? + + where("title LIKE ?", "%#{sanitize_sql_like(query)}%") + end + end +end \ No newline at end of file diff --git a/app/models/concerns/streamable.rb b/app/models/concerns/streamable.rb new file mode 100644 index 0000000..c76ef33 --- /dev/null +++ b/app/models/concerns/streamable.rb @@ -0,0 +1,19 @@ +module Streamable + extend ActiveSupport::Concern + + def stream_url + storage_location.adapter.stream_url(self) + end + + def streamable? + duration.present? && storage_location.enabled? && storage_location.adapter.readable? + end + + def stream_type + case source_type + when "s3" then :presigned + when "local" then :direct + else :proxy + end + end +end \ No newline at end of file diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 0000000..2bef56d --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :session + delegate :user, to: :session, allow_nil: true +end diff --git a/app/models/external_id.rb b/app/models/external_id.rb new file mode 100644 index 0000000..ebe9d6b --- /dev/null +++ b/app/models/external_id.rb @@ -0,0 +1,3 @@ +class ExternalId < ApplicationRecord + belongs_to :work +end diff --git a/app/models/media_file.rb b/app/models/media_file.rb new file mode 100644 index 0000000..f1424a3 --- /dev/null +++ b/app/models/media_file.rb @@ -0,0 +1,75 @@ +class MediaFile < ApplicationRecord + # Base class for all media files (Video, Audio, etc.) + # Uses Single Table Inheritance (STI) via the 'type' column + self.table_name = 'videos' + self.abstract_class = true + + include Streamable + include Processable + + # Common JSON stores for flexible metadata + store :fingerprints, accessors: [:xxhash64, :md5, :oshash, :phash] + store :media_metadata, accessors: [:duration, :codec, :bit_rate, :format] + + # Common associations + belongs_to :work + belongs_to :storage_location + has_many :playback_sessions, dependent: :destroy + + # Common validations + validates :filename, presence: true + validates :xxhash64, presence: true, uniqueness: { scope: :storage_location_id } + + # Common scopes + scope :web_compatible, -> { where(web_compatible: true) } + scope :needs_transcoding, -> { where(web_compatible: false) } + scope :recent, -> { order(created_at: :desc) } + + # Common delegations + delegate :display_title, to: :work, prefix: true, allow_nil: true + + # Common instance methods + def display_title + work&.display_title || filename + end + + def full_file_path + File.join(storage_location.path, filename) + end + + def format_duration + return "Unknown" unless duration + + hours = (duration / 3600).to_i + minutes = ((duration % 3600) / 60).to_i + seconds = (duration % 60).to_i + + if hours > 0 + "%d:%02d:%02d" % [hours, minutes, seconds] + else + "%d:%02d" % [minutes, seconds] + end + end + + # Template method for subclasses to override + def web_stream_path + # Default implementation - subclasses can override for specific behavior + if transcoded_permanently? && transcoded_path && File.exist?(transcoded_full_path) + return transcoded_full_path + end + if transcoded_path && File.exist?(temp_transcoded_full_path) + return temp_transcoded_full_path + end + full_file_path if web_compatible? + end + + def transcoded_full_path + return nil unless transcoded_path + File.join(storage_location.path, transcoded_path) + end + + def temp_transcoded_full_path + return nil unless transcoded_path + File.join(Rails.root, 'tmp', 'transcodes', storage_location.id.to_s, transcoded_path) + end +end \ No newline at end of file diff --git a/app/models/playback_session.rb b/app/models/playback_session.rb new file mode 100644 index 0000000..3693aea --- /dev/null +++ b/app/models/playback_session.rb @@ -0,0 +1,4 @@ +class PlaybackSession < ApplicationRecord + belongs_to :video + belongs_to :user +end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 0000000..cf376fb --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,3 @@ +class Session < ApplicationRecord + belongs_to :user +end diff --git a/app/models/storage_location.rb b/app/models/storage_location.rb new file mode 100644 index 0000000..29baea4 --- /dev/null +++ b/app/models/storage_location.rb @@ -0,0 +1,27 @@ +class StorageLocation < ApplicationRecord + has_many :videos, dependent: :destroy + + validates :name, presence: true + validates :path, presence: true, uniqueness: true + validates :storage_type, presence: true, inclusion: { in: %w[local] } + + validate :path_must_exist_and_be_readable + + def accessible? + File.exist?(path) && File.readable?(path) + end + + def video_count + videos.count + end + + def display_name + name + end + + private + + def path_must_exist_and_be_readable + errors.add(:path, "must exist and be readable") unless accessible? + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..c88d5b0 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,6 @@ +class User < ApplicationRecord + has_secure_password + has_many :sessions, dependent: :destroy + + normalizes :email_address, with: ->(e) { e.strip.downcase } +end diff --git a/app/models/video.rb b/app/models/video.rb new file mode 100644 index 0000000..56fcfc6 --- /dev/null +++ b/app/models/video.rb @@ -0,0 +1,20 @@ +class Video < MediaFile + # Video-specific associations + has_many :video_assets, dependent: :destroy + + # Video-specific metadata store + store :video_metadata, accessors: [:width, :height, :video_codec, :audio_codec, :frame_rate] + + # Video-specific instance methods + def resolution_label + return "Unknown" unless height + case height + when 0..480 then "SD" + when 481..720 then "720p" + when 721..1080 then "1080p" + when 1081..1440 then "1440p" + when 1441..2160 then "4K" + else "8K+" + end + end +end diff --git a/app/models/video_asset.rb b/app/models/video_asset.rb new file mode 100644 index 0000000..cd3bd00 --- /dev/null +++ b/app/models/video_asset.rb @@ -0,0 +1,3 @@ +class VideoAsset < ApplicationRecord + belongs_to :video +end diff --git a/app/models/work.rb b/app/models/work.rb new file mode 100644 index 0000000..6b5b532 --- /dev/null +++ b/app/models/work.rb @@ -0,0 +1,100 @@ +class Work < ApplicationRecord + # 1. Includes/Concerns + include Searchable + + # 2. JSON Store for flexible metadata + store :metadata, accessors: [:tmdb_data, :imdb_data, :custom_fields] + store :tmdb_data, accessors: [:overview, :poster_path, :backdrop_path, :release_date, :genres] + store :imdb_data, accessors: [:plot, :rating, :votes, :runtime, :director] + store :custom_fields + + # 3. Associations + has_many :videos, dependent: :nullify + has_many :external_ids, dependent: :destroy + has_one :primary_video, -> { order("(video_metadata->>'height')::int DESC") }, class_name: "Video" + + # 4. Validations + validates :title, presence: true + validates :year, numericality: { only_integer: true, greater_than: 1800 }, allow_nil: true + validates :rating, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }, allow_nil: true + + # 5. Scopes + scope :organized, -> { where(organized: true) } + scope :unorganized, -> { where(organized: false) } + scope :recent, -> { order(created_at: :desc) } + scope :by_title, -> { order(:title) } + scope :with_year, -> { where.not(year: nil) } + + # 6. Delegations + delegate :resolution_label, :duration, to: :primary_video, prefix: true, allow_nil: true + + # 7. Class methods + def self.search(query) + where("title LIKE ? OR director LIKE ?", "%#{sanitize_sql_like(query)}%", "%#{sanitize_sql_like(query)}%") + end + + def self.find_by_external_id(source, value) + joins(:external_ids).find_by(external_ids: { source: source, value: value }) + end + + # 8. Instance methods + def display_title + year ? "#{title} (#{year})" : title + end + + def video_count + videos.count + end + + def total_duration + videos.sum("(video_metadata->>'duration')::float") + end + + def available_versions + videos.group_by(&:resolution_label) + end + + def has_external_ids? + external_ids.exists? + end + + def poster_url + poster_path || tmdb_data['poster_path'] + end + + def backdrop_url + backdrop_path || tmdb_data['backdrop_path'] + end + + def description + return read_attribute(:description) if read_attribute(:description).present? + tmdb_data['overview'] || imdb_data['plot'] + end + + def effective_director + return read_attribute(:director) if read_attribute(:director).present? + imdb_data['director'] + end + + def effective_rating + return read_attribute(:rating) if read_attribute(:rating).present? + imdb_data['rating']&.to_f + end + + # Convenience accessors for common external IDs + # Auto-generated for all sources (will be implemented when we add ExternalId model logic) + # ExternalId.sources.keys.each do |source_name| + # define_method("#{source_name}_id") do + # external_ids.find_by(source: source_name)&.value + # end + # + # define_method("#{source_name}_id=") do |value| + # return if value.blank? + # external_ids.find_or_initialize_by(source: source_name).update!(value: value) + # end + # + # define_method("#{source_name}_url") do + # external_ids.find_by(source: source_name)&.url + # end + # end +end diff --git a/app/services/file_scanner_service.rb b/app/services/file_scanner_service.rb new file mode 100644 index 0000000..0294b15 --- /dev/null +++ b/app/services/file_scanner_service.rb @@ -0,0 +1,56 @@ +class FileScannerService + def initialize(storage_location) + @storage_location = storage_location + end + + def scan + return failure_result("Storage location not accessible") unless @storage_location.accessible? + + video_files = find_video_files + new_videos = process_files(video_files) + + success_result(new_videos) + rescue => e + failure_result(e.message) + end + + private + + def find_video_files + Dir.glob(File.join(@storage_location.path, "**", "*.{mp4,avi,mkv,mov,wmv,flv,webm,m4v}")) + end + + def process_files(file_paths) + new_videos = [] + + file_paths.each do |file_path| + filename = File.basename(file_path) + + next if Video.exists?(filename: filename, storage_location: @storage_location) + + video = Video.create!( + filename: filename, + storage_location: @storage_location, + work: Work.find_or_create_by(title: extract_title(filename)) + ) + + new_videos << video + VideoProcessorJob.perform_later(video.id) + end + + new_videos + end + + def extract_title(filename) + # Simple title extraction - can be enhanced + File.basename(filename, ".*").gsub(/[\[\(].*?[\]\)]/, "").strip + end + + def success_result(videos = []) + { success: true, videos: videos, message: "Found #{videos.length} new videos" } + end + + def failure_result(message) + { success: false, message: message } + end +end \ No newline at end of file diff --git a/app/services/result.rb b/app/services/result.rb new file mode 100644 index 0000000..55f7586 --- /dev/null +++ b/app/services/result.rb @@ -0,0 +1,35 @@ +class Result + attr_reader :data, :error + + def initialize(success:, data: {}, error: nil) + @success = success + @data = data + @error = error + end + + def success? + @success + end + + def failure? + !@success + end + + def self.success(data = {}) + new(success: true, data: data) + end + + def self.failure(error) + new(success: false, error: error) + end + + # Allow accessing data as methods + def method_missing(method, *args) + return @data[method] if @data.key?(method) + super + end + + def respond_to_missing?(method, include_private = false) + @data.key?(method) || super + end +end \ No newline at end of file diff --git a/app/services/storage_adapters/base_adapter.rb b/app/services/storage_adapters/base_adapter.rb new file mode 100644 index 0000000..fe51cdc --- /dev/null +++ b/app/services/storage_adapters/base_adapter.rb @@ -0,0 +1,46 @@ +module StorageAdapters + class BaseAdapter + def initialize(storage_location) + @storage_location = storage_location + end + + # Scan for video files and return array of relative paths + def scan + raise NotImplementedError, "#{self.class} must implement #scan" + end + + # Generate streaming URL for a video + def stream_url(video) + raise NotImplementedError, "#{self.class} must implement #stream_url" + end + + # Check if file exists at path + def exists?(file_path) + raise NotImplementedError, "#{self.class} must implement #exists?" + end + + # Check if storage can be read from + def readable? + raise NotImplementedError, "#{self.class} must implement #readable?" + end + + # Check if storage can be written to + def writable? + @storage_location.writable? + end + + # Write/copy file to storage + def write(source_path, dest_path) + raise NotImplementedError, "#{self.class} must implement #write" + end + + # Download file to local temp path (for processing) + def download_to_temp(video) + raise NotImplementedError, "#{self.class} must implement #download_to_temp" + end + + protected + + attr_reader :storage_location + end +end \ No newline at end of file diff --git a/app/services/storage_adapters/local_adapter.rb b/app/services/storage_adapters/local_adapter.rb new file mode 100644 index 0000000..3e8439b --- /dev/null +++ b/app/services/storage_adapters/local_adapter.rb @@ -0,0 +1,59 @@ +module StorageAdapters + class LocalAdapter < BaseAdapter + VIDEO_EXTENSIONS = %w[.mp4 .mkv .avi .mov .wmv .flv .webm .m4v].freeze + + def scan + return [] unless readable? + + pattern = if storage_location.scan_subdirectories + File.join(storage_location.path, "**", "*{#{VIDEO_EXTENSIONS.join(',')}}") + else + File.join(storage_location.path, "*{#{VIDEO_EXTENSIONS.join(',')}}") + end + + Dir.glob(pattern, File::FNM_CASEFOLD).map do |full_path| + full_path.sub(storage_location.path + "/", "") + end + end + + def stream_url(video) + full_path(video) + end + + def exists?(file_path) + File.exist?(full_path_from_relative(file_path)) + end + + def readable? + return false unless storage_location.path.present? + + File.directory?(storage_location.path) && File.readable?(storage_location.path) + end + + def writable? + super && File.writable?(storage_location.path) + end + + def write(source_path, dest_path) + dest_full_path = full_path_from_relative(dest_path) + FileUtils.mkdir_p(File.dirname(dest_full_path)) + FileUtils.cp(source_path, dest_full_path) + dest_path + end + + def download_to_temp(video) + # Already local, return path + full_path(video) + end + + def full_path(video) + full_path_from_relative(video.file_path) + end + + private + + def full_path_from_relative(file_path) + File.join(storage_location.path, file_path) + end + end +end \ No newline at end of file diff --git a/app/services/storage_discovery_service.rb b/app/services/storage_discovery_service.rb new file mode 100644 index 0000000..3a2b943 --- /dev/null +++ b/app/services/storage_discovery_service.rb @@ -0,0 +1,46 @@ +class StorageDiscoveryService + CATEGORIES = { + 'movies' => 'Movies', + 'tv' => 'TV Shows', + 'tv_shows' => 'TV Shows', + 'series' => 'TV Shows', + 'docs' => 'Documentaries', + 'documentaries' => 'Documentaries', + 'anime' => 'Anime', + 'cartoons' => 'Animation', + 'animation' => 'Animation', + 'sports' => 'Sports', + 'music' => 'Music Videos', + 'music_videos' => 'Music Videos', + 'kids' => 'Kids Content', + 'family' => 'Family Content' + }.freeze + + def self.discover_and_create + base_path = '/videos' + return [] unless Dir.exist?(base_path) + + discovered = [] + + Dir.children(base_path).each do |subdir| + dir_path = File.join(base_path, subdir) + next unless Dir.exist?(dir_path) + + category = categorize_directory(subdir) + storage = StorageLocation.find_or_create_by!( + name: "#{category}: #{subdir.titleize}", + path: dir_path, + storage_type: 'local' + ) + + discovered << storage + end + + discovered + end + + def self.categorize_directory(dirname) + downcase = dirname.downcase + CATEGORIES[downcase] || CATEGORIES.find { |key, _| downcase.include?(key) }&.last || 'Other' + end +end \ No newline at end of file diff --git a/app/services/video_metadata_extractor.rb b/app/services/video_metadata_extractor.rb new file mode 100644 index 0000000..c82c81a --- /dev/null +++ b/app/services/video_metadata_extractor.rb @@ -0,0 +1,12 @@ +class VideoMetadataExtractor + def initialize(file_path) + @file_path = file_path + @transcoder = VideoTranscoder.new + end + + def extract + return {} unless File.exist?(@file_path) + + @transcoder.extract_metadata(@file_path) + end +end \ No newline at end of file diff --git a/app/services/video_transcoder.rb b/app/services/video_transcoder.rb new file mode 100644 index 0000000..4936acb --- /dev/null +++ b/app/services/video_transcoder.rb @@ -0,0 +1,75 @@ +class VideoTranscoder + require 'streamio-ffmpeg' + + def initialize + @ffmpeg_path = ENV['FFMPEG_PATH'] || 'ffmpeg' + @ffprobe_path = ENV['FFPROBE_PATH'] || 'ffprobe' + end + + def transcode_for_web(input_path:, output_path:, on_progress: nil) + movie = FFMPEG::Movie.new(input_path) + + # Calculate progress callback + progress_callback = ->(progress) { + on_progress&.call(progress, 100) + } + + # Transcoding options for web compatibility + options = { + video_codec: 'libx264', + audio_codec: 'aac', + custom: [ + '-pix_fmt yuv420p', + '-preset medium', + '-crf 23', + '-movflags +faststart', + '-tune fastdecode' + ] + } + + movie.transcode(output_path, options, &progress_callback) + + output_path + end + + def extract_frame(input_path, seconds) + movie = FFMPEG::Movie.new(input_path) + output_path = "#{Rails.root}/tmp/thumbnail_#{SecureRandom.hex(8)}.jpg" + + movie.screenshot(output_path, seek_time: seconds, resolution: '320x240') + output_path + end + + def extract_metadata(input_path) + movie = FFMPEG::Movie.new(input_path) + + { + width: movie.width, + height: movie.height, + duration: movie.duration, + video_codec: movie.video_codec, + audio_codec: movie.audio_codec, + bit_rate: movie.bitrate, + frame_rate: movie.frame_rate, + format: movie.container + } + end + + def web_compatible?(input_path) + movie = FFMPEG::Movie.new(input_path) + + # Check if video is already web-compatible + return false unless movie.valid? + + # Common web-compatible formats + web_formats = %w[mp4 webm] + web_video_codecs = %w[h264 av1 vp9] + web_audio_codecs = %w[aac opus] + + format_compatible = web_formats.include?(movie.container.downcase) + video_compatible = web_video_codecs.include?(movie.video_codec&.downcase) + audio_compatible = movie.audio_codec.blank? || web_audio_codecs.include?(movie.audio_codec&.downcase) + + format_compatible && video_compatible && audio_compatible + end +end \ No newline at end of file diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb new file mode 100644 index 0000000..65798f8 --- /dev/null +++ b/app/views/passwords/edit.html.erb @@ -0,0 +1,21 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +

Update your password

+ + <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %> +
+ <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ <% end %> +
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb new file mode 100644 index 0000000..8360e02 --- /dev/null +++ b/app/views/passwords/new.html.erb @@ -0,0 +1,17 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +

Forgot your password?

+ + <%= form_with url: passwords_path, class: "contents" do |form| %> +
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ <% end %> +
diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb new file mode 100644 index 0000000..1b09154 --- /dev/null +++ b/app/views/passwords_mailer/reset.html.erb @@ -0,0 +1,6 @@ +

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

diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb new file mode 100644 index 0000000..aecee82 --- /dev/null +++ b/app/views/passwords_mailer/reset.text.erb @@ -0,0 +1,4 @@ +You can reset your password on +<%= edit_password_url(@user.password_reset_token) %> + +This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..308b04b --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,31 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + + <% if notice = flash[:notice] %> +

<%= notice %>

+ <% end %> + +

Sign in

+ + <%= form_with url: session_url, class: "contents" do |form| %> +
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+
+ <%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ +
+ <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %> +
+
+ <% end %> +
diff --git a/app/views/storage_locations/create.html.erb b/app/views/storage_locations/create.html.erb new file mode 100644 index 0000000..b1bdf30 --- /dev/null +++ b/app/views/storage_locations/create.html.erb @@ -0,0 +1,4 @@ +
+

StorageLocations#create

+

Find me in app/views/storage_locations/create.html.erb

+
diff --git a/app/views/storage_locations/destroy.html.erb b/app/views/storage_locations/destroy.html.erb new file mode 100644 index 0000000..5bffba7 --- /dev/null +++ b/app/views/storage_locations/destroy.html.erb @@ -0,0 +1,4 @@ +
+

StorageLocations#destroy

+

Find me in app/views/storage_locations/destroy.html.erb

+
diff --git a/app/views/storage_locations/index.html.erb b/app/views/storage_locations/index.html.erb new file mode 100644 index 0000000..cbbccb0 --- /dev/null +++ b/app/views/storage_locations/index.html.erb @@ -0,0 +1,81 @@ +
+
+

Video Library

+ <% if @storage_locations.any? %> + <%= link_to "New Storage Location", new_storage_location_path, + class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors" %> + <% end %> +
+ + <% if @storage_locations.empty? %> +
+
No storage locations found
+

+ Storage locations are automatically discovered from directories mounted under /videos +

+
+

Example Docker volume mounts:

+ + /path/to/movies:/videos/movies:ro
+ /path/to/tv_shows:/videos/tv:ro
+ /path/to/documentaries:/videos/docs:ro +
+
+
+ <% else %> +
+ <% @storage_locations.each do |storage_location| %> +
+
+

+ <%= link_to storage_location.name, storage_location, + class: "hover:text-blue-600 transition-colors" %> +

+ +
+

+ Path: + <%= storage_location.path %> +

+

+ Type: + <%= storage_location.storage_type.titleize %> +

+

+ Videos: + <%= storage_location.video_count %> +

+
+ + <% if storage_location.accessible? %> +
+ + + + Accessible +
+ <% else %> +
+ + + + Not Accessible +
+ <% end %> + +
+ <%= link_to "View Videos", storage_location, + class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-3 rounded text-sm transition-colors" %> + + <%= form_with(url: scan_storage_location_path(storage_location), method: :post, + class: "inline-flex") do |form| %> + <%= form.submit "Scan", + class: "bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-3 rounded text-sm cursor-pointer transition-colors" %> + <% end %> +
+
+
+ <% end %> +
+ <% end %> +
diff --git a/app/views/storage_locations/show.html.erb b/app/views/storage_locations/show.html.erb new file mode 100644 index 0000000..6f9d29b --- /dev/null +++ b/app/views/storage_locations/show.html.erb @@ -0,0 +1,118 @@ +
+
+
+

<%= @storage_location.name %>

+

+ Path: + <%= @storage_location.path %> +

+
+
+ <%= link_to "← Back to Library", storage_locations_path, + class: "bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 rounded-lg transition-colors" %> + + <%= form_with(url: scan_storage_location_path(@storage_location), method: :post, + class: "inline-flex") do |form| %> + <%= form.submit "Scan for Videos", + class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg cursor-pointer transition-colors" %> + <% end %> +
+
+ + <% if @videos.empty? %> +
+
No videos found
+

+ This storage location doesn't contain any video files yet. Try scanning for videos to add them to your library. +

+
+ <% else %> +
+
+

+ Videos (<%= @videos.count %>) +

+
+ +
+ <% @videos.each do |video| %> +
+
+ +
+ <% if video.video_assets.where(asset_type: 'thumbnail').any? %> + <%= image_tag video.video_assets.where(asset_type: 'thumbnail').first.file, + class: "w-24 h-16 object-cover rounded", alt: video.display_title %> + <% else %> +
+ + + +
+ <% end %> +
+ + +
+

+ <%= link_to video.display_title, video, + class: "hover:text-blue-600 transition-colors" %> +

+ +
+ + Duration: + <%= video.format_duration %> + + + Resolution: + <%= video.resolution_label %> + + + Size: + <%= number_to_human_size(video.video_metadata['file_size']) rescue "Unknown" %> + +
+ +
+ <% if video.web_compatible? %> + + Web Compatible + + <% else %> + + Needs Transcoding + + <% end %> + + <% if video.processed? %> + + Processed + + <% else %> + + Processing + + <% end %> + + <% if video.work&.title && video.work.title != video.display_title %> + + Part of: <%= link_to video.work.title, video.work, + class: "hover:text-blue-600 transition-colors" %> + + <% end %> +
+
+ + +
+ <%= link_to "Watch", video, + class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition-colors" %> +
+
+
+ <% end %> +
+
+ <% end %> +
diff --git a/app/views/videos/index.html.erb b/app/views/videos/index.html.erb new file mode 100644 index 0000000..481611f --- /dev/null +++ b/app/views/videos/index.html.erb @@ -0,0 +1,76 @@ +<% content_for :title, "Videos" %> + +
+

Video Library

+
+ <% if @storage_locations.any? %> + + <% end %> + <%= link_to "New Storage Location", new_admin_storage_location_path, class: "bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %> +
+
+ +<% if @videos.any? %> +
+ <% @videos.each do |video| %> +
+
+ <%# Placeholder for thumbnails - Phase 1C will add actual thumbnails %> +
+ + + +
+ <%# Badge for source type %> +
+ <%= video.storage_location.name %> +
+
+ +
+

+ <%= link_to video.display_title, video_path(video), class: "hover:text-blue-600" %> +

+ +
+
Duration: <%= video.formatted_duration %>
+
Size: <%= video.formatted_file_size %>
+ <% if video.resolution_label.present? %> +
Resolution: <%= video.resolution_label %>
+ <% end %> +
+ +
+
+ <% if video.processing_errors.present? %> + Failed + <% elsif video.processed? %> + Processed + <% else %> + Processing + <% end %> +
+ <%= link_to "Watch", video_path(video), class: "bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded text-xs" %> +
+
+
+ <% end %> +
+ + + <%== pagy_nav(@pagy) %> +<% else %> +
+ + + +

No videos found

+

Get started by adding a storage location and scanning for videos.

+ <%= link_to "Add Storage Location", new_admin_storage_location_path, class: "mt-4 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %> +
+<% end %> diff --git a/app/views/videos/show.html.erb b/app/views/videos/show.html.erb new file mode 100644 index 0000000..fab2ae3 --- /dev/null +++ b/app/views/videos/show.html.erb @@ -0,0 +1,106 @@ +<% content_for :title, @video.display_title %> + +
+
+
+ <%# Placeholder for video player - Phase 1B will add Video.js %> +
+ + + +
+
+ +
+
+

<%= @video.display_title %>

+ <% if @video.work.present? %> +

<%= link_to @video.work.display_title, work_path(@video.work), class: "hover:text-blue-600" %>

+ <% end %> +
+ +
+
+

Video Information

+
+
+
Duration:
+
<%= @video.formatted_duration %>
+
+
+
File Size:
+
<%= @video.formatted_file_size %>
+
+
+
Resolution:
+
<%= @video.resolution_label || "Unknown" %>
+
+
+
Format:
+
<%= @video.format || "Unknown" %>
+
+
+
+ +
+

Storage Information

+
+
+
Storage Location:
+
<%= @video.storage_location.name %>
+
+
+
Source Type:
+
<%= @video.source_type.humanize %>
+
+
+
File Path:
+
<%= @video.file_path %>
+
+
+
+
+ + <% if @video.video_metadata.present? %> +
+

Technical Details

+
+
+
+ Video Codec: + <%= @video.video_codec || "N/A" %> +
+
+ Audio Codec: + <%= @video.audio_codec || "N/A" %> +
+
+ Frame Rate: + <%= @video.frame_rate || "N/A" %> fps +
+
+
+
+ Bit Rate: + <%= @video.bit_rate ? "#{(@video.bit_rate / 1000).round(1)} kb/s" : "N/A" %> +
+
+ Dimensions: + + <%= @video.width || "N/A" %> × <%= @video.height || "N/A" %> + +
+
+
+
+ <% end %> + +
+ <%= link_to "Back to Videos", videos_path, class: "bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-md text-sm font-medium" %> + <% if @video.streamable? %> + <%= link_to "Watch Video", watch_video_path(@video), class: "bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %> + <% end %> +
+
+
+
diff --git a/app/views/works/index.html.erb b/app/views/works/index.html.erb new file mode 100644 index 0000000..a0ee49d --- /dev/null +++ b/app/views/works/index.html.erb @@ -0,0 +1,4 @@ +
+

Works#index

+

Find me in app/views/works/index.html.erb

+
diff --git a/app/views/works/show.html.erb b/app/views/works/show.html.erb new file mode 100644 index 0000000..7f1bee5 --- /dev/null +++ b/app/views/works/show.html.erb @@ -0,0 +1,4 @@ +
+

Works#show

+

Find me in app/views/works/show.html.erb

+
diff --git a/config/routes.rb b/config/routes.rb index 48254e8..0526fb0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,14 +1,39 @@ Rails.application.routes.draw do - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html - - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. + # Health check get "up" => "rails/health#show", as: :rails_health_check - # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) - # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + # Root - Phase 1: Storage locations as main entry + root "storage_locations#index" - # Defines the root path route ("/") - # root "posts#index" + # Phase 1: Storage locations focused routes + resources :storage_locations, only: [:index, :show, :create, :destroy] do + member do + post :scan + end + end + + resources :works, only: [:index, :show] do + resources :videos, only: [:show] + end + + resources :videos, only: [] do + member do + get :stream + patch :playback_position + post :retry_processing + end + + resources :playback_sessions, only: [:create] + end + + # Real-time job progress + resources :jobs, only: [:show] do + member do + get :progress + end + end + + # Rails authentication routes + resource :session + resources :passwords, param: :token end diff --git a/db/migrate/20251029113808_create_works.rb b/db/migrate/20251029113808_create_works.rb new file mode 100644 index 0000000..591321e --- /dev/null +++ b/db/migrate/20251029113808_create_works.rb @@ -0,0 +1,17 @@ +class CreateWorks < ActiveRecord::Migration[8.1] + def change + create_table :works do |t| + t.string :title + t.integer :year + t.string :director + t.text :description + t.decimal :rating + t.boolean :organized + t.string :poster_path + t.string :backdrop_path + t.text :metadata + + t.timestamps + end + end +end diff --git a/db/migrate/20251029113830_create_external_ids.rb b/db/migrate/20251029113830_create_external_ids.rb new file mode 100644 index 0000000..7f207a7 --- /dev/null +++ b/db/migrate/20251029113830_create_external_ids.rb @@ -0,0 +1,11 @@ +class CreateExternalIds < ActiveRecord::Migration[8.1] + def change + create_table :external_ids do |t| + t.references :work, null: false, foreign_key: true + t.integer :source + t.string :value + + t.timestamps + end + end +end diff --git a/db/migrate/20251029113850_create_storage_locations.rb b/db/migrate/20251029113850_create_storage_locations.rb new file mode 100644 index 0000000..d017ebf --- /dev/null +++ b/db/migrate/20251029113850_create_storage_locations.rb @@ -0,0 +1,17 @@ +class CreateStorageLocations < ActiveRecord::Migration[8.1] + def change + create_table :storage_locations do |t| + t.string :name + t.string :path + t.integer :location_type + t.boolean :writable + t.boolean :enabled + t.boolean :scan_subdirectories + t.integer :priority + t.text :settings + t.datetime :last_scanned_at + + t.timestamps + end + end +end diff --git a/db/migrate/20251029113911_create_videos.rb b/db/migrate/20251029113911_create_videos.rb new file mode 100644 index 0000000..1f0f82b --- /dev/null +++ b/db/migrate/20251029113911_create_videos.rb @@ -0,0 +1,31 @@ +class CreateVideos < ActiveRecord::Migration[8.1] + def change + create_table :videos do |t| + t.references :work, null: false, foreign_key: true + t.references :storage_location, null: false, foreign_key: true + t.string :title + t.string :file_path + t.string :file_hash + t.integer :file_size + t.float :duration + t.integer :width + t.integer :height + t.string :resolution_label + t.string :video_codec + t.string :audio_codec + t.integer :bit_rate + t.float :frame_rate + t.string :format + t.boolean :has_subtitles + t.string :version_type + t.integer :source_type + t.string :source_url + t.boolean :imported + t.boolean :processing_failed + t.text :error_message + t.text :metadata + + t.timestamps + end + end +end diff --git a/db/migrate/20251029113919_create_video_assets.rb b/db/migrate/20251029113919_create_video_assets.rb new file mode 100644 index 0000000..8d764ca --- /dev/null +++ b/db/migrate/20251029113919_create_video_assets.rb @@ -0,0 +1,11 @@ +class CreateVideoAssets < ActiveRecord::Migration[8.1] + def change + create_table :video_assets do |t| + t.references :video, null: false, foreign_key: true + t.integer :asset_type + t.text :metadata + + t.timestamps + end + end +end diff --git a/db/migrate/20251029113957_create_playback_sessions.rb b/db/migrate/20251029113957_create_playback_sessions.rb new file mode 100644 index 0000000..03e6b91 --- /dev/null +++ b/db/migrate/20251029113957_create_playback_sessions.rb @@ -0,0 +1,15 @@ +class CreatePlaybackSessions < ActiveRecord::Migration[8.1] + def change + create_table :playback_sessions do |t| + t.references :video, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.float :position + t.float :duration_watched + t.datetime :last_watched_at + t.boolean :completed + t.integer :play_count + + t.timestamps + end + end +end diff --git a/db/migrate/20251029120242_create_users.rb b/db/migrate/20251029120242_create_users.rb new file mode 100644 index 0000000..71f2ff1 --- /dev/null +++ b/db/migrate/20251029120242_create_users.rb @@ -0,0 +1,11 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email_address, null: false + t.string :password_digest, null: false + + t.timestamps + end + add_index :users, :email_address, unique: true + end +end diff --git a/db/migrate/20251029120243_create_sessions.rb b/db/migrate/20251029120243_create_sessions.rb new file mode 100644 index 0000000..ec9efdb --- /dev/null +++ b/db/migrate/20251029120243_create_sessions.rb @@ -0,0 +1,11 @@ +class CreateSessions < ActiveRecord::Migration[8.1] + def change + create_table :sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :ip_address + t.string :user_agent + + t.timestamps + end + end +end diff --git a/db/migrate/20251029120404_update_videos_from_architecture.rb b/db/migrate/20251029120404_update_videos_from_architecture.rb new file mode 100644 index 0000000..071d869 --- /dev/null +++ b/db/migrate/20251029120404_update_videos_from_architecture.rb @@ -0,0 +1,29 @@ +class UpdateVideosFromArchitecture < ActiveRecord::Migration[8.1] + def change + change_table :videos do |t| + # Make work reference optional (videos can exist without works initially) + t.change_null :work_id, true + + # Add defaults for boolean fields + t.change_default :imported, false + t.change_default :processing_failed, false + t.change_default :has_subtitles, false + + # Note: file_size is already integer, SQLite compatible with bigint + + # Add source_type default and make required fields not null + t.change_default :source_type, 0 + t.change_null :source_type, false + + # Make file_path required + t.change_null :file_path, false + end + + # Add indexes as specified in architecture + add_index :videos, [:storage_location_id, :file_path], unique: true + add_index :videos, :source_type + add_index :videos, :file_hash + add_index :videos, :imported + add_index :videos, [:work_id, :resolution_label] + end +end diff --git a/db/migrate/20251029120428_update_works_from_architecture.rb b/db/migrate/20251029120428_update_works_from_architecture.rb new file mode 100644 index 0000000..a0ec313 --- /dev/null +++ b/db/migrate/20251029120428_update_works_from_architecture.rb @@ -0,0 +1,16 @@ +class UpdateWorksFromArchitecture < ActiveRecord::Migration[8.1] + def change + change_table :works do |t| + # Add defaults for boolean fields + t.change_default :organized, false + + # Make title required + t.change_null :title, false + end + + # Add indexes as specified in architecture + add_index :works, :title + add_index :works, [:title, :year], unique: true + add_index :works, :organized + end +end diff --git a/db/migrate/20251029120434_update_storage_locations_from_architecture.rb b/db/migrate/20251029120434_update_storage_locations_from_architecture.rb new file mode 100644 index 0000000..b43c953 --- /dev/null +++ b/db/migrate/20251029120434_update_storage_locations_from_architecture.rb @@ -0,0 +1,24 @@ +class UpdateStorageLocationsFromArchitecture < ActiveRecord::Migration[8.1] + def change + change_table :storage_locations do |t| + # Add defaults for boolean fields + t.change_default :writable, false + t.change_default :enabled, true + t.change_default :scan_subdirectories, true + t.change_default :priority, 0 + + # Add location_type default and make required fields not null + t.change_default :location_type, 0 + t.change_null :location_type, false + + # Make name required + t.change_null :name, false + end + + # Add indexes as specified in architecture + add_index :storage_locations, :name, unique: true + add_index :storage_locations, :location_type + add_index :storage_locations, :enabled + add_index :storage_locations, :priority + end +end diff --git a/db/migrate/20251029120452_update_video_assets_from_architecture.rb b/db/migrate/20251029120452_update_video_assets_from_architecture.rb new file mode 100644 index 0000000..7e7e86e --- /dev/null +++ b/db/migrate/20251029120452_update_video_assets_from_architecture.rb @@ -0,0 +1,11 @@ +class UpdateVideoAssetsFromArchitecture < ActiveRecord::Migration[8.1] + def change + change_table :video_assets do |t| + # Make asset_type required + t.change_null :asset_type, false + end + + # Add indexes as specified in architecture + add_index :video_assets, [:video_id, :asset_type], unique: true + end +end diff --git a/db/migrate/20251029120500_update_playback_sessions_from_architecture.rb b/db/migrate/20251029120500_update_playback_sessions_from_architecture.rb new file mode 100644 index 0000000..6658ced --- /dev/null +++ b/db/migrate/20251029120500_update_playback_sessions_from_architecture.rb @@ -0,0 +1,15 @@ +class UpdatePlaybackSessionsFromArchitecture < ActiveRecord::Migration[8.1] + def change + change_table :playback_sessions do |t| + # Add defaults for fields + t.change_default :position, 0.0 + t.change_default :duration_watched, 0.0 + t.change_default :completed, false + t.change_default :play_count, 0 + end + + # Add indexes as specified in architecture + add_index :playback_sessions, [:video_id, :user_id], unique: true + add_index :playback_sessions, :last_watched_at + end +end diff --git a/db/migrate/20251029120528_update_external_ids_from_architecture.rb b/db/migrate/20251029120528_update_external_ids_from_architecture.rb new file mode 100644 index 0000000..4673f99 --- /dev/null +++ b/db/migrate/20251029120528_update_external_ids_from_architecture.rb @@ -0,0 +1,16 @@ +class UpdateExternalIdsFromArchitecture < ActiveRecord::Migration[8.1] + def change + change_table :external_ids do |t| + # Make source and value required + t.change_null :source, false + t.change_null :value, false + end + + # Add indexes as specified in architecture + # Ensure each source only appears once per work + add_index :external_ids, [:work_id, :source], unique: true + + # Fast lookup by external ID (for "find work by IMDB ID" queries) + add_index :external_ids, [:source, :value], unique: true + end +end diff --git a/db/migrate/20251029204614_add_json_stores_to_video.rb b/db/migrate/20251029204614_add_json_stores_to_video.rb new file mode 100644 index 0000000..707de3c --- /dev/null +++ b/db/migrate/20251029204614_add_json_stores_to_video.rb @@ -0,0 +1,7 @@ +class AddJsonStoresToVideo < ActiveRecord::Migration[8.1] + def change + add_column :videos, :fingerprints, :text + add_column :videos, :video_metadata, :text + add_column :videos, :processing_info, :text + end +end diff --git a/db/migrate/20251029204641_add_json_stores_to_work.rb b/db/migrate/20251029204641_add_json_stores_to_work.rb new file mode 100644 index 0000000..faf6944 --- /dev/null +++ b/db/migrate/20251029204641_add_json_stores_to_work.rb @@ -0,0 +1,7 @@ +class AddJsonStoresToWork < ActiveRecord::Migration[8.1] + def change + add_column :works, :tmdb_data, :text + add_column :works, :imdb_data, :text + add_column :works, :custom_fields, :text + end +end diff --git a/db/migrate/20251029215501_add_phase1_fields_to_videos.rb b/db/migrate/20251029215501_add_phase1_fields_to_videos.rb new file mode 100644 index 0000000..0f3dc68 --- /dev/null +++ b/db/migrate/20251029215501_add_phase1_fields_to_videos.rb @@ -0,0 +1,8 @@ +class AddPhase1FieldsToVideos < ActiveRecord::Migration[8.1] + def change + add_column :videos, :filename, :string + add_column :videos, :transcoded_path, :string + add_column :videos, :transcoded_permanently, :boolean + add_column :videos, :web_compatible, :boolean + end +end diff --git a/db/migrate/20251029215811_add_processed_to_videos.rb b/db/migrate/20251029215811_add_processed_to_videos.rb new file mode 100644 index 0000000..5cf4774 --- /dev/null +++ b/db/migrate/20251029215811_add_processed_to_videos.rb @@ -0,0 +1,5 @@ +class AddProcessedToVideos < ActiveRecord::Migration[8.1] + def change + add_column :videos, :processed, :boolean + end +end diff --git a/db/migrate/20251031022926_add_type_to_videos.rb b/db/migrate/20251031022926_add_type_to_videos.rb new file mode 100644 index 0000000..df443a8 --- /dev/null +++ b/db/migrate/20251031022926_add_type_to_videos.rb @@ -0,0 +1,6 @@ +class AddTypeToVideos < ActiveRecord::Migration[8.1] + def change + add_column :videos, :type, :string, default: 'Video', null: false + add_index :videos, :type + end +end diff --git a/db/schema.rb b/db/schema.rb index 03e7368..8ea649c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,5 +10,149 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 0) do +ActiveRecord::Schema[8.1].define(version: 2025_10_31_022926) do + create_table "external_ids", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "source", null: false + t.datetime "updated_at", null: false + t.string "value", null: false + t.integer "work_id", null: false + t.index ["source", "value"], name: "index_external_ids_on_source_and_value", unique: true + t.index ["work_id", "source"], name: "index_external_ids_on_work_id_and_source", unique: true + t.index ["work_id"], name: "index_external_ids_on_work_id" + end + + create_table "playback_sessions", force: :cascade do |t| + t.boolean "completed", default: false + t.datetime "created_at", null: false + t.float "duration_watched", default: 0.0 + t.datetime "last_watched_at" + t.integer "play_count", default: 0 + t.float "position", default: 0.0 + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.integer "video_id", null: false + t.index ["last_watched_at"], name: "index_playback_sessions_on_last_watched_at" + t.index ["user_id"], name: "index_playback_sessions_on_user_id" + t.index ["video_id", "user_id"], name: "index_playback_sessions_on_video_id_and_user_id", unique: true + t.index ["video_id"], name: "index_playback_sessions_on_video_id" + end + + create_table "sessions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "ip_address" + t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false + t.index ["user_id"], name: "index_sessions_on_user_id" + end + + create_table "storage_locations", force: :cascade do |t| + t.datetime "created_at", null: false + t.boolean "enabled", default: true + t.datetime "last_scanned_at" + t.integer "location_type", default: 0, null: false + t.string "name", null: false + t.string "path" + t.integer "priority", default: 0 + t.boolean "scan_subdirectories", default: true + t.text "settings" + t.datetime "updated_at", null: false + t.boolean "writable", default: false + t.index ["enabled"], name: "index_storage_locations_on_enabled" + t.index ["location_type"], name: "index_storage_locations_on_location_type" + t.index ["name"], name: "index_storage_locations_on_name", unique: true + t.index ["priority"], name: "index_storage_locations_on_priority" + end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email_address", null: false + t.string "password_digest", null: false + t.datetime "updated_at", null: false + t.index ["email_address"], name: "index_users_on_email_address", unique: true + end + + create_table "video_assets", force: :cascade do |t| + t.integer "asset_type", null: false + t.datetime "created_at", null: false + t.text "metadata" + t.datetime "updated_at", null: false + t.integer "video_id", null: false + t.index ["video_id", "asset_type"], name: "index_video_assets_on_video_id_and_asset_type", unique: true + t.index ["video_id"], name: "index_video_assets_on_video_id" + end + + create_table "videos", force: :cascade do |t| + t.string "audio_codec" + t.integer "bit_rate" + t.datetime "created_at", null: false + t.float "duration" + t.text "error_message" + t.string "file_hash" + t.string "file_path", null: false + t.integer "file_size" + t.string "filename" + t.text "fingerprints" + t.string "format" + t.float "frame_rate" + t.boolean "has_subtitles", default: false + t.integer "height" + t.boolean "imported", default: false + t.text "metadata" + t.boolean "processed" + t.boolean "processing_failed", default: false + t.text "processing_info" + t.string "resolution_label" + t.integer "source_type", default: 0, null: false + t.string "source_url" + t.integer "storage_location_id", null: false + t.string "title" + t.string "transcoded_path" + t.boolean "transcoded_permanently" + t.string "type", default: "Video", null: false + t.datetime "updated_at", null: false + t.string "version_type" + t.string "video_codec" + t.text "video_metadata" + t.boolean "web_compatible" + t.integer "width" + t.integer "work_id" + t.index ["file_hash"], name: "index_videos_on_file_hash" + t.index ["imported"], name: "index_videos_on_imported" + t.index ["source_type"], name: "index_videos_on_source_type" + t.index ["storage_location_id", "file_path"], name: "index_videos_on_storage_location_id_and_file_path", unique: true + t.index ["storage_location_id"], name: "index_videos_on_storage_location_id" + t.index ["type"], name: "index_videos_on_type" + t.index ["work_id", "resolution_label"], name: "index_videos_on_work_id_and_resolution_label" + t.index ["work_id"], name: "index_videos_on_work_id" + end + + create_table "works", force: :cascade do |t| + t.string "backdrop_path" + t.datetime "created_at", null: false + t.text "custom_fields" + t.text "description" + t.string "director" + t.text "imdb_data" + t.text "metadata" + t.boolean "organized", default: false + t.string "poster_path" + t.decimal "rating" + t.string "title", null: false + t.text "tmdb_data" + t.datetime "updated_at", null: false + t.integer "year" + t.index ["organized"], name: "index_works_on_organized" + t.index ["title", "year"], name: "index_works_on_title_and_year", unique: true + t.index ["title"], name: "index_works_on_title" + end + + add_foreign_key "external_ids", "works" + add_foreign_key "playback_sessions", "users" + add_foreign_key "playback_sessions", "videos" + add_foreign_key "sessions", "users" + add_foreign_key "video_assets", "videos" + add_foreign_key "videos", "storage_locations" + add_foreign_key "videos", "works" end diff --git a/docs/architecture.md b/docs/architecture.md index 0745116..47f4f5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,2803 +1,259 @@ -# Velour - Video Library Application Architecture Plan +# Velour - Video Library Application Architecture + +Velour is a self-hosted video library application built with Rails 8 and Hotwire, designed for personal video collections with optional multi-user support and federation capabilities. + +## Quick Start + +### Phase 1 (MVP) - Local Filesystem +- **Goal**: Complete local video library with grouping and transcoding +- **Timeline**: 5 weeks +- **Documentation**: [Phase 1 Details](./phases/phase_1.md) + +### Phase 2 - Authentication & Multi-User +- **Goal**: User management and per-user playback history +- **Documentation**: [Phase 2 Details](./phases/phase_2.md) + +### Phase 3 - Remote Sources & Import +- **Goal**: S3, JellyFin, and web directory integration +- **Documentation**: [Phase 3 Details](./phases/phase_3.md) + +### Phase 4 - Federation +- **Goal**: Share libraries between Velour instances +- **Documentation**: [Phase 4 Details](./phases/phase_4.md) + +### Phase 5 - Audio Support +- **Goal**: Add music and audiobook support using extensible MediaFile architecture +- **Documentation**: [Phase 5 Details](./phases/phase_5.md) ## Technology Stack -### Backend -- **Framework:** Ruby on Rails 8.x -- **Database:** SQLite3 (with potential migration path to PostgreSQL later) -- **Background Jobs:** Solid Queue (Rails 8 default) -- **Caching:** Solid Cache (Rails 8 default) -- **File Storage:** - - Active Storage (thumbnails/sprites/previews only) - - Direct filesystem paths for video files - - S3 SDK (aws-sdk-s3 gem) for remote video storage -- **Video Processing:** FFmpeg via streamio-ffmpeg gem +### Core Technologies +- **Ruby on Rails 8.x** - Modern Rails with Solid Queue and Solid Cache +- **SQLite3** - Single-user database (PostgreSQL path available) +- **Hotwire** - Turbo + Stimulus for reactive frontend +- **Video.js** - HTML5 video player with plugins +- **FFmpeg** - Video transcoding and metadata extraction +- **Active Storage** - File management for thumbnails/assets +- **TailwindCSS** - Utility-first CSS framework -### Frontend -- **Framework:** Hotwire (Turbo + Stimulus) -- **Video Player:** Video.js 8.x with custom plugins -- **Asset Pipeline:** Importmap-rails or esbuild -- **Styling:** TailwindCSS +### Key Rails 8 Features +- **Solid Queue** - Background job processing +- **ActiveJob 8.1 Continuations** - Progress tracking +- **Structured Event Reporting** - Real-time job updates +- **TurboFrame Permanent Attributes** - Uninterrupted video playback -### Authentication (Phase 2) -- **OIDC:** omniauth-openid-connect gem -- **Session Management:** Rails sessions with encrypted cookies +## Architecture Overview ---- +### Video Processing Pipeline +1. **File Discovery** - Automatic scanning of mounted directories +2. **Metadata Extraction** - FFmpeg analysis with xxhash64 deduplication +3. **Thumbnail Generation** - Preview images at 10% mark +4. **Transcoding** - Web-compatible MP4 creation for incompatible formats +5. **Storage** - Files remain in original locations with managed assets -## Database Schema +### Storage Flexibility +- **Local Files** - Primary storage, original files untouched +- **Heuristic Discovery** - Automatic categorization of mounted directories +- **Remote Sources** (Phase 3) - S3, JellyFin, web directories +- **Federation** (Phase 4) - Cross-instance sharing -### Core Models - -**Works** (canonical representation) -- Represents the conceptual "work" (e.g., "Batman [1989]") -- Has many Videos (different versions/qualities) +### Data Model ```ruby -- title (string, required) -- year (integer) -- director (string) -- description (text) -- rating (decimal) -- organized (boolean, default: false) -- poster_path (string) -- backdrop_path (string) -- metadata (jsonb) -``` - -**Videos** (instances of works) -- Physical video files across all sources -- Belongs to a Work -```ruby -- work_id (references works) -- storage_location_id (references storage_locations) -- title (string) -- file_path (string, required) # relative path or S3 key -- file_hash (string, indexed) -- file_size (bigint) -- duration (float) -- width (integer) -- height (integer) -- resolution_label (string) -- video_codec (string) -- audio_codec (string) -- bit_rate (integer) -- frame_rate (float) -- format (string) -- has_subtitles (boolean) -- version_type (string) -- source_type (string) # "local", "s3", "jellyfin", "web", "velour" -- source_url (string) # full URL for remote sources -- imported (boolean, default: false) # copied to writable storage? -- metadata (jsonb) -``` - -**StorageLocations** -- All video sources (readable and writable) -```ruby -- name (string, required) -- path (string) # local path or S3 bucket name -- location_type (string) # "local", "s3", "jellyfin", "web", "velour" -- writable (boolean, default: false) # can we import videos here? -- enabled (boolean, default: true) -- scan_subdirectories (boolean, default: true) -- priority (integer, default: 0) # for unified view ordering -- settings (jsonb) # config per type: - # S3: {region, access_key_id, secret_access_key, endpoint} - # JellyFin: {api_url, api_key, user_id} - # Web: {base_url, username, password, auth_type} - # Velour: {api_url, api_key} -- last_scanned_at (datetime) -``` - -**VideoAssets** -- Generated assets (stored via Active Storage) -```ruby -- video_id (references videos) -- asset_type (string) # "thumbnail", "preview", "sprite", "vtt" -- metadata (jsonb) -# Active Storage attachments -``` - -**PlaybackSessions** -```ruby -- video_id (references videos) -- user_id (references users, nullable) -- position (float) -- duration_watched (float) -- last_watched_at (datetime) -- completed (boolean) -- play_count (integer, default: 0) -``` - -**ImportJobs** (Phase 3) -- Track video imports from remote sources -```ruby -- video_id (references videos) # source video -- destination_location_id (references storage_locations) -- destination_path (string) -- status (string) # "pending", "downloading", "completed", "failed" -- progress (float) # 0-100% -- bytes_transferred (bigint) -- error_message (text) -- started_at (datetime) -- completed_at (datetime) -``` - -**Users** (Phase 2) -```ruby -- email (string, required, unique) -- name (string) -- role (integer, default: 0) # enum: member, admin -- provider (string) -- uid (string) -``` - -### Database Schema Implementation Notes - -**SQLite Limitations:** -- SQLite does NOT support `jsonb` type - must use `text` with `serialize :metadata, coder: JSON` -- SQLite does NOT support `enum` types - use integers with Rails enums -- Consider PostgreSQL migration path for production deployments - -**Rails Migration Best Practices:** - -```ruby -# db/migrate/20240101000001_create_works.rb -class CreateWorks < ActiveRecord::Migration[8.1] - def change - create_table :works do |t| - t.string :title, null: false - t.integer :year - t.string :director - t.text :description - t.decimal :rating, precision: 3, scale: 1 - t.boolean :organized, default: false, null: false - t.string :poster_path - t.string :backdrop_path - t.text :metadata # SQLite: use text, serialize in model - - t.timestamps - end - - add_index :works, :title - add_index :works, [:title, :year], unique: true - add_index :works, :organized - end -end - -# db/migrate/20240101000002_create_storage_locations.rb -class CreateStorageLocations < ActiveRecord::Migration[8.1] - def change - create_table :storage_locations do |t| - t.string :name, null: false - t.string :path - t.integer :location_type, null: false, default: 0 # enum - t.boolean :writable, default: false, null: false - t.boolean :enabled, default: true, null: false - t.boolean :scan_subdirectories, default: true, null: false - t.integer :priority, default: 0, null: false - t.text :settings # SQLite: encrypted text column - t.datetime :last_scanned_at - - t.timestamps - end - - add_index :storage_locations, :name, unique: true - add_index :storage_locations, :location_type - add_index :storage_locations, :enabled - add_index :storage_locations, :priority - end -end - -# db/migrate/20240101000003_create_videos.rb -class CreateVideos < ActiveRecord::Migration[8.1] - def change - create_table :videos do |t| - t.references :work, null: true, foreign_key: true, index: true - t.references :storage_location, null: false, foreign_key: true, index: true - t.string :title - t.string :file_path, null: false - t.string :file_hash, index: true - t.bigint :file_size - t.float :duration - t.integer :width - t.integer :height - t.string :resolution_label - t.string :video_codec - t.string :audio_codec - t.integer :bit_rate - t.float :frame_rate - t.string :format - t.boolean :has_subtitles, default: false - t.string :version_type - t.integer :source_type, null: false, default: 0 # enum - t.string :source_url - t.boolean :imported, default: false, null: false - t.boolean :processing_failed, default: false - t.text :error_message - t.text :metadata - - t.timestamps - end - - add_index :videos, [:storage_location_id, :file_path], unique: true - add_index :videos, :source_type - add_index :videos, :file_hash - add_index :videos, :imported - add_index :videos, [:work_id, :resolution_label] - end -end - -# db/migrate/20240101000004_create_video_assets.rb -class CreateVideoAssets < ActiveRecord::Migration[8.1] - def change - create_table :video_assets do |t| - t.references :video, null: false, foreign_key: true, index: true - t.integer :asset_type, null: false # enum - t.text :metadata - - t.timestamps - end - - add_index :video_assets, [:video_id, :asset_type], unique: true - end -end - -# db/migrate/20240101000005_create_playback_sessions.rb -class CreatePlaybackSessions < ActiveRecord::Migration[8.1] - def change - create_table :playback_sessions do |t| - t.references :video, null: false, foreign_key: true, index: true - t.references :user, null: true, foreign_key: true, index: true - t.float :position, default: 0.0 - t.float :duration_watched, default: 0.0 - t.datetime :last_watched_at - t.boolean :completed, default: false, null: false - t.integer :play_count, default: 0, null: false - - t.timestamps - end - - add_index :playback_sessions, [:video_id, :user_id], unique: true - add_index :playback_sessions, :last_watched_at - end -end - -# db/migrate/20240101000006_create_import_jobs.rb -class CreateImportJobs < ActiveRecord::Migration[8.1] - def change - create_table :import_jobs do |t| - t.references :video, null: false, foreign_key: true, index: true - t.references :destination_location, null: false, foreign_key: { to_table: :storage_locations } - t.string :destination_path - t.integer :status, null: false, default: 0 # enum - t.float :progress, default: 0.0 - t.bigint :bytes_transferred, default: 0 - t.text :error_message - t.datetime :started_at - t.datetime :completed_at - - t.timestamps - end - - add_index :import_jobs, :status - add_index :import_jobs, [:video_id, :status] - end -end - -# db/migrate/20240101000007_create_users.rb (Phase 2) -class CreateUsers < ActiveRecord::Migration[8.1] - def change - create_table :users do |t| - t.string :email, null: false - t.string :name - t.integer :role, default: 0, null: false # enum - t.string :provider - t.string :uid - - t.timestamps - end - - add_index :users, :email, unique: true - add_index :users, [:provider, :uid], unique: true - add_index :users, :role - end -end -``` - -**Enum Definitions:** - -```ruby -# app/models/video.rb -class Video < ApplicationRecord - enum source_type: { - local: 0, - s3: 1, - jellyfin: 2, - web: 3, - velour: 4 - } -end - -# app/models/storage_location.rb -class StorageLocation < ApplicationRecord - enum location_type: { - local: 0, - s3: 1, - jellyfin: 2, - web: 3, - velour: 4 - } -end - -# app/models/video_asset.rb -class VideoAsset < ApplicationRecord - enum asset_type: { - thumbnail: 0, - preview: 1, - sprite: 2, - vtt: 3 - } -end - -# app/models/import_job.rb -class ImportJob < ApplicationRecord - enum status: { - pending: 0, - downloading: 1, - processing: 2, - completed: 3, - failed: 4, - cancelled: 5 - } -end - -# app/models/user.rb (Phase 2) -class User < ApplicationRecord - enum role: { - member: 0, - admin: 1 - } -end -``` - ---- - -## Storage Architecture - -### Storage Location Types - -**1. Local Filesystem** (Readable + Writable) -```ruby -settings: {} -path: "/path/to/videos" -writable: true -``` - -**2. S3 Compatible Storage** (Readable + Writable) -```ruby -settings: { - region: "us-east-1", - access_key_id: "...", - secret_access_key: "...", - endpoint: "https://s3.amazonaws.com" # or Wasabi, Backblaze, etc. -} -path: "bucket-name" -writable: true -``` - -**3. JellyFin Server** (Readable only) -```ruby -settings: { - api_url: "https://jellyfin.example.com", - api_key: "...", - user_id: "..." -} -path: null -writable: false -``` - -**4. Web Directory** (Readable only) -```ruby -settings: { - base_url: "https://videos.example.com", - auth_type: "basic", # or "bearer", "none" - username: "...", - password: "..." -} -path: null -writable: false -``` - -**5. Velour Instance** (Readable only - Phase 4) -```ruby -settings: { - api_url: "https://velour.example.com", - api_key: "..." -} -path: null -writable: false -``` - -### Unified View Strategy - -**Library Aggregation:** -- Videos table contains entries from ALL sources -- `storage_location_id` links each video to its source -- Unified `/videos` view queries across all enabled locations -- Filter/group by `storage_location` to see source breakdown - -**Video Streaming Strategy:** -- Local: `send_file` with byte-range support -- S3: Generate presigned URLs (configurable expiry) -- JellyFin: Proxy through Rails or redirect to JellyFin stream -- Web: Proxy through Rails with auth forwarding -- Velour: Proxy or redirect to federated instance - -### Storage Adapter Pattern - -**Architecture:** Strategy pattern for pluggable storage backends. - -**Base Adapter Interface:** - -```ruby -# app/services/storage_adapters/base_adapter.rb -module StorageAdapters - class BaseAdapter - def initialize(storage_location) - @storage_location = storage_location - end - - # Scan for video files and return array of relative paths - def scan - raise NotImplementedError, "#{self.class} must implement #scan" - end - - # Generate streaming URL for a video - def stream_url(video) - raise NotImplementedError, "#{self.class} must implement #stream_url" - end - - # Check if file exists at path - def exists?(file_path) - raise NotImplementedError, "#{self.class} must implement #exists?" - end - - # Check if storage can be read from - def readable? - raise NotImplementedError, "#{self.class} must implement #readable?" - end - - # Check if storage can be written to - def writable? - @storage_location.writable? - end - - # Write/copy file to storage - def write(source_path, dest_path) - raise NotImplementedError, "#{self.class} must implement #write" - end - - # Download file to local temp path (for processing) - def download_to_temp(video) - raise NotImplementedError, "#{self.class} must implement #download_to_temp" - end - end -end -``` - -**Example: Local Adapter** - -```ruby -# app/services/storage_adapters/local_adapter.rb -module StorageAdapters - class LocalAdapter < BaseAdapter - VIDEO_EXTENSIONS = %w[.mp4 .mkv .avi .mov .wmv .flv .webm .m4v].freeze - - def scan - return [] unless readable? - - pattern = if @storage_location.scan_subdirectories - File.join(@storage_location.path, "**", "*{#{VIDEO_EXTENSIONS.join(',')}}") - else - File.join(@storage_location.path, "*{#{VIDEO_EXTENSIONS.join(',')}}") - end - - Dir.glob(pattern, File::FNM_CASEFOLD).map do |full_path| - full_path.sub(@storage_location.path + "/", "") - end - end - - def stream_url(video) - full_path(video) - end - - def exists?(file_path) - File.exist?(full_path_from_relative(file_path)) - end - - def readable? - File.directory?(@storage_location.path) && File.readable?(@storage_location.path) - end - - def writable? - super && File.writable?(@storage_location.path) - end - - def write(source_path, dest_path) - dest_full_path = full_path_from_relative(dest_path) - FileUtils.mkdir_p(File.dirname(dest_full_path)) - FileUtils.cp(source_path, dest_full_path) - dest_path - end - - def download_to_temp(video) - # Already local, return path - full_path(video) - end - - private - def full_path(video) - full_path_from_relative(video.file_path) - end - - def full_path_from_relative(file_path) - File.join(@storage_location.path, file_path) - end - end -end -``` - -**Example: S3 Adapter** - -```ruby -# app/services/storage_adapters/s3_adapter.rb -require 'aws-sdk-s3' - -module StorageAdapters - class S3Adapter < BaseAdapter - VIDEO_EXTENSIONS = %w[.mp4 .mkv .avi .mov .wmv .flv .webm .m4v].freeze - - def scan - return [] unless readable? - - prefix = @storage_location.scan_subdirectories ? "" : nil - - s3_client.list_objects_v2( - bucket: bucket_name, - prefix: prefix - ).contents.select do |obj| - VIDEO_EXTENSIONS.any? { |ext| obj.key.downcase.end_with?(ext) } - end.map(&:key) - end - - def stream_url(video) - s3_client.presigned_url( - :get_object, - bucket: bucket_name, - key: video.file_path, - expires_in: 3600 # 1 hour - ) - end - - def exists?(file_path) - s3_client.head_object(bucket: bucket_name, key: file_path) - true - rescue Aws::S3::Errors::NotFound - false - end - - def readable? - s3_client.head_bucket(bucket: bucket_name) - true - rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::Forbidden - false - end - - def write(source_path, dest_path) - File.open(source_path, 'rb') do |file| - s3_client.put_object( - bucket: bucket_name, - key: dest_path, - body: file - ) - end - dest_path - end - - def download_to_temp(video) - temp_file = Tempfile.new(['velour-video', File.extname(video.file_path)]) - s3_client.get_object( - bucket: bucket_name, - key: video.file_path, - response_target: temp_file.path - ) - temp_file.path - end - - private - def s3_client - @s3_client ||= Aws::S3::Client.new( - region: settings['region'], - access_key_id: settings['access_key_id'], - secret_access_key: settings['secret_access_key'], - endpoint: settings['endpoint'] - ) - end - - def bucket_name - @storage_location.path - end - - def settings - @storage_location.settings - end - end -end -``` - -**Model Integration:** - -```ruby -# app/models/storage_location.rb -class StorageLocation < ApplicationRecord - encrypts :settings - serialize :settings, coder: JSON - - enum location_type: { - local: 0, - s3: 1, - jellyfin: 2, - web: 3, - velour: 4 - } - - def adapter - @adapter ||= adapter_class.new(self) - end - - private - def adapter_class - "StorageAdapters::#{location_type.classify}Adapter".constantize - end -end -``` - -**File Organization:** - -``` -app/ -└── services/ - └── storage_adapters/ - ├── base_adapter.rb - ├── local_adapter.rb - ├── s3_adapter.rb - ├── jellyfin_adapter.rb # Phase 3 - ├── web_adapter.rb # Phase 3 - └── velour_adapter.rb # Phase 4 -``` - ---- - -## Video Processing Pipeline - -### Asset Generation Jobs - -**VideoProcessorJob** -- Only runs for videos we can access (local, S3, or importable) -- Downloads temp copy if remote -- Generates: - 1. Thumbnail (1920x1080 JPEG at 10% mark) - 2. Preview clip (30s MP4 at 720p) - 3. VTT sprite sheet (160x90 tiles, 5s intervals) - 4. Metadata extraction (FFprobe) -- Stores assets via Active Storage (S3 or local) -- Cleans up temp files - -**VideoImportJob** (Phase 3) -- Downloads video from remote source -- Shows progress (bytes transferred, %) -- Saves to writable storage location -- Creates new Video record for imported copy -- Links to same Work as source -- Triggers VideoProcessorJob on completion - -### Service Objects Architecture - -Service objects encapsulate complex business logic that doesn't belong in models or controllers. They follow the single responsibility principle and make code more testable. - -**1. FileScannerService** - -Orchestrates scanning a storage location for video files. - -```ruby -# app/services/file_scanner_service.rb -class FileScannerService - def initialize(storage_location) - @storage_location = storage_location - @adapter = storage_location.adapter - end - - def call - return Result.failure("Storage location not readable") unless @adapter.readable? - - @storage_location.update!(last_scanned_at: Time.current) - - file_paths = @adapter.scan - new_videos = [] - - file_paths.each do |file_path| - video = find_or_create_video(file_path) - new_videos << video if video.previously_new_record? - - # Queue processing for new or unprocessed videos - VideoProcessorJob.perform_later(video.id) if video.duration.nil? - end - - Result.success(videos_found: file_paths.size, new_videos: new_videos.size) - end - - private - def find_or_create_video(file_path) - Video.find_or_create_by!( - storage_location: @storage_location, - file_path: file_path - ) do |video| - video.title = extract_title_from_path(file_path) - video.source_type = @storage_location.location_type - end - end - - def extract_title_from_path(file_path) - File.basename(file_path, ".*") - .gsub(/[\._]/, " ") - .gsub(/\[.*?\]/, "") - .strip - end -end - -# Usage: -# result = FileScannerService.new(@storage_location).call -# if result.success? -# flash[:notice] = "Found #{result.videos_found} videos (#{result.new_videos} new)" -# end -``` - -**2. VideoMetadataExtractor** - -Extracts video metadata using FFprobe. - -```ruby -# app/services/video_metadata_extractor.rb -require 'streamio-ffmpeg' - -class VideoMetadataExtractor - def initialize(video) - @video = video - end - - def call - file_path = @video.storage_location.adapter.download_to_temp(@video) - movie = FFMPEG::Movie.new(file_path) - - @video.update!( - duration: movie.duration, - width: movie.width, - height: movie.height, - video_codec: movie.video_codec, - audio_codec: movie.audio_codec, - bit_rate: movie.bitrate, - frame_rate: movie.frame_rate, - format: movie.container, - file_size: movie.size, - resolution_label: calculate_resolution_label(movie.height), - file_hash: calculate_file_hash(file_path) - ) - - Result.success - rescue FFMPEG::Error => e - @video.update!(processing_failed: true, error_message: e.message) - Result.failure(e.message) - ensure - # Clean up temp file if it was downloaded - File.delete(file_path) if file_path && File.exist?(file_path) && file_path.include?('tmp') - end - - private - def calculate_resolution_label(height) - case height - when 0..480 then "SD" - when 481..720 then "720p" - when 721..1080 then "1080p" - when 1081..1440 then "1440p" - when 1441..2160 then "4K" - else "8K+" - end - end - - def calculate_file_hash(file_path) - # Hash first and last 64KB for speed (like Plex/Emby) - Digest::MD5.file(file_path).hexdigest - end -end -``` - -**3. DuplicateDetectorService** - -Finds potential duplicate videos based on file hash and title similarity. - -```ruby -# app/services/duplicate_detector_service.rb -class DuplicateDetectorService - def initialize(video = nil) - @video = video - end - - def call - # Find all videos without a work assigned - unorganized_videos = Video.where(work_id: nil) - - # Group by similar titles and file hashes - potential_groups = [] - - unorganized_videos.group_by(&:file_hash).each do |hash, videos| - next if videos.size < 2 - - potential_groups << { - type: :exact_duplicate, - videos: videos, - confidence: :high - } - end - - # Find similar titles using Levenshtein distance or fuzzy matching - unorganized_videos.find_each do |video| - similar = find_similar_titles(video, unorganized_videos) - if similar.any? - potential_groups << { - type: :similar_title, - videos: [video] + similar, - confidence: :medium - } - end - end - - Result.success(groups: potential_groups.uniq) - end - - private - def find_similar_titles(video, candidates) - return [] unless video.title - - candidates.select do |candidate| - next false if candidate.id == video.id - next false unless candidate.title - - similarity_score(video.title, candidate.title) > 0.8 - end - end - - def similarity_score(str1, str2) - # Simple implementation - could use gems like 'fuzzy_match' or 'levenshtein' - str1_clean = normalize_title(str1) - str2_clean = normalize_title(str2) - - return 1.0 if str1_clean == str2_clean - - # Jaccard similarity on words - words1 = str1_clean.split - words2 = str2_clean.split - - intersection = (words1 & words2).size - union = (words1 | words2).size - - intersection.to_f / union - end - - def normalize_title(title) - title.downcase - .gsub(/\[.*?\]/, "") # Remove brackets - .gsub(/\(.*?\)/, "") # Remove parentheses - .gsub(/[^\w\s]/, "") # Remove special chars - .strip - end -end -``` - -**4. WorkGrouperService** - -Groups videos into a work. - -```ruby -# app/services/work_grouper_service.rb -class WorkGrouperService - def initialize(video_ids, work_attributes = {}) - @video_ids = video_ids - @work_attributes = work_attributes - end - - def call - videos = Video.where(id: @video_ids).includes(:work) - - return Result.failure("No videos found") if videos.empty? - - # Use existing work if all videos belong to the same one - existing_work = videos.first.work if videos.all? { |v| v.work_id == videos.first.work_id } - - ActiveRecord::Base.transaction do - work = existing_work || create_work_from_videos(videos) - - videos.each do |video| - video.update!(work: work) - end - - Result.success(work: work) - end - end - - private - def create_work_from_videos(videos) - representative = videos.max_by(&:height) || videos.first - - Work.create!( - title: @work_attributes[:title] || extract_base_title(representative.title), - year: @work_attributes[:year], - organized: false - ) - end - - def extract_base_title(title) - # Extract base title by removing resolution, format markers, etc. - title.gsub(/\d{3,4}p/, "") - .gsub(/\b(BluRay|WEB-?DL|HDRip|DVDRip|4K|UHD)\b/i, "") - .gsub(/\[.*?\]/, "") - .strip - end -end -``` - -**5. VideoImporterService** (Phase 3) - -Handles downloading a video from remote source to writable storage. - -```ruby -# app/services/video_importer_service.rb -class VideoImporterService - def initialize(video, destination_location, destination_path = nil) - @source_video = video - @destination_location = destination_location - @destination_path = destination_path || video.file_path - @import_job = nil - end - - def call - return Result.failure("Destination is not writable") unless @destination_location.writable? - return Result.failure("Source is not readable") unless @source_video.storage_location.adapter.readable? - - # Create import job - @import_job = ImportJob.create!( - video: @source_video, - destination_location: @destination_location, - destination_path: @destination_path, - status: :pending - ) - - # Queue background job - VideoImportJob.perform_later(@import_job.id) - - Result.success(import_job: @import_job) - end -end -``` - -**File Organization:** - -``` -app/ -└── services/ - ├── storage_adapters/ # Storage backends - ├── file_scanner_service.rb - ├── video_metadata_extractor.rb - ├── duplicate_detector_service.rb - ├── work_grouper_service.rb - ├── video_importer_service.rb # Phase 3 - └── result.rb # Simple result object -``` - -**Result Object Pattern:** - -```ruby -# app/services/result.rb -class Result - attr_reader :data, :error - - def initialize(success:, data: {}, error: nil) - @success = success - @data = data - @error = error - end - - def success? - @success - end - - def failure? - !@success - end - - def self.success(data = {}) - new(success: true, data: data) - end - - def self.failure(error) - new(success: false, error: error) - end - - # Allow accessing data as methods - def method_missing(method, *args) - return @data[method] if @data.key?(method) - super - end - - def respond_to_missing?(method, include_private = false) - @data.key?(method) || super - end -end -``` - ---- - -## Model Organization - -Rails models follow a specific organization pattern for readability and maintainability. - -### Complete Model Examples - -**Work Model:** - -```ruby -# app/models/work.rb -class Work < ApplicationRecord - # 1. Includes/Concerns - include Searchable - - # 2. Serialization - serialize :metadata, coder: JSON - - # 3. Associations - has_many :videos, dependent: :nullify - has_one :primary_video, -> { order(height: :desc) }, class_name: "Video" - - # 4. Validations - validates :title, presence: true - validates :year, numericality: { only_integer: true, greater_than: 1800 }, allow_nil: true - validates :rating, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }, allow_nil: true - - # 5. Scopes - scope :organized, -> { where(organized: true) } - scope :unorganized, -> { where(organized: false) } - scope :recent, -> { order(created_at: :desc) } - scope :by_title, -> { order(:title) } - scope :with_year, -> { where.not(year: nil) } - - # 6. Delegations - delegate :resolution_label, :duration, to: :primary_video, prefix: true, allow_nil: true - - # 7. Class methods - def self.search(query) - where("title LIKE ? OR director LIKE ?", "%#{query}%", "%#{query}%") - end - - # 8. Instance methods - def display_title - year ? "#{title} (#{year})" : title - end - - def video_count - videos.count - end - - def total_duration - videos.sum(:duration) - end - - def available_versions - videos.group_by(&:resolution_label) - end -end -``` - -**Video Model:** - -```ruby -# app/models/video.rb -class Video < ApplicationRecord - # 1. Includes/Concerns - include Streamable - include Processable - - # 2. Serialization - serialize :metadata, coder: JSON - - # 3. Enums - enum source_type: { - local: 0, - s3: 1, - jellyfin: 2, - web: 3, - velour: 4 - } - - # 4. Associations - belongs_to :work, optional: true, touch: true - belongs_to :storage_location - has_many :video_assets, dependent: :destroy - has_many :playback_sessions, dependent: :destroy - has_one :import_job, dependent: :nullify - - has_one_attached :thumbnail - has_one_attached :preview_video - - # 5. Validations - validates :title, presence: true - validates :file_path, presence: true - validates :file_hash, presence: true - validates :source_type, presence: true - validate :file_exists_on_storage, on: :create - - # 6. Callbacks - before_save :normalize_title - after_create :queue_processing - - # 7. Scopes - scope :unprocessed, -> { where(duration: nil, processing_failed: false) } - scope :processed, -> { where.not(duration: nil) } - scope :failed, -> { where(processing_failed: true) } - scope :by_source, ->(type) { where(source_type: type) } - scope :imported, -> { where(imported: true) } - scope :recent, -> { order(created_at: :desc) } - scope :by_resolution, -> { order(height: :desc) } - scope :with_work, -> { where.not(work_id: nil) } - scope :without_work, -> { where(work_id: nil) } - - # 8. Delegations - delegate :name, :location_type, :adapter, to: :storage_location, prefix: true - delegate :display_title, to: :work, prefix: true, allow_nil: true - - # 9. Class methods - def self.search(query) - left_joins(:work) - .where("videos.title LIKE ? OR works.title LIKE ?", "%#{query}%", "%#{query}%") - .distinct - end - - def self.by_duration(min: nil, max: nil) - scope = all - scope = scope.where("duration >= ?", min) if min - scope = scope.where("duration <= ?", max) if max - scope - end - - # 10. Instance methods - def display_title - work_display_title || title || filename - end - - def filename - File.basename(file_path) - end - - def file_extension - File.extname(file_path).downcase - end - - def formatted_duration - return "Unknown" unless duration - - hours = (duration / 3600).to_i - minutes = ((duration % 3600) / 60).to_i - seconds = (duration % 60).to_i - - if hours > 0 - "%d:%02d:%02d" % [hours, minutes, seconds] - else - "%d:%02d" % [minutes, seconds] - end - end - - def formatted_file_size - return "Unknown" unless file_size - - units = ['B', 'KB', 'MB', 'GB', 'TB'] - size = file_size.to_f - unit_index = 0 - - while size >= 1024 && unit_index < units.length - 1 - size /= 1024.0 - unit_index += 1 - end - - "%.2f %s" % [size, units[unit_index]] - end - - def processable? - !processing_failed && storage_location_adapter.readable? - end - - def streamable? - duration.present? && storage_location.enabled? && storage_location_adapter.readable? - end - - private - def normalize_title - self.title = title.strip if title.present? - end - - def queue_processing - VideoProcessorJob.perform_later(id) if processable? - end - - def file_exists_on_storage - return if storage_location_adapter.exists?(file_path) - errors.add(:file_path, "does not exist on storage") - end -end -``` - -**StorageLocation Model:** - -```ruby -# app/models/storage_location.rb -class StorageLocation < ApplicationRecord - # 1. Encryption & Serialization - encrypts :settings - serialize :settings, coder: JSON - - # 2. Enums - enum location_type: { - local: 0, - s3: 1, - jellyfin: 2, - web: 3, - velour: 4 - } - - # 3. Associations - has_many :videos, dependent: :restrict_with_error - has_many :destination_imports, class_name: "ImportJob", foreign_key: :destination_location_id - - # 4. Validations - validates :name, presence: true, uniqueness: true - validates :location_type, presence: true - validates :path, presence: true, if: -> { local? || s3? } - validate :adapter_is_valid - - # 5. Scopes - scope :enabled, -> { where(enabled: true) } - scope :disabled, -> { where(enabled: false) } - scope :writable, -> { where(writable: true) } - scope :readable, -> { enabled } - scope :by_priority, -> { order(priority: :desc) } - scope :by_type, ->(type) { where(location_type: type) } - - # 6. Instance methods - def adapter - @adapter ||= adapter_class.new(self) - end - - def scan! - FileScannerService.new(self).call - end - - def accessible? - adapter.readable? - rescue StandardError - false - end - - def video_count - videos.count - end - - def last_scan_ago - return "Never" unless last_scanned_at - - distance_of_time_in_words(last_scanned_at, Time.current) - end - - private - def adapter_class - "StorageAdapters::#{location_type.classify}Adapter".constantize - end - - def adapter_is_valid - adapter.readable? - rescue StandardError => e - errors.add(:base, "Cannot access storage: #{e.message}") - end -end -``` - -**PlaybackSession Model:** - -```ruby -# app/models/playback_session.rb -class PlaybackSession < ApplicationRecord - # 1. Associations - belongs_to :video - belongs_to :user, optional: true - - # 2. Validations - validates :video, presence: true - validates :position, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true - validates :duration_watched, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true - - # 3. Callbacks - before_save :check_completion - - # 4. Scopes - scope :recent, -> { order(last_watched_at: :desc) } - scope :completed, -> { where(completed: true) } - scope :in_progress, -> { where(completed: false).where.not(position: 0) } - scope :for_user, ->(user) { where(user: user) } - - # 5. Class methods - def self.update_position(video, user, position, duration_watched = 0) - session = find_or_initialize_by(video: video, user: user) - session.position = position - session.duration_watched = (session.duration_watched || 0) + duration_watched - session.last_watched_at = Time.current - session.play_count += 1 if session.position.zero? - session.save! - end - - # 6. Instance methods - def progress_percentage - return 0 unless video.duration && position - - ((position / video.duration) * 100).round(1) - end - - def resume_position - completed? ? 0 : position - end - - private - def check_completion - if video.duration && position - self.completed = (position / video.duration) > 0.9 - end - end -end -``` - -### Model Concerns - -**Streamable Concern:** - -```ruby -# app/models/concerns/streamable.rb -module Streamable - extend ActiveSupport::Concern - - included do - # Add any class-level includes here - end - - def stream_url - storage_location.adapter.stream_url(self) - end - - def streamable? - duration.present? && storage_location.enabled? && storage_location.adapter.readable? - end - - def stream_type - case source_type - when "s3" then :presigned - when "local" then :direct - else :proxy - end - end -end -``` - -**Processable Concern:** - -```ruby -# app/models/concerns/processable.rb -module Processable - extend ActiveSupport::Concern - - included do - scope :processing_pending, -> { where(duration: nil, processing_failed: false) } - end - - def processed? - duration.present? - end - - def processing_pending? - !processed? && !processing_failed? - end - - def mark_processing_failed!(error_message) - update!(processing_failed: true, error_message: error_message) - end - - def retry_processing! - update!(processing_failed: false, error_message: nil) - VideoProcessorJob.perform_later(id) - end -end -``` - -**Searchable Concern:** - -```ruby -# app/models/concerns/searchable.rb -module Searchable - extend ActiveSupport::Concern - - class_methods do - def search(query) - return all if query.blank? - - where("title LIKE ?", "%#{sanitize_sql_like(query)}%") - end - end -end -``` - ---- - -## Frontend Architecture - -### Main Views (Hotwire) - -**Library Views:** -- `videos/index` - Unified library grid with filters by source -- `videos/show` - Video player page -- `works/index` - Works library (grouped by work) -- `works/show` - Work details with all versions/sources - -**Admin Views (Phase 2+):** -- `storage_locations/index` - Manage all sources -- `storage_locations/new` - Add new source (local/S3/JellyFin/etc) -- `import_jobs/index` - Monitor import progress -- `videos/:id/import` - Import modal/form - -### Stimulus Controllers - -**VideoPlayerController** -- Initialize Video.js with source detection -- Handle different streaming strategies (local/S3/proxy) -- Track playback position -- Quality switching for multiple versions - -**LibraryScanController** -- Trigger scans per storage location -- Real-time progress via Turbo Streams -- Show scan results and new videos found - -**VideoImportController** -- Select destination storage location -- Show import progress -- Cancel import jobs - -**WorkMergeController** -- Group videos into works -- Drag-and-drop UI -- Show all versions/sources for a work - -### Video.js Custom Plugins - -1. **resume-plugin** - Auto-resume from saved position -2. **track-plugin** - Send playback stats to Rails API -3. **quality-selector** - Switch between versions (same work, different sources/resolutions) -4. **thumbnails-plugin** - VTT sprite preview on seek - ---- - -## Authorization & Security - -### Phase 1: No Authentication (MVP) - -For MVP, all features are accessible without authentication. However, the authorization structure is designed to be auth-ready. - -**Current Pattern (Request Context):** - -```ruby -# app/models/current.rb -class Current < ActiveSupport::CurrentAttributes - attribute :user - attribute :request_id -end - -# app/controllers/application_controller.rb -class ApplicationController < ActionController::Base - before_action :set_current_attributes - - private - def set_current_attributes - Current.user = current_user if respond_to?(:current_user) - Current.request_id = request.uuid - end -end -``` - -### Phase 2: OIDC Authentication - -**User Model with Authorization:** - -```ruby -# app/models/user.rb -class User < ApplicationRecord - enum role: { member: 0, admin: 1 } - - validates :email, presence: true, uniqueness: true - - def admin? - role == "admin" - end - - def self.admin_from_env - admin_email = ENV['ADMIN_EMAIL'] - return nil unless admin_email - - find_by(email: admin_email) - end -end -``` - -**OIDC Configuration:** - -```ruby -# config/initializers/omniauth.rb -Rails.application.config.middleware.use OmniAuth::Builder do - provider :openid_connect, - name: :oidc, - issuer: ENV['OIDC_ISSUER'], - client_id: ENV['OIDC_CLIENT_ID'], - client_secret: ENV['OIDC_CLIENT_SECRET'], - scope: [:openid, :email, :profile] -end -``` - -**Sessions Controller:** - -```ruby -# app/controllers/sessions_controller.rb -class SessionsController < ApplicationController - skip_before_action :authenticate_user!, only: [:create] - - def create - auth_hash = request.env['omniauth.auth'] - user = User.find_or_create_from_omniauth(auth_hash) - - session[:user_id] = user.id - redirect_to root_path, notice: "Signed in successfully" - end - - def destroy - session[:user_id] = nil - redirect_to root_path, notice: "Signed out successfully" - end -end -``` - -### Model-Level Authorization - -```ruby -# app/models/storage_location.rb -class StorageLocation < ApplicationRecord - def editable_by?(user) - return true if user.nil? # Phase 1: no auth - user.admin? # Phase 2+: only admins - end - - def deletable_by?(user) - return true if user.nil? - user.admin? && videos.count.zero? - end -end - -# app/models/work.rb -class Work < ApplicationRecord - def editable_by?(user) - return true if user.nil? - user.present? # Any authenticated user - end -end -``` - -**Controller-Level Guards:** - -```ruby -# app/controllers/admin/base_controller.rb -module Admin - class BaseController < ApplicationController - before_action :require_admin! - - private - def require_admin! - return if Current.user&.admin? - - redirect_to root_path, alert: "Access denied" - end - end -end - -# app/controllers/admin/storage_locations_controller.rb -module Admin - class StorageLocationsController < Admin::BaseController - before_action :set_storage_location, only: [:edit, :update, :destroy] - before_action :authorize_storage_location, only: [:edit, :update, :destroy] - - def index - @storage_locations = StorageLocation.all - end - - def update - if @storage_location.update(storage_location_params) - redirect_to admin_storage_locations_path, notice: "Updated successfully" - else - render :edit, status: :unprocessable_entity - end - end - - private - def authorize_storage_location - head :forbidden unless @storage_location.editable_by?(Current.user) - end - end -end -``` - -### Credentials Management - -**Encrypted Settings:** - -```ruby -# app/models/storage_location.rb -class StorageLocation < ApplicationRecord - encrypts :settings # Rails 7+ built-in encryption - serialize :settings, coder: JSON -end - -# Generate encryption key: -# bin/rails db:encryption:init -# Add to config/credentials.yml.enc -``` - -**Rails Credentials:** - -```bash -# Edit credentials -bin/rails credentials:edit - -# Add: -# aws: -# access_key_id: <%= ENV["AWS_ACCESS_KEY_ID"] %> -# secret_access_key: <%= ENV["AWS_SECRET_ACCESS_KEY"] %> -``` - -### API Authentication (Phase 4 - Federation) - -**API Key Authentication:** - -```ruby -# app/controllers/api/federation/base_controller.rb -module Api - module Federation - class BaseController < ActionController::API - include ActionController::HttpAuthentication::Token::ControllerMethods - - before_action :authenticate_api_key! - - private - def authenticate_api_key! - return if Rails.env.development? - return unless ENV['ALLOW_FEDERATION'] == 'true' - - authenticate_or_request_with_http_token do |token, options| - ActiveSupport::SecurityUtils.secure_compare( - token, - ENV.fetch('VELOUR_API_KEY') - ) - end - end - end - end -end -``` - -**Rate Limiting:** - -```ruby -# config/initializers/rack_attack.rb (optional, recommended) -class Rack::Attack - throttle('api/ip', limit: 300, period: 5.minutes) do |req| - req.ip if req.path.start_with?('/api/') - end - - throttle('federation/api_key', limit: 1000, period: 1.hour) do |req| - req.env['HTTP_AUTHORIZATION'] if req.path.start_with?('/api/federation/') - end -end -``` - ---- - -## API Controller Architecture - -### Controller Organization - -``` -app/ -└── controllers/ - ├── application_controller.rb - ├── videos_controller.rb # HTML views - ├── works_controller.rb - ├── admin/ - │ ├── base_controller.rb - │ ├── storage_locations_controller.rb - │ └── import_jobs_controller.rb - └── api/ - ├── base_controller.rb - └── v1/ - ├── videos_controller.rb - ├── works_controller.rb - ├── playback_sessions_controller.rb - └── storage_locations_controller.rb - └── federation/ # Phase 4 - ├── base_controller.rb - ├── videos_controller.rb - └── works_controller.rb -``` - -### API Base Controllers - -**Internal API Base:** - -```ruby -# app/controllers/api/base_controller.rb -module Api - class BaseController < ActionController::API - include ActionController::Cookies - - rescue_from ActiveRecord::RecordNotFound, with: :not_found - rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity - - private - def not_found - render json: { error: "Not found" }, status: :not_found - end - - def unprocessable_entity(exception) - render json: { - error: "Validation failed", - details: exception.record.errors.full_messages - }, status: :unprocessable_entity - end - end -end -``` - -**Example API Controller:** - -```ruby -# app/controllers/api/v1/videos_controller.rb -module Api - module V1 - class VideosController < Api::BaseController - before_action :set_video, only: [:show, :stream] - - def index - @videos = Video.includes(:work, :storage_location) - .order(created_at: :desc) - .page(params[:page]) - .per(params[:per] || 50) - - render json: { - videos: @videos.as_json(include: [:work, :storage_location]), - meta: pagination_meta(@videos) - } - end - - def show - render json: @video.as_json( - include: { - work: { only: [:id, :title, :year] }, - storage_location: { only: [:id, :name, :location_type] } - }, - methods: [:stream_url, :formatted_duration] - ) - end - - def stream - # Route to appropriate streaming strategy - case @video.stream_type - when :presigned - render json: { stream_url: @video.stream_url } - when :direct - send_file @video.stream_url, - type: @video.format, - disposition: 'inline', - stream: true - when :proxy - # Implement proxy logic - redirect_to @video.stream_url, allow_other_host: true - end - end - - private - def set_video - @video = Video.find(params[:id]) - end - - def pagination_meta(collection) - { - current_page: collection.current_page, - next_page: collection.next_page, - prev_page: collection.prev_page, - total_pages: collection.total_pages, - total_count: collection.total_count - } - end - end - end -end -``` - -**Playback Tracking API:** - -```ruby -# app/controllers/api/v1/playback_sessions_controller.rb -module Api - module V1 - class PlaybackSessionsController < Api::BaseController - def update - video = Video.find(params[:video_id]) - user = Current.user # nil in Phase 1, actual user in Phase 2 - - session = PlaybackSession.update_position( - video, - user, - params[:position].to_f, - params[:duration_watched].to_f - ) - - render json: { success: true, session: session } - rescue ActiveRecord::RecordNotFound - render json: { error: "Video not found" }, status: :not_found - end - end - end -end -``` - ---- - -## Turbo Streams & Real-Time Updates - -### Scan Progress Broadcasting - -**Job with Turbo Streams:** - -```ruby -# app/jobs/file_scanner_job.rb -class FileScannerJob < ApplicationJob - queue_as :default - - def perform(storage_location_id) - @storage_location = StorageLocation.find(storage_location_id) - - broadcast_update(status: "started", progress: 0) - - result = FileScannerService.new(@storage_location).call - - if result.success? - broadcast_update( - status: "completed", - progress: 100, - videos_found: result.videos_found, - new_videos: result.new_videos - ) - else - broadcast_update(status: "failed", error: result.error) - end - end - - private - def broadcast_update(**data) - Turbo::StreamsChannel.broadcast_replace_to( - "storage_location_#{@storage_location.id}", - target: "scan_status", - partial: "admin/storage_locations/scan_status", - locals: { storage_location: @storage_location, **data } - ) - end -end -``` - -**View with Turbo Stream:** - -```erb -<%# app/views/admin/storage_locations/show.html.erb %> -<%= turbo_stream_from "storage_location_#{@storage_location.id}" %> - -
- <%= render "scan_status", storage_location: @storage_location, status: "idle" %> -
- -<%= button_to "Scan Now", scan_admin_storage_location_path(@storage_location), - method: :post, - data: { turbo_frame: "_top" }, - class: "btn btn-primary" %> -``` - -**Partial:** - -```erb -<%# app/views/admin/storage_locations/_scan_status.html.erb %> -
- <% case status %> - <% when "started" %> -
-

Scanning... <%= progress %>%

-
- <% when "completed" %> -
-

Scan completed! Found <%= videos_found %> videos (<%= new_videos %> new)

-
- <% when "failed" %> -
-

Scan failed: <%= error %>

-
- <% else %> -

Ready to scan

- <% end %> -
-``` - -### Import Progress Updates - -Similar pattern for VideoImportJob with progress broadcasting. - ---- - -## Testing Strategy - -### Test Organization - -``` -test/ -├── models/ -│ ├── video_test.rb -│ ├── work_test.rb -│ ├── storage_location_test.rb -│ ├── playback_session_test.rb -│ └── concerns/ -│ ├── streamable_test.rb -│ └── processable_test.rb -├── services/ -│ ├── file_scanner_service_test.rb -│ ├── video_metadata_extractor_test.rb -│ ├── duplicate_detector_service_test.rb -│ └── storage_adapters/ -│ ├── local_adapter_test.rb -│ └── s3_adapter_test.rb -├── jobs/ -│ ├── video_processor_job_test.rb -│ └── file_scanner_job_test.rb -├── controllers/ -│ ├── videos_controller_test.rb -│ ├── works_controller_test.rb -│ └── api/ -│ └── v1/ -│ └── videos_controller_test.rb -└── system/ - ├── video_playback_test.rb - ├── library_browsing_test.rb - └── work_grouping_test.rb -``` - -### Example Tests - -**Model Test:** - -```ruby -# test/models/video_test.rb -require "test_helper" - -class VideoTest < ActiveSupport::TestCase - test "belongs to storage location" do - video = videos(:one) - assert_instance_of StorageLocation, video.storage_location - end - - test "validates presence of required fields" do - video = Video.new - assert_not video.valid? - assert_includes video.errors[:title], "can't be blank" - assert_includes video.errors[:file_path], "can't be blank" - end - - test "formatted_duration returns correct format" do - video = videos(:one) - video.duration = 3665 # 1 hour, 1 minute, 5 seconds - assert_equal "1:01:05", video.formatted_duration - end - - test "streamable? returns true when video is processed and storage is enabled" do - video = videos(:one) - video.duration = 100 - video.storage_location.update!(enabled: true) - - assert video.streamable? - end -end -``` - -**Service Test:** - -```ruby -# test/services/file_scanner_service_test.rb -require "test_helper" - -class FileScannerServiceTest < ActiveSupport::TestCase - setup do - @storage_location = storage_locations(:local_movies) - end - - test "scans directory and creates video records" do - # Stub adapter scan method - @storage_location.adapter.stub :scan, ["movie1.mp4", "movie2.mkv"] do - result = FileScannerService.new(@storage_location).call - - assert result.success? - assert_equal 2, result.videos_found - end - end - - test "updates last_scanned_at timestamp" do - @storage_location.adapter.stub :scan, [] do - FileScannerService.new(@storage_location).call - - @storage_location.reload - assert_not_nil @storage_location.last_scanned_at - end - end -end -``` - -**System Test:** - -```ruby -# test/system/video_playback_test.rb -require "application_system_test_case" - -class VideoPlaybackTest < ApplicationSystemTestCase - test "playing a video updates playback position" do - video = videos(:one) - - visit video_path(video) - assert_selector "video#video-player" - - # Simulate video playback (would need JS execution) - # assert_changes -> { video.playback_sessions.first&.position } - end - - test "resume functionality loads saved position" do - video = videos(:one) - PlaybackSession.create!(video: video, position: 30.0) - - visit video_path(video) - - # Assert player starts at saved position - # (implementation depends on Video.js setup) - end -end -``` - ---- - -## API Design (RESTful) - -### Video Playback API (Internal) -``` -GET /api/v1/videos/:id/stream # Stream video (route to appropriate source) -GET /api/v1/videos/:id/presigned # Get presigned S3 URL -GET /api/v1/videos/:id/metadata # Get video metadata -POST /api/v1/videos/:id/playback # Update playback position -GET /api/v1/videos/:id/assets # Get thumbnails, sprites -``` - -### Library Management API -``` -GET /api/v1/videos # List all videos (unified view) -GET /api/v1/works # List works with grouped videos -POST /api/v1/works/:id/merge # Merge videos into work -GET /api/v1/storage_locations # List all sources -POST /api/v1/storage_locations # Add new source -POST /api/v1/storage_locations/:id/scan # Trigger scan -GET /api/v1/storage_locations/:id/scan_status -``` - -### Import API (Phase 3) -``` -POST /api/v1/videos/:id/import # Import video to writable storage -GET /api/v1/import_jobs # List import jobs -GET /api/v1/import_jobs/:id # Get import status -DELETE /api/v1/import_jobs/:id # Cancel import -``` - -### Federation API (Phase 4) - Public to other Velour instances -``` -GET /api/v1/federation/videos # List available videos -GET /api/v1/federation/videos/:id # Get video details -GET /api/v1/federation/videos/:id/stream # Stream video (with API key auth) -GET /api/v1/federation/works # List works -``` - ---- - -## Required Gems - -Add these to the Gemfile: - -```ruby -# Gemfile - -# Core gems (already present in Rails 8) -gem "rails", "~> 8.1.1" -gem "sqlite3", ">= 2.1" -gem "puma", ">= 5.0" -gem "importmap-rails" -gem "turbo-rails" -gem "stimulus-rails" -gem "tailwindcss-rails" -gem "solid_cache" -gem "solid_queue" -gem "solid_cable" -gem "image_processing", "~> 1.2" - -# Video processing -gem "streamio-ffmpeg" # FFmpeg wrapper for metadata extraction - -# Pagination -gem "pagy" # Fast, lightweight pagination - -# AWS SDK for S3 support (Phase 3, but add early) -gem "aws-sdk-s3" - -# Phase 2: Authentication -# gem "omniauth-openid-connect" -# gem "omniauth-rails_csrf_protection" - -# Phase 3: Remote sources -# gem "httparty" # For JellyFin/Web APIs -# gem "down" # For downloading remote files - -# Development & Test -group :development, :test do - gem "debug", platforms: %i[mri windows] - gem "bundler-audit" - gem "brakeman" - gem "rubocop-rails-omakase" -end - -group :development do - gem "web-console" - gem "bullet" # N+1 query detection - gem "strong_migrations" # Catch unsafe migrations -end - -group :test do - gem "capybara" - gem "selenium-webdriver" - gem "mocha" # Stubbing/mocking -end - -# Optional but recommended -# gem "rack-attack" # Rate limiting (Phase 4) -``` - ---- - -## Route Structure - -```ruby -# config/routes.rb -Rails.application.routes.draw do - # Health check - get "up" => "rails/health#show", as: :rails_health_check - - # Root - root "videos#index" - - # Main UI routes - resources :videos, only: [:index, :show] do - member do - get :watch # Player page - post :import # Phase 3: Import to writable storage - end - end - - resources :works, only: [:index, :show] do - member do - post :merge # Merge videos into this work - end - end - - # Admin routes (Phase 2+) - namespace :admin do - root "dashboard#index" - - resources :storage_locations do - member do - post :scan - get :scan_status - end - end - - resources :import_jobs, only: [:index, :show, :destroy] do - member do - post :cancel - end - end - - resources :users, only: [:index, :edit, :update] # Phase 2 - end - - # Internal API (for JS/Stimulus) - namespace :api do - namespace :v1 do - resources :videos, only: [:index, :show] do - member do - get :stream - get :presigned # For S3 presigned URLs - end - end - - resources :works, only: [:index, :show] - - resources :playback_sessions, only: [] do - collection do - post :update # POST /api/v1/playback_sessions/update - end - end - - resources :storage_locations, only: [:index] - end - - # Federation API (Phase 4) - namespace :federation do - resources :videos, only: [:index, :show] do - member do - get :stream - end - end - - resources :works, only: [:index, :show] - end - end - - # Authentication routes (Phase 2) - # get '/auth/:provider/callback', to: 'sessions#create' - # delete '/sign_out', to: 'sessions#destroy', as: :sign_out -end -``` - ---- +Work (canonical content) - has_many -> Videos (different files/qualities) +Video (inherits from MediaFile) - belongs_to -> StorageLocation (where file lives) +Video - has_many -> VideoAssets (thumbnails, previews) +Video - has_many -> PlaybackSessions (user viewing history) +Work - has_many -> ExternalIds (IMDb, TMDb references) + +**Extensible Architecture**: MediaFile base class supports future Audio model +MediaFile - common functionality (streaming, processing, metadata) +Video - video-specific functionality (resolution, codecs) +Audio (Phase 5) - audio-specific functionality (bitrate, sample rate, album art) +``` + +## Key Features + +### Phase 1 (MVP) +- ✅ Local video library scanning and organization +- ✅ Video streaming with seeking support +- ✅ Automatic transcoding to web-compatible formats +- ✅ Duplicate detection and grouping into "works" +- ✅ Uninterrupted playback with TurboFrame +- ✅ Real-time processing progress with Turbo Streams + +### Phase 2 (Multi-User) +- ✅ Rails authentication with OIDC extension +- ✅ Admin user bootstrap flow +- ✅ Per-user playback history and preferences +- ✅ Storage location management (admin only) + +### Phase 3 (Remote Sources) +- ✅ S3 storage location support +- ✅ JellyFin server integration +- ✅ Web directory browsing +- ✅ Video import system with progress tracking + +### Phase 4 (Federation) +- ✅ Cross-instance API with authentication +- ✅ Remote video streaming +- ✅ Federated library discovery +- ✅ Security and rate limiting + +### Phase 5 (Audio Support) +- ✅ Audio model inheriting from MediaFile +- ✅ Music library with album/artist organization +- ✅ Audiobook support with chapter tracking +- ✅ Audio processing and transcoding pipeline ## Development Phases -### Phase 1: MVP (Local Filesystem) +### Phase 1A: Core Foundation (Week 1-2) +- Models and migrations +- Storage adapter pattern +- File scanning service +- Basic UI -Phase 1 is broken into 4 sub-phases for manageable milestones: +### Phase 1B: Video Playback (Week 3) +- Video streaming with byte-range support +- Video.js integration +- Playback session tracking -#### Phase 1A: Core Foundation (Week 1-2) +### Phase 1C: Processing Pipeline (Week 4) +- FFmpeg integration +- Background job processing +- Thumbnail generation -**Goal:** Basic models and database setup +### Phase 1D: Works & Grouping (Week 5) +- Duplicate detection +- Manual grouping interface +- Search and filtering -1. **Generate models with migrations:** - - Works - - Videos - - StorageLocations - - PlaybackSessions (basic structure) +## Quick Setup -2. **Implement models:** - - Add validations, scopes, associations - - Add concerns (Streamable, Processable, Searchable) - - Serialize metadata fields - -3. **Create storage adapter pattern:** - - BaseAdapter interface - - LocalAdapter implementation - - Test adapter with sample directory - -4. **Create basic services:** - - FileScannerService (scan local directory) - - Result object pattern - -5. **Simple UI:** - - Videos index page (list only, no thumbnails yet) - - Basic TailwindCSS styling - - Storage locations admin page - -**Deliverable:** Can scan a local directory and see videos in database - -#### Phase 1B: Video Playback (Week 3) - -**Goal:** Working video player with streaming - -1. **Video streaming:** - - Videos controller with show action - - Stream action for serving video files - - Byte-range support for seeking - -2. **Video.js integration:** - - Add Video.js via Importmap - - Create VideoPlayerController (Stimulus) - - Basic player UI on videos#show page - -3. **Playback tracking:** - - Implement PlaybackSession.update_position - - Create API endpoint for position updates - - Basic resume functionality (load last position) - -4. **Playback tracking plugin:** - - Custom Video.js plugin to track position - - Send updates to Rails API every 10 seconds - - Save position on pause/stop - -**Deliverable:** Can watch videos with resume functionality - -#### Phase 1C: Processing Pipeline (Week 4) - -**Goal:** Video metadata extraction and asset generation - -1. **Video metadata extraction:** - - VideoMetadataExtractor service - - FFmpeg integration via streamio-ffmpeg - - Extract duration, resolution, codecs, file hash - -2. **Background processing:** - - VideoProcessorJob - - Queue processing for new videos - - Error handling and retry logic - -3. **Thumbnail generation:** - - Generate thumbnail at 10% mark - - Store via Active Storage - - Display thumbnails on index page - -4. **Video assets:** - - VideoAssets model - - Store thumbnails, previews (phase 1C: thumbnails only) - - VTT sprites (defer to Phase 1D if time-constrained) - -5. **Processing UI:** - - Show processing status on video cards - - Processing failed indicator - - Retry processing action - -**Deliverable:** Videos automatically processed with thumbnails - -#### Phase 1D: Works & Grouping (Week 5) - -**Goal:** Group duplicate videos into works - -1. **Works functionality:** - - Works model fully implemented - - Works index/show pages - - Display videos grouped by work - -2. **Duplicate detection:** - - DuplicateDetectorService - - Find videos with same file hash - - Find videos with similar titles - -3. **Work grouping UI:** - - WorkGrouperService - - Manual grouping interface - - Drag-and-drop or checkbox selection - - Create work from selected videos - -4. **Works display:** - - Works index with thumbnails - - Works show with all versions - - Version selector (resolution, format) - -5. **Polish:** - - Search functionality - - Filtering by source, resolution - - Sorting options - - Pagination with Pagy - -**Deliverable:** Full MVP with work grouping and polished UI - -### Phase 2: Authentication & Multi-User -1. User model and OIDC integration -2. Admin role management (ENV: ADMIN_EMAIL) -3. Per-user playback history -4. User management UI -5. Storage location management UI - -### Phase 3: Remote Sources & Import -1. **S3 Storage Location:** - - S3 scanner (list bucket objects) - - Presigned URL streaming - - Import to/from S3 -2. **JellyFin Integration:** - - JellyFin API client - - Sync metadata - - Proxy streaming -3. **Web Directories:** - - HTTP directory parser - - Auth support (basic, bearer) -4. **Import System:** - - VideoImportJob with progress tracking - - Import UI with destination selection - - Background download with resume support -5. **Unified Library View:** - - Filter by source - - Show source badges on videos - - Multi-source search - -### Phase 4: Federation -1. Public API for other Velour instances -2. API key authentication -3. Velour storage location type -4. Federated video discovery -5. Cross-instance streaming - ---- - -## File Organization - -### Local Storage Structure +### Docker Compose +```yaml +version: '3.8' +services: + velour: + build: . + ports: + - "3000:3000" + volumes: + - /path/to/movies:/videos/movies:ro + - /path/to/tv:/videos/tv:ro + - ./velour_data:/app/velour_data + environment: + - RAILS_ENV=production + - ADMIN_EMAIL=admin@yourdomain.com ``` -storage/ -├── assets/ # Active Storage (thumbnails, previews, sprites) -│ └── [active_storage_blobs] -└── tmp/ # Temporary processing files -``` - -### Video Files -- **Local:** Direct filesystem paths (not copied/moved) -- **S3:** Stored in configured bucket -- **Remote:** Referenced by URL, optionally imported to local/S3 - ---- - -## Key Implementation Decisions - -### Storage Flexibility -- Videos are NOT managed by Active Storage -- Local videos: store absolute/relative paths -- S3 videos: store bucket + key -- Remote videos: store source URL + metadata -- Active Storage ONLY for generated assets (thumbnails, etc.) - -### Import Strategy (Phase 3) -1. User selects video from remote source -2. User selects destination (writable storage location) -3. VideoImportJob starts download -4. Progress tracked via Turbo Streams -5. On completion: - - New Video record created (imported: true) - - Linked to same Work as source - - Assets generated - - Original remote video remains linked - -### Unified View -- Single `/videos` index shows all sources -- Filter dropdown: "All Sources", "Local", "S3", "JellyFin", etc. -- Source badge on each video card -- Search across all sources -- Sort by: title, date added, last watched, etc. - -### Streaming Strategy by Source -```ruby -# Local filesystem -send_file video.full_path, type: video.mime_type, disposition: 'inline' - -# S3 -redirect_to s3_client.presigned_url(:get_object, bucket: ..., key: ..., expires_in: 3600) - -# JellyFin -redirect_to "#{jellyfin_url}/Videos/#{video.source_id}/stream?api_key=..." - -# Web directory -# Proxy through Rails with auth headers - -# Velour federation -redirect_to "#{velour_url}/api/v1/federation/videos/#{video.source_id}/stream?api_key=..." -``` - -### Performance Considerations -- Lazy asset generation (only when video viewed) -- S3 presigned URLs (no proxy for large files) -- Caching of metadata and thumbnails -- Cursor-based pagination for large libraries -- Background scanning with incremental updates - ---- - -## Configuration (ENV) - -```bash -# Admin -ADMIN_EMAIL=admin@example.com - -# OIDC (Phase 2) -OIDC_ISSUER=https://auth.example.com -OIDC_CLIENT_ID=velour -OIDC_CLIENT_SECRET=secret - -# Default Storage -DEFAULT_SCAN_PATH=/path/to/videos - -# S3 (optional default) -AWS_REGION=us-east-1 -AWS_ACCESS_KEY_ID=... -AWS_SECRET_ACCESS_KEY=... -AWS_S3_BUCKET=velour-videos - -# Processing -FFMPEG_THREADS=4 -THUMBNAIL_SIZE=1920x1080 -PREVIEW_DURATION=30 -SPRITE_INTERVAL=5 - -# Federation (Phase 4) -VELOUR_API_KEY=secret-key-for-federation -ALLOW_FEDERATION=true -``` - ---- - -## Video.js Implementation (Inspiration from Stash) - -Based on analysis of the Stash application, we'll use: - -- **Video.js v8.x** as the core player -- **Custom plugins** for: - - Resume functionality - - Playback tracking - - Quality/version selector - - VTT thumbnails on seek bar -- **Streaming format support:** - - Direct MP4/MKV streaming with byte-range - - DASH/HLS for adaptive streaming (future) - - Multi-quality source selection - -### Key Features from Stash Worth Implementing: -1. Scene markers on timeline (our "chapters" equivalent) -2. Thumbnail sprite preview on hover -3. Keyboard shortcuts -4. Mobile-optimized controls -5. Resume from last position -6. Play duration and count tracking -7. AirPlay/Chromecast support (future) - ---- - -## Error Handling & Job Configuration - -### Job Retry Strategy - -```ruby -# app/jobs/video_processor_job.rb -class VideoProcessorJob < ApplicationJob - queue_as :default - - # Retry with exponential backoff - retry_on StandardError, wait: :polynomially_longer, attempts: 3 - - # Don't retry if video not found - discard_on ActiveRecord::RecordNotFound do |job, error| - Rails.logger.warn "Video not found for processing: #{error.message}" - end - - def perform(video_id) - video = Video.find(video_id) - - # Extract metadata - result = VideoMetadataExtractor.new(video).call - - return unless result.success? - - # Generate thumbnail (Phase 1C) - ThumbnailGeneratorService.new(video).call - rescue FFMPEG::Error => e - video.mark_processing_failed!(e.message) - raise # Will retry - end -end -``` - -### Background Job Monitoring - -```ruby -# app/controllers/admin/jobs_controller.rb (optional) -module Admin - class JobsController < Admin::BaseController - def index - @running_jobs = SolidQueue::Job.where(finished_at: nil).limit(50) - @failed_jobs = SolidQueue::Job.where.not(error: nil).limit(50) - end - - def retry - job = SolidQueue::Job.find(params[:id]) - job.retry! - redirect_to admin_jobs_path, notice: "Job queued for retry" - end - end -end -``` - ---- - -## Configuration Summary ### Environment Variables - ```bash -# .env (Phase 1) -DEFAULT_SCAN_PATH=/path/to/your/videos -FFMPEG_THREADS=4 -THUMBNAIL_SIZE=1920x1080 +# Required +RAILS_MASTER_KEY=your_master_key +ADMIN_EMAIL=admin@yourdomain.com -# .env (Phase 2) -ADMIN_EMAIL=admin@example.com -OIDC_ISSUER=https://auth.example.com -OIDC_CLIENT_ID=velour -OIDC_CLIENT_SECRET=your-secret +# Video Processing +FFMPEG_PATH=/usr/bin/ffmpeg +VIDEOS_PATH=./velour_data/videos +MAX_TRANSCODE_SIZE_GB=50 -# .env (Phase 3) -AWS_REGION=us-east-1 -AWS_ACCESS_KEY_ID=your-key -AWS_SECRET_ACCESS_KEY=your-secret -AWS_S3_BUCKET=velour-videos +# Background jobs (SolidQueue) +SOLID_QUEUE_PROCESSES="*:2" # 2 workers for all queues -# .env (Phase 4) -VELOUR_API_KEY=your-api-key -ALLOW_FEDERATION=true +# Optional (Phase 2+) +OIDC_ISSUER=https://your-provider.com +OIDC_CLIENT_ID=your_client_id + +# Optional (Phase 3+) +AWS_ACCESS_KEY_ID=your_key +AWS_SECRET_ACCESS_KEY=your_secret ``` -### Database Encryption +## File Structure -```bash -# Generate encryption keys -bin/rails db:encryption:init - -# Add output to config/credentials.yml.enc: -# active_record_encryption: -# primary_key: ... -# deterministic_key: ... -# key_derivation_salt: ... +``` +/app +├── app/ +│ ├── models/ # ActiveRecord models +│ ├── services/ # Business logic objects +│ ├── jobs/ # Background jobs +│ └── controllers/ # Web controllers +├── lib/ +│ └── osmoviehash.rb # Video fingerprinting +├── config/ +│ └── routes.rb # Application routes +└── docs/ + ├── architecture.md # This file + └── phases/ # Detailed phase documentation + ├── phase_1.md + ├── phase_2.md + ├── phase_3.md + └── phase_4.md ``` ---- +## Design Decisions -## Implementation Checklist +### Why Rails + Hotwire? +- **Convention over Configuration** - Faster development +- **Integrated System** - Single framework for frontend and backend +- **Real-time Capabilities** - Turbo Streams for job progress +- **Video Focus** - Uninterrupted playback with TurboFrame -### Phase 1A: Core Foundation +### Why SQLite First? +- **Single User Focus** - Simpler deployment and maintenance +- **Migration Path** - Easy upgrade to PostgreSQL if needed +- **Performance** - Excellent for personal video libraries -- [ ] Install required gems (`streamio-ffmpeg`, `pagy`) -- [ ] Generate models with proper migrations -- [ ] Implement model validations and associations -- [ ] Create model concerns (Streamable, Processable, Searchable) -- [ ] Build storage adapter pattern (BaseAdapter, LocalAdapter) -- [ ] Implement FileScannerService -- [ ] Create Result object pattern -- [ ] Build videos index page with TailwindCSS -- [ ] Create admin storage locations CRUD -- [ ] Write tests for models and adapters +### Why Separate Transcoding? +- **Original Preservation** - Never modify source files +- **Quality Choice** - Users keep original quality when available +- **Storage Efficiency** - Transcode only when needed +- **Browser Compatibility** - Web-friendly formats for streaming -### Phase 1B: Video Playback +## Getting Started -- [ ] Create videos#show action with byte-range support -- [ ] Add Video.js via Importmap -- [ ] Build VideoPlayerController (Stimulus) -- [ ] Implement PlaybackSession.update_position -- [ ] Create API endpoint for position tracking -- [ ] Build custom Video.js tracking plugin -- [ ] Implement resume functionality -- [ ] Write system tests for playback - -### Phase 1C: Processing Pipeline - -- [ ] Implement VideoMetadataExtractor service -- [ ] Create VideoProcessorJob with retry logic -- [ ] Build ThumbnailGeneratorService -- [ ] Set up Active Storage for thumbnails -- [ ] Display thumbnails on index page -- [ ] Add processing status indicators -- [ ] Implement retry processing action -- [ ] Write tests for processing services - -### Phase 1D: Works & Grouping - -- [ ] Fully implement Works model -- [ ] Create Works index/show pages -- [ ] Implement DuplicateDetectorService -- [ ] Build WorkGrouperService -- [ ] Create work grouping UI -- [ ] Add version selector on work pages -- [ ] Implement search functionality -- [ ] Add filtering and sorting -- [ ] Integrate Pagy pagination -- [ ] Polish UI with TailwindCSS - ---- - -## Next Steps - -### To Begin Implementation: - -1. **Review this architecture document** with team/stakeholders -2. **Set up development environment:** - - Install FFmpeg (`brew install ffmpeg` on macOS) - - Verify Rails 8.1.1+ installed - - Create new Rails app (already done in `/Users/dkam/Development/velour`) - -3. **Start Phase 1A:** +1. **Clone and setup**: ```bash - # Add gems - bundle add streamio-ffmpeg pagy aws-sdk-s3 - - # Generate models - rails generate model Work title:string year:integer director:string description:text rating:decimal organized:boolean poster_path:string backdrop_path:string metadata:text - rails generate model StorageLocation name:string path:string location_type:integer writable:boolean enabled:boolean scan_subdirectories:boolean priority:integer settings:text last_scanned_at:datetime - rails generate model Video work:references storage_location:references title:string file_path:string file_hash:string file_size:bigint duration:float width:integer height:integer resolution_label:string video_codec:string audio_codec:string bit_rate:integer frame_rate:float format:string has_subtitles:boolean version_type:string source_type:integer source_url:string imported:boolean processing_failed:boolean error_message:text metadata:text - rails generate model VideoAsset video:references asset_type:integer metadata:text - rails generate model PlaybackSession video:references user:references position:float duration_watched:float last_watched_at:datetime completed:boolean play_count:integer - - # Run migrations - rails db:migrate - - # Start server - bin/dev + git clone + cd velour + bundle install + rails db:setup ``` -4. **Follow the Phase 1A checklist** (see above) +2. **Configure storage**: + ```bash + # Edit docker-compose.yml + volumes: + - /path/to/your/movies:/videos/movies:ro + - /path/to/your/tv:/videos/tv:ro + ``` -5. **Iterate through Phase 1B, 1C, 1D** +3. **Start the application**: + ```bash + docker-compose up + # or + rails server + ``` + +4. **Visit http://localhost:3000** and follow the first-user setup + +## Contributing + +When adding features: + +1. **Follow Rails Conventions** - Use generators and standard patterns +2. **Maintain Phase Structure** - Add features to appropriate phase +3. **Test Thoroughly** - Include model, service, and system tests +4. **Update Documentation** - Keep architecture docs current + +## Resources + +- [Rails Guides](https://guides.rubyonrails.org/) +- [Hotwire Documentation](https://hotwired.dev/) +- [Video.js Documentation](https://videojs.com/) +- [FFmpeg Documentation](https://ffmpeg.org/ffmpeg.html) --- -## Architecture Decision Records - -Key architectural decisions made: - -1. **SQLite for MVP** - Simple, file-based, perfect for single-user. Migration path to PostgreSQL documented. -2. **Storage Adapter Pattern** - Pluggable backends allow adding S3, JellyFin, etc. without changing core logic. -3. **Service Objects** - Complex business logic extracted from controllers/models for testability. -4. **Hotwire over React** - Server-rendered HTML with Turbo Streams for real-time updates. Less JS complexity. -5. **Video.js** - Proven, extensible, well-documented player with broad format support. -6. **Rails Enums (integers)** - SQLite-compatible, performant, database-friendly. -7. **Active Storage for assets only** - Videos managed by storage adapters, not Active Storage. -8. **Pagy over Kaminari** - Faster, simpler pagination with smaller footprint. -9. **Model-level authorization** - Simple for MVP, easy upgrade path to Pundit/Action Policy. -10. **Phase 1 broken into 4 sub-phases** - Manageable milestones with clear deliverables. - ---- - -## Support & Resources - -- **Rails Guides:** https://guides.rubyonrails.org -- **Hotwire Docs:** https://hotwired.dev -- **Video.js Docs:** https://docs.videojs.com -- **FFmpeg Docs:** https://ffmpeg.org/documentation.html -- **TailwindCSS:** https://tailwindcss.com/docs - ---- - -**Document Version:** 1.0 -**Last Updated:** <%= Time.current.strftime("%Y-%m-%d") %> -**Status:** Ready for Phase 1A Implementation +**Next Steps**: Start with [Phase 1 (MVP)](./phases/phase_1.md) for complete implementation details. \ No newline at end of file diff --git a/docs/phases/phase_1.md b/docs/phases/phase_1.md new file mode 100644 index 0000000..0194c0a --- /dev/null +++ b/docs/phases/phase_1.md @@ -0,0 +1,548 @@ +# Velour Phase 1: MVP (Local Filesystem) + +Phase 1 delivers a complete video library application for local files with grouping, transcoding, and playback. This is the foundation that provides immediate value. + +**Architecture Note**: This phase implements a extensible MediaFile architecture using Single Table Inheritance (STI). Video inherits from MediaFile, preparing the system for future audio support in Phase 5. + +## Technology Stack + +### Core Components +- **Ruby on Rails 8.x** with SQLite3 +- **Hotwire** (Turbo + Stimulus) for frontend +- **Solid Queue** for background jobs +- **Video.js** for video playback +- **FFmpeg** for video processing +- **Active Storage** for thumbnails/assets +- **TailwindCSS** for styling + +## Database Schema (Core Models) + +### Work Model +```ruby +class Work < ApplicationRecord + validates :title, presence: true + + has_many :videos, dependent: :destroy + has_many :external_ids, dependent: :destroy + + scope :organized, -> { where(organized: true) } + scope :unorganized, -> { where(organized: false) } + + def primary_video + videos.order(created_at: :desc).first + end +end +``` + +### MediaFile Model (Base Class) +```ruby +class MediaFile < ApplicationRecord + # Base class for all media files using STI + include Streamable, Processable + + # Common associations + belongs_to :work + belongs_to :storage_location + has_many :playback_sessions, dependent: :destroy + + # Common metadata stores + store :fingerprints, accessors: [:xxhash64, :md5, :oshash, :phash] + store :media_metadata, accessors: [:duration, :codec, :bit_rate, :format] + + # Common validations and methods + validates :filename, presence: true + validates :xxhash64, presence: true, uniqueness: { scope: :storage_location_id } + + scope :web_compatible, -> { where(web_compatible: true) } + scope :needs_transcoding, -> { where(web_compatible: false) } + + def display_title + work&.display_title || filename + end + + def full_file_path + File.join(storage_location.path, filename) + end + + def format_duration + # Duration formatting logic + end +end +``` + +### Video Model (Inherits from MediaFile) +```ruby +class Video < MediaFile + # Video-specific associations + has_many :video_assets, dependent: :destroy + + # Video-specific metadata + store :video_metadata, accessors: [:width, :height, :video_codec, :audio_codec, :frame_rate] + + # Video-specific methods + def resolution_label + # Resolution-based quality labeling (SD, 720p, 1080p, 4K, etc.) + end +end +``` + +### StorageLocation Model +```ruby +class StorageLocation < ApplicationRecord + has_many :videos, dependent: :destroy + + validates :name, presence: true + validates :path, presence: true, uniqueness: true + validates :storage_type, presence: true, inclusion: { in: %w[local] } + + validate :path_must_exist_and_be_readable + + def accessible? + File.exist?(path) && File.readable?(path) + end + + private + + def path_must_exist_and_be_readable + errors.add(:path, "must exist and be readable") unless accessible? + end +end +``` + +## Storage Architecture (Local Only) + +### Directory Structure +```bash +# User's original media directories (read-only references) +/movies/action/Die Hard (1988).mkv +/movies/anime/Your Name (2016).webm +/movies/scifi/The Matrix (1999).avi + +# Velour transcoded files (same directories, web-compatible) +/movies/action/Die Hard (1988).web.mp4 +/movies/anime/Your Name (2016).web.mp4 +/movies/scifi/The Matrix (1999).web.mp4 + +# Velour managed directories +./velour_data/ +├── assets/ # Active Storage for generated content +│ └── thumbnails/ # Video screenshots +└── tmp/ # Temporary processing files + └── transcodes/ # Temporary transcode storage +``` + +### Docker Volume Mounting with Heuristic Discovery + +Users mount their video directories under `/videos` and Velour automatically discovers and categorizes them: + +```yaml +# docker-compose.yml +volumes: + - /path/to/user/movies:/videos/movies:ro + - /path/to/user/tv_shows:/videos/tv:ro + - /path/to/user/documentaries:/videos/docs:ro + - /path/to/user/anime:/videos/anime:ro + - ./velour_data:/app/velour_data +``` + +### Automatic Storage Location Discovery +```ruby +# app/services/storage_discovery_service.rb +class StorageDiscoveryService + CATEGORIES = { + 'movies' => 'Movies', + 'tv' => 'TV Shows', + 'tv_shows' => 'TV Shows', + 'series' => 'TV Shows', + 'docs' => 'Documentaries', + 'documentaries' => 'Documentaries', + 'anime' => 'Anime', + 'cartoons' => 'Animation', + 'animation' => 'Animation', + 'sports' => 'Sports', + 'music' => 'Music Videos', + 'music_videos' => 'Music Videos', + 'kids' => 'Kids Content', + 'family' => 'Family Content' + }.freeze + + def self.discover_and_create + base_path = '/videos' + return [] unless Dir.exist?(base_path) + + discovered = [] + + Dir.children(base_path).each do |subdir| + dir_path = File.join(base_path, subdir) + next unless Dir.exist?(dir_path) + + category = categorize_directory(subdir) + storage = StorageLocation.find_or_create_by!( + name: "#{category}: #{subdir.titleize}", + path: dir_path, + storage_type: 'local' + ) + + discovered << storage + end + + discovered + end + + def self.categorize_directory(dirname) + downcase = dirname.downcase + CATEGORIES[downcase] || CATEGORIES.find { |key, _| downcase.include?(key) }&.last || 'Other' + end +end +``` + +## Video Processing Pipeline + +### Background Job with ActiveJob 8.1 Continuations +```ruby +class VideoProcessorJob < ApplicationJob + include ActiveJob::Statuses + + def perform(video_id) + video = Video.find(video_id) + + progress.update(stage: "metadata", total: 100, current: 0) + metadata = VideoMetadataExtractor.new(video.full_file_path).extract + video.update!(video_metadata: metadata) + progress.update(stage: "metadata", total: 100, current: 100) + + progress.update(stage: "thumbnail", total: 100, current: 0) + generate_thumbnail(video) + progress.update(stage: "thumbnail", total: 100, current: 100) + + unless video.web_compatible? + progress.update(stage: "transcode", total: 100, current: 0) + transcode_video(video) + progress.update(stage: "transcode", total: 100, current: 100) + end + + progress.update(stage: "complete", total: 100, current: 100) + end + + private + + def generate_thumbnail(video) + # Generate thumbnail at 10% of duration + thumbnail_path = VideoTranscoder.new.extract_frame(video.full_file_path, video.duration * 0.1) + video.video_assets.create!(asset_type: "thumbnail", file: File.open(thumbnail_path)) + end + + def transcode_video(video) + transcoder = VideoTranscoder.new + output_path = video.full_file_path.gsub(/\.[^.]+$/, '.web.mp4') + + transcoder.transcode_for_web( + input_path: video.full_file_path, + output_path: output_path, + on_progress: ->(current, total) { progress.update(current: current) } + ) + + video.update!( + transcoded_path: File.basename(output_path), + transcoded_permanently: true, + web_compatible: true + ) + end +end +``` + +## Frontend Architecture + +### Uninterrupted Video Playback +```erb + + +
+ +
+
+ + +

<%= @work.title %>

+

Duration: <%= format_duration(@video.duration) %>

+
+``` + +### Video Player Stimulus Controller +```javascript +// app/javascript/controllers/video_player_controller.js +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { videoId: Number, startPosition: Number } + + connect() { + this.player = videojs(this.element, { + controls: true, + responsive: true, + fluid: true, + playbackRates: [0.5, 1, 1.25, 1.5, 2] + }) + + this.player.ready(() => { + if (this.startPositionValue > 0) { + this.player.currentTime(this.startPositionValue) + } + }) + + // Save position every 10 seconds + this.interval = setInterval(() => { + this.savePosition() + }, 10000) + + // Save on pause + this.player.on("pause", () => this.savePosition()) + } + + disconnect() { + clearInterval(this.interval) + this.player.dispose() + } + + savePosition() { + const position = Math.floor(this.player.currentTime()) + + fetch(`/videos/${this.videoIdValue}/playback-position`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ position }) + }) + } +} +``` + +## Service Objects + +### File Scanner Service +```ruby +class FileScannerService + def initialize(storage_location) + @storage_location = storage_location + end + + def scan + return failure_result("Storage location not accessible") unless @storage_location.accessible? + + video_files = find_video_files + new_videos = process_files(video_files) + + success_result(new_videos) + rescue => e + failure_result(e.message) + end + + private + + def find_video_files + Dir.glob(File.join(@storage_location.path, "**", "*.{mp4,avi,mkv,mov,wmv,flv,webm,m4v}")) + end + + def process_files(file_paths) + new_videos = [] + + file_paths.each do |file_path| + filename = File.basename(file_path) + + next if Video.exists?(filename: filename, storage_location: @storage_location) + + video = Video.create!( + filename: filename, + storage_location: @storage_location, + work: Work.find_or_create_by(title: extract_title(filename)) + ) + + new_videos << video + VideoProcessorJob.perform_later(video.id) + end + + new_videos + end + + def extract_title(filename) + # Simple title extraction - can be enhanced + File.basename(filename, ".*").gsub(/[\[\(].*?[\]\)]/, "").strip + end + + def success_result(videos = []) + { success: true, videos: videos, message: "Found #{videos.length} new videos" } + end + + def failure_result(message) + { success: false, message: message } + end +end +``` + +### Video Transcoder Service +```ruby +class VideoTranscoder + def transcode_for_web(input_path:, output_path:, on_progress: nil) + movie = FFMPEG::Movie.new(input_path) + + # Calculate progress callback + progress_callback = ->(progress) { + on_progress&.call(progress, 100) + } + + movie.transcode(output_path, { + video_codec: "libx264", + audio_codec: "aac", + custom: %w[ + -pix_fmt yuv420p + -preset medium + -crf 23 + -movflags +faststart + -tune fastdecode + ] + }, &progress_callback) + end + + def extract_frame(input_path, seconds) + movie = FFMPEG::Movie.new(input_path) + output_path = "#{Rails.root}/tmp/thumbnail_#{SecureRandom.hex(8)}.jpg" + + movie.screenshot(output_path, seek_time: seconds, resolution: "320x240") + output_path + end +end +``` + +## Routes + +```ruby +Rails.application.routes.draw do + root "storage_locations#index" + + resources :storage_locations, only: [:index, :show, :create, :destroy] do + member do + post :scan + end + end + + resources :works, only: [:index, :show] do + resources :videos, only: [:show] + end + + resources :videos, only: [] do + member do + get :stream + patch :playback_position + post :retry_processing + end + + resources :playback_sessions, only: [:create] + end + + # Real-time job progress + resources :jobs, only: [:show] do + member do + get :progress + end + end +end +``` + +## Implementation Sub-Phases + +### Phase 1A: Core Foundation (Week 1-2) +1. Generate models with migrations +2. Implement model validations and associations +3. Create storage adapter pattern +4. Create FileScannerService +5. Basic UI for storage locations and video listing + +### Phase 1B: Video Playback (Week 3) +1. Video streaming controller with byte-range support +2. Video.js integration with Stimulus controller +3. Playback session tracking +4. Resume functionality + +### Phase 1C: Processing Pipeline (Week 4) +1. Video metadata extraction with FFmpeg +2. Background job processing with progress tracking +3. Thumbnail generation and storage +4. Processing UI with status indicators + +### Phase 1D: Works & Grouping (Week 5) +1. Duplicate detection by file hash +2. Manual grouping interface +3. Works display with version selection +4. Search, filtering, and pagination + +## Configuration + +### Environment Variables +```bash +# Required +RAILS_ENV=development +RAILS_MASTER_KEY=your_master_key + +# Video processing +FFMPEG_PATH=/usr/bin/ffmpeg +FFPROBE_PATH=/usr/bin/ffprobe + +# Storage +VIDEOS_PATH=./velour_data/videos +MAX_TRANSCODE_SIZE_GB=50 + +# Background jobs (SolidQueue runs with defaults) +SOLID_QUEUE_PROCESSES="*:2" # 2 workers for all queues +``` + +### Docker Configuration +```dockerfile +FROM ruby:3.3-alpine + +# Install FFmpeg and other dependencies +RUN apk add --no-cache \ + ffmpeg \ + imagemagick \ + sqlite-dev \ + nodejs \ + npm + +# Install Rails and other gems +COPY Gemfile* /app/ +RUN bundle install + +# Copy application code +COPY . /app/ + +# Create directories +RUN mkdir -p /app/velour_data/{assets,tmp} + +EXPOSE 3000 +CMD ["rails", "server", "-b", "0.0.0.0"] +``` + +## Testing Strategy + +### Model Tests +- Model validations and associations +- Scopes and class methods +- JSON store accessor functionality + +### Service Tests +- FileScannerService with sample directory +- VideoTranscoder service methods +- Background job processing + +### System Tests +- Video playback functionality +- File scanning workflow +- Work grouping interface + +This Phase 1 delivers a complete, useful video library application that provides immediate value while establishing the foundation for future enhancements. \ No newline at end of file diff --git a/docs/phases/phase_2.md b/docs/phases/phase_2.md new file mode 100644 index 0000000..d27ca8a --- /dev/null +++ b/docs/phases/phase_2.md @@ -0,0 +1,486 @@ +# Velour Phase 2: Authentication & Multi-User + +Phase 2 adds user management, authentication, and multi-user support while maintaining the simplicity of the core application. + +## Authentication Architecture + +### Rails Authentication Generators + OIDC Extension +We use Rails' built-in authentication generators as the foundation, extended with OIDC support for enterprise environments. + +### User Model +```ruby +class User < ApplicationRecord + # Include default devise modules or Rails authentication + # Devise modules: :database_authenticatable, :registerable, + # :recoverable, :rememberable, :validatable + + has_many :playback_sessions, dependent: :destroy + has_many :user_preferences, dependent: :destroy + + validates :email, presence: true, uniqueness: true + + enum role: { user: 0, admin: 1 } + + def admin? + role == "admin" || email == ENV.fetch("ADMIN_EMAIL", "").downcase + end + + def can_manage_storage? + admin? + end + + def can_manage_users? + admin? + end +end +``` + +### Authentication Setup +```bash +# Generate Rails authentication +rails generate authentication + +# Add OIDC support +gem 'omniauth-openid-connect' +bundle install +``` + +### OIDC Configuration +```ruby +# config/initializers/omniauth.rb +Rails.application.config.middleware.use OmniAuth::Builder do + provider :openid_connect, { + name: :oidc, + issuer: ENV['OIDC_ISSUER'], + client_id: ENV['OIDC_CLIENT_ID'], + client_secret: ENV['OIDC_CLIENT_SECRET'], + scope: [:openid, :email, :profile], + response_type: :code, + client_options: { + identifier: ENV['OIDC_CLIENT_ID'], + secret: ENV['OIDC_CLIENT_SECRET'], + redirect_uri: "#{ENV['RAILS_HOST']}/auth/oidc/callback" + } + } +end +``` + +## First User Bootstrap Flow + +### Initial Setup +```ruby +# db/seeds.rb +admin_email = ENV.fetch('ADMIN_EMAIL', 'admin@velour.local') +User.find_or_create_by!(email: admin_email) do |user| + user.password = SecureRandom.hex(16) + user.role = :admin + puts "Created admin user: #{admin_email}" + puts "Password: #{user.password}" +end +``` + +### First Login Controller +```ruby +class FirstSetupController < ApplicationController + before_action :ensure_no_users_exist + before_action :require_admin_setup, only: [:create_admin] + + def show + @user = User.new + end + + def create_admin + @user = User.new(user_params) + @user.role = :admin + + if @user.save + session[:user_id] = @user.id + redirect_to root_path, notice: "Admin account created successfully!" + else + render :show, status: :unprocessable_entity + end + end + + private + + def ensure_no_users_exist + redirect_to root_path if User.exists? + end + + def require_admin_setup + redirect_to first_setup_path unless ENV.key?('ADMIN_EMAIL') + end + + def user_params + params.require(:user).permit(:email, :password, :password_confirmation) + end +end +``` + +## User Management + +### User Preferences +```ruby +class UserPreference < ApplicationRecord + belongs_to :user + + store :settings, coder: JSON, accessors: [ + :default_video_quality, + :auto_play_next, + :subtitle_language, + :theme + ] + + validates :user_id, presence: true, uniqueness: true +end +``` + +### Per-User Playback Sessions +```ruby +class PlaybackSession < ApplicationRecord + belongs_to :user + belongs_to :video + + scope :for_user, ->(user) { where(user: user) } + scope :recent, -> { order(updated_at: :desc) } + + def self.resume_position_for(video, user) + for_user(user).where(video: video).last&.position || 0 + end +end +``` + +## Authorization & Security + +### Model-Level Authorization +```ruby +# app/models/concerns/authorizable.rb +module Authorizable + extend ActiveSupport::Concern + + def viewable_by?(user) + true # All videos viewable by all users + end + + def editable_by?(user) + user.admin? + end +end + +# Include in Video and Work models +class Video < ApplicationRecord + include Authorizable + # ... rest of model +end +``` + +### Controller Authorization +```ruby +class ApplicationController < ActionController::Base + before_action :authenticate_user! + before_action :set_current_user + + private + + def set_current_user + Current.user = current_user + end + + def require_admin + redirect_to root_path, alert: "Access denied" unless current_user&.admin? + end +end + +class StorageLocationsController < ApplicationController + before_action :require_admin, except: [:index, :show] + + # ... rest of controller +end +``` + +## Updated Controllers for Multi-User + +### Videos Controller +```ruby +class VideosController < ApplicationController + before_action :set_video, only: [:show, :stream, :update_position] + before_action :authorize_video + + def show + @work = @video.work + @last_position = PlaybackSession.resume_position_for(@video, current_user) + + # Create playback session for tracking + @playback_session = current_user.playback_sessions.create!( + video: @video, + position: @last_position + ) + end + + def stream + unless @video.viewable_by?(current_user) + head :forbidden + return + end + + send_file @video.web_stream_path, + type: "video/mp4", + disposition: "inline", + range: request.headers['Range'] + end + + def update_position + current_user.playback_sessions.where(video: @video).last&.update!( + position: params[:position], + completed: params[:completed] || false + ) + + head :ok + end + + private + + def set_video + @video = Video.find(params[:id]) + end + + def authorize_video + head :forbidden unless @video.viewable_by?(current_user) + end +end +``` + +### Storage Locations Controller (Admin Only) +```ruby +class StorageLocationsController < ApplicationController + before_action :require_admin, except: [:index, :show] + before_action :set_storage_location, only: [:show, :destroy, :scan] + + def index + @storage_locations = StorageLocation.accessible.order(:name) + end + + def show + @videos = @storage_location.videos.includes(:work).order(:filename) + end + + def create + @storage_location = StorageLocation.new(storage_location_params) + + if @storage_location.save + redirect_to @storage_location, notice: 'Storage location was successfully created.' + else + render :new, status: :unprocessable_entity + end + end + + def destroy + if @storage_location.videos.exists? + redirect_to @storage_location, alert: 'Cannot delete storage location with videos.' + else + @storage_location.destroy + redirect_to storage_locations_path, notice: 'Storage location was successfully deleted.' + end + end + + def scan + service = FileScannerService.new(@storage_location) + result = service.scan + + if result[:success] + redirect_to @storage_location, notice: result[:message] + else + redirect_to @storage_location, alert: result[:message] + end + end + + private + + def set_storage_location + @storage_location = StorageLocation.find(params[:id]) + end + + def storage_location_params + params.require(:storage_location).permit(:name, :path, :storage_type) + end +end +``` + +## User Interface Updates + +### User Navigation +```erb + + +``` + +### Admin Panel +```erb + +
+

Admin Dashboard

+ +
+
+

Users

+

<%= User.count %>

+

Total users

+ <%= link_to 'Manage Users', admin_users_path, class: 'mt-2 text-blue-500 hover:text-blue-700' %> +
+ +
+

Videos

+

<%= Video.count %>

+

Total videos

+ <%= link_to 'View Library', works_path, class: 'mt-2 text-green-500 hover:text-green-700' %> +
+ +
+

Storage

+

<%= StorageLocation.count %>

+

Storage locations

+ <%= link_to 'Manage Storage', storage_locations_path, class: 'mt-2 text-purple-500 hover:text-purple-700' %> +
+
+ +
+

System Status

+
+
+ Background Jobs: + <%= SolidQueue::Job.count %> +
+
+ Processing Jobs: + <%= SolidQueue::Job.where(finished_at: nil).count %> +
+
+ Failed Jobs: + <%= SolidQueue::FailedExecution.count %> +
+
+
+
+``` + +## Environment Configuration + +### New Environment Variables +```bash +# Authentication +ADMIN_EMAIL=admin@yourdomain.com +RAILS_HOST=https://your-velour-domain.com + +# OIDC (optional) +OIDC_ISSUER=https://your-oidc-provider.com +OIDC_CLIENT_ID=your_client_id +OIDC_CLIENT_SECRET=your_client_secret + +# Session management +RAILS_SESSION_COOKIE_SECURE=true +RAILS_SESSION_COOKIE_SAME_SITE=lax +``` + +## Testing for Phase 2 + +### Authentication Tests +```ruby +# test/integration/authentication_test.rb +class AuthenticationTest < ActionDispatch::IntegrationTest + test "first user can create admin account" do + User.delete_all + + get first_setup_path + assert_response :success + + post first_setup_path, params: { + user: { + email: "admin@example.com", + password: "password123", + password_confirmation: "password123" + } + } + + assert_redirected_to root_path + assert_equal "admin@example.com", User.last.email + assert User.last.admin? + end + + test "regular users cannot access admin features" do + user = users(:regular) + + sign_in user + get storage_locations_path + assert_redirected_to root_path + + post storage_locations_path, params: { + storage_location: { + name: "Test", + path: "/test", + storage_type: "local" + } + } + assert_redirected_to root_path + end +end +``` + +## Migration from Phase 1 + +### Database Migration +```ruby +class AddAuthenticationToUsers < ActiveRecord::Migration[7.1] + def change + create_table :users do |t| + t.string :email, null: false, index: { unique: true } + t.string :encrypted_password, null: false + t.string :role, default: 'user', null: false + t.timestamps null: false + end + + create_table :user_preferences do |t| + t.references :user, null: false, foreign_key: true + t.json :settings, default: {} + t.timestamps null: false + end + + # Add user_id to existing playback_sessions + add_reference :playback_sessions, :user, null: false, foreign_key: true + + # Create first admin if ADMIN_EMAIL is set + if ENV.key?('ADMIN_EMAIL') + User.create!( + email: ENV['ADMIN_EMAIL'], + password: SecureRandom.hex(16), + role: 'admin' + ) + end + end +end +``` + +Phase 2 provides a complete multi-user system while maintaining the simplicity of Phase 1. Users can have personal playback history, and administrators can manage the system through a clean interface. \ No newline at end of file diff --git a/docs/phases/phase_3.md b/docs/phases/phase_3.md new file mode 100644 index 0000000..5dbbefe --- /dev/null +++ b/docs/phases/phase_3.md @@ -0,0 +1,773 @@ +# Velour Phase 3: Remote Sources & Import + +Phase 3 extends the video library to support remote storage sources like S3, JellyFin servers, and web directories. This allows users to access and import videos from multiple locations. + +## Extended Storage Architecture + +### New Storage Location Types +```ruby +class StorageLocation < ApplicationRecord + has_many :videos, dependent: :destroy + + validates :name, presence: true + validates :storage_type, presence: true, inclusion: { in: %w[local s3 jellyfin web] } + + store :configuration, accessors: [ + # S3 configuration + :bucket, :region, :access_key_id, :secret_access_key, :endpoint, + # JellyFin configuration + :server_url, :api_key, :username, + # Web directory configuration + :base_url, :auth_type, :username, :password, :headers + ], coder: JSON + + # Storage-type specific validations + validate :validate_s3_configuration, if: -> { s3? } + validate :validate_jellyfin_configuration, if: -> { jellyfin? } + validate :validate_web_configuration, if: -> { web? } + + enum storage_type: { local: 0, s3: 1, jellyfin: 2, web: 3 } + + def accessible? + case storage_type + when 'local' + File.exist?(path) && File.readable?(path) + when 's3' + s3_client&.bucket(bucket)&.exists? + when 'jellyfin' + jellyfin_client&.ping? + when 'web' + web_accessible? + else + false + end + end + + def scanner + case storage_type + when 'local' + LocalFileScanner.new(self) + when 's3' + S3Scanner.new(self) + when 'jellyfin' + JellyFinScanner.new(self) + when 'web' + WebDirectoryScanner.new(self) + end + end + + def streamer + case storage_type + when 'local' + LocalStreamer.new(self) + when 's3' + S3Streamer.new(self) + when 'jellyfin' + JellyFinStreamer.new(self) + when 'web' + WebStreamer.new(self) + end + end + + private + + def validate_s3_configuration + %w[bucket region access_key_id secret_access_key].each do |field| + errors.add(:configuration, "#{field} is required for S3 storage") if send(field).blank? + end + end + + def validate_jellyfin_configuration + %w[server_url api_key].each do |field| + errors.add(:configuration, "#{field} is required for JellyFin storage") if send(field).blank? + end + end + + def validate_web_configuration + errors.add(:configuration, "base_url is required for web storage") if base_url.blank? + end +end +``` + +## S3 Storage Implementation + +### S3 Scanner Service +```ruby +class S3Scanner + def initialize(storage_location) + @storage_location = storage_location + @client = s3_client + end + + def scan + return failure_result("S3 bucket not accessible") unless @storage_location.accessible? + + video_files = find_video_files_in_s3 + new_videos = process_s3_files(video_files) + + success_result(new_videos) + rescue Aws::Errors::ServiceError => e + failure_result("S3 error: #{e.message}") + end + + private + + def s3_client + @s3_client ||= Aws::S3::Client.new( + region: @storage_location.region, + access_key_id: @storage_location.access_key_id, + secret_access_key: @storage_location.secret_access_key, + endpoint: @storage_location.endpoint # Optional for S3-compatible services + ) + end + + def find_video_files_in_s3 + bucket = Aws::S3::Bucket.new(@storage_location.bucket, client: s3_client) + + video_extensions = %w[.mp4 .avi .mkv .mov .wmv .flv .webm .m4v] + + bucket.objects(prefix: "") + .select { |obj| video_extensions.any? { |ext| obj.key.end_with?(ext) } } + .to_a + end + + def process_s3_files(s3_objects) + new_videos = [] + + s3_objects.each do |s3_object| + filename = File.basename(s3_object.key) + + next if Video.exists?(filename: filename, storage_location: @storage_location) + + video = Video.create!( + filename: filename, + storage_location: @storage_location, + work: Work.find_or_create_by(title: extract_title(filename)), + file_size: s3_object.size, + video_metadata: { + remote_url: s3_object.key, + last_modified: s3_object.last_modified + } + ) + + new_videos << video + VideoProcessorJob.perform_later(video.id) + end + + new_videos + end + + def extract_title(filename) + File.basename(filename, ".*").gsub(/[\[\(].*?[\]\)]/, "").strip + end + + def success_result(videos = []) + { success: true, videos: videos, message: "Found #{videos.length} new videos in S3" } + end + + def failure_result(message) + { success: false, message: message } + end +end +``` + +### S3 Streamer +```ruby +class S3Streamer + def initialize(storage_location) + @storage_location = storage_location + @client = s3_client + end + + def stream(video, range: nil) + s3_object = s3_object_for_video(video) + + if range + # Handle byte-range requests for seeking + range_header = "bytes=#{range}" + resp = @client.get_object( + bucket: @storage_location.bucket, + key: video.video_metadata['remote_url'], + range: range_header + ) + + { + body: resp.body, + status: 206, # Partial content + headers: { + 'Content-Range' => "bytes #{range}/#{s3_object.size}", + 'Content-Length' => resp.content_length, + 'Accept-Ranges' => 'bytes', + 'Content-Type' => 'video/mp4' + } + } + else + resp = @client.get_object( + bucket: @storage_location.bucket, + key: video.video_metadata['remote_url'] + ) + + { + body: resp.body, + status: 200, + headers: { + 'Content-Length' => resp.content_length, + 'Content-Type' => 'video/mp4' + } + } + end + end + + def presigned_url(video, expires_in: 1.hour) + signer = Aws::S3::Presigner.new(client: @client) + + signer.presigned_url( + :get_object, + bucket: @storage_location.bucket, + key: video.video_metadata['remote_url'], + expires_in: expires_in.to_i + ) + end + + private + + def s3_client + @s3_client ||= Aws::S3::Client.new( + region: @storage_location.region, + access_key_id: @storage_location.access_key_id, + secret_access_key: @storage_location.secret_access_key, + endpoint: @storage_location.endpoint + ) + end + + def s3_object_for_video(video) + @client.get_object( + bucket: @storage_location.bucket, + key: video.video_metadata['remote_url'] + ) + end +end +``` + +## JellyFin Integration + +### JellyFin Client +```ruby +class JellyFinClient + def initialize(server_url:, api_key:, username: nil) + @server_url = server_url.chomp('/') + @api_key = api_key + @username = username + @http = Faraday.new(url: @server_url) do |faraday| + faraday.headers['X-Emby-Token'] = @api_key + faraday.adapter Faraday.default_adapter + end + end + + def ping? + response = @http.get('/System/Ping') + response.success? + rescue + false + end + + def libraries + response = @http.get('/Library/VirtualFolders') + JSON.parse(response.body) + end + + def movies(library_id = nil) + path = library_id ? "/Users/#{user_id}/Items?ParentId=#{library_id}&IncludeItemTypes=Movie&Recursive=true" : "/Users/#{user_id}/Items?IncludeItemTypes=Movie&Recursive=true" + response = @http.get(path) + JSON.parse(response.body)['Items'] + end + + def tv_shows(library_id = nil) + path = library_id ? "/Users/#{user_id}/Items?ParentId=#{library_id}&IncludeItemTypes=Series&Recursive=true" : "/Users/#{user_id}/Items?IncludeItemTypes=Series&Recursive=true" + response = @http.get(path) + JSON.parse(response.body)['Items'] + end + + def episodes(show_id) + response = @http.get("/Shows/#{show_id}/Episodes?UserId=#{user_id}") + JSON.parse(response.body)['Items'] + end + + def streaming_url(item_id) + "#{@server_url}/Videos/#{item_id}/stream?Static=true&MediaSourceId=#{item_id}&DeviceId=Velour&api_key=#{@api_key}" + end + + def item_details(item_id) + response = @http.get("/Users/#{user_id}/Items/#{item_id}") + JSON.parse(response.body) + end + + private + + def user_id + @user_id ||= begin + response = @http.get('/Users') + users = JSON.parse(response.body) + + if @username + user = users.find { |u| u['Name'] == @username } + user&.dig('Id') || users.first['Id'] + else + users.first['Id'] + end + end + end +end +``` + +### JellyFin Scanner +```ruby +class JellyFinScanner + def initialize(storage_location) + @storage_location = storage_location + @client = jellyfin_client + end + + def scan + return failure_result("JellyFin server not accessible") unless @storage_location.accessible? + + movies = @client.movies + shows = @client.tv_shows + episodes = [] + + shows.each do |show| + episodes.concat(@client.episodes(show['Id'])) + end + + all_items = movies + episodes + new_videos = process_jellyfin_items(all_items) + + success_result(new_videos) + rescue => e + failure_result("JellyFin error: #{e.message}") + end + + private + + def jellyfin_client + @client ||= JellyFinClient.new( + server_url: @storage_location.server_url, + api_key: @storage_location.api_key, + username: @storage_location.username + ) + end + + def process_jellyfin_items(items) + new_videos = [] + + items.each do |item| + next unless item['MediaType'] == 'Video' + + title = item['Name'] + year = item['ProductionYear'] + work_title = year ? "#{title} (#{year})" : title + + work = Work.find_or_create_by(title: work_title) do |w| + w.year = year + w.description = item['Overview'] + end + + video = Video.find_or_initialize_by( + filename: item['Id'], + storage_location: @storage_location + ) + + if video.new_record? + video.update!( + work: work, + video_metadata: { + jellyfin_id: item['Id'], + media_type: item['Type'], + runtime: item['RunTimeTicks'] ? item['RunTimeTicks'] / 10_000_000 : nil, + premiere_date: item['PremiereDate'], + community_rating: item['CommunityRating'], + genres: item['Genres'] + } + ) + + new_videos << video + VideoProcessorJob.perform_later(video.id) + end + end + + new_videos + end + + def success_result(videos = []) + { success: true, videos: videos, message: "Found #{videos.length} new videos from JellyFin" } + end + + def failure_result(message) + { success: false, message: message } + end +end +``` + +### JellyFin Streamer +```ruby +class JellyFinStreamer + def initialize(storage_location) + @storage_location = storage_location + @client = jellyfin_client + end + + def stream(video, range: nil) + jellyfin_id = video.video_metadata['jellyfin_id'] + stream_url = @client.streaming_url(jellyfin_id) + + # For JellyFin, we typically proxy the stream + if range + proxy_stream_with_range(stream_url, range) + else + proxy_stream(stream_url) + end + end + + private + + def jellyfin_client + @client ||= JellyFinClient.new( + server_url: @storage_location.server_url, + api_key: @storage_location.api_key, + username: @storage_location.username + ) + end + + def proxy_stream(url) + response = Faraday.get(url) + + { + body: response.body, + status: response.status, + headers: response.headers + } + end + + def proxy_stream_with_range(url, range) + response = Faraday.get(url, nil, { 'Range' => "bytes=#{range}" }) + + { + body: response.body, + status: response.status, + headers: response.headers + } + end +end +``` + +## Video Import System + +### Import Job with Progress Tracking +```ruby +class VideoImportJob < ApplicationJob + include ActiveJob::Statuses + + def perform(video_id, destination_storage_location_id) + video = Video.find(video_id) + destination = StorageLocation.find(destination_storage_location_id) + + progress.update(stage: "download", total: 100, current: 0) + + # Download file from source + downloaded_file = download_video(video, destination) do |current, total| + progress.update(current: (current.to_f / total * 50).to_i) # Download is 50% of progress + end + + progress.update(stage: "process", total: 100, current: 50) + + # Create new video record in destination + new_video = Video.create!( + filename: video.filename, + storage_location: destination, + work: video.work, + file_size: video.file_size + ) + + # Copy file to destination + destination_path = File.join(destination.path, video.filename) + FileUtils.cp(downloaded_file.path, destination_path) + + # Process the new video + VideoProcessorJob.perform_later(new_video.id) + + progress.update(stage: "complete", total: 100, current: 100) + + # Clean up temp file + File.delete(downloaded_file.path) + end + + private + + def download_video(video, destination, &block) + case video.storage_location.storage_type + when 's3' + download_from_s3(video, &block) + when 'jellyfin' + download_from_jellyfin(video, &block) + when 'web' + download_from_web(video, &block) + else + raise "Unsupported import from #{video.storage_location.storage_type}" + end + end + + def download_from_s3(video, &block) + temp_file = Tempfile.new(['video_import', File.extname(video.filename)]) + + s3_client = Aws::S3::Client.new( + region: video.storage_location.region, + access_key_id: video.storage_location.access_key_id, + secret_access_key: video.storage_location.secret_access_key + ) + + s3_client.get_object( + bucket: video.storage_location.bucket, + key: video.video_metadata['remote_url'], + response_target: temp_file.path + ) do |chunk| + yield(chunk.bytes_written, chunk.size) if block_given? + end + + temp_file + end + + def download_from_jellyfin(video, &block) + temp_file = Tempfile.new(['video_import', File.extname(video.filename)]) + + jellyfin_id = video.video_metadata['jellyfin_id'] + client = JellyFinClient.new( + server_url: video.storage_location.server_url, + api_key: video.storage_location.api_key + ) + + stream_url = client.streaming_url(jellyfin_id) + + # Download with progress tracking + uri = URI(stream_url) + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| + request = Net::HTTP::Get.new(uri) + http.request(request) do |response| + total_size = response['Content-Length'].to_i + downloaded = 0 + + response.read_body do |chunk| + temp_file.write(chunk) + downloaded += chunk.bytesize + yield(downloaded, total_size) if block_given? + end + end + end + + temp_file + end +end +``` + +### Import UI +```erb + +<% if video.storage_location.remote? && current_user.admin? %> +
+ + + + + + +
+<% end %> +``` + +### Import Stimulus Controller +```javascript +// app/javascript/controllers/import_dialog_controller.js +import { Controller } from "@hotwired/stimulus" +import { get } from "@rails/request.js" + +export default class extends Controller { + static targets = ["dialog", "progress", "progressBar", "progressText", "destination"] + + show() { + this.dialogTarget.classList.remove("hidden") + } + + hide() { + this.dialogTarget.classList.add("hidden") + } + + async import(event) { + const videoId = event.target.dataset.videoId + const destinationId = this.destinationTarget.value + + this.hide() + this.progressTarget.classList.remove("hidden") + + try { + // Start import job + const response = await post("/videos/import", { + body: JSON.stringify({ + video_id: videoId, + destination_storage_location_id: destinationId + }) + }) + + const { jobId } = await response.json + + // Poll for progress + this.pollProgress(jobId) + } catch (error) { + console.error("Import failed:", error) + this.progressTarget.innerHTML = ` +
+

Import failed

+

${error.message}

+
+ ` + } + } + + async pollProgress(jobId) { + const updateProgress = async () => { + try { + const response = await get(`/jobs/${jobId}/progress`) + const progress = await response.json + + this.progressBarTarget.style.width = `${progress.current}%` + this.progressTextTarget.textContent = `${progress.stage}: ${progress.current}%` + + if (progress.stage === "complete") { + this.progressTarget.innerHTML = ` +
+

Import complete!

+
+ ` + setTimeout(() => { + window.location.reload() + }, 2000) + } else if (progress.stage === "failed") { + this.progressTarget.innerHTML = ` +
+

Import failed

+

${progress.error || "Unknown error"}

+
+ ` + } else { + setTimeout(updateProgress, 1000) + } + } catch (error) { + console.error("Failed to get progress:", error) + setTimeout(updateProgress, 1000) + } + } + + updateProgress() + } +} +``` + +## Environment Configuration + +### New Environment Variables +```bash +# S3 Configuration +AWS_ACCESS_KEY_ID=your_access_key +AWS_SECRET_ACCESS_KEY=your_secret_key +AWS_DEFAULT_REGION=us-east-1 + +# Import Settings +MAX_IMPORT_SIZE_GB=10 +IMPORT_TIMEOUT_MINUTES=60 + +# Rate limiting +JELLYFIN_RATE_LIMIT_DELAY=1 # seconds between requests +``` + +### New Gems +```ruby +# Gemfile +gem 'aws-sdk-s3', '~> 1' +gem 'faraday', '~> 2.0' +gem 'httparty', '~> 0.21' +``` + +## Routes for Phase 3 +```ruby +# Add to existing routes +resources :videos, only: [] do + member do + post :import + end +end + +namespace :admin do + resources :storage_locations do + member do + post :test_connection + end + end +end +``` + +Phase 3 enables users to access video libraries from multiple remote sources while maintaining a unified interface. The import system allows bringing remote videos into local storage for offline access and transcoding. \ No newline at end of file diff --git a/docs/phases/phase_4.md b/docs/phases/phase_4.md new file mode 100644 index 0000000..3b79dc2 --- /dev/null +++ b/docs/phases/phase_4.md @@ -0,0 +1,636 @@ +# Velour Phase 4: Federation + +Phase 4 enables federation between multiple Velour instances, allowing users to share and access video libraries across different servers while maintaining security and access controls. + +## Federation Architecture + +### Overview +Velour federation allows instances to: +- Share video libraries with other trusted instances +- Stream videos from remote servers +- Sync metadata and work information +- Maintain access control and authentication + +### Federation Storage Location Type +```ruby +class StorageLocation < ApplicationRecord + # ... existing code ... + + validates :storage_type, inclusion: { in: %w[local s3 jellyfin web velour] } + + store :configuration, accessors: [ + # Existing configurations... + # Velour federation configuration + :remote_instance_url, :api_key, :instance_name, :trusted_instances + ], coder: JSON + + enum storage_type: { local: 0, s3: 1, jellyfin: 2, web: 3, velour: 4 } + + def federation_client + return nil unless velour? + @federation_client ||= VelourFederationClient.new( + instance_url: remote_instance_url, + api_key: api_key + ) + end + + def accessible? + case storage_type + # ... existing cases ... + when 'velour' + federation_client&.ping? + else + false + end + end + + def scanner + case storage_type + # ... existing cases ... + when 'velour' + VelourFederationScanner.new(self) + else + super + end + end + + def streamer + case storage_type + # ... existing cases ... + when 'velour' + VelourFederationStreamer.new(self) + else + super + end + end +end +``` + +## Federation API Authentication + +### API Key Management +```ruby +class ApiKey < ApplicationRecord + belongs_to :user + has_many :federation_connections, dependent: :destroy + + validates :key, presence: true, uniqueness: true + validates :name, presence: true + validates :permissions, presence: true + + store :permissions, coder: JSON, accessors: [:can_read, :can_stream, :can_metadata] + + before_validation :generate_key, on: :create + + def self.generate_key + SecureRandom.hex(32) + end + + private + + def generate_key + self.key ||= self.class.generate_key + end +end +``` + +### Federation Connections +```ruby +class FederationConnection < ApplicationRecord + belongs_to :api_key + belongs_to :storage_location + + validates :remote_instance_url, presence: true, uniqueness: { scope: :api_key_id } + validates :status, inclusion: { in: %w[pending active suspended rejected] } + + enum status: { pending: 0, active: 1, suspended: 2, rejected: 3 } + + def active? + status == "active" + end +end +``` + +## Federation Client + +### Velour Federation Client +```ruby +class VelourFederationClient + def initialize(instance_url:, api_key:) + @instance_url = instance_url.chomp('/') + @api_key = api_key + @http = Faraday.new(url: @instance_url) do |faraday| + faraday.headers['Authorization'] = "Bearer #{@api_key}" + faraday.headers['User-Agent'] = "Velour/#{Velour::VERSION}" + faraday.request :json + faraday.response :json + faraday.adapter Faraday.default_adapter + end + end + + def ping? + response = @http.get('/api/v1/ping') + response.success? + rescue + false + end + + def instance_info + response = @http.get('/api/v1/instance') + response.success? ? response.body : nil + end + + def works(page: 1, per_page: 50) + response = @http.get('/api/v1/works', params: { + page: page, + per_page: per_page + }) + response.success? ? response.body : [] + end + + def work_details(work_id) + response = @http.get("/api/v1/works/#{work_id}") + response.success? ? response.body : nil + end + + def video_stream_url(video_id) + "#{@instance_url}/api/v1/videos/#{video_id}/stream" + end + + def video_metadata(video_id) + response = @http.get("/api/v1/videos/#{video_id}") + response.success? ? response.body : nil + end + + def search_videos(query:, page: 1, per_page: 20) + response = @http.get('/api/v1/videos/search', params: { + query: query, + page: page, + per_page: per_page + }) + response.success? ? response.body : [] + end +end +``` + +## Federation Scanner + +### Velour Federation Scanner +```ruby +class VelourFederationScanner + def initialize(storage_location) + @storage_location = storage_location + @client = @storage_location.federation_client + end + + def scan + return failure_result("Remote Velour instance not accessible") unless @storage_location.accessible? + + instance_info = @client.instance_info + return failure_result("Failed to get instance info") unless instance_info + + works = @client.works(per_page: 100) # Start with first 100 works + new_videos = process_remote_works(works) + + success_result(new_videos, instance_info) + rescue => e + failure_result("Federation error: #{e.message}") + end + + private + + def process_remote_works(works) + new_videos = [] + + works.each do |work_data| + # Create or find local work + work = Work.find_or_create_by( + title: work_data['title'], + year: work_data['year'] + ) do |w| + w.description = work_data['description'] + w.director = work_data['director'] + w.rating = work_data['rating'] + end + + # Process videos for this work + work_data['videos'].each do |video_data| + video = Video.find_or_initialize_by( + filename: video_data['id'], # Use remote ID as filename + storage_location: @storage_location + ) + + if video.new_record? + video.update!( + work: work, + file_size: video_data['file_size'], + web_compatible: video_data['web_compatible'], + video_metadata: { + remote_video_id: video_data['id'], + remote_instance_url: @storage_location.remote_instance_url, + duration: video_data['duration'], + width: video_data['width'], + height: video_data['height'], + video_codec: video_data['video_codec'], + audio_codec: video_data['audio_codec'] + }, + fingerprints: video_data['fingerprints'] + ) + + new_videos << video + # Note: We don't process remote videos locally + # We just catalog them for streaming + end + end + end + + new_videos + end + + def success_result(videos = [], instance_info = {}) + { + success: true, + videos: videos, + message: "Found #{videos.length} videos from #{@storage_location.instance_name}", + instance_info: instance_info + } + end + + def failure_result(message) + { success: false, message: message } + end +end +``` + +## Federation Streamer + +### Velour Federation Streamer +```ruby +class VelourFederationStreamer + def initialize(storage_location) + @storage_location = storage_location + @client = @storage_location.federation_client + end + + def stream(video, range: nil) + remote_video_id = video.video_metadata['remote_video_id'] + stream_url = @client.video_stream_url(remote_video_id) + + if range + proxy_stream_with_range(stream_url, range) + else + proxy_stream(stream_url) + end + end + + def thumbnail_url(video) + remote_video_id = video.video_metadata['remote_video_id'] + "#{@storage_location.remote_instance_url}/api/v1/videos/#{remote_video_id}/thumbnail" + end + + private + + def proxy_stream(url) + response = Faraday.get(url) do |req| + req.headers['Authorization'] = "Bearer #{@storage_location.api_key}" + end + + { + body: response.body, + status: response.status, + headers: response.headers + } + end + + def proxy_stream_with_range(url, range) + response = Faraday.get(url) do |req| + req.headers['Authorization'] = "Bearer #{@storage_location.api_key}" + req.headers['Range'] = "bytes=#{range}" + end + + { + body: response.body, + status: response.status, + headers: response.headers + } + end +end +``` + +## Federation API Endpoints + +### API Controllers +```ruby +# app/controllers/api/v1/base_controller.rb +class Api::V1::BaseController < ActionController::Base + before_action :authenticate_api_request + + private + + def authenticate_api_request + token = request.headers['Authorization']&.gsub(/^Bearer\s+/, '') + api_key = ApiKey.find_by(key: token) + + if api_key&.active? + @current_api_key = api_key + @current_user = api_key.user + else + render json: { error: 'Unauthorized' }, status: :unauthorized + end + end + + def authorize_federation + head :forbidden unless @current_api_key&.can_read + end + + def authorize_streaming + head :forbidden unless @current_api_key&.can_stream + end +end + +# app/controllers/api/v1/instance_controller.rb +class Api::V1::InstanceController < Api::V1::BaseController + before_action :authorize_federation + + def ping + render json: { status: 'ok', timestamp: Time.current.iso8601 } + end + + def show + render json: { + name: 'Velour Instance', + version: Velour::VERSION, + description: 'Video library federation node', + total_works: Work.count, + total_videos: Video.count, + total_storage: Video.sum(:file_size) + } + end +end + +# app/controllers/api/v1/works_controller.rb +class Api::V1::WorksController < Api::V1::BaseController + before_action :authorize_federation + + def index + works = Work.includes(:videos) + .order(:title) + .page(params[:page]) + .per(params[:per_page] || 50) + + render json: { + works: works.map { |work| serialize_work(work) }, + pagination: { + current_page: works.current_page, + total_pages: works.total_pages, + total_count: works.total_count + } + } + end + + def show + work = Work.includes(videos: :storage_location).find(params[:id]) + render json: serialize_work(work) + end + + private + + def serialize_work(work) + { + id: work.id, + title: work.title, + year: work.year, + description: work.description, + director: work.director, + rating: work.rating, + organized: work.organized, + created_at: work.created_at.iso8601, + videos: work.videos.map { |video| serialize_video(video) } + } + end + + def serialize_video(video) + { + id: video.id, + filename: video.filename, + file_size: video.file_size, + web_compatible: video.web_compatible?, + duration: video.duration, + width: video.width, + height: video.height, + video_codec: video.video_codec, + audio_codec: video.audio_codec, + fingerprints: video.fingerprints, + storage_type: video.storage_location.storage_type, + created_at: video.created_at.iso8601 + } + end +end + +# app/controllers/api/v1/videos_controller.rb +class Api::V1::VideosController < Api::V1::BaseController + before_action :set_video, only: [:show, :stream, :thumbnail] + before_action :authorize_federation, except: [:stream, :thumbnail] + before_action :authorize_streaming, only: [:stream, :thumbnail] + + def show + render json: serialize_video(@video) + end + + def stream + streamer = @video.storage_location.streamer + result = streamer.stream(@video, range: request.headers['Range']) + + send_data result[:body], + type: result[:headers]['Content-Type'] || 'video/mp4', + disposition: 'inline', + status: result[:status], + headers: result[:headers].slice('Content-Range', 'Content-Length', 'Accept-Ranges') + end + + def thumbnail + if @video.video_assets.where(asset_type: 'thumbnail').exists? + asset = @video.video_assets.where(asset_type: 'thumbnail').first + redirect_to rails_blob_url(asset.file, disposition: 'inline') + else + head :not_found + end + end + + def search + query = params[:query] + return render json: [] if query.blank? + + videos = Video.joins(:work) + .where('works.title ILIKE ?', "%#{query}%") + .includes(:work, :storage_location) + .page(params[:page]) + .per(params[:per_page] || 20) + + render json: { + videos: videos.map { |video| serialize_video(video) }, + pagination: { + current_page: videos.current_page, + total_pages: videos.total_pages, + total_count: videos.total_count + } + } + end + + private + + def set_video + @video = Video.find(params[:id]) + end + + def serialize_video(video) + { + id: video.id, + filename: video.filename, + work_id: video.work_id, + work_title: video.work.title, + file_size: video.file_size, + web_compatible: video.web_compatible?, + duration: video.duration, + width: video.width, + height: video.height, + video_codec: video.video_codec, + audio_codec: video.audio_codec, + fingerprints: video.fingerprints, + storage_type: video.storage_location.storage_type, + created_at: video.created_at.iso8601 + } + end +end +``` + +## Federation Management UI + +### Federation Connections Management +```erb + +
+

Federation Connections

+ + <% if @api_keys.any? %> +
+ <%= link_to 'Create New Connection', new_admin_federation_connection_path, class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %> +
+ +
+
    + <% @connections.each do |connection| %> +
  • +
    +
    +

    + <%= connection.storage_location.name %> +

    +

    + <%= connection.remote_instance_url %> +

    +

    + API Key: <%= connection.api_key.name %> +

    +
    +
    + + <%= connection.status.titleize %> + + <%= link_to 'Edit', edit_admin_federation_connection_path(connection), class: 'text-blue-600 hover:text-blue-900' %> + <%= link_to 'Test', test_admin_federation_connection_path(connection), method: :post, class: 'text-green-600 hover:text-green-900' %> + <%= link_to 'Delete', admin_federation_connection_path(connection), method: :delete, + data: { confirm: 'Are you sure?' }, class: 'text-red-600 hover:text-red-900' %> +
    +
    +
  • + <% end %> +
+
+ <% else %> +
+
+
+ + + +
+
+

+ You need to create an API key before you can establish federation connections. +

+
+ <%= link_to 'Create API Key', new_admin_api_key_path, class: 'text-yellow-700 underline hover:text-yellow-600' %> +
+
+
+
+ <% end %> +
+``` + +## Security Considerations + +### Federation Security Best Practices +1. **API Key Management**: Regular rotation and limited scope +2. **Access Control**: Minimum required permissions +3. **Rate Limiting**: Prevent abuse from federated instances +4. **Audit Logging**: Track all federated access +5. **Network Security**: HTTPS-only federation connections + +### Rate Limiting +```ruby +# app/controllers/concerns/rate_limited.rb +module RateLimited + extend ActiveSupport::Concern + + included do + before_action :check_rate_limit, only: [:index, :show, :stream] + end + + private + + def check_rate_limit + return unless @current_api_key + + key = "federation_rate_limit:#{@current_api_key.id}" + count = Rails.cache.increment(key, 1, expires_in: 1.hour) + + if count > rate_limit + Rails.logger.warn "Rate limit exceeded for API key #{@current_api_key.id}" + render json: { error: 'Rate limit exceeded' }, status: :too_many_requests + end + end + + def rate_limit + case action_name + when 'stream' + 1000 # requests per hour + else + 5000 # requests per hour + end + end +end +``` + +## Environment Configuration + +### Federation Configuration +```bash +# Federation settings +FEDERATION_ENABLED=true +MAX_FEDERATED_CONNECTIONS=10 +FEDERATION_RATE_LIMIT_PER_HOUR=5000 + +# Federation security +FEDERATION_REQUIRE_HTTPS=true +FEDERATION_TOKEN_EXPIRY_HOURS=24 +``` + +Phase 4 enables a distributed network of Velour instances that can share video libraries while maintaining security and access controls. This allows organizations to federate their video collections across multiple servers or locations. \ No newline at end of file diff --git a/docs/phases/phase_5.md b/docs/phases/phase_5.md new file mode 100644 index 0000000..9dc5fe1 --- /dev/null +++ b/docs/phases/phase_5.md @@ -0,0 +1,324 @@ +# Velour Phase 5: Audio Support (Music & Audiobooks) + +Phase 5 extends Velour from a video library to a comprehensive media library by adding support for music and audiobooks. This builds upon the extensible MediaFile architecture established in Phase 1. + +## Technology Stack + +### Audio Processing Components +- **FFmpeg** - Audio transcoding and metadata extraction (extends existing video processing) +- **Ruby Audio Gems** - ID3 tag parsing, waveform generation +- **Active Storage** - Album art and waveform visualization storage +- **MediaInfo** - Comprehensive audio metadata extraction + +## Database Schema Extensions + +### Audio Model (inherits from MediaFile) +```ruby +class Audio < MediaFile + # Audio-specific associations + has_many :audio_assets, dependent: :destroy # album art, waveforms + + # Audio-specific metadata store + store :audio_metadata, accessors: [:sample_rate, :channels, :artist, :album, :track_number, :genre, :year] + + # Audio-specific methods + def quality_label + return "Unknown" unless bit_rate + case bit_rate + when 0..128 then "128kbps" + when 129..192 then "192kbps" + when 193..256 then "256kbps" + when 257..320 then "320kbps" + else "Lossless" + end + end + + def format_type + return "Unknown" unless format + case format&.downcase + when "mp3" then "MP3" + when "flac" then "FLAC" + when "wav" then "WAV" + when "aac", "m4a" then "AAC" + when "ogg" then "OGG Vorbis" + else format&.upcase + end + end +end + +class AudioAsset < ApplicationRecord + belongs_to :audio + + enum asset_type: { album_art: 0, waveform: 1, lyrics: 2 } + + # Uses Active Storage for file storage + has_one_attached :file +end +``` + +### Extended Work Model +```ruby +class Work < ApplicationRecord + # Existing video associations + has_many :videos, dependent: :destroy + has_many :external_ids, dependent: :destroy + + # New audio associations + has_many :audios, dependent: :destroy + + # Enhanced primary media selection + def primary_media + (audios + videos).sort_by(&:created_at).last + end + + def primary_video + videos.order(created_at: :desc).first + end + + def primary_audio + audios.order(created_at: :desc).first + end + + # Content type detection + def video_content? + videos.exists? + end + + def audio_content? + audios.exists? + end + + def mixed_content? + video_content? && audio_content? + end +end +``` + +## Audio Processing Pipeline + +### AudioProcessorJob +```ruby +class AudioProcessorJob < ApplicationJob + queue_as :processing + + def perform(audio_id) + audio = Audio.find(audio_id) + + # Extract audio metadata + AudioMetadataExtractor.new(audio).extract! + + # Generate album art if embedded + AlbumArtExtractor.new(audio).extract! + + # Generate waveform visualization + WaveformGenerator.new(audio).generate! + + # Check web compatibility and transcode if needed + unless AudioTranscoder.new(audio).web_compatible? + AudioTranscoderJob.perform_later(audio_id) + end + + audio.update!(processed: true) + rescue => e + audio.update!(processing_error: e.message) + raise + end +end +``` + +### AudioTranscoderJob +```ruby +class AudioTranscoderJob < ApplicationJob + queue_as :transcoding + + def perform(audio_id) + audio = Audio.find(audio_id) + AudioTranscoder.new(audio).transcode_for_web! + end +end +``` + +## File Discovery Extensions + +### Enhanced FileScannerService +```ruby +class FileScannerService + AUDIO_EXTENSIONS = %w[mp3 flac wav aac m4a ogg wma].freeze + + def scan_directory(storage_location) + # Existing video scanning logic + scan_videos(storage_location) + + # New audio scanning logic + scan_audio(storage_location) + end + + private + + def scan_audio(storage_location) + AUDIO_EXTENSIONS.each do |ext| + Dir.glob(File.join(storage_location.path, "**", "*.#{ext}")).each do |file_path| + process_audio_file(file_path, storage_location) + end + end + end + + def process_audio_file(file_path, storage_location) + filename = File.basename(file_path) + return if Audio.joins(:storage_location).exists?(filename: filename, storage_locations: { id: storage_location.id }) + + # Create Work based on filename parsing (album/track structure) + work = find_or_create_audio_work(filename, file_path) + + # Create Audio record + Audio.create!( + work: work, + storage_location: storage_location, + filename: filename, + xxhash64: calculate_xxhash64(file_path) + ) + + AudioProcessorJob.perform_later(audio.id) + end +end +``` + +## User Interface Extensions + +### Audio Player Integration +- **Video.js Audio Plugin** - Extend existing video player for audio +- **Waveform Visualization** - Interactive seeking with waveform display +- **Chapter Support** - Essential for audiobooks +- **Speed Control** - Variable playback speed for audiobooks + +### Library Organization +- **Album View** - Grid layout with album art +- **Artist Pages** - Discography and album organization +- **Audiobook Progress** - Chapter tracking and resume functionality +- **Mixed Media Collections** - Works containing both video and audio content + +### Audio-Specific Features +- **Playlist Creation** - Custom playlists for music +- **Shuffle Play** - Random playback for albums/artists +- **Gapless Playback** - Seamless track transitions +- **Lyrics Display** - Embedded or external lyrics support + +## Implementation Timeline + +### Phase 5A: Audio Foundation (Week 1-2) +- Create Audio model inheriting from MediaFile +- Implement AudioProcessorJob and audio metadata extraction +- Extend FileScannerService for audio formats +- Basic audio streaming endpoint + +### Phase 5B: Audio Processing (Week 3) +- Album art extraction and storage +- Waveform generation +- Audio transcoding for web compatibility +- Quality optimization and format conversion + +### Phase 5C: User Interface (Week 4) +- Audio player component (extends Video.js) +- Album and artist browsing interfaces +- Audio library management views +- Search and filtering for audio content + +### Phase 5D: Advanced Features (Week 5) +- Chapter support for audiobooks +- Playlist creation and management +- Mixed media Works (video + audio) +- Audio-specific user preferences + +## Migration Strategy + +### Database Migrations +```ruby +# Extend videos table for STI (already done in Phase 1) +# Add audio-specific columns if needed +class AddAudioFeatures < ActiveRecord::Migration[8.1] + def change + create_table :audio_assets do |t| + t.references :audio, null: false, foreign_key: true + t.string :asset_type + t.timestamps + end + + # Audio-specific indexes + add_index :audios, :artist if column_exists?(:audios, :artist) + add_index :audios, :album if column_exists?(:audios, :album) + end +end +``` + +### Backward Compatibility +- All existing video functionality remains unchanged +- Video URLs and routes continue to work identically +- Database migration is additive (type column only) +- No breaking changes to existing API + +## Configuration + +### Environment Variables +```bash +# Audio Processing (extends existing video processing) +FFMPEG_PATH=/usr/bin/ffmpeg +AUDIO_TRANSCODE_QUALITY=high +MAX_AUDIO_TRANSCODE_SIZE_GB=10 + +# Audio Features +ENABLE_AUDIO_SCANNING=true +ENABLE_WAVEFORM_GENERATION=true +AUDIO_THUMBNAIL_SIZE=300x300 +``` + +### Storage Considerations +- Album art storage in Active Storage +- Waveform images (generated per track) +- Potential audio transcoding cache +- Audio-specific metadata storage + +## Testing Strategy + +### Model Tests +- Audio model validation and inheritance +- Work model mixed content handling +- Audio metadata extraction accuracy + +### Integration Tests +- Audio processing pipeline end-to-end +- Audio streaming with seeking support +- File scanner audio discovery + +### System Tests +- Audio player functionality +- Album/artist interface navigation +- Mixed media library browsing + +## Performance Considerations + +### Audio Processing +- Parallel audio metadata extraction +- Efficient album art extraction +- Optimized waveform generation +- Background transcoding queue management + +### Storage Optimization +- Compressed waveform storage +- Album art caching and optimization +- Efficient audio streaming with range requests + +### User Experience +- Fast audio library browsing +- Quick album art loading +- Responsive audio player controls + +## Future Extensions + +### Phase 5+ Possibilities +- **Podcast Support** - RSS feed integration and episode management +- **Radio Streaming** - Internet radio station integration +- **Music Discovery** - Similar artist recommendations +- **Audio Bookmarks** - Detailed note-taking for audiobooks +- **Social Features** - Sharing playlists and recommendations + +This phase transforms Velour from a video library into a comprehensive personal media platform while maintaining the simplicity and robustness of the existing architecture. \ No newline at end of file diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb new file mode 100644 index 0000000..e1a1b03 --- /dev/null +++ b/test/controllers/passwords_controller_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +class PasswordsControllerTest < ActionDispatch::IntegrationTest + setup { @user = User.take } + + test "new" do + get new_password_path + assert_response :success + end + + test "create" do + post passwords_path, params: { email_address: @user.email_address } + assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ] + assert_redirected_to new_session_path + + follow_redirect! + assert_notice "reset instructions sent" + end + + test "create for an unknown user redirects but sends no mail" do + post passwords_path, params: { email_address: "missing-user@example.com" } + assert_enqueued_emails 0 + assert_redirected_to new_session_path + + follow_redirect! + assert_notice "reset instructions sent" + end + + test "edit" do + get edit_password_path(@user.password_reset_token) + assert_response :success + end + + test "edit with invalid password reset token" do + get edit_password_path("invalid token") + assert_redirected_to new_password_path + + follow_redirect! + assert_notice "reset link is invalid" + end + + test "update" do + assert_changes -> { @user.reload.password_digest } do + put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" } + assert_redirected_to new_session_path + end + + follow_redirect! + assert_notice "Password has been reset" + end + + test "update with non matching passwords" do + token = @user.password_reset_token + assert_no_changes -> { @user.reload.password_digest } do + put password_path(token), params: { password: "no", password_confirmation: "match" } + assert_redirected_to edit_password_path(token) + end + + follow_redirect! + assert_notice "Passwords did not match" + end + + private + def assert_notice(text) + assert_select "div", /#{text}/ + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 0000000..07d72ef --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + setup { @user = User.take } + + test "new" do + get new_session_path + assert_response :success + end + + test "create with valid credentials" do + post session_path, params: { email_address: @user.email_address, password: "password" } + + assert_redirected_to root_path + assert cookies[:session_id] + end + + test "create with invalid credentials" do + post session_path, params: { email_address: @user.email_address, password: "wrong" } + + assert_redirected_to new_session_path + assert_nil cookies[:session_id] + end + + test "destroy" do + sign_in_as(User.take) + + delete session_path + + assert_redirected_to new_session_path + assert_empty cookies[:session_id] + end +end diff --git a/test/controllers/storage_locations_controller_test.rb b/test/controllers/storage_locations_controller_test.rb new file mode 100644 index 0000000..e2b1456 --- /dev/null +++ b/test/controllers/storage_locations_controller_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class StorageLocationsControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get storage_locations_index_url + assert_response :success + end + + test "should get show" do + get storage_locations_show_url + assert_response :success + end + + test "should get create" do + get storage_locations_create_url + assert_response :success + end + + test "should get destroy" do + get storage_locations_destroy_url + assert_response :success + end +end diff --git a/test/controllers/videos_controller_test.rb b/test/controllers/videos_controller_test.rb new file mode 100644 index 0000000..d498b0f --- /dev/null +++ b/test/controllers/videos_controller_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class VideosControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get videos_index_url + assert_response :success + end + + test "should get show" do + get videos_show_url + assert_response :success + end +end diff --git a/test/controllers/works_controller_test.rb b/test/controllers/works_controller_test.rb new file mode 100644 index 0000000..58c058b --- /dev/null +++ b/test/controllers/works_controller_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class WorksControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get works_index_url + assert_response :success + end + + test "should get show" do + get works_show_url + assert_response :success + end +end diff --git a/test/fixtures/external_ids.yml b/test/fixtures/external_ids.yml new file mode 100644 index 0000000..c4cbd05 --- /dev/null +++ b/test/fixtures/external_ids.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + work: one + source: 1 + value: MyString + +two: + work: two + source: 1 + value: MyString diff --git a/test/fixtures/playback_sessions.yml b/test/fixtures/playback_sessions.yml new file mode 100644 index 0000000..18458de --- /dev/null +++ b/test/fixtures/playback_sessions.yml @@ -0,0 +1,19 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + video: one + user: one + position: 1.5 + duration_watched: 1.5 + last_watched_at: 2025-10-29 22:39:57 + completed: false + play_count: 1 + +two: + video: two + user: two + position: 1.5 + duration_watched: 1.5 + last_watched_at: 2025-10-29 22:39:57 + completed: false + play_count: 1 diff --git a/test/fixtures/storage_locations.yml b/test/fixtures/storage_locations.yml new file mode 100644 index 0000000..c927bf9 --- /dev/null +++ b/test/fixtures/storage_locations.yml @@ -0,0 +1,23 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + path: MyString + location_type: 1 + writable: false + enabled: false + scan_subdirectories: false + priority: 1 + settings: MyText + last_scanned_at: 2025-10-29 22:38:50 + +two: + name: MyString + path: MyString + location_type: 1 + writable: false + enabled: false + scan_subdirectories: false + priority: 1 + settings: MyText + last_scanned_at: 2025-10-29 22:38:50 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..0951563 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,9 @@ +<% password_digest = BCrypt::Password.create("password") %> + +one: + email_address: one@example.com + password_digest: <%= password_digest %> + +two: + email_address: two@example.com + password_digest: <%= password_digest %> diff --git a/test/fixtures/video_assets.yml b/test/fixtures/video_assets.yml new file mode 100644 index 0000000..53d4f4e --- /dev/null +++ b/test/fixtures/video_assets.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + video: one + asset_type: 1 + metadata: MyText + +two: + video: two + asset_type: 1 + metadata: MyText diff --git a/test/fixtures/videos.yml b/test/fixtures/videos.yml new file mode 100644 index 0000000..d88c09e --- /dev/null +++ b/test/fixtures/videos.yml @@ -0,0 +1,51 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + work: one + storage_location: one + title: MyString + file_path: MyString + file_hash: MyString + file_size: 1 + duration: 1.5 + width: 1 + height: 1 + resolution_label: MyString + video_codec: MyString + audio_codec: MyString + bit_rate: 1 + frame_rate: 1.5 + format: MyString + has_subtitles: false + version_type: MyString + source_type: 1 + source_url: MyString + imported: false + processing_failed: false + error_message: MyText + metadata: MyText + +two: + work: two + storage_location: two + title: MyString + file_path: MyString + file_hash: MyString + file_size: 1 + duration: 1.5 + width: 1 + height: 1 + resolution_label: MyString + video_codec: MyString + audio_codec: MyString + bit_rate: 1 + frame_rate: 1.5 + format: MyString + has_subtitles: false + version_type: MyString + source_type: 1 + source_url: MyString + imported: false + processing_failed: false + error_message: MyText + metadata: MyText diff --git a/test/fixtures/works.yml b/test/fixtures/works.yml new file mode 100644 index 0000000..259a8f6 --- /dev/null +++ b/test/fixtures/works.yml @@ -0,0 +1,23 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + title: MyString + year: 1 + director: MyString + description: MyText + rating: 9.99 + organized: false + poster_path: MyString + backdrop_path: MyString + metadata: MyText + +two: + title: MyString + year: 1 + director: MyString + description: MyText + rating: 9.99 + organized: false + poster_path: MyString + backdrop_path: MyString + metadata: MyText diff --git a/test/mailers/previews/passwords_mailer_preview.rb b/test/mailers/previews/passwords_mailer_preview.rb new file mode 100644 index 0000000..01d07ec --- /dev/null +++ b/test/mailers/previews/passwords_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer +class PasswordsMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset + def reset + PasswordsMailer.reset(User.take) + end +end diff --git a/test/models/external_id_test.rb b/test/models/external_id_test.rb new file mode 100644 index 0000000..8d53e08 --- /dev/null +++ b/test/models/external_id_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ExternalIdTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/playback_session_test.rb b/test/models/playback_session_test.rb new file mode 100644 index 0000000..f9c36ec --- /dev/null +++ b/test/models/playback_session_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class PlaybackSessionTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/storage_location_test.rb b/test/models/storage_location_test.rb new file mode 100644 index 0000000..f3c2bed --- /dev/null +++ b/test/models/storage_location_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class StorageLocationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..83445c4 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + test "downcases and strips email_address" do + user = User.new(email_address: " DOWNCASED@EXAMPLE.COM ") + assert_equal("downcased@example.com", user.email_address) + end +end diff --git a/test/models/video_asset_test.rb b/test/models/video_asset_test.rb new file mode 100644 index 0000000..5a4139e --- /dev/null +++ b/test/models/video_asset_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class VideoAssetTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/video_test.rb b/test/models/video_test.rb new file mode 100644 index 0000000..d3ed367 --- /dev/null +++ b/test/models/video_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class VideoTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/work_test.rb b/test/models/work_test.rb new file mode 100644 index 0000000..b9306b8 --- /dev/null +++ b/test/models/work_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class WorkTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..85c54c6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,7 @@ ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" +require_relative "test_helpers/session_test_helper" module ActiveSupport class TestCase diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb new file mode 100644 index 0000000..0686378 --- /dev/null +++ b/test/test_helpers/session_test_helper.rb @@ -0,0 +1,19 @@ +module SessionTestHelper + def sign_in_as(user) + Current.session = user.sessions.create! + + ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar| + cookie_jar.signed[:session_id] = Current.session.id + cookies["session_id"] = cookie_jar[:session_id] + end + end + + def sign_out + Current.session&.destroy! + cookies.delete("session_id") + end +end + +ActiveSupport.on_load(:action_dispatch_integration_test) do + include SessionTestHelper +end