mirror of
https://github.com/kieraneglin/pinchflat.git
synced 2026-01-23 02:24:24 +00:00
[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
This commit is contained in:
parent
fe5c00dbef
commit
e7adc9d68f
15 changed files with 298 additions and 70 deletions
|
|
@ -105,13 +105,13 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||
|
||||
:ok
|
||||
|
||||
{:recovered, _} ->
|
||||
{:recovered, _media_item, _message} ->
|
||||
{:error, :retry}
|
||||
|
||||
{:error, :unsuitable_for_download} ->
|
||||
{:error, :unsuitable_for_download, _message} ->
|
||||
{:ok, :non_retry}
|
||||
|
||||
{:error, message} ->
|
||||
{:error, _error_atom, message} ->
|
||||
action_on_error(message)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Utils.StringUtils
|
||||
alias Pinchflat.Metadata.NfoBuilder
|
||||
alias Pinchflat.Metadata.MetadataParser
|
||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||
|
|
@ -20,16 +21,53 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||
|
||||
@doc """
|
||||
Downloads media for a media item, updating the media item based on the metadata
|
||||
returned by yt-dlp. Also saves the entire metadata response to the associated
|
||||
media_metadata record.
|
||||
returned by yt-dlp. Encountered errors are saved to the Media Item record. Saves
|
||||
the entire metadata response to the associated media_metadata record.
|
||||
|
||||
NOTE: related methods (like the download worker) won't download if the media item's source
|
||||
NOTE: related methods (like the download worker) won't download if Pthe media item's source
|
||||
is set to not download media. However, I'm not enforcing that here since I need this for testing.
|
||||
This may change in the future but I'm not stressed.
|
||||
|
||||
Returns {:ok, %MediaItem{}} | {:error, any, ...any}
|
||||
Returns {:ok, %MediaItem{}} | {:error, atom(), String.t()} | {:recovered, %MediaItem{}, String.t()}
|
||||
"""
|
||||
def download_for_media_item(%MediaItem{} = media_item, override_opts \\ []) do
|
||||
case attempt_download_and_update_for_media_item(media_item, override_opts) do
|
||||
{:ok, media_item} ->
|
||||
# Returns {:ok, %MediaItem{}}
|
||||
Media.update_media_item(media_item, %{last_error: nil})
|
||||
|
||||
{:error, error_atom, message} ->
|
||||
Media.update_media_item(media_item, %{last_error: StringUtils.wrap_string(message)})
|
||||
|
||||
{:error, error_atom, message}
|
||||
|
||||
{:recovered, media_item, message} ->
|
||||
{:ok, updated_media_item} = Media.update_media_item(media_item, %{last_error: StringUtils.wrap_string(message)})
|
||||
|
||||
{:recovered, updated_media_item, message}
|
||||
end
|
||||
end
|
||||
|
||||
# Looks complicated, but here's the key points:
|
||||
# - download_with_options runs a pre-check to see if the media item is suitable for download.
|
||||
# - If the media item fails the precheck, it returns {:error, :unsuitable_for_download, message}
|
||||
# - If the precheck passes but the download fails, it normally returns {:error, :download_failed, message}
|
||||
# - However, there are some errors we can recover from (eg: failure to communicate with SponsorBlock).
|
||||
# In this case, we attempt the download anyway and update the media item with what details we do have.
|
||||
# This case returns {:recovered, updated_media_item, message}
|
||||
# - If we attempt a retry but it fails, we return {:error, :unrecoverable, message}
|
||||
# - If there is an unknown error unrelated to the above, we return {:error, :unknown, message}
|
||||
# - Finally, if there is no error, we update the media item with the parsed JSON and return {:ok, updated_media_item}
|
||||
#
|
||||
# Restated, here are the return values for each case:
|
||||
# - On success: {:ok, updated_media_item}
|
||||
# - On initial failure but successfully recovered: {:recovered, updated_media_item, message}
|
||||
# - On error: {:error, error_atom, message} where error_atom is one of:
|
||||
# - `:unsuitable_for_download` if the media item fails the precheck
|
||||
# - `:unrecoverable` if there was an initial failure and the recovery attempt failed
|
||||
# - `:download_failed` for all other yt-dlp-related downloading errors
|
||||
# - `:unknown` for any other errors, including those not related to yt-dlp
|
||||
defp attempt_download_and_update_for_media_item(media_item, override_opts) do
|
||||
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
|
||||
|
||||
|
|
@ -38,31 +76,30 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||
update_media_item_from_parsed_json(media_with_preloads, parsed_json)
|
||||
|
||||
{:error, :unsuitable_for_download} ->
|
||||
Logger.warning(
|
||||
message =
|
||||
"Media item ##{media_with_preloads.id} isn't suitable for download yet. May be an active or processing live stream"
|
||||
)
|
||||
|
||||
{:error, :unsuitable_for_download}
|
||||
Logger.warning(message)
|
||||
|
||||
{:error, :unsuitable_for_download, message}
|
||||
|
||||
{:error, message, _exit_code} ->
|
||||
Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}")
|
||||
|
||||
if String.contains?(to_string(message), recoverable_errors()) do
|
||||
attempt_update_media_item(media_with_preloads, output_filepath)
|
||||
|
||||
{:recovered, message}
|
||||
attempt_recovery_from_error(media_with_preloads, output_filepath, message)
|
||||
else
|
||||
{:error, message}
|
||||
{:error, :download_failed, message}
|
||||
end
|
||||
|
||||
err ->
|
||||
Logger.error("Unknown error downloading media item ##{media_with_preloads.id}: #{inspect(err)}")
|
||||
|
||||
{:error, "Unknown error: #{inspect(err)}"}
|
||||
{:error, :unknown, "Unknown error: #{inspect(err)}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp attempt_update_media_item(media_with_preloads, output_filepath) do
|
||||
defp attempt_recovery_from_error(media_with_preloads, output_filepath, error_message) do
|
||||
with {:ok, contents} <- File.read(output_filepath),
|
||||
{:ok, parsed_json} <- Phoenix.json_library().decode(contents) do
|
||||
Logger.info("""
|
||||
|
|
@ -71,12 +108,13 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||
anyway
|
||||
""")
|
||||
|
||||
update_media_item_from_parsed_json(media_with_preloads, parsed_json)
|
||||
{:ok, updated_media_item} = update_media_item_from_parsed_json(media_with_preloads, parsed_json)
|
||||
{:recovered, updated_media_item, error_message}
|
||||
else
|
||||
err ->
|
||||
Logger.error("Unable to recover error for media item ##{media_with_preloads.id}: #{inspect(err)}")
|
||||
|
||||
{:error, :retry_failed}
|
||||
{:error, :unrecoverable, error_message}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||
:thumbnail_filepath,
|
||||
:metadata_filepath,
|
||||
:nfo_filepath,
|
||||
:last_error,
|
||||
# These are user or system controlled fields
|
||||
:prevent_download,
|
||||
:prevent_culling,
|
||||
|
|
@ -88,6 +89,7 @@ defmodule Pinchflat.Media.MediaItem do
|
|||
# Will very likely revisit because I can't leave well-enough alone.
|
||||
field :subtitle_filepaths, {:array, {:array, :string}}, default: []
|
||||
|
||||
field :last_error, :string
|
||||
field :prevent_download, :boolean, default: false
|
||||
field :prevent_culling, :boolean, default: false
|
||||
field :culled_at, :utc_datetime
|
||||
|
|
|
|||
|
|
@ -35,4 +35,13 @@ defmodule Pinchflat.Utils.StringUtils do
|
|||
def double_brace(string) do
|
||||
"{{ #{string} }}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Wraps a string in quotes if it's not already a string. Useful for working with
|
||||
error messages whose types can vary.
|
||||
|
||||
Returns binary()
|
||||
"""
|
||||
def wrap_string(message) when is_binary(message), do: message
|
||||
def wrap_string(message), do: "#{inspect(message)}"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
|||
use Phoenix.Component, global_prefixes: ~w(x-)
|
||||
|
||||
alias PinchflatWeb.CoreComponents
|
||||
alias PinchflatWeb.CustomComponents.TextComponents
|
||||
|
||||
@doc """
|
||||
Render a button
|
||||
|
|
@ -104,7 +105,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
|||
|
||||
def icon_button(assigns) do
|
||||
~H"""
|
||||
<div class="group relative inline-block">
|
||||
<TextComponents.tooltip position="bottom" tooltip={@tooltip} tooltip_class="text-nowrap">
|
||||
<button
|
||||
class={[
|
||||
"flex justify-center items-center rounded-lg ",
|
||||
|
|
@ -117,18 +118,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
|||
>
|
||||
<CoreComponents.icon name={@icon_name} class="text-stroke" />
|
||||
</button>
|
||||
<div
|
||||
:if={@tooltip}
|
||||
class={[
|
||||
"hidden absolute left-1/2 top-full z-20 mt-3 -translate-x-1/2 whitespace-nowrap rounded-md",
|
||||
"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"
|
||||
]}
|
||||
>
|
||||
<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">
|
||||
</span>
|
||||
<span>{@tooltip}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TextComponents.tooltip>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -146,4 +146,60 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||
<.localized_number number={@num} /> {@suffix}
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a tooltip with the given content
|
||||
"""
|
||||
|
||||
attr :tooltip, :string, required: true
|
||||
attr :position, :string, default: ""
|
||||
attr :tooltip_class, :any, default: ""
|
||||
attr :tooltip_arrow_class, :any, default: ""
|
||||
slot :inner_block
|
||||
|
||||
def tooltip(%{position: "bottom-right"} = assigns) do
|
||||
~H"""
|
||||
<.tooltip tooltip={@tooltip} tooltip_class={@tooltip_class} tooltip_arrow_class={["-top-1", @tooltip_arrow_class]}>
|
||||
{render_slot(@inner_block)}
|
||||
</.tooltip>
|
||||
"""
|
||||
end
|
||||
|
||||
def tooltip(%{position: "bottom"} = assigns) do
|
||||
~H"""
|
||||
<.tooltip
|
||||
tooltip={@tooltip}
|
||||
tooltip_class={["left-1/2 -translate-x-1/2", @tooltip_class]}
|
||||
tooltip_arrow_class={["-top-1 left-1/2 -translate-x-1/2", @tooltip_arrow_class]}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</.tooltip>
|
||||
"""
|
||||
end
|
||||
|
||||
def tooltip(assigns) do
|
||||
~H"""
|
||||
<div class="group relative inline-block cursor-pointer">
|
||||
<div>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
<div
|
||||
:if={@tooltip}
|
||||
class={[
|
||||
"hidden absolute top-full z-20 mt-3 whitespace-nowrap rounded-md",
|
||||
"p-1.5 text-sm font-medium opacity-0 drop-shadow-4 group-hover:opacity-100 group-hover:block bg-meta-4",
|
||||
"border border-form-strokedark text-wrap",
|
||||
@tooltip_class
|
||||
]}
|
||||
>
|
||||
<span class={[
|
||||
"border-t border-l border-form-strokedark absolute -z-10 h-2 w-2 rotate-45 rounded-sm bg-meta-4",
|
||||
@tooltip_arrow_class
|
||||
]}>
|
||||
</span>
|
||||
<div class="px-3">{@tooltip}</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
</.link>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
|
||||
<div class="rounded-sm border py-5 pt-6 shadow-default border-strokedark bg-boxdark px-7.5">
|
||||
<div class="max-w-full">
|
||||
<.tabbed_layout>
|
||||
<:tab_append>
|
||||
|
|
@ -24,7 +24,15 @@
|
|||
</:tab_append>
|
||||
|
||||
<:tab title="Media" id="media">
|
||||
<div class="flex flex-col gap-10 dark:text-white">
|
||||
<div class="flex flex-col gap-10 text-white">
|
||||
<section :if={@media_item.last_error} class="mt-6">
|
||||
<div class="flex items-center gap-1 mb-2">
|
||||
<.icon name="hero-exclamation-circle-solid" class="text-red-500" />
|
||||
<h3 class="font-bold text-xl">Last Error</h3>
|
||||
</div>
|
||||
<span>{@media_item.last_error}</span>
|
||||
</section>
|
||||
|
||||
<%= if media_file_exists?(@media_item) do %>
|
||||
<section class="grid grid-cols-1 xl:gap-6 mt-6">
|
||||
<div>
|
||||
|
|
@ -54,19 +62,21 @@
|
|||
</section>
|
||||
<% end %>
|
||||
|
||||
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
||||
<section>
|
||||
<strong>Source:</strong>
|
||||
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
|
||||
{@media_item.source.custom_name}
|
||||
</.subtle_link>
|
||||
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
||||
<h3 class="font-bold text-xl mb-2">Raw Attributes</h3>
|
||||
<section>
|
||||
<strong>Source:</strong>
|
||||
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
|
||||
{@media_item.source.custom_name}
|
||||
</.subtle_link>
|
||||
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</:tab>
|
||||
<:tab title="Tasks" id="tasks">
|
||||
<%= if match?([_|_], @media_item.tasks) do %>
|
||||
<.table rows={@media_item.tasks} table_class="text-black dark:text-white">
|
||||
<.table rows={@media_item.tasks} table_class="text-white">
|
||||
<:col :let={task} label="Worker">
|
||||
{task.job.worker}
|
||||
</:col>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@
|
|||
|
||||
<:tab title="Media Profile" id="media-profile">
|
||||
<div class="flex flex-col gap-10 text-white">
|
||||
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
||||
<.list_items_from_map map={Map.from_struct(@media_profile)} />
|
||||
<section>
|
||||
<h3 class="font-bold text-xl mt-6 mb-2">Raw Attributes</h3>
|
||||
<.list_items_from_map map={Map.from_struct(@media_profile)} />
|
||||
</section>
|
||||
</div>
|
||||
</:tab>
|
||||
<:tab title="Sources" id="sources">
|
||||
|
|
|
|||
|
|
@ -28,10 +28,22 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|
|||
</span>
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<.table rows={@records} table_class="text-white">
|
||||
<:col :let={media_item} label="Title" class="truncate max-w-xs">
|
||||
<.subtle_link href={~p"/sources/#{media_item.source_id}/media/#{media_item}"}>
|
||||
{media_item.title}
|
||||
</.subtle_link>
|
||||
<:col :let={media_item} label="Title" class="max-w-xs">
|
||||
<section class="flex items-center space-x-1">
|
||||
<.tooltip
|
||||
:if={media_item.last_error}
|
||||
tooltip={media_item.last_error}
|
||||
position="bottom-right"
|
||||
tooltip_class="w-64"
|
||||
>
|
||||
<.icon name="hero-exclamation-circle-solid" class="text-red-500" />
|
||||
</.tooltip>
|
||||
<span class="truncate">
|
||||
<.subtle_link href={~p"/sources/#{media_item.source_id}/media/#{media_item.id}"}>
|
||||
{media_item.title}
|
||||
</.subtle_link>
|
||||
</span>
|
||||
</section>
|
||||
</:col>
|
||||
<:col :let={media_item} label="Upload Date">
|
||||
{DateTime.to_date(media_item.uploaded_at)}
|
||||
|
|
|
|||
|
|
@ -46,10 +46,22 @@ defmodule PinchflatWeb.Sources.MediaItemTableLive do
|
|||
</div>
|
||||
</header>
|
||||
<.table rows={@records} table_class="text-white">
|
||||
<:col :let={media_item} label="Title" class="truncate max-w-xs">
|
||||
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
||||
{media_item.title}
|
||||
</.subtle_link>
|
||||
<:col :let={media_item} label="Title" class="max-w-xs">
|
||||
<section class="flex items-center space-x-1">
|
||||
<.tooltip
|
||||
:if={media_item.last_error}
|
||||
tooltip={media_item.last_error}
|
||||
position="bottom-right"
|
||||
tooltip_class="w-64"
|
||||
>
|
||||
<.icon name="hero-exclamation-circle-solid" class="text-red-500" />
|
||||
</.tooltip>
|
||||
<span class="truncate">
|
||||
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
||||
{media_item.title}
|
||||
</.subtle_link>
|
||||
</span>
|
||||
</section>
|
||||
</:col>
|
||||
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?">
|
||||
<.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} />
|
||||
|
|
@ -205,6 +217,6 @@ defmodule PinchflatWeb.Sources.MediaItemTableLive do
|
|||
|
||||
# Selecting only what we need GREATLY speeds up queries on large tables
|
||||
defp select_fields do
|
||||
[:id, :title, :uploaded_at, :prevent_download]
|
||||
[:id, :title, :uploaded_at, :prevent_download, :last_error]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -24,16 +24,18 @@
|
|||
</:tab_append>
|
||||
|
||||
<:tab title="Source" id="source">
|
||||
<div class="flex flex-col gap-10 text-white">
|
||||
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
||||
<div class="flex flex-col text-white gap-10">
|
||||
<section>
|
||||
<strong>Media Profile:</strong>
|
||||
<.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
||||
{@source.media_profile.name}
|
||||
</.subtle_link>
|
||||
</section>
|
||||
<h3 class="font-bold text-xl mb-2 mt-6">Raw Attributes</h3>
|
||||
<section>
|
||||
<strong>Media Profile:</strong>
|
||||
<.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
||||
{@source.media_profile.name}
|
||||
</.subtle_link>
|
||||
</section>
|
||||
|
||||
<.list_items_from_map map={Map.from_struct(@source)} />
|
||||
<.list_items_from_map map={Map.from_struct(@source)} />
|
||||
</section>
|
||||
</div>
|
||||
</:tab>
|
||||
<:tab title="Pending" id="pending">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue