Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9953e4d316 | |||
| b62eb2bc6b | |||
| 464a595045 | |||
| 05f33acd78 | |||
| e7adc9d68f | |||
| fe5c00dbef | |||
| 28f0d8ca6e |
@@ -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)}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +62,8 @@
|
|||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
<section>
|
||||||
|
<h3 class="font-bold text-xl mb-2">Raw Attributes</h3>
|
||||||
<section>
|
<section>
|
||||||
<strong>Source:</strong>
|
<strong>Source:</strong>
|
||||||
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
|
<.subtle_link href={~p"/sources/#{@media_item.source_id}"}>
|
||||||
@@ -62,11 +71,12 @@
|
|||||||
</.subtle_link>
|
</.subtle_link>
|
||||||
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
<.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>
|
||||||
|
<h3 class="font-bold text-xl mt-6 mb-2">Raw Attributes</h3>
|
||||||
<.list_items_from_map map={Map.from_struct(@media_profile)} />
|
<.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">
|
||||||
|
<.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}
|
{media_item.title}
|
||||||
</.subtle_link>
|
</.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">
|
||||||
|
<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}"}>
|
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
||||||
{media_item.title}
|
{media_item.title}
|
||||||
</.subtle_link>
|
</.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,8 +24,9 @@
|
|||||||
</: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>
|
||||||
|
<h3 class="font-bold text-xl mb-2 mt-6">Raw Attributes</h3>
|
||||||
<section>
|
<section>
|
||||||
<strong>Media Profile:</strong>
|
<strong>Media Profile:</strong>
|
||||||
<.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
<.subtle_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
</section>
|
</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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user