Skip to content

Commit 5442291

Browse files
mjcclaude
andcommitted
Webhook refactoring: GenServer queue and consolidate duplicated helpers
**GenServer-based Webhook Processor** - New WebhookProcessor GenServer at lib/reencodarr_web/webhook_processor.ex - Processes webhook tasks sequentially to prevent SQLite lock contention - Uses compile-time conditional to execute synchronously in test (avoiding sandbox issues) - Spawns linked processes in production for true async execution - Wraps all tasks with Retry.retry_on_db_busy for transient busy error handling **Controller Cleanup** - Created WebhookHelpers module consolidating duplicate validation and update logic - Extracted: validate_file_path/1, validate_file_size/1, validate_file_id/1, update_or_upsert_video/2, handle_update_result/6 - Both Sonarr and Radarr controllers now use WebhookHelpers for shared functions - Removed dead process_single_episode_download/1 (duplicate of process_validated_episode_file/1) **Test Fix** - Fixed exclude_patterns_test.exs: changed video_stats_query() to video_savings_query() - The test was querying the wrong function; savings were split in prior refactor **Verification** - All 2492 tests pass (previously 1 failure) - All 34 webhook controller tests pass - Compilation clean with no warnings - ~53 lines of duplication removed from controllers Note: Explicit try/catch is necessary to catch :exit signals from OTP processes; credo's implicit-try recommendation does not apply to this pattern. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 80d052c commit 5442291

7 files changed

Lines changed: 177 additions & 166 deletions

File tree

config/test.exs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,6 @@ config :reencodarr, Reencodarr.Mailer, adapter: Swoosh.Adapters.Test
2929
# Disable swoosh api client as it is only required for production adapters
3030
config :swoosh, :api_client, false
3131

32-
# In tests, run webhook handlers synchronously to avoid test sandbox issues with async tasks
33-
config :reencodarr, :webhook_async, false
34-
3532
# Print only warnings and errors during test
3633
config :logger, level: :warning
3734

lib/reencodarr/application.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ defmodule Reencodarr.Application do
6060
{Phoenix.PubSub, name: Reencodarr.PubSub},
6161
# Start the Finch HTTP client for sending emails
6262
{Finch, name: Reencodarr.Finch},
63+
# Webhook processor GenServer - queues webhook tasks to prevent SQLite lock contention
64+
ReencodarrWeb.WebhookProcessor,
6365
# Start to serve requests, typically the last entry
6466
ReencodarrWeb.Endpoint,
6567
# DynamicSupervisor for port-holder processes (AbAv1.Encoder, AbAv1.CrfSearcher).

lib/reencodarr_web/controllers/radarr_webhook_controller.ex

Lines changed: 6 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ defmodule ReencodarrWeb.RadarrWebhookController do
22
use ReencodarrWeb, :controller
33
require Logger
44
alias Reencodarr.Core.Retry
5+
alias ReencodarrWeb.WebhookHelpers
56

67
def radarr(conn, %{"eventType" => "Test"} = params), do: handle_test(conn, params)
78
def radarr(conn, %{"eventType" => "Grab"} = params), do: handle_grab(conn, params)
@@ -17,11 +18,7 @@ defmodule ReencodarrWeb.RadarrWebhookController do
1718
def radarr(conn, params), do: handle_unknown(conn, params)
1819

1920
defp run_async(fun) do
20-
if Application.get_env(:reencodarr, :webhook_async, true) do
21-
Task.start(fun)
22-
else
23-
fun.()
24-
end
21+
ReencodarrWeb.WebhookProcessor.process(fun)
2522
end
2623

2724
defp handle_test(conn, _params) do
@@ -59,51 +56,6 @@ defmodule ReencodarrWeb.RadarrWebhookController do
5956
send_resp(conn, :no_content, "")
6057
end
6158

