Much base work started
This commit is contained in:
16
app/channels/application_cable/connection.rb
Normal file
16
app/channels/application_cable/connection.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
identified_by :current_user
|
||||
|
||||
def connect
|
||||
set_current_user || reject_unauthorized_connection
|
||||
end
|
||||
|
||||
private
|
||||
def set_current_user
|
||||
if session = Session.find_by(id: cookies.signed[:session_id])
|
||||
self.current_user = session.user
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
62
app/controllers/admin/storage_locations_controller.rb
Normal file
62
app/controllers/admin/storage_locations_controller.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
module Admin
|
||||
class StorageLocationsController < ApplicationController
|
||||
before_action :set_storage_location, only: [:show, :edit, :update, :destroy]
|
||||
|
||||
def index
|
||||
@storage_locations = StorageLocation.all
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
@storage_location = StorageLocation.new
|
||||
end
|
||||
|
||||
def create
|
||||
@storage_location = StorageLocation.new(storage_location_params)
|
||||
|
||||
if @storage_location.save
|
||||
redirect_to [:admin, @storage_location], notice: "Storage location was successfully created."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @storage_location.update(storage_location_params)
|
||||
redirect_to [:admin, @storage_location], notice: "Storage location was successfully updated."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@storage_location.destroy
|
||||
redirect_to admin_storage_locations_url, notice: "Storage location was successfully destroyed."
|
||||
end
|
||||
|
||||
def scan
|
||||
# Placeholder for scan functionality
|
||||
redirect_to [:admin, @storage_location], notice: "Scan functionality will be implemented."
|
||||
end
|
||||
|
||||
def scan_status
|
||||
# Placeholder for scan status
|
||||
render json: { status: "idle" }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_storage_location
|
||||
@storage_location = StorageLocation.find(params[:id])
|
||||
end
|
||||
|
||||
def storage_location_params
|
||||
params.require(:storage_location).permit(:name, :path, :location_type, :writable, :enabled, :scan_subdirectories, :priority, :settings)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,5 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Authentication
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
|
||||
|
||||
52
app/controllers/concerns/authentication.rb
Normal file
52
app/controllers/concerns/authentication.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
module Authentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :require_authentication
|
||||
helper_method :authenticated?
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def allow_unauthenticated_access(**options)
|
||||
skip_before_action :require_authentication, **options
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def authenticated?
|
||||
resume_session
|
||||
end
|
||||
|
||||
def require_authentication
|
||||
resume_session || request_authentication
|
||||
end
|
||||
|
||||
def resume_session
|
||||
Current.session ||= find_session_by_cookie
|
||||
end
|
||||
|
||||
def find_session_by_cookie
|
||||
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||
end
|
||||
|
||||
def request_authentication
|
||||
session[:return_to_after_authenticating] = request.url
|
||||
redirect_to new_session_path
|
||||
end
|
||||
|
||||
def after_authentication_url
|
||||
session.delete(:return_to_after_authenticating) || root_url
|
||||
end
|
||||
|
||||
def start_new_session_for(user)
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||
Current.session = session
|
||||
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
|
||||
end
|
||||
end
|
||||
|
||||
def terminate_session
|
||||
Current.session.destroy
|
||||
cookies.delete(:session_id)
|
||||
end
|
||||
end
|
||||
35
app/controllers/passwords_controller.rb
Normal file
35
app/controllers/passwords_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class PasswordsController < ApplicationController
|
||||
allow_unauthenticated_access
|
||||
before_action :set_user_by_token, only: %i[ edit update ]
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
if user = User.find_by(email_address: params[:email_address])
|
||||
PasswordsMailer.reset(user).deliver_later
|
||||
end
|
||||
|
||||
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @user.update(params.permit(:password, :password_confirmation))
|
||||
@user.sessions.destroy_all
|
||||
redirect_to new_session_path, notice: "Password has been reset."
|
||||
else
|
||||
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_user_by_token
|
||||
@user = User.find_by_password_reset_token!(params[:token])
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||
end
|
||||
end
|
||||
21
app/controllers/sessions_controller.rb
Normal file
21
app/controllers/sessions_controller.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
class SessionsController < ApplicationController
|
||||
allow_unauthenticated_access only: %i[ new create ]
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
if user = User.authenticate_by(params.permit(:email_address, :password))
|
||||
start_new_session_for user
|
||||
redirect_to after_authentication_url
|
||||
else
|
||||
redirect_to new_session_path, alert: "Try another email address or password."
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
terminate_session
|
||||
redirect_to new_session_path, status: :see_other
|
||||
end
|
||||
end
|
||||
49
app/controllers/storage_locations_controller.rb
Normal file
49
app/controllers/storage_locations_controller.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
class StorageLocationsController < ApplicationController
|
||||
before_action :set_storage_location, only: [:show, :destroy, :scan]
|
||||
|
||||
def index
|
||||
@storage_locations = StorageLocation.all
|
||||
# Auto-discover storage locations on index page load
|
||||
StorageDiscoveryService.discover_and_create
|
||||
end
|
||||
|
||||
def show
|
||||
@videos = @storage_location.videos.includes(:work).recent
|
||||
end
|
||||
|
||||
def create
|
||||
@storage_location = StorageLocation.new(storage_location_params)
|
||||
|
||||
if @storage_location.save
|
||||
redirect_to @storage_location, notice: 'Storage location was successfully created.'
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@storage_location.destroy
|
||||
redirect_to storage_locations_url, notice: 'Storage location was successfully destroyed.'
|
||||
end
|
||||
|
||||
def scan
|
||||
scanner = FileScannerService.new(@storage_location)
|
||||
result = scanner.scan
|
||||
|
||||
if result[:success]
|
||||
redirect_to @storage_location, notice: result[:message]
|
||||
else
|
||||
redirect_to @storage_location, alert: result[:message]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_storage_location
|
||||
@storage_location = StorageLocation.find(params[:id])
|
||||
end
|
||||
|
||||
def storage_location_params
|
||||
params.require(:storage_location).permit(:name, :path, :storage_type)
|
||||
end
|
||||
end
|
||||
58
app/controllers/videos_controller.rb
Normal file
58
app/controllers/videos_controller.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
class VideosController < ApplicationController
|
||||
before_action :set_video, only: [:show, :stream, :playback_position, :retry_processing]
|
||||
|
||||
def show
|
||||
@work = @video.work
|
||||
@last_position = get_last_playback_position
|
||||
end
|
||||
|
||||
def stream
|
||||
file_path = @video.web_stream_path
|
||||
|
||||
unless file_path && File.exist?(file_path)
|
||||
head :not_found
|
||||
return
|
||||
end
|
||||
|
||||
send_file file_path,
|
||||
filename: @video.filename,
|
||||
type: 'video/mp4',
|
||||
disposition: 'inline',
|
||||
stream: true,
|
||||
buffer_size: 4096
|
||||
end
|
||||
|
||||
def playback_position
|
||||
position = params[:position].to_i
|
||||
session = get_or_create_playback_session
|
||||
session.update!(position: position, last_played_at: Time.current)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def retry_processing
|
||||
VideoProcessorJob.perform_later(@video.id)
|
||||
redirect_to @video, notice: 'Video processing has been queued.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_video
|
||||
@video = Video.find(params[:id])
|
||||
end
|
||||
|
||||
def get_last_playback_position
|
||||
# Get from current user's session or cookie
|
||||
session_key = "video_position_#{@video.id}"
|
||||
session[session_key] || 0
|
||||
end
|
||||
|
||||
def get_or_create_playback_session
|
||||
# For Phase 1, we'll use a simple session-based approach
|
||||
# Phase 2 will use proper user authentication
|
||||
PlaybackSession.find_or_initialize_by(
|
||||
video: @video,
|
||||
session_id: session.id.to_s,
|
||||
user_id: nil # Will be populated in Phase 2
|
||||
)
|
||||
end
|
||||
end
|
||||
18
app/controllers/works_controller.rb
Normal file
18
app/controllers/works_controller.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class WorksController < ApplicationController
|
||||
before_action :set_work, only: [:show]
|
||||
|
||||
def index
|
||||
@works = Work.includes(:videos).recent
|
||||
end
|
||||
|
||||
def show
|
||||
@videos = @work.videos.includes(:storage_location).recent
|
||||
@primary_video = @work.primary_video
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_work
|
||||
@work = Work.find(params[:id])
|
||||
end
|
||||
end
|
||||
2
app/helpers/videos_helper.rb
Normal file
2
app/helpers/videos_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module VideosHelper
|
||||
end
|
||||
63
app/jobs/video_processor_job.rb
Normal file
63
app/jobs/video_processor_job.rb
Normal file
@@ -0,0 +1,63 @@
|
||||
class VideoProcessorJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(video_id)
|
||||
video = Video.find(video_id)
|
||||
|
||||
# Extract metadata
|
||||
metadata = VideoMetadataExtractor.new(video.full_file_path).extract
|
||||
video.update!(video_metadata: metadata)
|
||||
|
||||
# Check if web compatible
|
||||
transcoder = VideoTranscoder.new
|
||||
web_compatible = transcoder.web_compatible?(video.full_file_path)
|
||||
video.update!(web_compatible: web_compatible)
|
||||
|
||||
# Generate thumbnail
|
||||
generate_thumbnail(video)
|
||||
|
||||
# Transcode if needed
|
||||
unless web_compatible
|
||||
transcode_video(video, transcoder)
|
||||
end
|
||||
|
||||
video.update!(processed: true)
|
||||
rescue => e
|
||||
video.update!(processing_errors: e.message)
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_thumbnail(video)
|
||||
transcoder = VideoTranscoder.new
|
||||
|
||||
# Generate thumbnail at 10% of duration or 5 seconds if duration unknown
|
||||
thumbnail_time = video.duration ? video.duration * 0.1 : 5
|
||||
thumbnail_path = transcoder.extract_frame(video.full_file_path, thumbnail_time)
|
||||
|
||||
# Attach thumbnail as video asset
|
||||
video.video_assets.create!(
|
||||
asset_type: 'thumbnail',
|
||||
file: File.open(thumbnail_path)
|
||||
)
|
||||
|
||||
# Clean up temporary file
|
||||
File.delete(thumbnail_path) if File.exist?(thumbnail_path)
|
||||
end
|
||||
|
||||
def transcode_video(video, transcoder)
|
||||
output_path = video.full_file_path.gsub(/\.[^.]+$/, '.web.mp4')
|
||||
|
||||
transcoder.transcode_for_web(
|
||||
input_path: video.full_file_path,
|
||||
output_path: output_path
|
||||
)
|
||||
|
||||
video.update!(
|
||||
transcoded_path: File.basename(output_path),
|
||||
transcoded_permanently: true,
|
||||
web_compatible: true
|
||||
)
|
||||
end
|
||||
end
|
||||
6
app/mailers/passwords_mailer.rb
Normal file
6
app/mailers/passwords_mailer.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class PasswordsMailer < ApplicationMailer
|
||||
def reset(user)
|
||||
@user = user
|
||||
mail subject: "Reset your password", to: user.email_address
|
||||
end
|
||||
end
|
||||
26
app/models/concerns/processable.rb
Normal file
26
app/models/concerns/processable.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
module Processable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :processing_pending, -> { where("video_metadata->>'duration' IS NULL") }
|
||||
end
|
||||
|
||||
def processed?
|
||||
duration.present?
|
||||
end
|
||||
|
||||
def processing_pending?
|
||||
!processed? && processing_errors.blank?
|
||||
end
|
||||
|
||||
def mark_processing_failed!(error_message)
|
||||
self.processing_errors = [error_message]
|
||||
save!
|
||||
end
|
||||
|
||||
def retry_processing!
|
||||
self.processing_errors = []
|
||||
save!
|
||||
# VideoProcessorJob.perform_later(id) - will be implemented later
|
||||
end
|
||||
end
|
||||
11
app/models/concerns/searchable.rb
Normal file
11
app/models/concerns/searchable.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
module Searchable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def search(query)
|
||||
return all if query.blank?
|
||||
|
||||
where("title LIKE ?", "%#{sanitize_sql_like(query)}%")
|
||||
end
|
||||
end
|
||||
end
|
||||
19
app/models/concerns/streamable.rb
Normal file
19
app/models/concerns/streamable.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
module Streamable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def stream_url
|
||||
storage_location.adapter.stream_url(self)
|
||||
end
|
||||
|
||||
def streamable?
|
||||
duration.present? && storage_location.enabled? && storage_location.adapter.readable?
|
||||
end
|
||||
|
||||
def stream_type
|
||||
case source_type
|
||||
when "s3" then :presigned
|
||||
when "local" then :direct
|
||||
else :proxy
|
||||
end
|
||||
end
|
||||
end
|
||||
4
app/models/current.rb
Normal file
4
app/models/current.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :session
|
||||
delegate :user, to: :session, allow_nil: true
|
||||
end
|
||||
3
app/models/external_id.rb
Normal file
3
app/models/external_id.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class ExternalId < ApplicationRecord
|
||||
belongs_to :work
|
||||
end
|
||||
75
app/models/media_file.rb
Normal file
75
app/models/media_file.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
class MediaFile < ApplicationRecord
|
||||
# Base class for all media files (Video, Audio, etc.)
|
||||
# Uses Single Table Inheritance (STI) via the 'type' column
|
||||
self.table_name = 'videos'
|
||||
self.abstract_class = true
|
||||
|
||||
include Streamable
|
||||
include Processable
|
||||
|
||||
# Common JSON stores for flexible metadata
|
||||
store :fingerprints, accessors: [:xxhash64, :md5, :oshash, :phash]
|
||||
store :media_metadata, accessors: [:duration, :codec, :bit_rate, :format]
|
||||
|
||||
# Common associations
|
||||
belongs_to :work
|
||||
belongs_to :storage_location
|
||||
has_many :playback_sessions, dependent: :destroy
|
||||
|
||||
# Common validations
|
||||
validates :filename, presence: true
|
||||
validates :xxhash64, presence: true, uniqueness: { scope: :storage_location_id }
|
||||
|
||||
# Common scopes
|
||||
scope :web_compatible, -> { where(web_compatible: true) }
|
||||
scope :needs_transcoding, -> { where(web_compatible: false) }
|
||||
scope :recent, -> { order(created_at: :desc) }
|
||||
|
||||
# Common delegations
|
||||
delegate :display_title, to: :work, prefix: true, allow_nil: true
|
||||
|
||||
# Common instance methods
|
||||
def display_title
|
||||
work&.display_title || filename
|
||||
end
|
||||
|
||||
def full_file_path
|
||||
File.join(storage_location.path, filename)
|
||||
end
|
||||
|
||||
def format_duration
|
||||
return "Unknown" unless duration
|
||||
|
||||
hours = (duration / 3600).to_i
|
||||
minutes = ((duration % 3600) / 60).to_i
|
||||
seconds = (duration % 60).to_i
|
||||
|
||||
if hours > 0
|
||||
"%d:%02d:%02d" % [hours, minutes, seconds]
|
||||
else
|
||||
"%d:%02d" % [minutes, seconds]
|
||||
end
|
||||
end
|
||||
|
||||
# Template method for subclasses to override
|
||||
def web_stream_path
|
||||
# Default implementation - subclasses can override for specific behavior
|
||||
if transcoded_permanently? && transcoded_path && File.exist?(transcoded_full_path)
|
||||
return transcoded_full_path
|
||||
end
|
||||
if transcoded_path && File.exist?(temp_transcoded_full_path)
|
||||
return temp_transcoded_full_path
|
||||
end
|
||||
full_file_path if web_compatible?
|
||||
end
|
||||
|
||||
def transcoded_full_path
|
||||
return nil unless transcoded_path
|
||||
File.join(storage_location.path, transcoded_path)
|
||||
end
|
||||
|
||||
def temp_transcoded_full_path
|
||||
return nil unless transcoded_path
|
||||
File.join(Rails.root, 'tmp', 'transcodes', storage_location.id.to_s, transcoded_path)
|
||||
end
|
||||
end
|
||||
4
app/models/playback_session.rb
Normal file
4
app/models/playback_session.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class PlaybackSession < ApplicationRecord
|
||||
belongs_to :video
|
||||
belongs_to :user
|
||||
end
|
||||
3
app/models/session.rb
Normal file
3
app/models/session.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Session < ApplicationRecord
|
||||
belongs_to :user
|
||||
end
|
||||
27
app/models/storage_location.rb
Normal file
27
app/models/storage_location.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
class StorageLocation < ApplicationRecord
|
||||
has_many :videos, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :path, presence: true, uniqueness: true
|
||||
validates :storage_type, presence: true, inclusion: { in: %w[local] }
|
||||
|
||||
validate :path_must_exist_and_be_readable
|
||||
|
||||
def accessible?
|
||||
File.exist?(path) && File.readable?(path)
|
||||
end
|
||||
|
||||
def video_count
|
||||
videos.count
|
||||
end
|
||||
|
||||
def display_name
|
||||
name
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def path_must_exist_and_be_readable
|
||||
errors.add(:path, "must exist and be readable") unless accessible?
|
||||
end
|
||||
end
|
||||
6
app/models/user.rb
Normal file
6
app/models/user.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class User < ApplicationRecord
|
||||
has_secure_password
|
||||
has_many :sessions, dependent: :destroy
|
||||
|
||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||
end
|
||||
20
app/models/video.rb
Normal file
20
app/models/video.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
class Video < MediaFile
|
||||
# Video-specific associations
|
||||
has_many :video_assets, dependent: :destroy
|
||||
|
||||
# Video-specific metadata store
|
||||
store :video_metadata, accessors: [:width, :height, :video_codec, :audio_codec, :frame_rate]
|
||||
|
||||
# Video-specific instance methods
|
||||
def resolution_label
|
||||
return "Unknown" unless height
|
||||
case height
|
||||
when 0..480 then "SD"
|
||||
when 481..720 then "720p"
|
||||
when 721..1080 then "1080p"
|
||||
when 1081..1440 then "1440p"
|
||||
when 1441..2160 then "4K"
|
||||
else "8K+"
|
||||
end
|
||||
end
|
||||
end
|
||||
3
app/models/video_asset.rb
Normal file
3
app/models/video_asset.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class VideoAsset < ApplicationRecord
|
||||
belongs_to :video
|
||||
end
|
||||
100
app/models/work.rb
Normal file
100
app/models/work.rb
Normal file
@@ -0,0 +1,100 @@
|
||||
class Work < ApplicationRecord
|
||||
# 1. Includes/Concerns
|
||||
include Searchable
|
||||
|
||||
# 2. JSON Store for flexible metadata
|
||||
store :metadata, accessors: [:tmdb_data, :imdb_data, :custom_fields]
|
||||
store :tmdb_data, accessors: [:overview, :poster_path, :backdrop_path, :release_date, :genres]
|
||||
store :imdb_data, accessors: [:plot, :rating, :votes, :runtime, :director]
|
||||
store :custom_fields
|
||||
|
||||
# 3. Associations
|
||||
has_many :videos, dependent: :nullify
|
||||
has_many :external_ids, dependent: :destroy
|
||||
has_one :primary_video, -> { order("(video_metadata->>'height')::int DESC") }, class_name: "Video"
|
||||
|
||||
# 4. Validations
|
||||
validates :title, presence: true
|
||||
validates :year, numericality: { only_integer: true, greater_than: 1800 }, allow_nil: true
|
||||
validates :rating, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }, allow_nil: true
|
||||
|
||||
# 5. Scopes
|
||||
scope :organized, -> { where(organized: true) }
|
||||
scope :unorganized, -> { where(organized: false) }
|
||||
scope :recent, -> { order(created_at: :desc) }
|
||||
scope :by_title, -> { order(:title) }
|
||||
scope :with_year, -> { where.not(year: nil) }
|
||||
|
||||
# 6. Delegations
|
||||
delegate :resolution_label, :duration, to: :primary_video, prefix: true, allow_nil: true
|
||||
|
||||
# 7. Class methods
|
||||
def self.search(query)
|
||||
where("title LIKE ? OR director LIKE ?", "%#{sanitize_sql_like(query)}%", "%#{sanitize_sql_like(query)}%")
|
||||
end
|
||||
|
||||
def self.find_by_external_id(source, value)
|
||||
joins(:external_ids).find_by(external_ids: { source: source, value: value })
|
||||
end
|
||||
|
||||
# 8. Instance methods
|
||||
def display_title
|
||||
year ? "#{title} (#{year})" : title
|
||||
end
|
||||
|
||||
def video_count
|
||||
videos.count
|
||||
end
|
||||
|
||||
def total_duration
|
||||
videos.sum("(video_metadata->>'duration')::float")
|
||||
end
|
||||
|
||||
def available_versions
|
||||
videos.group_by(&:resolution_label)
|
||||
end
|
||||
|
||||
def has_external_ids?
|
||||
external_ids.exists?
|
||||
end
|
||||
|
||||
def poster_url
|
||||
poster_path || tmdb_data['poster_path']
|
||||
end
|
||||
|
||||
def backdrop_url
|
||||
backdrop_path || tmdb_data['backdrop_path']
|
||||
end
|
||||
|
||||
def description
|
||||
return read_attribute(:description) if read_attribute(:description).present?
|
||||
tmdb_data['overview'] || imdb_data['plot']
|
||||
end
|
||||
|
||||
def effective_director
|
||||
return read_attribute(:director) if read_attribute(:director).present?
|
||||
imdb_data['director']
|
||||
end
|
||||
|
||||
def effective_rating
|
||||
return read_attribute(:rating) if read_attribute(:rating).present?
|
||||
imdb_data['rating']&.to_f
|
||||
end
|
||||
|
||||
# Convenience accessors for common external IDs
|
||||
# Auto-generated for all sources (will be implemented when we add ExternalId model logic)
|
||||
# ExternalId.sources.keys.each do |source_name|
|
||||
# define_method("#{source_name}_id") do
|
||||
# external_ids.find_by(source: source_name)&.value
|
||||
# end
|
||||
#
|
||||
# define_method("#{source_name}_id=") do |value|
|
||||
# return if value.blank?
|
||||
# external_ids.find_or_initialize_by(source: source_name).update!(value: value)
|
||||
# end
|
||||
#
|
||||
# define_method("#{source_name}_url") do
|
||||
# external_ids.find_by(source: source_name)&.url
|
||||
# end
|
||||
# end
|
||||
end
|
||||
56
app/services/file_scanner_service.rb
Normal file
56
app/services/file_scanner_service.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class FileScannerService
|
||||
def initialize(storage_location)
|
||||
@storage_location = storage_location
|
||||
end
|
||||
|
||||
def scan
|
||||
return failure_result("Storage location not accessible") unless @storage_location.accessible?
|
||||
|
||||
video_files = find_video_files
|
||||
new_videos = process_files(video_files)
|
||||
|
||||
success_result(new_videos)
|
||||
rescue => e
|
||||
failure_result(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_video_files
|
||||
Dir.glob(File.join(@storage_location.path, "**", "*.{mp4,avi,mkv,mov,wmv,flv,webm,m4v}"))
|
||||
end
|
||||
|
||||
def process_files(file_paths)
|
||||
new_videos = []
|
||||
|
||||
file_paths.each do |file_path|
|
||||
filename = File.basename(file_path)
|
||||
|
||||
next if Video.exists?(filename: filename, storage_location: @storage_location)
|
||||
|
||||
video = Video.create!(
|
||||
filename: filename,
|
||||
storage_location: @storage_location,
|
||||
work: Work.find_or_create_by(title: extract_title(filename))
|
||||
)
|
||||
|
||||
new_videos << video
|
||||
VideoProcessorJob.perform_later(video.id)
|
||||
end
|
||||
|
||||
new_videos
|
||||
end
|
||||
|
||||
def extract_title(filename)
|
||||
# Simple title extraction - can be enhanced
|
||||
File.basename(filename, ".*").gsub(/[\[\(].*?[\]\)]/, "").strip
|
||||
end
|
||||
|
||||
def success_result(videos = [])
|
||||
{ success: true, videos: videos, message: "Found #{videos.length} new videos" }
|
||||
end
|
||||
|
||||
def failure_result(message)
|
||||
{ success: false, message: message }
|
||||
end
|
||||
end
|
||||
35
app/services/result.rb
Normal file
35
app/services/result.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class Result
|
||||
attr_reader :data, :error
|
||||
|
||||
def initialize(success:, data: {}, error: nil)
|
||||
@success = success
|
||||
@data = data
|
||||
@error = error
|
||||
end
|
||||
|
||||
def success?
|
||||
@success
|
||||
end
|
||||
|
||||
def failure?
|
||||
!@success
|
||||
end
|
||||
|
||||
def self.success(data = {})
|
||||
new(success: true, data: data)
|
||||
end
|
||||
|
||||
def self.failure(error)
|
||||
new(success: false, error: error)
|
||||
end
|
||||
|
||||
# Allow accessing data as methods
|
||||
def method_missing(method, *args)
|
||||
return @data[method] if @data.key?(method)
|
||||
super
|
||||
end
|
||||
|
||||
def respond_to_missing?(method, include_private = false)
|
||||
@data.key?(method) || super
|
||||
end
|
||||
end
|
||||
46
app/services/storage_adapters/base_adapter.rb
Normal file
46
app/services/storage_adapters/base_adapter.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
module StorageAdapters
|
||||
class BaseAdapter
|
||||
def initialize(storage_location)
|
||||
@storage_location = storage_location
|
||||
end
|
||||
|
||||
# Scan for video files and return array of relative paths
|
||||
def scan
|
||||
raise NotImplementedError, "#{self.class} must implement #scan"
|
||||
end
|
||||
|
||||
# Generate streaming URL for a video
|
||||
def stream_url(video)
|
||||
raise NotImplementedError, "#{self.class} must implement #stream_url"
|
||||
end
|
||||
|
||||
# Check if file exists at path
|
||||
def exists?(file_path)
|
||||
raise NotImplementedError, "#{self.class} must implement #exists?"
|
||||
end
|
||||
|
||||
# Check if storage can be read from
|
||||
def readable?
|
||||
raise NotImplementedError, "#{self.class} must implement #readable?"
|
||||
end
|
||||
|
||||
# Check if storage can be written to
|
||||
def writable?
|
||||
@storage_location.writable?
|
||||
end
|
||||
|
||||
# Write/copy file to storage
|
||||
def write(source_path, dest_path)
|
||||
raise NotImplementedError, "#{self.class} must implement #write"
|
||||
end
|
||||
|
||||
# Download file to local temp path (for processing)
|
||||
def download_to_temp(video)
|
||||
raise NotImplementedError, "#{self.class} must implement #download_to_temp"
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
attr_reader :storage_location
|
||||
end
|
||||
end
|
||||
59
app/services/storage_adapters/local_adapter.rb
Normal file
59
app/services/storage_adapters/local_adapter.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
module StorageAdapters
|
||||
class LocalAdapter < BaseAdapter
|
||||
VIDEO_EXTENSIONS = %w[.mp4 .mkv .avi .mov .wmv .flv .webm .m4v].freeze
|
||||
|
||||
def scan
|
||||
return [] unless readable?
|
||||
|
||||
pattern = if storage_location.scan_subdirectories
|
||||
File.join(storage_location.path, "**", "*{#{VIDEO_EXTENSIONS.join(',')}}")
|
||||
else
|
||||
File.join(storage_location.path, "*{#{VIDEO_EXTENSIONS.join(',')}}")
|
||||
end
|
||||
|
||||
Dir.glob(pattern, File::FNM_CASEFOLD).map do |full_path|
|
||||
full_path.sub(storage_location.path + "/", "")
|
||||
end
|
||||
end
|
||||
|
||||
def stream_url(video)
|
||||
full_path(video)
|
||||
end
|
||||
|
||||
def exists?(file_path)
|
||||
File.exist?(full_path_from_relative(file_path))
|
||||
end
|
||||
|
||||
def readable?
|
||||
return false unless storage_location.path.present?
|
||||
|
||||
File.directory?(storage_location.path) && File.readable?(storage_location.path)
|
||||
end
|
||||
|
||||
def writable?
|
||||
super && File.writable?(storage_location.path)
|
||||
end
|
||||
|
||||
def write(source_path, dest_path)
|
||||
dest_full_path = full_path_from_relative(dest_path)
|
||||
FileUtils.mkdir_p(File.dirname(dest_full_path))
|
||||
FileUtils.cp(source_path, dest_full_path)
|
||||
dest_path
|
||||
end
|
||||
|
||||
def download_to_temp(video)
|
||||
# Already local, return path
|
||||
full_path(video)
|
||||
end
|
||||
|
||||
def full_path(video)
|
||||
full_path_from_relative(video.file_path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def full_path_from_relative(file_path)
|
||||
File.join(storage_location.path, file_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
46
app/services/storage_discovery_service.rb
Normal file
46
app/services/storage_discovery_service.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
class StorageDiscoveryService
|
||||
CATEGORIES = {
|
||||
'movies' => 'Movies',
|
||||
'tv' => 'TV Shows',
|
||||
'tv_shows' => 'TV Shows',
|
||||
'series' => 'TV Shows',
|
||||
'docs' => 'Documentaries',
|
||||
'documentaries' => 'Documentaries',
|
||||
'anime' => 'Anime',
|
||||
'cartoons' => 'Animation',
|
||||
'animation' => 'Animation',
|
||||
'sports' => 'Sports',
|
||||
'music' => 'Music Videos',
|
||||
'music_videos' => 'Music Videos',
|
||||
'kids' => 'Kids Content',
|
||||
'family' => 'Family Content'
|
||||
}.freeze
|
||||
|
||||
def self.discover_and_create
|
||||
base_path = '/videos'
|
||||
return [] unless Dir.exist?(base_path)
|
||||
|
||||
discovered = []
|
||||
|
||||
Dir.children(base_path).each do |subdir|
|
||||
dir_path = File.join(base_path, subdir)
|
||||
next unless Dir.exist?(dir_path)
|
||||
|
||||
category = categorize_directory(subdir)
|
||||
storage = StorageLocation.find_or_create_by!(
|
||||
name: "#{category}: #{subdir.titleize}",
|
||||
path: dir_path,
|
||||
storage_type: 'local'
|
||||
)
|
||||
|
||||
discovered << storage
|
||||
end
|
||||
|
||||
discovered
|
||||
end
|
||||
|
||||
def self.categorize_directory(dirname)
|
||||
downcase = dirname.downcase
|
||||
CATEGORIES[downcase] || CATEGORIES.find { |key, _| downcase.include?(key) }&.last || 'Other'
|
||||
end
|
||||
end
|
||||
12
app/services/video_metadata_extractor.rb
Normal file
12
app/services/video_metadata_extractor.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class VideoMetadataExtractor
|
||||
def initialize(file_path)
|
||||
@file_path = file_path
|
||||
@transcoder = VideoTranscoder.new
|
||||
end
|
||||
|
||||
def extract
|
||||
return {} unless File.exist?(@file_path)
|
||||
|
||||
@transcoder.extract_metadata(@file_path)
|
||||
end
|
||||
end
|
||||
75
app/services/video_transcoder.rb
Normal file
75
app/services/video_transcoder.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
class VideoTranscoder
|
||||
require 'streamio-ffmpeg'
|
||||
|
||||
def initialize
|
||||
@ffmpeg_path = ENV['FFMPEG_PATH'] || 'ffmpeg'
|
||||
@ffprobe_path = ENV['FFPROBE_PATH'] || 'ffprobe'
|
||||
end
|
||||
|
||||
def transcode_for_web(input_path:, output_path:, on_progress: nil)
|
||||
movie = FFMPEG::Movie.new(input_path)
|
||||
|
||||
# Calculate progress callback
|
||||
progress_callback = ->(progress) {
|
||||
on_progress&.call(progress, 100)
|
||||
}
|
||||
|
||||
# Transcoding options for web compatibility
|
||||
options = {
|
||||
video_codec: 'libx264',
|
||||
audio_codec: 'aac',
|
||||
custom: [
|
||||
'-pix_fmt yuv420p',
|
||||
'-preset medium',
|
||||
'-crf 23',
|
||||
'-movflags +faststart',
|
||||
'-tune fastdecode'
|
||||
]
|
||||
}
|
||||
|
||||
movie.transcode(output_path, options, &progress_callback)
|
||||
|
||||
output_path
|
||||
end
|
||||
|
||||
def extract_frame(input_path, seconds)
|
||||
movie = FFMPEG::Movie.new(input_path)
|
||||
output_path = "#{Rails.root}/tmp/thumbnail_#{SecureRandom.hex(8)}.jpg"
|
||||
|
||||
movie.screenshot(output_path, seek_time: seconds, resolution: '320x240')
|
||||
output_path
|
||||
end
|
||||
|
||||
def extract_metadata(input_path)
|
||||
movie = FFMPEG::Movie.new(input_path)
|
||||
|
||||
{
|
||||
width: movie.width,
|
||||
height: movie.height,
|
||||
duration: movie.duration,
|
||||
video_codec: movie.video_codec,
|
||||
audio_codec: movie.audio_codec,
|
||||
bit_rate: movie.bitrate,
|
||||
frame_rate: movie.frame_rate,
|
||||
format: movie.container
|
||||
}
|
||||
end
|
||||
|
||||
def web_compatible?(input_path)
|
||||
movie = FFMPEG::Movie.new(input_path)
|
||||
|
||||
# Check if video is already web-compatible
|
||||
return false unless movie.valid?
|
||||
|
||||
# Common web-compatible formats
|
||||
web_formats = %w[mp4 webm]
|
||||
web_video_codecs = %w[h264 av1 vp9]
|
||||
web_audio_codecs = %w[aac opus]
|
||||
|
||||
format_compatible = web_formats.include?(movie.container.downcase)
|
||||
video_compatible = web_video_codecs.include?(movie.video_codec&.downcase)
|
||||
audio_compatible = movie.audio_codec.blank? || web_audio_codecs.include?(movie.audio_codec&.downcase)
|
||||
|
||||
format_compatible && video_compatible && audio_compatible
|
||||
end
|
||||
end
|
||||
21
app/views/passwords/edit.html.erb
Normal file
21
app/views/passwords/edit.html.erb
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="mx-auto md:w-2/3 w-full">
|
||||
<% if alert = flash[:alert] %>
|
||||
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
|
||||
<% end %>
|
||||
|
||||
<h1 class="font-bold text-4xl">Update your password</h1>
|
||||
|
||||
<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
|
||||
<div class="my-5">
|
||||
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="inline">
|
||||
<%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
17
app/views/passwords/new.html.erb
Normal file
17
app/views/passwords/new.html.erb
Normal file
@@ -0,0 +1,17 @@
|
||||
<div class="mx-auto md:w-2/3 w-full">
|
||||
<% if alert = flash[:alert] %>
|
||||
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
|
||||
<% end %>
|
||||
|
||||
<h1 class="font-bold text-4xl">Forgot your password?</h1>
|
||||
|
||||
<%= form_with url: passwords_path, class: "contents" do |form| %>
|
||||
<div class="my-5">
|
||||
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="inline">
|
||||
<%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
6
app/views/passwords_mailer/reset.html.erb
Normal file
6
app/views/passwords_mailer/reset.html.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
<p>
|
||||
You can reset your password on
|
||||
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
|
||||
|
||||
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
||||
</p>
|
||||
4
app/views/passwords_mailer/reset.text.erb
Normal file
4
app/views/passwords_mailer/reset.text.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
You can reset your password on
|
||||
<%= edit_password_url(@user.password_reset_token) %>
|
||||
|
||||
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
||||
31
app/views/sessions/new.html.erb
Normal file
31
app/views/sessions/new.html.erb
Normal file
@@ -0,0 +1,31 @@
|
||||
<div class="mx-auto md:w-2/3 w-full">
|
||||
<% if alert = flash[:alert] %>
|
||||
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
|
||||
<% end %>
|
||||
|
||||
<% if notice = flash[:notice] %>
|
||||
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
|
||||
<% end %>
|
||||
|
||||
<h1 class="font-bold text-4xl">Sign in</h1>
|
||||
|
||||
<%= form_with url: session_url, class: "contents" do |form| %>
|
||||
<div class="my-5">
|
||||
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:flex sm:items-center sm:gap-4">
|
||||
<div class="inline">
|
||||
<%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-500 sm:mt-0">
|
||||
<%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
4
app/views/storage_locations/create.html.erb
Normal file
4
app/views/storage_locations/create.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">StorageLocations#create</h1>
|
||||
<p>Find me in app/views/storage_locations/create.html.erb</p>
|
||||
</div>
|
||||
4
app/views/storage_locations/destroy.html.erb
Normal file
4
app/views/storage_locations/destroy.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">StorageLocations#destroy</h1>
|
||||
<p>Find me in app/views/storage_locations/destroy.html.erb</p>
|
||||
</div>
|
||||
81
app/views/storage_locations/index.html.erb
Normal file
81
app/views/storage_locations/index.html.erb
Normal file
@@ -0,0 +1,81 @@
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Video Library</h1>
|
||||
<% if @storage_locations.any? %>
|
||||
<%= link_to "New Storage Location", new_storage_location_path,
|
||||
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @storage_locations.empty? %>
|
||||
<div class="text-center py-12 bg-white rounded-lg shadow">
|
||||
<div class="text-gray-500 text-lg mb-4">No storage locations found</div>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Storage locations are automatically discovered from directories mounted under <code class="bg-gray-100 px-2 py-1 rounded">/videos</code>
|
||||
</p>
|
||||
<div class="text-sm text-gray-500">
|
||||
<p class="mb-2">Example Docker volume mounts:</p>
|
||||
<code class="block bg-gray-100 p-3 rounded text-left">
|
||||
/path/to/movies:/videos/movies:ro<br>
|
||||
/path/to/tv_shows:/videos/tv:ro<br>
|
||||
/path/to/documentaries:/videos/docs:ro
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<% @storage_locations.each do |storage_location| %>
|
||||
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow">
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
||||
<%= link_to storage_location.name, storage_location,
|
||||
class: "hover:text-blue-600 transition-colors" %>
|
||||
</h2>
|
||||
|
||||
<div class="text-gray-600 text-sm mb-4">
|
||||
<p class="mb-1">
|
||||
<span class="font-medium">Path:</span>
|
||||
<code class="bg-gray-100 px-1 py-0.5 rounded text-xs"><%= storage_location.path %></code>
|
||||
</p>
|
||||
<p class="mb-1">
|
||||
<span class="font-medium">Type:</span>
|
||||
<%= storage_location.storage_type.titleize %>
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium">Videos:</span>
|
||||
<%= storage_location.video_count %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% if storage_location.accessible? %>
|
||||
<div class="flex items-center text-green-600 text-sm mb-4">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Accessible
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex items-center text-red-600 text-sm mb-4">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Not Accessible
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<%= link_to "View Videos", storage_location,
|
||||
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-3 rounded text-sm transition-colors" %>
|
||||
|
||||
<%= form_with(url: scan_storage_location_path(storage_location), method: :post,
|
||||
class: "inline-flex") do |form| %>
|
||||
<%= form.submit "Scan",
|
||||
class: "bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-3 rounded text-sm cursor-pointer transition-colors" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
118
app/views/storage_locations/show.html.erb
Normal file
118
app/views/storage_locations/show.html.erb
Normal file
@@ -0,0 +1,118 @@
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900"><%= @storage_location.name %></h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
<span class="font-medium">Path:</span>
|
||||
<code class="bg-gray-100 px-2 py-1 rounded"><%= @storage_location.path %></code>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<%= link_to "← Back to Library", storage_locations_path,
|
||||
class: "bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 rounded-lg transition-colors" %>
|
||||
|
||||
<%= form_with(url: scan_storage_location_path(@storage_location), method: :post,
|
||||
class: "inline-flex") do |form| %>
|
||||
<%= form.submit "Scan for Videos",
|
||||
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg cursor-pointer transition-colors" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @videos.empty? %>
|
||||
<div class="text-center py-12 bg-white rounded-lg shadow">
|
||||
<div class="text-gray-500 text-lg mb-4">No videos found</div>
|
||||
<p class="text-gray-600">
|
||||
This storage location doesn't contain any video files yet. Try scanning for videos to add them to your library.
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
Videos (<%= @videos.count %>)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-200">
|
||||
<% @videos.each do |video| %>
|
||||
<div class="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- Thumbnail placeholder -->
|
||||
<div class="flex-shrink-0">
|
||||
<% if video.video_assets.where(asset_type: 'thumbnail').any? %>
|
||||
<%= image_tag video.video_assets.where(asset_type: 'thumbnail').first.file,
|
||||
class: "w-24 h-16 object-cover rounded", alt: video.display_title %>
|
||||
<% else %>
|
||||
<div class="w-24 h-16 bg-gray-200 rounded flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Video info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-1">
|
||||
<%= link_to video.display_title, video,
|
||||
class: "hover:text-blue-600 transition-colors" %>
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-wrap gap-4 text-sm text-gray-600 mb-2">
|
||||
<span>
|
||||
<span class="font-medium">Duration:</span>
|
||||
<%= video.format_duration %>
|
||||
</span>
|
||||
<span>
|
||||
<span class="font-medium">Resolution:</span>
|
||||
<%= video.resolution_label %>
|
||||
</span>
|
||||
<span>
|
||||
<span class="font-medium">Size:</span>
|
||||
<%= number_to_human_size(video.video_metadata['file_size']) rescue "Unknown" %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3 text-sm">
|
||||
<% if video.web_compatible? %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Web Compatible
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Needs Transcoding
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<% if video.processed? %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Processed
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Processing
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<% if video.work&.title && video.work.title != video.display_title %>
|
||||
<span class="text-gray-500">
|
||||
Part of: <%= link_to video.work.title, video.work,
|
||||
class: "hover:text-blue-600 transition-colors" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex-shrink-0">
|
||||
<%= link_to "Watch", video,
|
||||
class: "bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition-colors" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
76
app/views/videos/index.html.erb
Normal file
76
app/views/videos/index.html.erb
Normal file
@@ -0,0 +1,76 @@
|
||||
<% content_for :title, "Videos" %>
|
||||
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Video Library</h1>
|
||||
<div class="flex gap-2">
|
||||
<% if @storage_locations.any? %>
|
||||
<select class="rounded-md border-gray-300 border px-3 py-2 text-sm" id="storage-filter">
|
||||
<option value="">All Sources</option>
|
||||
<% @storage_locations.each do |location| %>
|
||||
<option value="<%= location.id %>"><%= location.display_name %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<% end %>
|
||||
<%= link_to "New Storage Location", new_admin_storage_location_path, class: "bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @videos.any? %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<% @videos.each do |video| %>
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div class="aspect-video bg-gray-200 relative">
|
||||
<%# Placeholder for thumbnails - Phase 1C will add actual thumbnails %>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0120 8.618m6.418 2.276L11 14.914M4.418 4.418a2 2 0 00-2.828 0l-4.418 4.418a2 2 0 002.828 0l4.418-4.418a2 2 0 012.828 0l4.418 4.418a2 2 0 012.828 0l4.418-4.418z" />
|
||||
</svg>
|
||||
</div>
|
||||
<%# Badge for source type %>
|
||||
<div class="absolute top-2 left-2 bg-gray-800 text-white text-xs px-2 py-1 rounded">
|
||||
<%= video.storage_location.name %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-900 truncate mb-2">
|
||||
<%= link_to video.display_title, video_path(video), class: "hover:text-blue-600" %>
|
||||
</h3>
|
||||
|
||||
<div class="text-sm text-gray-500 space-y-1">
|
||||
<div>Duration: <%= video.formatted_duration %></div>
|
||||
<div>Size: <%= video.formatted_file_size %></div>
|
||||
<% if video.resolution_label.present? %>
|
||||
<div>Resolution: <%= video.resolution_label %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex justify-between items-center">
|
||||
<div class="text-xs text-gray-400">
|
||||
<% if video.processing_errors.present? %>
|
||||
<span class="text-red-500">Failed</span>
|
||||
<% elsif video.processed? %>
|
||||
<span class="text-green-500">Processed</span>
|
||||
<% else %>
|
||||
<span class="text-yellow-500">Processing</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= link_to "Watch", video_path(video), class: "bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded text-xs" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Pagination with Pagy -->
|
||||
<%== pagy_nav(@pagy) %>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M9 16h6" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900">No videos found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by adding a storage location and scanning for videos.</p>
|
||||
<%= link_to "Add Storage Location", new_admin_storage_location_path, class: "mt-4 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %>
|
||||
</div>
|
||||
<% end %>
|
||||
106
app/views/videos/show.html.erb
Normal file
106
app/views/videos/show.html.erb
Normal file
@@ -0,0 +1,106 @@
|
||||
<% content_for :title, @video.display_title %>
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="aspect-video bg-gray-200 relative">
|
||||
<%# Placeholder for video player - Phase 1B will add Video.js %>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 0 0-5.656 0M9 10h.01M15 5.5c0 0 0-4.95-5.39 0-7.28 0-7.28 0A4 4 0 0 1 5.5 8.78a4 4 0 0 1 0 7.28 0 7.28a4 4 0 0 1-7.28 0c0 0-4.95 4.95-5.39 0-7.28 0A4 4 0 0 1 15.5 5.5c0 0 0 0 7.28 0 7.28a4 4 0 0 0 0-7.28 0" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900"><%= @video.display_title %></h1>
|
||||
<% if @video.work.present? %>
|
||||
<p class="text-gray-600"><%= link_to @video.work.display_title, work_path(@video.work), class: "hover:text-blue-600" %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-2">Video Information</h3>
|
||||
<dl class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-500">Duration:</dt>
|
||||
<dd class="text-sm text-gray-900"><%= @video.formatted_duration %></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-500">File Size:</dt>
|
||||
<dd class="text-sm text-gray-900"><%= @video.formatted_file_size %></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-500">Resolution:</dt>
|
||||
<dd class="text-sm text-gray-900"><%= @video.resolution_label || "Unknown" %></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-500">Format:</dt>
|
||||
<dd class="text-sm text-gray-900"><%= @video.format || "Unknown" %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-2">Storage Information</h3>
|
||||
<dl class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-500">Storage Location:</dt>
|
||||
<dd class="text-sm text-gray-900"><%= @video.storage_location.name %></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-500">Source Type:</dt>
|
||||
<dd class="text-sm text-gray-900"><%= @video.source_type.humanize %></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-500">File Path:</dt>
|
||||
<dd class="text-sm text-gray-900 truncate"><%= @video.file_path %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @video.video_metadata.present? %>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-2">Technical Details</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm font-medium text-gray-500">Video Codec:</span>
|
||||
<span class="text-sm text-gray-900"><%= @video.video_codec || "N/A" %></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm font-medium text-gray-500">Audio Codec:</span>
|
||||
<span class="text-sm text-gray-900"><%= @video.audio_codec || "N/A" %></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm font-medium text-gray-500">Frame Rate:</span>
|
||||
<span class="text-sm text-gray-900"><%= @video.frame_rate || "N/A" %> fps</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm font-medium text-gray-500">Bit Rate:</span>
|
||||
<span class="text-sm text-gray-900"><%= @video.bit_rate ? "#{(@video.bit_rate / 1000).round(1)} kb/s" : "N/A" %></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm font-medium text-500">Dimensions:</span>
|
||||
<span class="text-sm text-gray-900">
|
||||
<%= @video.width || "N/A" %> × <%= @video.height || "N/A" %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= link_to "Back to Videos", videos_path, class: "bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-md text-sm font-medium" %>
|
||||
<% if @video.streamable? %>
|
||||
<%= link_to "Watch Video", watch_video_path(@video), class: "bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
4
app/views/works/index.html.erb
Normal file
4
app/views/works/index.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Works#index</h1>
|
||||
<p>Find me in app/views/works/index.html.erb</p>
|
||||
</div>
|
||||
4
app/views/works/show.html.erb
Normal file
4
app/views/works/show.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Works#show</h1>
|
||||
<p>Find me in app/views/works/show.html.erb</p>
|
||||
</div>
|
||||
Reference in New Issue
Block a user