diff --git a/app/models/tv.rb b/app/models/tv.rb index fa623c80..6d0f3888 100644 --- a/app/models/tv.rb +++ b/app/models/tv.rb @@ -38,12 +38,33 @@ class Tv < Video end has_many :seasons, -> { order_by_season_number }, dependent: :destroy, inverse_of: :tv + has_many :episodes, -> { order_by_episode_number }, through: :seasons before_validation { broadcast(:tv_validating, self) } before_save { broadcast(:tv_saving, self) } after_commit { broadcast(:tv_saved, self) } - def min_max_run_time_seconds - (episode_run_time.min * 60)..(episode_run_time.max * 60) + def duration_range + return if ripped_disk_titles_durations.empty? && episodes_runtime.empty? + + range = round_up_to_nearest_minute( + duration_stats.interquartile_range || + episode_runtime_stats.interquartile_range || + DEFAULT_RANGE + ) + average_runtime = duration_stats.weighted_average || + episode_runtime_stats.weighted_average + + return if average_runtime.nil? + + (average_runtime - range)..(average_runtime + range) + end + + def episode_runtime_stats + @episode_runtime_stats ||= StatsService.call(episodes_runtime) + end + + def episodes_runtime + @episodes_runtime ||= episodes.map(&:runtime).uniq.compact_blank end end diff --git a/app/models/video.rb b/app/models/video.rb index 3adc4cb5..8c881d06 100644 --- a/app/models/video.rb +++ b/app/models/video.rb @@ -28,6 +28,8 @@ # class Video < ApplicationRecord include Wisper::Publisher + DEFAULT_RANGE = (60 * 3).seconds # 3 minutes + enum :rating, { 'N/A': 0, NR: 1, 'NC-17': 2, R: 3, 'PG-13': 4, PG: 5, G: 6 } has_many :disk_titles, dependent: :nullify @@ -46,7 +48,11 @@ class Video < ApplicationRecord validates :title, presence: true def duration_stats - @duration_stats ||= StatsService.call(ripped_disk_titles&.map(&:duration)&.compact_blank || []) + @duration_stats ||= StatsService.call(ripped_disk_titles_durations) + end + + def ripped_disk_titles_durations + @ripped_disk_titles_durations ||= Array.wrap(ripped_disk_titles).map(&:duration).compact_blank end def movie? @@ -94,4 +100,10 @@ def release_or_air_date def plex_name release_or_air_date ? "#{title} (#{release_or_air_date.year})" : title end + + private + + def round_up_to_nearest_minute(seconds) + (seconds.to_f / 60).ceil * 60 + end end diff --git a/app/services/episode_disk_title_selector_service.rb b/app/services/episode_disk_title_selector_service.rb index 4b9001ea..174f0639 100644 --- a/app/services/episode_disk_title_selector_service.rb +++ b/app/services/episode_disk_title_selector_service.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class EpisodeDiskTitleSelectorService < ApplicationService - DEFAULT_RANGE = (60 * 3).seconds # 3 minutes - Info = Data.define( :disk_title, :episode, @@ -53,15 +51,7 @@ def ripped?(episode) end def within_range?(episode, disk_title) - runtime_range(episode)&.include?(disk_title.duration) - end - - def runtime_range(episode) - runtime = episode.tv.duration_stats.weighted_average || episode.runtime - range = episode.tv.duration_stats.interquartile_range || DEFAULT_RANGE - return if runtime.nil? - - (runtime - range)...(runtime + range) + episode.tv.duration_range&.include?(disk_title.duration) end def selected_disk_titles diff --git a/app/services/stats_service.rb b/app/services/stats_service.rb index 8fef6453..6dbe17f2 100644 --- a/app/services/stats_service.rb +++ b/app/services/stats_service.rb @@ -1,12 +1,15 @@ # frozen_string_literal: true class StatsService < ApplicationService + Z_SCORE_THRESHOLD = 5 Info = Data.define( :median, :weighted_average, :median_difference, :weighted_average_difference, - :interquartile_range + :interquartile_range, + :z_score, + :differences ) param :list, Types::Array.of(Types::Coercible::Integer) @@ -17,7 +20,9 @@ def call weighted_average, median_difference, weighted_average_difference, - interquartile_range + interquartile_range, + z_score, + differences ) end @@ -65,6 +70,21 @@ def interquartile_range filtered_differences.max end + def z_score + return if differences.empty? + + mean = differences.sum.to_f / differences.size + + # Calculate standard deviation + variance = differences.map { |x| (x - mean)**2 }.sum / differences.size + stdev = Math.sqrt(variance) + + differences.select do |x| + z_score = (x - mean) / stdev + z_score.abs < Z_SCORE_THRESHOLD + end.max + end + def calc_weight_average(array, median) weights = array.map do |item| 1.0 / (1.0 + (item - median).abs) diff --git a/app/views/seasons/show.html.erb b/app/views/seasons/show.html.erb index f63ec5f6..1dc3354b 100644 --- a/app/views/seasons/show.html.erb +++ b/app/views/seasons/show.html.erb @@ -23,9 +23,28 @@ Title Select <% if @tv.duration_stats.weighted_average %> - (<%= distance_of_time_in_words(@tv.duration_stats.weighted_average - @tv.duration_stats.interquartile_range, 0, include_seconds: false) %> - - - <%= distance_of_time_in_words(@tv.duration_stats.weighted_average + @tv.duration_stats.interquartile_range, 0, include_seconds: false) %>) + (<%= distance_of_time_in_words(@tv.duration_range.min, 0, include_seconds: params[:debug] == 'true') %> + .. + <%= distance_of_time_in_words(@tv.duration_range.max, 0, include_seconds: params[:debug] == 'true') %>) + + <% if params[:debug] == 'true' %> +
+ median: <%= distance_of_time_in_words @tv.duration_stats.median %> +
+ weighted average: <%= distance_of_time_in_words @tv.duration_stats.weighted_average %> +
+ weighted average difference: <%= distance_of_time_in_words @tv.duration_stats.weighted_average_difference %> +
+ interquartile range: <%= distance_of_time_in_words(@tv.duration_stats.interquartile_range) %> +
+ median difference: <%= distance_of_time_in_words(@tv.duration_stats.median_difference) %> +
+ z score: <%= distance_of_time_in_words @tv.duration_stats.z_score %> +
+ Durations: <%= @tv.ripped_disk_titles_durations.sort.uniq.join(', ') %> +
+ Differences: <%= @tv.duration_stats.differences.join(', ') %> + <% end %> <% end %>
diff --git a/current_version.txt b/current_version.txt index eef47253..fa757323 100644 --- a/current_version.txt +++ b/current_version.txt @@ -1 +1 @@ -v5.12.0 +v5.13.0 diff --git a/spec/services/episode_disk_title_selector_service_spec.rb b/spec/services/episode_disk_title_selector_service_spec.rb index 2382bed5..385a83a6 100644 --- a/spec/services/episode_disk_title_selector_service_spec.rb +++ b/spec/services/episode_disk_title_selector_service_spec.rb @@ -13,6 +13,11 @@ let(:disk) { build_stubbed(:disk, disk_titles: [disk_title]) } let(:disk_title) { build_stubbed(:disk_title, duration: 10.minutes) } + before do + tv.association(:ripped_disk_titles).target = [disk_title] + tv.association(:ripped_disk_titles).loaded! + end + it { expect(call.first).to be_a(described_class::Info) } it { expect(call.first.disk_title.id).to eq(disk_title.id) } it { expect(call.first.episode.id).to eq(episodes.first.id) } @@ -26,6 +31,11 @@ let(:disk_title_a) { build_stubbed(:disk_title, duration: 10.minutes) } let(:disk_title_b) { build_stubbed(:disk_title, duration: 10.minutes) } + before do + tv.association(:ripped_disk_titles).target = [disk_title_a, disk_title_b] + tv.association(:ripped_disk_titles).loaded! + end + it { expect(call.first.episode.id).to eq(episodes.first.id) } it { expect(call.first.disk_title.id).to eq(disk_title_a.id) } it { expect(call.second.episode.id).to eq(episodes.second.id) } diff --git a/spec/services/stats_service_spec.rb b/spec/services/stats_service_spec.rb index c038887c..617995b9 100644 --- a/spec/services/stats_service_spec.rb +++ b/spec/services/stats_service_spec.rb @@ -8,14 +8,19 @@ context 'when a bunch of values are given' do let(:list) { [2579, 2498, 2549, 2575, 2573, 2486, 2518, 2580, 2494, 2577, 2575, 2575, 2577, 2443, 2578, 2441, 2521, 2561, 2580, 2534, 2571, 2565, 5062] } + let(:differences) do + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22, 23, 24, 26, 27, 28, 29, 30, 31, 32, 35, 36, 37, 39, 40, 41, 43, 44, 45, 46, 47, 48, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 67, 71, 73, + 75, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 89, 91, 92, 93, 94, 106, 108, 118, 120, 122, 124, 128, 130, 132, 134, 135, 136, 137, 138, 139, 2482, 2483, 2484, 2485, 2487, 2489, 2491, 2497, 2501, 2513, 2528, 2541, 2544, + 2564, 2568, 2576, 2619, 2621] + end - it { is_expected.to eq(described_class::Info.new(2575, 2573.7497394471216, 62.5, 62.79612125669019, 139)) } + it { is_expected.to eq(described_class::Info.new(2575, 2573.7497394471216, 62.5, 62.79612125669019, 139, 2621, differences)) } end context 'when nothing is given' do let(:list) { [] } - it { is_expected.to eq(described_class::Info.new(nil, nil, nil, nil, nil)) } + it { is_expected.to eq(described_class::Info.new(nil, nil, nil, nil, nil, nil, [])) } end end end