Much base work started
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user