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

View 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

View 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

View 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

View 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

View 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