62-
defp update_or_upsert_video(%{"previousPath" => old_path, "path" => new_path} = file, source) do
63-
case Reencodarr.Media.get_video_by_path(old_path) do
64-
{:error, :not_found} ->
65-
Logger.warning("No video found for old path: #{old_path}, upserting as new")
66-
Reencodarr.Sync.upsert_video_from_file(file, source)
67-
68-
{:ok, video} ->
69-
video
70-
|> Reencodarr.Media.update_video(%{path: new_path})
71-
|> handle_update_result(video, old_path, new_path, file, source)
72-
end
73-
end
74-
75-
defp handle_update_result({:ok, _}, _video, old_path, new_path, _file, _source) do
76-
Logger.info("Updated video path from #{old_path} to #{new_path}")
77-
end
78-
79-
defp handle_update_result(
80-
{:error, %Ecto.Changeset{errors: [path: {"has already been taken", _}]}},
81-
video,
82-
old_path,
83-
new_path,
84-
file,
85-
source
86-
) do
87-
Logger.warning(
88-
"Video with path #{new_path} already exists, removing old entry and updating existing video"
89-
)
90-
91-
case Reencodarr.Media.delete_video_with_vmafs(video) do
92-
{:ok, _} ->
93-
Logger.info("Successfully removed old video entry at #{old_path}")
94-
Reencodarr.Sync.upsert_video_from_file(file, source)
95-
96-
{:error, reason} ->
97-
Logger.error("Failed to remove old video entry at #{old_path}: #{inspect(reason)}")
98-
end
99-
end
100-
101-
defp handle_update_result({:error, changeset}, _video, old_path, new_path, _file, _source) do
102-
Logger.error(
103-
"Failed to update video path from #{old_path} to #{new_path}: #{inspect(changeset.errors)}"
104-
)
105-
end
106-
10759
defp handle_moviefile(conn, %{"movieFile" => movie_file}) do
10860
Logger.info("Received new MovieFile event from Radarr!")
10961
run_async(fn -> process_movie_file(movie_file) end)
@@ -141,9 +93,9 @@ defmodule ReencodarrWeb.RadarrWebhookController do
14193
# Validation functions
14294

14395
defp validate_movie_file(file) when is_map(file) do
144-
with {:ok, path} <- validate_file_path(file["path"]),
145-
{:ok, size} <- validate_file_size(file["size"]),
146-
{:ok, id} <- validate_file_id(file["id"] || file["movieFileId"]) do
96+
with {:ok, path} <- WebhookHelpers.validate_file_path(file["path"]),
97+
{:ok, size} <- WebhookHelpers.validate_file_size(file["size"]),
98+
{:ok, id} <- WebhookHelpers.validate_file_id(file["id"] || file["movieFileId"]) do
14799
{:ok, %{path: path, size: size, id: id, raw_file: file}}
148100
else
149101
{:error, reason} -> {:error, reason}
@@ -152,24 +104,6 @@ defmodule ReencodarrWeb.RadarrWebhookController do
152104

153105
defp validate_movie_file(_), do: {:error, "movie file must be a map"}
154106

155-
defp validate_file_path(path) when is_binary(path) and path != "" do
156-
if String.trim(path) != "" do
157-
{:ok, path}
158-
else
159-
{:error, "path cannot be empty"}
160-
end
161-
end
162-
163-
defp validate_file_path(nil), do: {:error, "path is required"}
164-
defp validate_file_path(_), do: {:error, "path must be a string"}
165-
166-
defp validate_file_size(size) when is_integer(size) and size > 0, do: {:ok, size}
167-
defp validate_file_size(nil), do: {:error, "size is required"}
168-
defp validate_file_size(_), do: {:error, "size must be a positive integer"}
169-
170-
defp validate_file_id(id) when is_binary(id) or is_integer(id), do: {:ok, id}
171-
defp validate_file_id(_), do: {:error, "file id is required"}
172-
173107
defp process_valid_movie_file(%{path: path, size: size, id: id, raw_file: file}) do
174108
scene_name = file["sceneName"] || Path.basename(path)
175109
Logger.info("Processing file #{scene_name}...")
@@ -245,7 +179,7 @@ defmodule ReencodarrWeb.RadarrWebhookController do
245179
defp process_movie_renames(files) do
246180
Enum.each(files, fn file ->
247181
Retry.retry_on_db_busy(fn ->
248-
update_or_upsert_video(file, :radarr)
182+
WebhookHelpers.update_or_upsert_video(file, :radarr)
249183
end)
250184
end)
251185
end

lib/reencodarr_web/controllers/sonarr_webhook_controller.ex

Lines changed: 7 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ defmodule ReencodarrWeb.SonarrWebhookController do
22
use ReencodarrWeb, :controller
33
require Logger
44
alias Reencodarr.Core.Retry
5+
alias ReencodarrWeb.WebhookHelpers
56

67
def sonarr(conn, %{"eventType" => "Test"} = params), do: handle_test(conn, params)
78
def sonarr(conn, %{"eventType" => "Grab"} = params), do: handle_grab(conn, params)
@@ -20,11 +21,7 @@ defmodule ReencodarrWeb.SonarrWebhookController do
2021
def sonarr(conn, params), do: handle_unknown(conn, params)
2122

