Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhanced Episode Handling, Duration Calculation, and Exception Handling in Video Models and Services #501

Merged
merged 1 commit into from
Nov 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions app/models/tv.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 13 additions & 1 deletion app/models/video.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?
Expand Down Expand Up @@ -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
12 changes: 1 addition & 11 deletions app/services/episode_disk_title_selector_service.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# frozen_string_literal: true

class EpisodeDiskTitleSelectorService < ApplicationService
DEFAULT_RANGE = (60 * 3).seconds # 3 minutes

Info = Data.define(
:disk_title,
:episode,
Expand Down Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions app/services/stats_service.rb
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -17,7 +20,9 @@ def call
weighted_average,
median_difference,
weighted_average_difference,
interquartile_range
interquartile_range,
z_score,
differences
)
end

Expand Down Expand Up @@ -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)
Expand Down
25 changes: 22 additions & 3 deletions app/views/seasons/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,28 @@
Title Select
<small>
<% 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' %>
<br>
median: <%= distance_of_time_in_words @tv.duration_stats.median %>
<br>
weighted average: <%= distance_of_time_in_words @tv.duration_stats.weighted_average %>
<br>
weighted average difference: <%= distance_of_time_in_words @tv.duration_stats.weighted_average_difference %>
<br>
interquartile range: <%= distance_of_time_in_words(@tv.duration_stats.interquartile_range) %>
<br>
median difference: <%= distance_of_time_in_words(@tv.duration_stats.median_difference) %>
<br>
z score: <%= distance_of_time_in_words @tv.duration_stats.z_score %>
<br>
Durations: <%= @tv.ripped_disk_titles_durations.sort.uniq.join(', ') %>
<br>
Differences: <%= @tv.duration_stats.differences.join(', ') %>
<% end %>
<% end %>
</small>
</th>
Expand Down
2 changes: 1 addition & 1 deletion current_version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v5.12.0
v5.13.0
10 changes: 10 additions & 0 deletions spec/services/episode_disk_title_selector_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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) }
Expand Down
9 changes: 7 additions & 2 deletions spec/services/stats_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading