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