diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 775240d5..6463ae8b 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -61,6 +61,18 @@ body { margin: auto; } +.overflow-hidden { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.overflow-hidden:hover { + white-space: normal; + overflow: visible; + text-overflow: clip; +} + // Console for basic shell output .console-content { display: flex; diff --git a/app/components/progress_bar_component.html.erb b/app/components/progress_bar_component.html.erb index c96b14a9..474b86c9 100644 --- a/app/components/progress_bar_component.html.erb +++ b/app/components/progress_bar_component.html.erb @@ -1,16 +1,24 @@
-
-
+
+
+
+
+
+
-
-
- <%= message.presence %> <% if show_percentage %> - ... - <%= number_to_percentage completed, precision: 2 %> - <% if eta.present? %> - (<%= eta %>) - <% end %> +
+ <%= number_to_percentage completed, precision: 2 %> +
<% end %>
+
+ <%= message.presence&.html_safe %> +
+ <% if eta.present? %> +
ETA: <%= eta %>
+ <% end %> + <% if progress.present? %> +
Progress: <%= progress %>
+ <% end %>
diff --git a/app/components/progress_bar_component.rb b/app/components/progress_bar_component.rb index 30f227af..c126686e 100644 --- a/app/components/progress_bar_component.rb +++ b/app/components/progress_bar_component.rb @@ -7,4 +7,5 @@ class ProgressBarComponent < ViewComponent::Base option :message, optional: true option :show_percentage, default: -> { true } option :eta, optional: true + option :progress, optional: true end diff --git a/app/components/rip_process_component.html.erb b/app/components/rip_process_component.html.erb index 9adb7897..7209ea54 100644 --- a/app/components/rip_process_component.html.erb +++ b/app/components/rip_process_component.html.erb @@ -13,7 +13,7 @@ completed: job.metadata['completed'], status: :info, message: job.metadata['title'], - eta: + eta: job.metadata['eta'] ) ) %> diff --git a/app/components/upload_process_component.html.erb b/app/components/upload_process_component.html.erb index 981a6f33..ef45e9fe 100644 --- a/app/components/upload_process_component.html.erb +++ b/app/components/upload_process_component.html.erb @@ -14,12 +14,17 @@ ProgressBarComponent.new( show_percentage: video_blob_job.present?, status: :info, - completed: percentage(video_blob_job&.completed, blob.byte_size), - message: video_blob_job ? "Uploading #{blob.title}" : "Pending upload #{blob.title} ##{blob.id}", - eta: (eta(video_blob_job, blob) if video_blob_job) + completed: video_blob_job&.completed, + message: video_blob_job ? "Uploading #{link_to_video(blob)}" : "Pending #{link_to_video(blob)} ##{blob.id}", + eta: video_blob_job&.metadata&.fetch('eta', nil), + progress: [ + video_blob_job&.metadata&.fetch('progress', nil), + video_blob_job&.metadata&.fetch('rate', nil) + ].compact_blank.join(' ') ) ) %> +
<% end %> <% uploaded_recently_video_blobs.each do |blob| %> @@ -29,7 +34,7 @@ show_percentage: false, status: :success, completed: 100, - message: "Uploaded #{blob.title}" + message: "Uploaded #{link_to_video(blob)}" ) ) %> diff --git a/app/components/upload_process_component.rb b/app/components/upload_process_component.rb index 69b085bb..a49c6626 100644 --- a/app/components/upload_process_component.rb +++ b/app/components/upload_process_component.rb @@ -31,6 +31,14 @@ def uploaded_recently_video_blobs .limit(3) end + def link_to_video(blob) + return link_to(blob.title, movie_path(blob.video)) if blob.video.is_a?(Movie) + + return link_to(blob.title, tv_season_path(blob.video, blob.episode.season)) if blob.episode && blob.video.is_a?(Tv) + + blob.title + end + def job_active? job&.active? end diff --git a/app/listeners/mkv_progress_listener.rb b/app/listeners/mkv_progress_listener.rb index b8ad896e..a0ac098c 100644 --- a/app/listeners/mkv_progress_listener.rb +++ b/app/listeners/mkv_progress_listener.rb @@ -61,8 +61,12 @@ def mkv_started(cmd) def mkv_raw_line(mkv_message) case mkv_message when MkvParser::PRGV - job.completed = percentage(mkv_message.current, mkv_message.pmax) + tracker(mkv_message.pmax) + increment_progress_bar(mkv_message.current) + job.completed = tracker.percentage_component.percentage_with_precision + job.metadata['eta'] = tracker.time_component.estimated_without_label when MkvParser::PRGT, MkvParser::PRGC + @tracker = nil job.title = [video_blob&.title, mkv_message.name].compact_blank.join("\n") when MkvParser::MSG job.add_message(mkv_message.message) @@ -74,6 +78,19 @@ def mkv_raw_line(mkv_message) private + def tracker(total = nil) + @tracker = nil if total && @tracker&.total != total + @tracker ||= ProgressTracker::Base.new(total:) + end + + def increment_progress_bar(progress) + return if tracker.finished? + + tracker.progress = progress + rescue ProgressBar::InvalidProgressError + tracker&.finish + end + def reload_page! cable_ready[BroadcastChannel.channel_name].reload cable_ready.broadcast diff --git a/app/listeners/upload_progress_listener.rb b/app/listeners/upload_progress_listener.rb index 0462d289..2cb05fe8 100644 --- a/app/listeners/upload_progress_listener.rb +++ b/app/listeners/upload_progress_listener.rb @@ -12,8 +12,8 @@ class UploadProgressListener attr_reader :completed - def upload_progress(total_uploaded:) - job.completed = total_uploaded + def upload_progress(tracker:) + update_meta_from_tracker(tracker) return if next_update.future? job.save! @@ -25,15 +25,15 @@ def upload_ready upload_started end - def upload_started - job.completed = 0 + def upload_started(tracker: nil) + update_meta_from_tracker(tracker) job.metadata['video_blob_id'] = video_blob.id job.save! update_component end - def upload_finished - job.completed = video_blob.byte_size + def upload_finished(tracker:) + update_meta_from_tracker(tracker) job.save! video_blob.update!(uploadable: false, uploaded_on: Time.current) update_component @@ -46,6 +46,15 @@ def upload_error(exception) private + def update_meta_from_tracker(tracker) + return if tracker.nil? + + job.completed = tracker.percentage_component.percentage_with_precision + job.metadata['eta'] = tracker.time_component.estimated_without_label + job.metadata['rate'] = "#{tracker.rate_component.rate_of_change_with_precision} KB/sec" + job.metadata['progress'] = "#{tracker.progress} KB" + end + def update_component component = UploadProcessComponent.new cable_ready[BroadcastChannel.channel_name].morph \ diff --git a/app/services/ftp/upload_mkv_service.rb b/app/services/ftp/upload_mkv_service.rb index 26f0cfca..08f644fa 100644 --- a/app/services/ftp/upload_mkv_service.rb +++ b/app/services/ftp/upload_mkv_service.rb @@ -14,7 +14,7 @@ def call try_to { ftp_upload_file } tmp_destroy_folder mark_as_uploaded! - broadcast(:upload_finished) + broadcast(:upload_finished, tracker:) rescue StandardError => e broadcast(:upload_error, e) raise e @@ -22,6 +22,8 @@ def call private + attr_reader :tracker + def ftp_destroy_if_file_exists ftp.delete(video_blob.plex_path) rescue Net::FTPPermError => e @@ -41,14 +43,31 @@ def ftp_create_directory end def ftp_upload_file - broadcast(:upload_started) - total_uploaded = 0 + @tracker = ProgressTracker::Base.new( + total: bytes_to_kilobyte(file.size), + rate_scale: ->(rate) { rate / 1024 } + ) + broadcast(:upload_started, tracker:) ftp.putbinaryfile(file, video_blob.plex_path) do |chunk| - total_uploaded += chunk.size - broadcast(:upload_progress, total_uploaded:) + increment_progress_bar(chunk.size) + broadcast(:upload_progress, tracker:) end end + def increment_progress_bar(increment) + return if tracker.finished? + + @total_bytes ||= 0 + @total_bytes += increment + tracker.progress = bytes_to_kilobyte(@total_bytes) + rescue ProgressBar::InvalidProgressError + tracker&.finish + end + + def bytes_to_kilobyte(bites) + bites / 1000 + end + def mark_as_uploaded! video_blob.update!(uploaded_on: Time.current, uploadable: false) end diff --git a/config/application.rb b/config/application.rb index 1fde7ea1..3d29961f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -18,6 +18,7 @@ Bundler.require(*Rails.groups) require 'view_component/engine' require 'sys/filesystem' +require './lib/progress_tracker/base' module PlexRipper VERSION = File.read(File.expand_path('./current_version.txt')).gsub('v', '').strip diff --git a/current_version.txt b/current_version.txt index cce56663..268fccb1 100644 --- a/current_version.txt +++ b/current_version.txt @@ -1 +1 @@ -v5.4.1 +v5.5.0 diff --git a/lib/progress_tracker/base.rb b/lib/progress_tracker/base.rb new file mode 100644 index 00000000..cf7155d1 --- /dev/null +++ b/lib/progress_tracker/base.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require_relative 'time' +require_relative 'timer' +require_relative 'progress' +require_relative 'projector' +require_relative 'projectors/smoothed_average' +require_relative 'component/rate' +require_relative 'component/time' +require_relative 'component/percentage' + +module ProgressTracker + class Base + extend Forwardable + + def_delegators :progressable, + :progress, + :total + def initialize(options = {}) + options[:projector] ||= {} + + self.autostart = options.fetch(:autostart, true) + self.autofinish = options.fetch(:autofinish, true) + self.finished = false + + self.timer = Timer.new(options) + self.projector = Projector + .from_type(options[:projector][:type]) + .new(options[:projector]) + self.progressable = Progress.new(options) + + options = options.merge(progress: progressable, + projector:, + timer:) + + self.percentage_component = Components::Percentage.new(options) + self.rate_component = Components::Rate.new(options) + self.time_component = Components::Time.new(options) + + start at: options[:starting_at] if autostart + end + + def start(options = {}) + timer.start + update_progress(:start, options) + end + + def finish + return if finished? + + output.with_refresh do + self.finished = true + progressable.finish + timer.stop + end + end + + def pause + output.with_refresh { timer.pause } unless paused? + end + + def stop + output.with_refresh { timer.stop } unless stopped? + end + + def resume + output.with_refresh { timer.resume } if stopped? + end + + def reset + output.with_refresh do + self.finished = false + progressable.reset + projector.reset + timer.reset + end + end + + def stopped? + timer.stopped? || finished? + end + + alias paused? stopped? + + def finished? + finished || (autofinish && progressable.finished?) + end + + def started? # rubocop:disable Rails/Delegate + timer.started? + end + + def decrement + update_progress(:decrement) + end + + def increment + update_progress(:increment) + end + + def progress=(new_progress) + update_progress(:progress=, new_progress) + end + + def total=(new_total) + update_progress(:total=, new_total) + end + + def inspect + "#" + end + + attr_accessor :percentage_component, + :rate_component, + :time_component + + protected + + attr_accessor :autofinish, + :autostart, + :finished, + :progressable, + :projector, + :timer + + def update_progress(*) + progressable.__send__(*) + projector.__send__(*) + timer.stop if finished? + end + end +end diff --git a/lib/progress_tracker/component/percentage.rb b/lib/progress_tracker/component/percentage.rb new file mode 100644 index 00000000..02cfb06a --- /dev/null +++ b/lib/progress_tracker/component/percentage.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ProgressTracker + module Components + class Percentage + attr_accessor :progress + + def initialize(options = {}) + self.progress = options[:progress] + end + + def percentage + progress.percentage_completed.to_s + end + + def justified_percentage + progress.percentage_completed.to_s.rjust(3) + end + + def percentage_with_precision + progress.percentage_completed_with_precision + end + + def justified_percentage_with_precision + progress.percentage_completed_with_precision.to_s.rjust(6) + end + end + end +end diff --git a/lib/progress_tracker/component/rate.rb b/lib/progress_tracker/component/rate.rb new file mode 100644 index 00000000..84a404aa --- /dev/null +++ b/lib/progress_tracker/component/rate.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module ProgressTracker + module Components + class Rate + attr_accessor :rate_scale, + :timer, + :progress + + def initialize(options = {}) + self.rate_scale = options[:rate_scale] || ->(x) { x } + self.timer = options[:timer] + self.progress = options[:progress] + end + + def rate_of_change(format_string = '%i') + return '0' if elapsed_seconds <= 0 + + format_string % scaled_rate + end + + def rate_of_change_with_precision + rate_of_change('%.2f') + end + + private + + def scaled_rate + rate_scale.call(base_rate) + end + + def base_rate + progress.absolute / elapsed_seconds + end + + def elapsed_seconds + timer.elapsed_whole_seconds.to_f + end + end + end +end diff --git a/lib/progress_tracker/component/time.rb b/lib/progress_tracker/component/time.rb new file mode 100644 index 00000000..d851335a --- /dev/null +++ b/lib/progress_tracker/component/time.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module ProgressTracker + module Components + class Time + TIME_FORMAT = '%02d:%02d:%02d' + OOB_TIME_FORMATS = [:unknown, :friendly, nil].freeze + OOB_LIMIT_IN_HOURS = 99 + OOB_UNKNOWN_TIME_TEXT = '??:??:??' + OOB_FRIENDLY_TIME_TEXT = '> 4 Days' + NO_TIME_ELAPSED_TEXT = '--:--:--' + ESTIMATED_LABEL = ' ETA' + ELAPSED_LABEL = 'Time' + WALL_CLOCK_FORMAT = '%H:%M:%S' + OOB_TEXT_TO_FORMAT = { + unknown: OOB_UNKNOWN_TIME_TEXT, + friendly: OOB_FRIENDLY_TIME_TEXT + }.freeze + + def initialize(options = {}) + self.timer = options[:timer] + self.progress = options[:progress] + self.projector = options[:projector] + end + + def estimated_without_label(out_of_bounds_time_format = nil) + estimated(out_of_bounds_time_format) + end + + def estimated_with_label(out_of_bounds_time_format = nil) + "#{ESTIMATED_LABEL}: #{estimated(out_of_bounds_time_format)}" + end + + def elapsed_with_label + "#{ELAPSED_LABEL}: #{elapsed}" + end + + def estimated_with_no_oob + estimated_with_elapsed_fallback(nil) + end + + def estimated_with_unknown_oob + estimated_with_elapsed_fallback(:unknown) + end + + def estimated_with_friendly_oob + estimated_with_elapsed_fallback(:friendly) + end + + def estimated_wall_clock + return timer.stopped_at.strftime(WALL_CLOCK_FORMAT) if progress.finished? + return NO_TIME_ELAPSED_TEXT unless timer.started? + + memo_estimated_seconds_remaining = estimated_seconds_remaining + return NO_TIME_ELAPSED_TEXT unless memo_estimated_seconds_remaining + + (timer.now + memo_estimated_seconds_remaining) + .strftime(WALL_CLOCK_FORMAT) + end + + protected + + attr_accessor :timer, + :progress, + :projector + + private + + def estimated(out_of_bounds_time_format) + memo_estimated_seconds_remaining = estimated_seconds_remaining + + return OOB_UNKNOWN_TIME_TEXT unless memo_estimated_seconds_remaining + + hours, minutes, seconds = timer.divide_seconds(memo_estimated_seconds_remaining) + + if hours > OOB_LIMIT_IN_HOURS && out_of_bounds_time_format + OOB_TEXT_TO_FORMAT.fetch(out_of_bounds_time_format) + else + format(TIME_FORMAT, hours, minutes, seconds) + end + end + + def elapsed + return NO_TIME_ELAPSED_TEXT unless timer.started? + + hours, minutes, seconds = timer.divide_seconds(timer.elapsed_whole_seconds) + + format(TIME_FORMAT, hours, minutes, seconds) + end + + def estimated_with_elapsed_fallback(out_of_bounds_time_format) + return elapsed_with_label if progress.finished? + + estimated_with_label(out_of_bounds_time_format) + end + + def estimated_seconds_remaining + return if progress.unknown? || projector.none? || progress.none? || timer.stopped? || timer.reset? + + (timer.elapsed_seconds * ((progress.total / projector.projection) - 1)).round + end + end + end +end diff --git a/lib/progress_tracker/progress.rb b/lib/progress_tracker/progress.rb new file mode 100644 index 00000000..80861247 --- /dev/null +++ b/lib/progress_tracker/progress.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'ruby-progressbar/errors/invalid_progress_error' + +module ProgressTracker + class Progress + DEFAULT_TOTAL = 100 + DEFAULT_BEGINNING_POSITION = 0 + + attr_reader :total, + :progress + attr_accessor :starting_position + + def initialize(options = {}) + self.total = options.fetch(:total, DEFAULT_TOTAL) + + start(at: DEFAULT_BEGINNING_POSITION) + end + + def start(options = {}) + self.progress = + self.starting_position = options[:at] || progress + end + + def finish + self.progress = total unless unknown? + end + + def finished? + @progress == @total + end + + def increment + warn "WARNING: Your progress bar is currently at #{progress} out of #{total}" if progress == total + + self.progress += 1 unless progress == total + end + + def decrement + warn "WARNING: Your progress bar is currently at #{progress} out of #{total}" if progress.zero? + + self.progress -= 1 unless progress.zero? + end + + def reset + start(at: starting_position) + end + + def progress=(new_progress) + if total && new_progress > total + raise ProgressBar::InvalidProgressError, + "You can't set the item's current value to be greater than the total." + end + + @progress = new_progress + end + + def total=(new_total) + unless progress.nil? || new_total.nil? || new_total >= progress + raise ProgressBar::InvalidProgressError, + "You can't set the item's total value to less than the current progress." + end + + @total = new_total + end + + def percentage_completed + return 0 if total.nil? + return 100 if total.zero? + + # progress / total * 100 + # + # Doing this way so we can avoid converting each + # number to a float and then back to an integer. + # + (progress * 100 / total).to_i + end + + def none? + progress.zero? + end + + def unknown? + progress.nil? || total.nil? + end + + def total_with_unknown_indicator + total || '??' + end + + def percentage_completed_with_precision + return 100.0 if total.zero? + return 0.0 if total.nil? + + format('%5.2f', (progress * 100 / total.to_f * 100).floor / 100.0) + end + + def absolute + progress - starting_position + end + end +end diff --git a/lib/progress_tracker/projector.rb b/lib/progress_tracker/projector.rb new file mode 100644 index 00000000..48b2ffa8 --- /dev/null +++ b/lib/progress_tracker/projector.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'ruby-progressbar/projectors/smoothed_average' + +module ProgressTracker + class Projector + DEFAULT_PROJECTOR = ProgressBar::Projectors::SmoothedAverage + NAME_TO_PROJECTOR_MAP = { + 'smoothed' => ProgressBar::Projectors::SmoothedAverage + }.freeze + + def self.from_type(name) + NAME_TO_PROJECTOR_MAP.fetch(name, DEFAULT_PROJECTOR) + end + end +end diff --git a/lib/progress_tracker/projectors/smoothed_average.rb b/lib/progress_tracker/projectors/smoothed_average.rb new file mode 100644 index 00000000..7d70a7aa --- /dev/null +++ b/lib/progress_tracker/projectors/smoothed_average.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module ProgressTracker + module Projectors + class SmoothedAverage + DEFAULT_STRENGTH = 0.1 + DEFAULT_BEGINNING_POSITION = 0 + + attr_accessor :samples, + :strength + attr_reader :projection + + def initialize(options = {}) + self.samples = [] + self.projection = 0.0 + self.strength = options[:strength] || DEFAULT_STRENGTH + + start(at: DEFAULT_BEGINNING_POSITION) + end + + def start(options = {}) + self.projection = 0.0 + self.progress = samples[0] = (options[:at] || progress) + end + + def decrement + self.progress -= 1 + end + + def increment + self.progress += 1 + end + + def progress + samples[1] + end + + def total=(_new_total); end + + def reset + start(at: samples[0]) + end + + def progress=(new_progress) + samples[1] = new_progress + self.projection = + self.class.calculate( + @projection, + absolute, + strength + ) + end + + def none? + projection.zero? + end + + def self.calculate(current_projection, new_value, rate) + (new_value * (1.0 - rate)) + (current_projection * rate) + end + + protected + + attr_writer :projection + + private + + def absolute + samples[1] - samples[0] + end + end + end +end diff --git a/lib/progress_tracker/time.rb b/lib/progress_tracker/time.rb new file mode 100644 index 00000000..0240a652 --- /dev/null +++ b/lib/progress_tracker/time.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ProgressTracker + class Time + TIME_MOCKING_LIBRARY_METHODS = [ + :__simple_stub__now, # ActiveSupport + :now_without_mock_time, # Timecop + :now_without_delorean, # Delorean + :now # Unmocked + ].freeze + + def initialize(time = ::Time) + self.time = time + end + + def now + time.__send__(unmocked_time_method) + end + + def unmocked_time_method + @unmocked_time_method ||= TIME_MOCKING_LIBRARY_METHODS.find do |method| + time.respond_to? method + end + end + + protected + + attr_accessor :time + end +end +# rubocop:enable Style/InlineComment diff --git a/lib/progress_tracker/timer.rb b/lib/progress_tracker/timer.rb new file mode 100644 index 00000000..c50f61a1 --- /dev/null +++ b/lib/progress_tracker/timer.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module ProgressTracker + class Timer + attr_accessor :started_at, + :stopped_at + + def initialize(options = {}) + self.time = options[:time] || ::ProgressTracker::Time.new + end + + def start + self.started_at = stopped? ? time.now - (stopped_at - started_at) : time.now + self.stopped_at = nil + end + + def stop + return unless started? + + self.stopped_at = time.now + end + + def pause + stop + end + + def resume + start + end + + delegate :now, to: :time + + def started? + started_at + end + + def stopped? + stopped_at + end + + def reset + self.started_at = nil + self.stopped_at = nil + end + + def reset? + !started_at + end + + def restart + reset + start + end + + def elapsed_seconds + return 0 unless started? + + ((stopped_at || time.now) - started_at) + end + + def elapsed_whole_seconds + elapsed_seconds.floor + end + + def divide_seconds(seconds) + hours, seconds = seconds.divmod(3600) + minutes, seconds = seconds.divmod(60) + + [hours, minutes, seconds] + end + + protected + + attr_accessor :time + end +end diff --git a/spec/listeners/upload_progress_listener_spec.rb b/spec/listeners/upload_progress_listener_spec.rb index e5d6b418..77d5f19a 100644 --- a/spec/listeners/upload_progress_listener_spec.rb +++ b/spec/listeners/upload_progress_listener_spec.rb @@ -6,7 +6,9 @@ subject(:listener) { described_class.new(**args) } describe '#upload_progress' do - subject(:upload_progress) { listener.upload_progress(total_uploaded: 10) } + subject(:upload_progress) { listener.upload_progress(tracker:) } + + let(:tracker) { ProgressTracker::Base.new } let(:video_blob) { build_stubbed(:video_blob) } let(:job) { build(:job) } @@ -21,7 +23,12 @@ it 'changes the completed size based on the chunk size' do upload_progress - expect(listener.job.metadata).to eq('completed' => 10) + expect(listener.job.metadata).to eq({ + 'completed' => 0.0, + 'eta' => '??:??:??', + 'rate' => '0 KB/sec', + 'progress' => '0 KB' + }) end end end