Skip to content

Commit afc233d

Browse files
committed
Add bulk retry by failure code
1 parent f1e52b0 commit afc233d

4 files changed

Lines changed: 222 additions & 0 deletions

File tree

lib/reencodarr/media.ex

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,6 +1105,84 @@ defmodule Reencodarr.Media do
11051105
end
11061106
end
11071107

1108+
@doc """
1109+
Lists distinct unresolved failure codes for videos currently in the failed state.
1110+
1111+
Intended for operator bulk-retry actions in the failures UI.
1112+
"""
1113+
@spec list_failed_video_failure_codes(non_neg_integer()) :: [
1114+
%{code: String.t(), count: integer()}
1115+
]
1116+
def list_failed_video_failure_codes(limit \\ 12) do
1117+
from(v in Video,
1118+
join: f in VideoFailure,
1119+
on: f.video_id == v.id,
1120+
where: v.state == :failed and f.resolved == false and not is_nil(f.failure_code),
1121+
group_by: f.failure_code,
1122+
order_by: [desc: count(f.id), asc: f.failure_code],
1123+
limit: ^limit,
1124+
select: %{code: f.failure_code, count: count(f.id)}
1125+
)
1126+
|> Repo.all()
1127+
end
1128+
1129+
@doc """
1130+
Retries failed videos whose unresolved failures include the given failure code.
1131+
1132+
Videos are moved back to `:needs_analysis`, their bitrate is cleared to force
1133+
re-analysis, and all unresolved failure records for those videos are resolved.
1134+
"""
1135+
@spec retry_failed_videos_by_failure_code(String.t()) :: %{
1136+
videos_retried: integer(),
1137+
failures_resolved: integer()
1138+
}
1139+
def retry_failed_videos_by_failure_code(failure_code) when is_binary(failure_code) do
1140+
trimmed_code = String.trim(failure_code)
1141+
1142+
if trimmed_code == "" do
1143+
%{videos_retried: 0, failures_resolved: 0}
1144+
else
1145+
write_transaction(
1146+
fn ->
1147+
video_ids =
1148+
from(v in Video,
1149+
join: f in VideoFailure,
1150+
on: f.video_id == v.id,
1151+
where:
1152+
v.state == :failed and f.resolved == false and f.failure_code == ^trimmed_code,
1153+
group_by: v.id,
1154+
order_by: [asc: v.id],
1155+
select: v.id
1156+
)
1157+
|> Repo.all()
1158+
1159+
videos_retried_count = length(video_ids)
1160+
1161+
now = DateTime.utc_now()
1162+
1163+
{failures_resolved_count, _} =
1164+
from(f in VideoFailure,
1165+
where: f.video_id in ^video_ids and f.resolved == false
1166+
)
1167+
|> Repo.update_all(set: [resolved: true, resolved_at: now])
1168+
1169+
from(v in Video, where: v.id in ^video_ids)
1170+
|> Repo.update_all(set: [state: :needs_analysis, bitrate: nil, updated_at: now])
1171+
1172+
%{
1173+
videos_retried: videos_retried_count,
1174+
failures_resolved: failures_resolved_count
1175+
}
1176+
end,
1177+
label: :media_retry_failed_videos_by_failure_code
1178+
)
1179+
|> case do
1180+
{:ok, result} -> result
1181+
{:error, _reason} -> %{videos_retried: 0, failures_resolved: 0}
1182+
end
1183+
end
1184+
end
1185+
11081186
# Consolidated shared logic for video deletion
11091187
defp delete_videos_by_ids(video_ids) do
11101188
deleted_videos = fetch_dashboard_video_snapshots_by_ids(video_ids)

lib/reencodarr_web/live/failures_live.ex

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,20 @@ defmodule ReencodarrWeb.FailuresLive do
108108
{:noreply, put_flash(socket, :info, "All failed videos have been reset")}
109109
end
110110

111+
@impl true
112+
def handle_event("retry_failure_code", %{"code" => failure_code}, socket) do
113+
result = Media.retry_failed_videos_by_failure_code(failure_code)
114+
115+
socket = load_failures_data(socket)
116+
117+
{:noreply,
118+
put_flash(
119+
socket,
120+
:info,
121+
"Queued retry for #{result.videos_retried} failed videos with #{failure_code}"
122+
)}
123+
end
124+
111125
@impl true
112126
def handle_event("toggle_details", %{"video_id" => video_id}, socket) do
113127
video_id = Parsers.parse_int(video_id)
@@ -392,6 +406,33 @@ defmodule ReencodarrWeb.FailuresLive do
392406
</div>
393407
</div>
394408
</div>
409+
410+
<%= if @failure_code_actions != [] do %>
411+
<div class="bg-gray-800 rounded-lg shadow-lg p-4 border border-gray-700">
412+
<div class="flex flex-col gap-3">
413+
<div>
414+
<h2 class="text-sm font-semibold text-white">Retry By Error Code</h2>
415+
<p class="text-xs text-gray-400">
416+
Retry all failed videos whose unresolved failures include the selected code by sending them back to analysis.
417+
</p>
418+
</div>
419+
<div class="flex flex-wrap gap-2">
420+
<%= for action <- @failure_code_actions do %>
421+
<button
422+
phx-click="retry_failure_code"
423+
phx-value-code={action.code}
424+
class="inline-flex items-center gap-2 rounded-lg border border-gray-600 bg-gray-750 px-3 py-2 text-xs font-medium text-gray-200 transition-colors hover:bg-gray-700"
425+
>
426+
<span>{action.code}</span>
427+
<span class="rounded bg-gray-900 px-1.5 py-0.5 text-[11px] text-gray-300">
428+
{action.count}
429+
</span>
430+
</button>
431+
<% end %>
432+
</div>
433+
</div>
434+
</div>
435+
<% end %>
395436
396437
<!-- Failed Videos Table -->
397438
<div class="bg-gray-800 rounded-lg shadow-lg overflow-hidden border border-gray-700">
@@ -741,6 +782,7 @@ defmodule ReencodarrWeb.FailuresLive do
741782
|> assign(:video_failures, %{})
742783
|> assign(:failure_stats, %{recent_count: 0})
743784
|> assign(:failure_patterns, [])
785+
|> assign(:failure_code_actions, [])
744786
|> assign(:total_count, 0)
745787
|> assign(:total_pages, 0)
746788
end
@@ -763,6 +805,7 @@ defmodule ReencodarrWeb.FailuresLive do
763805
# Get failure statistics and patterns
764806
failure_stats = Media.get_failure_statistics(days_back: 7)
765807
failure_patterns = Media.get_common_failure_patterns(5)
808+
failure_code_actions = Media.list_failed_video_failure_codes()
766809

767810
# Calculate pagination info
768811
total_pages = ceil(total_count / per_page)
@@ -773,6 +816,7 @@ defmodule ReencodarrWeb.FailuresLive do
773816
|> assign(:video_failures, video_failures)
774817
|> assign(:failure_stats, summarize_failure_stats(failure_stats))
775818
|> assign(:failure_patterns, failure_patterns)
819+
|> assign(:failure_code_actions, failure_code_actions)
776820
|> assign(:total_count, total_count)
777821
|> assign(:total_pages, total_pages)
778822
end

test/reencodarr/media_test.exs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,70 @@ defmodule Reencodarr.MediaTest do
11731173
assert reloaded1.state == :needs_analysis
11741174
assert reloaded2.state == :needs_analysis
11751175
end
1176+
1177+
test "list_failed_video_failure_codes/1 groups unresolved failure codes for failed videos" do
1178+
{:ok, exit_143} = Fixtures.video_fixture(%{state: :failed})
1179+
{:ok, timeout} = Fixtures.video_fixture(%{state: :failed})
1180+
{:ok, resolved_video} = Fixtures.video_fixture(%{state: :failed})
1181+
1182+
Media.record_video_failure(exit_143, :encoding, :resource_exhaustion,
1183+
code: "EXIT_143",
1184+
message: "Killed"
1185+
)
1186+
1187+
Media.record_video_failure(timeout, :encoding, :timeout,
1188+
code: "TIMEOUT",
1189+
message: "Timed out"
1190+
)
1191+
1192+
Media.record_video_failure(resolved_video, :encoding, :resource_exhaustion,
1193+
code: "EXIT_143",
1194+
message: "Should be excluded because it is resolved"
1195+
)
1196+
1197+
Media.resolve_video_failures(resolved_video.id)
1198+
1199+
assert Media.list_failed_video_failure_codes()
1200+
|> Enum.map(& &1.code) == ["EXIT_143", "TIMEOUT"]
1201+
end
1202+
1203+
test "retry_failed_videos_by_failure_code/1 retries matching failed videos only" do
1204+
{:ok, exit_143_a} = Fixtures.video_fixture(%{state: :failed, bitrate: 5_000_000})
1205+
{:ok, exit_143_b} = Fixtures.video_fixture(%{state: :failed, bitrate: 6_000_000})
1206+
{:ok, timeout} = Fixtures.video_fixture(%{state: :failed, bitrate: 7_000_000})
1207+
1208+
Media.record_video_failure(exit_143_a, :encoding, :resource_exhaustion,
1209+
code: "EXIT_143",
1210+
message: "Killed"
1211+
)
1212+
1213+
Media.record_video_failure(exit_143_b, :encoding, :resource_exhaustion,
1214+
code: "EXIT_143",
1215+
message: "Killed again"
1216+
)
1217+
1218+
Media.record_video_failure(timeout, :encoding, :timeout,
1219+
code: "TIMEOUT",
1220+
message: "Timed out"
1221+
)
1222+
1223+
result = Media.retry_failed_videos_by_failure_code("EXIT_143")
1224+
1225+
assert result.videos_retried == 2
1226+
assert result.failures_resolved == 2
1227+
1228+
assert Media.get_video!(exit_143_a.id).state == :needs_analysis
1229+
assert Media.get_video!(exit_143_b.id).state == :needs_analysis
1230+
assert Media.get_video!(timeout.id).state == :failed
1231+
1232+
assert Media.get_video!(exit_143_a.id).bitrate == nil
1233+
assert Media.get_video!(exit_143_b.id).bitrate == nil
1234+
assert Media.get_video!(timeout.id).bitrate == 7_000_000
1235+
1236+
assert Media.get_video_failures(exit_143_a.id) == []
1237+
assert Media.get_video_failures(exit_143_b.id) == []
1238+
assert length(Media.get_video_failures(timeout.id)) == 1
1239+
end
11761240
end
11771241

11781242
describe "test helpers" do

test/reencodarr_web/live/failures_live_test.exs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,42 @@ defmodule ReencodarrWeb.FailuresLiveTest do
157157
end
158158
end
159159

160+
describe "retry_failure_code event" do
161+
test "renders retry-by-code actions and retries matching failures", %{conn: conn} do
162+
{:ok, exit_143_video} =
163+
Fixtures.video_fixture(%{path: "/media/exit_143_video.mkv", state: :failed})
164+
165+
{:ok, timeout_video} =
166+
Fixtures.video_fixture(%{path: "/media/timeout_video.mkv", state: :failed})
167+
168+
Media.record_video_failure(exit_143_video, :encoding, :resource_exhaustion,
169+
code: "EXIT_143",
170+
message: "Killed"
171+
)
172+
173+
Media.record_video_failure(timeout_video, :encoding, :timeout,
174+
code: "TIMEOUT",
175+
message: "Timed out"
176+
)
177+
178+
{:ok, view, _} = live(conn, ~p"/failures")
179+
html = loaded_html(view)
180+
181+
assert html =~ "Retry By Error Code"
182+
assert html =~ "EXIT_143"
183+
assert html =~ "TIMEOUT"
184+
185+
html =
186+
view
187+
|> element("button[phx-click='retry_failure_code'][phx-value-code='EXIT_143']")
188+
|> render_click()
189+
190+
assert html =~ "Failures"
191+
assert Media.get_video!(exit_143_video.id).state == :needs_analysis
192+
assert Media.get_video!(timeout_video.id).state == :failed
193+
end
194+
end
195+
160196
# ---------------------------------------------------------------------------
161197
# Row interactions (requires at least one failure in DB)
162198
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)