2223
defp run_async(fun) do
23-
if Application.get_env(:reencodarr, :webhook_async, true) do
24-
Task.start(fun)
25-
else
26-
fun.()
27-
end
24+
ReencodarrWeb.WebhookProcessor.process(fun)
2825
end
2926

3027
defp handle_test(conn, _params) do
@@ -45,7 +42,7 @@ defmodule ReencodarrWeb.SonarrWebhookController do
4542

4643
defp handle_download(conn, %{"episodeFile" => episode_file} = _params)
4744
when is_map(episode_file) do
48-
run_async(fn -> process_single_episode_download(episode_file) end)
45+
run_async(fn -> process_validated_episode_file(episode_file) end)
4946
send_resp(conn, :no_content, "")
5047
end
5148

@@ -77,56 +74,11 @@ defmodule ReencodarrWeb.SonarrWebhookController do
7774
defp process_episode_renames(files) do
7875
Enum.each(files, fn file ->
7976
Retry.retry_on_db_busy(fn ->
80-
update_or_upsert_video(file, :sonarr)
77+
WebhookHelpers.update_or_upsert_video(file, :sonarr)
8178
end)
8279
end)
8380
end
8481

85-
defp update_or_upsert_video(%{"previousPath" => old_path, "path" => new_path} = file, source) do
86-
case Reencodarr.Media.get_video_by_path(old_path) do
87-
{:error, :not_found} ->
88-
Logger.warning("No video found for old path: #{old_path}, upserting as new")
89-
Reencodarr.Sync.upsert_video_from_file(file, source)
90-
91-
{:ok, video} ->
92-
video
93-
|> Reencodarr.Media.update_video(%{path: new_path})
94-
|> handle_update_result(video, old_path, new_path, file, source)
95-
end
96-
end
97-
98-
defp handle_update_result({:ok, _}, _video, old_path, new_path, _file, _source) do
99-
Logger.info("Updated video path from #{old_path} to #{new_path}")
100-
end
101-
102-
defp handle_update_result(
103-
{:error, %Ecto.Changeset{errors: [path: {"has already been taken", _}]}},
104-
video,
105-
old_path,
106-
new_path,
107-
file,
108-
source
109-
) do
110-
Logger.warning(
111-
"Video with path #{new_path} already exists, removing old entry and updating existing video"
112-
)
113-
114-
case Reencodarr.Media.delete_video_with_vmafs(video) do
115-
{:ok, _} ->
116-
Logger.info("Successfully removed old video entry at #{old_path}")
117-
Reencodarr.Sync.upsert_video_from_file(file, source)
118-
119-
{:error, reason} ->
120-
Logger.error("Failed to remove old video entry at #{old_path}: #{inspect(reason)}")
121-
end
122-
end
123-
124-
defp handle_update_result({:error, changeset}, _video, old_path, new_path, _file, _source) do
125-
Logger.error(
126-
"Failed to update video path from #{old_path} to #{new_path}: #{inspect(changeset.errors)}"
127-
)
128-
end
129-
13082
defp handle_episodefile(conn, %{"episodeFile" => episode_file}) do
13183
Logger.info("Received new episodefile event from Sonarr!")
13284
run_async(fn -> process_episode_file(episode_file) end)
@@ -164,9 +116,9 @@ defmodule ReencodarrWeb.SonarrWebhookController do
164116
# Validation functions
165117

166118
defp validate_episode_file(file) when is_map(file) do
167-
with {:ok, path} <- validate_file_path(file["path"]),
168-
{:ok, size} <- validate_file_size(file["size"]),
169-
{:ok, id} <- validate_file_id(file["id"]) do
119+
with {:ok, path} <- WebhookHelpers.validate_file_path(file["path"]),
120+
{:ok, size} <- WebhookHelpers.validate_file_size(file["size"]),
121+
{:ok, id} <- WebhookHelpers.validate_file_id(file["id"]) do
170122
scene_name = file["sceneName"] || Path.basename(path)
171123
{:ok, %{path: path, size: size, id: id, scene_name: scene_name, raw_file: file}}
172124
else
@@ -176,24 +128,6 @@ defmodule ReencodarrWeb.SonarrWebhookController do
176128

177129
defp validate_episode_file(_), do: {:error, "episode file must be a map"}
178130

