Compare commits

..

7 Commits

Author SHA1 Message Date
Kieran Eglin 9953e4d316 bumped version 2025-02-20 15:49:44 -08:00
Kieran b62eb2bc6b [Bugfix] Improve YouTube shorts detection for new YouTube pants (#618)
* Update youtube shorts detection to support youtube pants

* Updates a test
2025-02-20 15:49:09 -08:00
Kieran Eglin 464a595045 readme wording 2025-02-14 15:10:06 -08:00
Kieran Eglin 05f33acd78 Added note to README 2025-02-14 15:06:37 -08:00
Kieran e7adc9d68f [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
2025-02-12 10:17:24 -08:00
Kieran fe5c00dbef [Enhancement] Download failures due to videos being members-only are not immediately retried (#609) 2025-02-10 12:13:37 -08:00
rebel onion 28f0d8ca6e [Enhancement] Support Multiple YouTube API Keys (#606)
* feat: multiple YouTube API keys

* fix: requested changes
2025-02-10 11:30:28 -08:00
23 changed files with 410 additions and 84 deletions
+3
View File
@@ -1,3 +1,6 @@
> [!IMPORTANT]
> (2025-02-14) [zakkarry](https://github.com/sponsors/zakkarry), who is a collaborator on [cross-seed](https://github.com/cross-seed/cross-seed) and an extremely helpful community member in general, is facing hard times due to medical debt and family illness. If you're able, please consider [sponsoring him on GitHub](https://github.com/sponsors/zakkarry) or donating via [buymeacoffee](https://tip.ary.dev). Tell him I sent you!
<p align="center"> <p align="center">
<img <img
src="priv/static/images/originals/logo-white-wordmark-with-background.png" src="priv/static/images/originals/logo-white-wordmark-with-background.png"
@@ -105,13 +105,13 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
:ok :ok
{:recovered, _} -> {:recovered, _media_item, _message} ->
{:error, :retry} {:error, :retry}
{:error, :unsuitable_for_download} -> {:error, :unsuitable_for_download, _message} ->
{:ok, :non_retry} {:ok, :non_retry}
{:error, message} -> {:error, _error_atom, message} ->
action_on_error(message) action_on_error(message)
end end
end end
@@ -129,7 +129,11 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
defp action_on_error(message) do defp action_on_error(message) do
# This will attempt re-download at the next indexing, but it won't be retried # This will attempt re-download at the next indexing, but it won't be retried
# immediately as part of job failure logic # immediately as part of job failure logic
non_retryable_errors = ["Video unavailable", "Sign in to confirm"] non_retryable_errors = [
"Video unavailable",
"Sign in to confirm",
"This video is available to this channel's members"
]
if String.contains?(to_string(message), non_retryable_errors) do if String.contains?(to_string(message), non_retryable_errors) do
Logger.error("yt-dlp download will not be retried: #{inspect(message)}") Logger.error("yt-dlp download will not be retried: #{inspect(message)}")
+53 -15
View File
@@ -10,6 +10,7 @@ defmodule Pinchflat.Downloading.MediaDownloader do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Utils.StringUtils
alias Pinchflat.Metadata.NfoBuilder alias Pinchflat.Metadata.NfoBuilder
alias Pinchflat.Metadata.MetadataParser alias Pinchflat.Metadata.MetadataParser
alias Pinchflat.Metadata.MetadataFileHelpers alias Pinchflat.Metadata.MetadataFileHelpers
@@ -20,16 +21,53 @@ defmodule Pinchflat.Downloading.MediaDownloader do
@doc """ @doc """
Downloads media for a media item, updating the media item based on the metadata 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 returned by yt-dlp. Encountered errors are saved to the Media Item record. Saves
media_metadata record. 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. 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. 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 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) output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile]) 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) update_media_item_from_parsed_json(media_with_preloads, parsed_json)
{:error, :unsuitable_for_download} -> {: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" "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} -> {:error, message, _exit_code} ->
Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}") Logger.error("yt-dlp download error for media item ##{media_with_preloads.id}: #{inspect(message)}")
if String.contains?(to_string(message), recoverable_errors()) do if String.contains?(to_string(message), recoverable_errors()) do
attempt_update_media_item(media_with_preloads, output_filepath) attempt_recovery_from_error(media_with_preloads, output_filepath, message)
{:recovered, message}
else else
{:error, message} {:error, :download_failed, message}
end end
err -> err ->
Logger.error("Unknown error downloading media item ##{media_with_preloads.id}: #{inspect(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
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), with {:ok, contents} <- File.read(output_filepath),
{:ok, parsed_json} <- Phoenix.json_library().decode(contents) do {:ok, parsed_json} <- Phoenix.json_library().decode(contents) do
Logger.info(""" Logger.info("""
@@ -71,12 +108,13 @@ defmodule Pinchflat.Downloading.MediaDownloader do
anyway 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 else
err -> err ->
Logger.error("Unable to recover error for media item ##{media_with_preloads.id}: #{inspect(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
end end
+43 -4
View File
@@ -12,6 +12,8 @@ defmodule Pinchflat.FastIndexing.YoutubeApi do
@behaviour YoutubeBehaviour @behaviour YoutubeBehaviour
@agent_name {:global, __MODULE__.KeyIndex}
@doc """ @doc """
Determines if the YouTube API is enabled for fast indexing by checking Determines if the YouTube API is enabled for fast indexing by checking
if the user has an API key set if the user has an API key set
@@ -19,7 +21,7 @@ defmodule Pinchflat.FastIndexing.YoutubeApi do
Returns boolean() Returns boolean()
""" """
@impl YoutubeBehaviour @impl YoutubeBehaviour
def enabled?(), do: is_binary(api_key()) def enabled?, do: Enum.any?(api_keys())
@doc """ @doc """
Fetches the recent media IDs from the YouTube API for a given source. Fetches the recent media IDs from the YouTube API for a given source.
@@ -74,8 +76,45 @@ defmodule Pinchflat.FastIndexing.YoutubeApi do
|> FunctionUtils.wrap_ok() |> FunctionUtils.wrap_ok()
end end
defp api_key do defp api_keys do
Settings.get!(:youtube_api_key) case Settings.get!(:youtube_api_key) do
nil ->
[]
keys ->
keys
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
end
defp get_or_start_api_key_agent do
case Agent.start(fn -> 0 end, name: @agent_name) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
end
end
# Gets the next API key in round-robin fashion
defp next_api_key do
keys = api_keys()
case keys do
[] ->
nil
keys ->
pid = get_or_start_api_key_agent()
current_index =
Agent.get_and_update(pid, fn current ->
{current, rem(current + 1, length(keys))}
end)
Logger.debug("Using YouTube API key: #{Enum.at(keys, current_index)}")
Enum.at(keys, current_index)
end
end end
defp construct_api_endpoint(playlist_id) do defp construct_api_endpoint(playlist_id) do
@@ -83,7 +122,7 @@ defmodule Pinchflat.FastIndexing.YoutubeApi do
property_type = "contentDetails" property_type = "contentDetails"
max_results = 50 max_results = 50
"#{api_base}?part=#{property_type}&maxResults=#{max_results}&playlistId=#{playlist_id}&key=#{api_key()}" "#{api_base}?part=#{property_type}&maxResults=#{max_results}&playlistId=#{playlist_id}&key=#{next_api_key()}"
end end
defp http_client do defp http_client do
+2
View File
@@ -40,6 +40,7 @@ defmodule Pinchflat.Media.MediaItem do
:thumbnail_filepath, :thumbnail_filepath,
:metadata_filepath, :metadata_filepath,
:nfo_filepath, :nfo_filepath,
:last_error,
# These are user or system controlled fields # These are user or system controlled fields
:prevent_download, :prevent_download,
:prevent_culling, :prevent_culling,
@@ -88,6 +89,7 @@ defmodule Pinchflat.Media.MediaItem do
# Will very likely revisit because I can't leave well-enough alone. # Will very likely revisit because I can't leave well-enough alone.
field :subtitle_filepaths, {:array, {:array, :string}}, default: [] field :subtitle_filepaths, {:array, {:array, :string}}, default: []
field :last_error, :string
field :prevent_download, :boolean, default: false field :prevent_download, :boolean, default: false
field :prevent_culling, :boolean, default: false field :prevent_culling, :boolean, default: false
field :culled_at, :utc_datetime field :culled_at, :utc_datetime
+9
View File
@@ -35,4 +35,13 @@ defmodule Pinchflat.Utils.StringUtils do
def double_brace(string) do def double_brace(string) do
"{{ #{string} }}" "{{ #{string} }}"
end 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 end
+1 -1
View File
@@ -151,7 +151,7 @@ defmodule Pinchflat.YtDlp.Media do
# #
# These don't fail if duration or aspect_ratio are missing # These don't fail if duration or aspect_ratio are missing
# due to Elixir's comparison semantics # due to Elixir's comparison semantics
response["duration"] <= 60 && response["aspect_ratio"] <= 0.85 response["duration"] <= 180 && response["aspect_ratio"] <= 0.85
end end
end end
@@ -3,6 +3,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
use Phoenix.Component, global_prefixes: ~w(x-) use Phoenix.Component, global_prefixes: ~w(x-)
alias PinchflatWeb.CoreComponents alias PinchflatWeb.CoreComponents
alias PinchflatWeb.CustomComponents.TextComponents
@doc """ @doc """
Render a button Render a button
@@ -104,7 +105,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
def icon_button(assigns) do def icon_button(assigns) do
~H""" ~H"""
<div class="group relative inline-block"> <TextComponents.tooltip position="bottom" tooltip={@tooltip} tooltip_class="text-nowrap">
<button <button
class={[ class={[
"flex justify-center items-center rounded-lg ", "flex justify-center items-center rounded-lg ",
@@ -117,18 +118,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
> >
<CoreComponents.icon name={@icon_name} class="text-stroke" /> <CoreComponents.icon name={@icon_name} class="text-stroke" />
</button> </button>
<div </TextComponents.tooltip>
: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>
""" """
end end
end end
@@ -146,4 +146,60 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
<.localized_number number={@num} /> {@suffix} <.localized_number number={@num} /> {@suffix}
""" """
end 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 end
@@ -16,7 +16,7 @@
</.link> </.link>
</nav> </nav>
</div> </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"> <div class="max-w-full">
<.tabbed_layout> <.tabbed_layout>
<:tab_append> <:tab_append>
@@ -24,7 +24,15 @@
</:tab_append> </:tab_append>
<:tab title="Media" id="media"> <: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 %> <%= if media_file_exists?(@media_item) do %>
<section class="grid grid-cols-1 xl:gap-6 mt-6"> <section class="grid grid-cols-1 xl:gap-6 mt-6">
<div> <div>
@@ -54,19 +62,21 @@
</section> </section>
<% end %> <% end %>
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
<section> <section>
<strong>Source:</strong> <h3 class="font-bold text-xl mb-2">Raw Attributes</h3>
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}> <section>
{@media_item.source.custom_name} <strong>Source:</strong>
</.subtle_link> <.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
<.list_items_from_map map={Map.from_struct(@media_item)} /> {@media_item.source.custom_name}
</.subtle_link>
<.list_items_from_map map={Map.from_struct(@media_item)} />
</section>
</section> </section>
</div> </div>
</:tab> </:tab>
<:tab title="Tasks" id="tasks"> <:tab title="Tasks" id="tasks">
<%= if match?([_|_], @media_item.tasks) do %> <%= 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"> <:col :let={task} label="Worker">
{task.job.worker} {task.job.worker}
</:col> </:col>
@@ -25,8 +25,10 @@
<:tab title="Media Profile" id="media-profile"> <:tab title="Media Profile" id="media-profile">
<div class="flex flex-col gap-10 text-white"> <div class="flex flex-col gap-10 text-white">
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3> <section>
<.list_items_from_map map={Map.from_struct(@media_profile)} /> <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> </div>
</:tab> </:tab>
<:tab title="Sources" id="sources"> <:tab title="Sources" id="sources">
@@ -28,10 +28,22 @@ defmodule Pinchflat.Pages.HistoryTableLive do
</span> </span>
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.table rows={@records} table_class="text-white"> <.table rows={@records} table_class="text-white">
<:col :let={media_item} label="Title" class="truncate max-w-xs"> <:col :let={media_item} label="Title" class="max-w-xs">
<.subtle_link href={~p"/sources/#{media_item.source_id}/media/#{media_item}"}> <section class="flex items-center space-x-1">
{media_item.title} <.tooltip
</.subtle_link> :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>
<:col :let={media_item} label="Upload Date"> <:col :let={media_item} label="Upload Date">
{DateTime.to_date(media_item.uploaded_at)} {DateTime.to_date(media_item.uploaded_at)}
@@ -34,9 +34,9 @@
<.input <.input
field={f[:youtube_api_key]} field={f[:youtube_api_key]}
placeholder="ABC123" placeholder="ABC123,DEF456"
type="text" type="text"
label="YouTube API Key" label="YouTube API Key(s)"
help={youtube_api_help()} help={youtube_api_help()}
html_help={true} html_help={true}
inputclass="font-mono text-sm mr-4" inputclass="font-mono text-sm mr-4"
@@ -46,10 +46,22 @@ defmodule PinchflatWeb.Sources.MediaItemTableLive do
</div> </div>
</header> </header>
<.table rows={@records} table_class="text-white"> <.table rows={@records} table_class="text-white">
<:col :let={media_item} label="Title" class="truncate max-w-xs"> <:col :let={media_item} label="Title" class="max-w-xs">
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}> <section class="flex items-center space-x-1">
{media_item.title} <.tooltip
</.subtle_link> :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>
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?"> <: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"} /> <.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 # Selecting only what we need GREATLY speeds up queries on large tables
defp select_fields do defp select_fields do
[:id, :title, :uploaded_at, :prevent_download] [:id, :title, :uploaded_at, :prevent_download, :last_error]
end end
end end
@@ -24,16 +24,18 @@
</:tab_append> </:tab_append>
<:tab title="Source" id="source"> <:tab title="Source" id="source">
<div class="flex flex-col gap-10 text-white"> <div class="flex flex-col text-white gap-10">
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
<section> <section>
<strong>Media Profile:</strong> <h3 class="font-bold text-xl mb-2 mt-6">Raw Attributes</h3>
<.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}> <section>
{@source.media_profile.name} <strong>Media Profile:</strong>
</.subtle_link> <.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
</section> {@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> </div>
</:tab> </:tab>
<:tab title="Pending" id="pending"> <:tab title="Pending" id="pending">
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "2025.1.27", version: "2025.2.20",
elixir: "~> 1.17", elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 497 KiB

@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddLastErrorToMediaItem do
use Ecto.Migration
def change do
alter table(:media_items) do
add :last_error, :string
end
end
end
@@ -147,6 +147,22 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end) end)
end end
test "does not set the job to retryable you aren't a member", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, _opts, _ot, _addl ->
{:error, "This video is available to this channel's members on level: foo", 1}
end)
Oban.Testing.with_testing_mode(:inline, fn ->
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id, quality_upgrade?: true}))
assert job.state == "completed"
end)
end
test "ensures error are returned in a 2-item tuple", %{media_item: media_item} do test "ensures error are returned in a 2-item tuple", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"} _url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
@@ -60,7 +60,8 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})} {:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})}
end) end)
assert {:error, :unsuitable_for_download} = MediaDownloader.download_for_media_item(media_item) assert {:error, :unsuitable_for_download, message} = MediaDownloader.download_for_media_item(media_item)
assert message =~ "Media item ##{media_item.id} isn't suitable for download yet."
end end
test "non-recoverable errors are passed through", %{media_item: media_item} do test "non-recoverable errors are passed through", %{media_item: media_item} do
@@ -69,7 +70,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
_url, :download, _opts, _ot, _addl -> {:error, :some_error, 1} _url, :download, _opts, _ot, _addl -> {:error, :some_error, 1}
end) end)
assert {:error, :some_error} = MediaDownloader.download_for_media_item(media_item) assert {:error, :download_failed, :some_error} = MediaDownloader.download_for_media_item(media_item)
end end
test "unknown errors are passed through", %{media_item: media_item} do test "unknown errors are passed through", %{media_item: media_item} do
@@ -78,7 +79,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
_url, :download, _opts, _ot, _addl -> {:error, :some_error} _url, :download, _opts, _ot, _addl -> {:error, :some_error}
end) end)
assert {:error, message} = MediaDownloader.download_for_media_item(media_item) assert {:error, :unknown, message} = MediaDownloader.download_for_media_item(media_item)
assert message == "Unknown error: {:error, :some_error}" assert message == "Unknown error: {:error, :some_error}"
end end
end end
@@ -107,13 +108,15 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
expect(YtDlpRunnerMock, :run, 0, fn _url, :download, _opts, _ot, _addl -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, 0, fn _url, :download, _opts, _ot, _addl -> {:ok, ""} end)
assert {:error, :unsuitable_for_download} = MediaDownloader.download_for_media_item(media_item) assert {:error, :unsuitable_for_download, message} = MediaDownloader.download_for_media_item(media_item)
assert message =~ "Media item ##{media_item.id} isn't suitable for download yet."
end end
test "returns unexpected errors from the download status determination method", %{media_item: media_item} do test "returns unexpected errors from the download status determination method", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl -> {:error, :what_tha} end) expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl -> {:error, :what_tha} end)
assert {:error, "Unknown error: {:error, :what_tha}"} = MediaDownloader.download_for_media_item(media_item) assert {:error, :unknown, "Unknown error: {:error, :what_tha}"} =
MediaDownloader.download_for_media_item(media_item)
end end
end end
@@ -184,15 +187,21 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock" message = "Unable to communicate with SponsorBlock"
expect(YtDlpRunnerMock, :run, 2, fn expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"} {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> _url, :download, _opts, _ot, addl ->
[{:output_filepath, filepath} | _] = addl
File.write(filepath, render_metadata(:media_metadata))
{:error, message, 1} {:error, message, 1}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end) end)
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item) assert {:recovered, _media_item, ^message} = MediaDownloader.download_for_media_item(media_item)
end end
test "attempts to update the media item on recoverable errors", %{media_item: media_item} do test "attempts to update the media item on recoverable errors", %{media_item: media_item} do
@@ -212,11 +221,59 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
{:ok, ""} {:ok, ""}
end) end)
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item) assert {:recovered, updated_media_item, ^message} = MediaDownloader.download_for_media_item(media_item)
assert DateTime.diff(DateTime.utc_now(), updated_media_item.media_downloaded_at) < 2
assert String.ends_with?(updated_media_item.media_filepath, ".mkv")
end
test "returns an unrecoverable tuple if recovery fails", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock"
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, _opts, _ot, _addl ->
# This errors because the metadata is not written to the file so JSON parsing fails
{:error, message, 1}
end)
assert {:error, :unrecoverable, ^message} = MediaDownloader.download_for_media_item(media_item)
end
test "sets the last_error appropriately when recovered", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 3, fn
_url, :download, _opts, _ot, addl ->
[{:output_filepath, filepath} | _] = addl
File.write(filepath, render_metadata(:media_metadata))
{:error, "Unable to communicate with SponsorBlock", 1}
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
assert {:recovered, updated_media_item, _message} = MediaDownloader.download_for_media_item(media_item)
assert updated_media_item.last_error == "Unable to communicate with SponsorBlock"
end
test "sets the last_error appropriately when unrecoverable", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, _opts, _ot, _addl ->
{:error, "Unable to communicate with SponsorBlock", 1}
end)
assert {:error, :unrecoverable, _message} = MediaDownloader.download_for_media_item(media_item)
media_item = Repo.reload(media_item) media_item = Repo.reload(media_item)
assert DateTime.diff(DateTime.utc_now(), media_item.media_downloaded_at) < 2 assert media_item.last_error == "Unable to communicate with SponsorBlock"
assert String.ends_with?(media_item.media_filepath, ".mkv")
end end
end end
@@ -324,6 +381,25 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
File.rm(updated_media_item.metadata_filepath) File.rm(updated_media_item.metadata_filepath)
end end
test "sets the last_error to nil on success" do
media_item = media_item_fixture(%{last_error: "Some error"})
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
assert updated_media_item.last_error == nil
end
test "sets the last_error to the error message on failure", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:error, :some_error}
end)
assert {:error, :unknown, _message} = MediaDownloader.download_for_media_item(media_item)
media_item = Repo.reload(media_item)
assert media_item.last_error == "Unknown error: {:error, :some_error}"
end
end end
describe "download_for_media_item/3 when testing NFO generation" do describe "download_for_media_item/3 when testing NFO generation" do
@@ -7,31 +7,67 @@ defmodule Pinchflat.FastIndexing.YoutubeApiTest do
alias Pinchflat.FastIndexing.YoutubeApi alias Pinchflat.FastIndexing.YoutubeApi
describe "enabled?/0" do describe "enabled?/0" do
test "returns true if the user has set a YouTube API key" do test "returns true if the user has set YouTube API keys" do
Settings.set(youtube_api_key: "key1, key2")
assert YoutubeApi.enabled?()
end
test "returns true with a single API key" do
Settings.set(youtube_api_key: "test_key") Settings.set(youtube_api_key: "test_key")
assert YoutubeApi.enabled?() assert YoutubeApi.enabled?()
end end
test "returns false if the user has not set an API key" do test "returns false if the user has not set any API keys" do
Settings.set(youtube_api_key: nil) Settings.set(youtube_api_key: nil)
refute YoutubeApi.enabled?()
end
test "returns false if only empty or whitespace keys are provided" do
Settings.set(youtube_api_key: " , ,")
refute YoutubeApi.enabled?() refute YoutubeApi.enabled?()
end end
end end
describe "get_recent_media_ids/1" do describe "get_recent_media_ids/1" do
setup do setup do
case :global.whereis_name(YoutubeApi.KeyIndex) do
:undefined -> :ok
pid -> Agent.stop(pid)
end
source = source_fixture() source = source_fixture()
Settings.set(youtube_api_key: "test_key") Settings.set(youtube_api_key: "key1, key2")
{:ok, source: source} {:ok, source: source}
end end
test "rotates through API keys", %{source: source} do
expect(HTTPClientMock, :get, fn url, _headers ->
assert url =~ "key=key1"
{:ok, "{}"}
end)
expect(HTTPClientMock, :get, fn url, _headers ->
assert url =~ "key=key2"
{:ok, "{}"}
end)
expect(HTTPClientMock, :get, fn url, _headers ->
assert url =~ "key=key1"
{:ok, "{}"}
end)
# three calls to verify rotation
YoutubeApi.get_recent_media_ids(source)
YoutubeApi.get_recent_media_ids(source)
YoutubeApi.get_recent_media_ids(source)
end
test "calls the expected URL", %{source: source} do test "calls the expected URL", %{source: source} do
expect(HTTPClientMock, :get, fn url, headers -> expect(HTTPClientMock, :get, fn url, headers ->
api_base = "https://youtube.googleapis.com/youtube/v3/playlistItems" api_base = "https://youtube.googleapis.com/youtube/v3/playlistItems"
request_url = "#{api_base}?part=contentDetails&maxResults=50&playlistId=#{source.collection_id}&key=test_key" request_url = "#{api_base}?part=contentDetails&maxResults=50&playlistId=#{source.collection_id}&key=key1"
assert url == request_url assert url == request_url
assert headers == [accept: "application/json"] assert headers == [accept: "application/json"]
@@ -33,4 +33,14 @@ defmodule Pinchflat.Utils.StringUtilsTest do
assert StringUtils.double_brace("hello") == "{{ hello }}" assert StringUtils.double_brace("hello") == "{{ hello }}"
end end
end end
describe "wrap_string/1" do
test "returns strings as-is" do
assert StringUtils.wrap_string("hello") == "hello"
end
test "returns other values as inspected strings" do
assert StringUtils.wrap_string(1) == "1"
end
end
end end
+1 -1
View File
@@ -269,7 +269,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
response = %{ response = %{
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 0.5, "aspect_ratio" => 0.5,
"duration" => 59, "duration" => 150,
"upload_date" => "20210101" "upload_date" => "20210101"
} }