Much base work started
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
Dan Milne
2025-10-31 14:36:14 +11:00
parent 4a35bf6758
commit 88a906064f
97 changed files with 5333 additions and 2774 deletions

View 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

View 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

View 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
View File

@@ -0,0 +1,4 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end

View File

@@ -0,0 +1,3 @@
class ExternalId < ApplicationRecord
belongs_to :work
end

75
app/models/media_file.rb Normal file
View 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

View File

@@ -0,0 +1,4 @@
class PlaybackSession < ApplicationRecord
belongs_to :video
belongs_to :user
end

3
app/models/session.rb Normal file
View File

@@ -0,0 +1,3 @@
class Session < ApplicationRecord
belongs_to :user
end

View 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
View 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
View 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

View File

@@ -0,0 +1,3 @@
class VideoAsset < ApplicationRecord
belongs_to :video
end

100
app/models/work.rb Normal file
View 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