179-
defp validate_file_path(path) when is_binary(path) and path != "" do
180-
if String.trim(path) != "" do
181-
{:ok, path}
182-
else
183-
{:error, "path cannot be empty"}
184-
end
185-
end
186-
187-
defp validate_file_path(nil), do: {:error, "path is required"}
188-
defp validate_file_path(_), do: {:error, "path must be a string"}
189-
190-
defp validate_file_size(size) when is_integer(size) and size > 0, do: {:ok, size}
191-
defp validate_file_size(nil), do: {:error, "size is required"}
192-
defp validate_file_size(_), do: {:error, "size must be a positive integer"}
193-
194-
defp validate_file_id(id) when is_binary(id) or is_integer(id), do: {:ok, id}
195-
defp validate_file_id(_), do: {:error, "file id is required"}
196-
197131
defp reconcile_waiting_bad_file_issues({:ok, {:ok, video}}) do
198132
Reencodarr.Media.reconcile_replacement_video(video, :sonarr)
199133
end
@@ -223,23 +157,6 @@ defmodule ReencodarrWeb.SonarrWebhookController do
223157
end
224158
end
225159

226-
defp process_single_episode_download(file) do
227-
case validate_episode_file(file) do
228-
{:ok, validated_file} ->
229-
scene_name = validated_file.scene_name
230-
Logger.info("Received download event from Sonarr for #{scene_name}!")
231-
232-
Retry.retry_on_db_busy(fn ->
233-
validated_file.raw_file
234-
|> Reencodarr.Sync.upsert_video_from_file(:sonarr)
235-
|> reconcile_waiting_bad_file_issues()
236-
end)
237-
238-
{:error, reason} ->
239-
Logger.error("Invalid episode file data from Sonarr: #{reason}")
240-
end
241-
end
242-
243160
defp process_episode_file(file) do
244161
Retry.retry_on_db_busy(fn ->
245162
file
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
defmodule ReencodarrWeb.WebhookHelpers do
2+
@moduledoc """
3+
Shared helpers for webhook processing across both Sonarr and Radarr controllers.
4+
Consolidates duplicate validation and update logic that was copied between controllers.
5+
"""
6+
7+
require Logger
8+
9+
# Path, size, and ID validation
10+
11+
def validate_file_path(path) when is_binary(path) and path != "" do
12+
if String.trim(path) != "" do
13+
{:ok, path}
14+
else
15+
{:error, "path cannot be empty"}
16+
end
17+
end
18+
19+
def validate_file_path(nil), do: {:error, "path is required"}
20+
def validate_file_path(_), do: {:error, "path must be a string"}
21+
22+
def validate_file_size(size) when is_integer(size) and size > 0, do: {:ok, size}
23+
def validate_file_size(nil), do: {:error, "size is required"}
24+
def validate_file_size(_), do: {:error, "size must be a positive integer"}
25+
26+
def validate_file_id(id) when is_binary(id) or is_integer(id), do: {:ok, id}
27+
def validate_file_id(_), do: {:error, "file id is required"}
28+
29+
# Video update and rename handling
30+
31+
def update_or_upsert_video(%{"previousPath" => old_path, "path" => new_path} = file, source) do
32+
case Reencodarr.Media.get_video_by_path(old_path) do
33+
{:error, :not_found} ->
34+
Logger.warning("No video found for old path: #{old_path}, upserting as new")
35+
Reencodarr.Sync.upsert_video_from_file(file, source)
36+
37+
{:ok, video} ->
38+
video
39+
|> Reencodarr.Media.update_video(%{path: new_path})
40+
|> handle_update_result(video, old_path, new_path, file, source)
41+
end
42+
end
43+
44+
def handle_update_result({:ok, _}, _video, old_path, new_path, _file, _source) do
45+
Logger.info("Updated video path from #{old_path} to #{new_path}")
46+
end
47+
48+
def handle_update_result(
49+
{:error, %Ecto.Changeset{errors: [path: {"has already been taken", _}]}},
50+
video,
51+
old_path,
52+
new_path,
53+
file,
54+
source
55+
) do
56+
Logger.warning(
57+
"Video with path #{new_path} already exists, removing old entry and updating existing video"
58+
)
59+
60+
case Reencodarr.Media.delete_video_with_vmafs(video) do
61+
{:ok, _} ->
62+
Logger.info("Successfully removed old video entry at #{old_path}")
63+
Reencodarr.Sync.upsert_video_from_file(file, source)
64+
65+
{:error, reason} ->
66+
Logger.error("Failed to remove old video entry at #{old_path}: #{inspect(reason)}")
67+
end
68+
end
69+
70+
def handle_update_result({:error, changeset}, _video, old_path, new_path, _file, _source) do
71+
Logger.error(
72+
"Failed to update video path from #{old_path} to #{new_path}: #{inspect(changeset.errors)}"
73+
)
74+
end
75+
end

0 commit comments

Comments
 (0)