Skip to content

Commit e7adc9d

Browse files
authored
[Enhancement] Record and display errors related to downloading (#610)
* Added last_error to media item table * Error messages are now persisted to the last_error field * Minor layout updates * Added help tooltip to source content view * Added error information to homepage tables * Remove unneeded index * Added docs to tooltip component
1 parent fe5c00d commit e7adc9d

File tree

15 files changed

+298
-70
lines changed

15 files changed

+298
-70
lines changed

lib/pinchflat/downloading/media_download_worker.ex

+3-3
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,13 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
105105

106106
:ok
107107

108-
{:recovered, _} ->
108+
{:recovered, _media_item, _message} ->
109109
{:error, :retry}
110110

111-
{:error, :unsuitable_for_download} ->
111+
{:error, :unsuitable_for_download, _message} ->
112112
{:ok, :non_retry}
113113

114-
{:error, message} ->
114+
{:error, _error_atom, message} ->
115115
action_on_error(message)
116116
end
117117
end

lib/pinchflat/downloading/media_downloader.ex

+53-15
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule Pinchflat.Downloading.MediaDownloader do
1010
alias Pinchflat.Repo
1111
alias Pinchflat.Media
1212
alias Pinchflat.Media.MediaItem
13+
alias Pinchflat.Utils.StringUtils
1314
alias Pinchflat.Metadata.NfoBuilder
1415
alias Pinchflat.Metadata.MetadataParser
1516
alias Pinchflat.Metadata.MetadataFileHelpers
@@ -20,16 +21,53 @@ defmodule Pinchflat.Downloading.MediaDownloader do
2021

2122
@doc """
2223
Downloads media for a media item, updating the media item based on the metadata
23-
returned by yt-dlp. Also saves the entire metadata response to the associated
24-
media_metadata record.
24+
returned by yt-dlp. Encountered errors are saved to the Media Item record. Saves
25+
the entire metadata response to the associated media_metadata record.
2526
26-
NOTE: related methods (like the download worker) won't download if the media item's source
27+
NOTE: related methods (like the download worker) won't download if Pthe media item's source
2728
is set to not download media. However, I'm not enforcing that here since I need this for testing.
2829
This may change in the future but I'm not stressed.
2930
30-
Returns {:ok, %MediaItem{}} | {:error, any, ...any}
31+
Returns {:ok, %MediaItem{}} | {:error, atom(), String.t()} | {:recovered, %MediaItem{}, String.t()}
3132
"""
3233
def download_for_media_item(%MediaItem{} = media_item, override_opts \\ []) do
34+
case attempt_download_and_update_for_media_item(media_item, override_opts) do
35+
{:ok, media_item} ->
36+
# Returns {:ok, %MediaItem{}}
37+
Media.update_media_item(media_item, %{last_error: nil})
38+
39+
{:error, error_atom, message} ->
40+
Media.update_media_item(media_item, %{last_error: StringUtils.wrap_string(message)})
41+
42+
{:error, error_atom, message}
43+
44+
{:recovered, media_item, message} ->
45+
{:ok, updated_media_item} = Media.update_media_item(media_item, %{last_error: StringUtils.wrap_string(message)})
46+
47+
{:recovered, updated_media_item, message}
48+
end
49+
end
50+
51+
# Looks complicated, but here's the key points:
52+
# - download_with_options runs a pre-check to see if the media item is suitable for download.
53+
# - If the media item fails the precheck, it returns {:error, :unsuitable_for_download, message}
54+
# - If the precheck passes but the download fails, it normally returns {:error, :download_failed, message}
55+
# - However, there are some errors we can recover from (eg: failure to communicate with SponsorBlock).
56+
# In this case, we attempt the download anyway and update the media item with what details we do have.
57+
# This case returns {:recovered, updated_media_item, message}
58+
# - If we attempt a retry but it fails, we return {:error, :unrecoverable, message}
59+
# - If there is an unknown error unrelated to the above, we return {:error, :unknown, message}
60+
# - Finally, if there is no error, we update the media item with the parsed JSON and return {:ok, updated_media_item}
61+
#
62+
# Restated, here are the return values for each case:
63+
# - On success: {:ok, updated_media_item}
64+
# - On initial failure but successfully recovered: {:recovered, updated_media_item, message}
65+
# - On error: {:error, error_atom, message} where error_atom is one of:
66+
# - `:unsuitable_for_download` if the media item fails the precheck
67+
# - `:unrecoverable` if there was an initial failure and the recovery attempt failed
68+
# - `:download_failed` for all other yt-dlp-related downloading errors
69+
# - `:unknown` for any other errors, including those not related to yt-dlp
70+
defp attempt_download_and_update_for_media_item(media_item, override_opts) do
3371
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
3472
media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
3573

@@ -38,31 +76,30 @@ defmodule Pinchflat.Downloading.MediaDownloader do
3876
update_media_item_from_parsed_json(media_with_preloads, parsed_json)
3977

4078
{:error, :unsuitable_for_download} ->
41-
Logger.warning(
79+
message =
4280
"Media item ##{media_with_preloads.id} isn't suitable for download yet. May be an active or processing live stream"
43-
)
4481

45-
{:error, :unsuitable_for_download}
82+
Logger.warning(message)
83+
84+
{:error, :unsuitable_for_download, message}
4685

4786
{:error, message, _exit_code} ->
4887
Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}")
4988

5089
if String.contains?(to_string(message), recoverable_errors()) do
51-
attempt_update_media_item(media_with_preloads, output_filepath)
52-
53-
{:recovered, message}
90+
attempt_recovery_from_error(media_with_preloads, output_filepath, message)
5491
else
55-
{:error, message}
92+
{:error, :download_failed, message}
5693
end
5794

5895
err ->
5996
Logger.error("Unknown error downloading media item ##{media_with_preloads.id}: #{inspect(err)}")
6097

61-
{:error, "Unknown error: #{inspect(err)}"}
98+
{:error, :unknown, "Unknown error: #{inspect(err)}"}
6299
end
63100
end
64101

65-
defp attempt_update_media_item(media_with_preloads, output_filepath) do
102+
defp attempt_recovery_from_error(media_with_preloads, output_filepath, error_message) do
66103
with {:ok, contents} <- File.read(output_filepath),
67104
{:ok, parsed_json} <- Phoenix.json_library().decode(contents) do
68105
Logger.info("""
@@ -71,12 +108,13 @@ defmodule Pinchflat.Downloading.MediaDownloader do
71108
anyway
72109
""")
73110

74-
update_media_item_from_parsed_json(media_with_preloads, parsed_json)
111+
{:ok, updated_media_item} = update_media_item_from_parsed_json(media_with_preloads, parsed_json)
112+
{:recovered, updated_media_item, error_message}
75113
else
76114
err ->
77115
Logger.error("Unable to recover error for media item ##{media_with_preloads.id}: #{inspect(err)}")
78116

79-
{:error, :retry_failed}
117+
{:error, :unrecoverable, error_message}
80118
end
81119
end
82120

lib/pinchflat/media/media_item.ex

+2
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ defmodule Pinchflat.Media.MediaItem do
4040
:thumbnail_filepath,
4141
:metadata_filepath,
4242
:nfo_filepath,
43+
:last_error,
4344
# These are user or system controlled fields
4445
:prevent_download,
4546
:prevent_culling,
@@ -88,6 +89,7 @@ defmodule Pinchflat.Media.MediaItem do
8889
# Will very likely revisit because I can't leave well-enough alone.
8990
field :subtitle_filepaths, {:array, {:array, :string}}, default: []
9091

92+
field :last_error, :string
9193
field :prevent_download, :boolean, default: false
9294
field :prevent_culling, :boolean, default: false
9395
field :culled_at, :utc_datetime

lib/pinchflat/utils/string_utils.ex

+9
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,13 @@ defmodule Pinchflat.Utils.StringUtils do
3535
def double_brace(string) do
3636
"{{ #{string} }}"
3737
end
38+
39+
@doc """
40+
Wraps a string in quotes if it's not already a string. Useful for working with
41+
error messages whose types can vary.
42+
43+
Returns binary()
44+
"""
45+
def wrap_string(message) when is_binary(message), do: message
46+
def wrap_string(message), do: "#{inspect(message)}"
3847
end

lib/pinchflat_web/components/custom_components/button_components.ex

+3-13
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
33
use Phoenix.Component, global_prefixes: ~w(x-)
44

55
alias PinchflatWeb.CoreComponents
6+
alias PinchflatWeb.CustomComponents.TextComponents
67

78
@doc """
89
Render a button
@@ -104,7 +105,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
104105

105106
def icon_button(assigns) do
106107
~H"""
107-
<div class="group relative inline-block">
108+
<TextComponents.tooltip position="bottom" tooltip={@tooltip} tooltip_class="text-nowrap">
108109
<button
109110
class={[
110111
"flex justify-center items-center rounded-lg ",
@@ -117,18 +118,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
117118
>
118119
<CoreComponents.icon name={@icon_name} class="text-stroke" />
119120
</button>
120-
<div
121-
:if={@tooltip}
122-
class={[
123-
"hidden absolute left-1/2 top-full z-20 mt-3 -translate-x-1/2 whitespace-nowrap rounded-md",
124-
"px-4.5 py-1.5 text-sm font-medium opacity-0 drop-shadow-4 group-hover:opacity-100 group-hover:block bg-meta-4"
125-
]}
126-
>
127-
<span class="border-light absolute -top-1 left-1/2 -z-10 h-2 w-2 -translate-x-1/2 rotate-45 rounded-sm bg-meta-4">
128-
</span>
129-
<span>{@tooltip}</span>
130-
</div>
131-
</div>
121+
</TextComponents.tooltip>
132122
"""
133123
end
134124
end

lib/pinchflat_web/components/custom_components/text_components.ex

+56
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,60 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
146146
<.localized_number number={@num} /> {@suffix}
147147
"""
148148
end
149+
150+
@doc """
151+
Renders a tooltip with the given content
152+
"""
153+
154+
attr :tooltip, :string, required: true
155+
attr :position, :string, default: ""
156+
attr :tooltip_class, :any, default: ""
157+
attr :tooltip_arrow_class, :any, default: ""
158+
slot :inner_block
159+
160+
def tooltip(%{position: "bottom-right"} = assigns) do
161+
~H"""
162+
<.tooltip tooltip={@tooltip} tooltip_class={@tooltip_class} tooltip_arrow_class={["-top-1", @tooltip_arrow_class]}>
163+
{render_slot(@inner_block)}
164+
</.tooltip>
165+
"""
166+
end
167+
168+
def tooltip(%{position: "bottom"} = assigns) do
169+
~H"""
170+
<.tooltip
171+
tooltip={@tooltip}
172+
tooltip_class={["left-1/2 -translate-x-1/2", @tooltip_class]}
173+
tooltip_arrow_class={["-top-1 left-1/2 -translate-x-1/2", @tooltip_arrow_class]}
174+
>
175+
{render_slot(@inner_block)}
176+
</.tooltip>
177+
"""
178+
end
179+
180+
def tooltip(assigns) do
181+
~H"""
182+
<div class="group relative inline-block cursor-pointer">
183+
<div>
184+
{render_slot(@inner_block)}
185+
</div>
186+
<div
187+
:if={@tooltip}
188+
class={[
189+
"hidden absolute top-full z-20 mt-3 whitespace-nowrap rounded-md",
190+
"p-1.5 text-sm font-medium opacity-0 drop-shadow-4 group-hover:opacity-100 group-hover:block bg-meta-4",
191+
"border border-form-strokedark text-wrap",
192+
@tooltip_class
193+
]}
194+
>
195+
<span class={[
196+
"border-t border-l border-form-strokedark absolute -z-10 h-2 w-2 rotate-45 rounded-sm bg-meta-4",
197+
@tooltip_arrow_class
198+
]}>
199+
</span>
200+
<div class="px-3">{@tooltip}</div>
201+
</div>
202+
</div>
203+
"""
204+
end
149205
end

lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex

+19-9
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,23 @@
1616
</.link>
1717
</nav>
1818
</div>
19-
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
19+
<div class="rounded-sm border py-5 pt-6 shadow-default border-strokedark bg-boxdark px-7.5">
2020
<div class="max-w-full">
2121
<.tabbed_layout>
2222
<:tab_append>
2323
<.actions_dropdown media_item={@media_item} />
2424
</:tab_append>
2525

2626
<:tab title="Media" id="media">
27-
<div class="flex flex-col gap-10 dark:text-white">
27+
<div class="flex flex-col gap-10 text-white">
28+
<section :if={@media_item.last_error} class="mt-6">
29+
<div class="flex items-center gap-1 mb-2">
30+
<.icon name="hero-exclamation-circle-solid" class="text-red-500" />
31+
<h3 class="font-bold text-xl">Last Error</h3>
32+
</div>
33+
<span>{@media_item.last_error}</span>
34+
</section>
35+
2836
<%= if media_file_exists?(@media_item) do %>
2937
<section class="grid grid-cols-1 xl:gap-6 mt-6">
3038
<div>
@@ -54,19 +62,21 @@
5462
</section>
5563
<% end %>
5664

57-
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
5865
<section>
59-
<strong>Source:</strong>
60-
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
61-
{@media_item.source.custom_name}
62-
</.subtle_link>
63-
<.list_items_from_map map={Map.from_struct(@media_item)} />
66+
<h3 class="font-bold text-xl mb-2">Raw Attributes</h3>
67+
<section>
68+
<strong>Source:</strong>
69+
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
70+
{@media_item.source.custom_name}
71+
</.subtle_link>
72+
<.list_items_from_map map={Map.from_struct(@media_item)} />
73+
</section>
6474
</section>
6575
</div>
6676
</:tab>
6777
<:tab title="Tasks" id="tasks">
6878
<%= if match?([_|_], @media_item.tasks) do %>
69-
<.table rows={@media_item.tasks} table_class="text-black dark:text-white">
79+
<.table rows={@media_item.tasks} table_class="text-white">
7080
<:col :let={task} label="Worker">
7181
{task.job.worker}
7282
</:col>

lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex

+4-2
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@
2525

2626
<:tab title="Media Profile" id="media-profile">
2727
<div class="flex flex-col gap-10 text-white">
28-
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
29-
<.list_items_from_map map={Map.from_struct(@media_profile)} />
28+
<section>
29+
<h3 class="font-bold text-xl mt-6 mb-2">Raw Attributes</h3>
30+
<.list_items_from_map map={Map.from_struct(@media_profile)} />
31+
</section>
3032
</div>
3133
</:tab>
3234
<:tab title="Sources" id="sources">

lib/pinchflat_web/controllers/pages/page_html/history_table_live.ex

+16-4
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,22 @@ defmodule Pinchflat.Pages.HistoryTableLive do
2828
</span>
2929
<div class="max-w-full overflow-x-auto">
3030
<.table rows={@records} table_class="text-white">
31-
<:col :let={media_item} label="Title" class="truncate max-w-xs">
32-
<.subtle_link href={~p"/sources/#{media_item.source_id}/media/#{media_item}"}>
33-
{media_item.title}
34-
</.subtle_link>
31+
<:col :let={media_item} label="Title" class="max-w-xs">
32+
<section class="flex items-center space-x-1">
33+
<.tooltip
34+
:if={media_item.last_error}
35+
tooltip={media_item.last_error}
36+
position="bottom-right"
37+
tooltip_class="w-64"
38+
>
39+
<.icon name="hero-exclamation-circle-solid" class="text-red-500" />
40+
</.tooltip>
41+
<span class="truncate">
42+
<.subtle_link href={~p"/sources/#{media_item.source_id}/media/#{media_item.id}"}>
43+
{media_item.title}
44+
</.subtle_link>
45+
</span>
46+
</section>
3547
</:col>
3648
<:col :let={media_item} label="Upload Date">
3749
{DateTime.to_date(media_item.uploaded_at)}

lib/pinchflat_web/controllers/sources/source_html/media_item_table_live.ex

+17-5
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,22 @@ defmodule PinchflatWeb.Sources.MediaItemTableLive do
4646
</div>
4747
</header>
4848
<.table rows={@records} table_class="text-white">
49-
<:col :let={media_item} label="Title" class="truncate max-w-xs">
50-
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
51-
{media_item.title}
52-
</.subtle_link>
49+
<:col :let={media_item} label="Title" class="max-w-xs">
50+
<section class="flex items-center space-x-1">
51+
<.tooltip
52+
:if={media_item.last_error}
53+
tooltip={media_item.last_error}
54+
position="bottom-right"
55+
tooltip_class="w-64"
56+
>
57+
<.icon name="hero-exclamation-circle-solid" class="text-red-500" />
58+
</.tooltip>
59+
<span class="truncate">
60+
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
61+
{media_item.title}
62+
</.subtle_link>
63+
</span>
64+
</section>
5365
</:col>
5466
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?">
5567
<.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} />
@@ -205,6 +217,6 @@ defmodule PinchflatWeb.Sources.MediaItemTableLive do
205217

206218
# Selecting only what we need GREATLY speeds up queries on large tables
207219
defp select_fields do
208-
[:id, :title, :uploaded_at, :prevent_download]
220+
[:id, :title, :uploaded_at, :prevent_download, :last_error]
209221
end
210222
end

0 commit comments

Comments
 (0)