Compare commits

...

12 Commits

Author SHA1 Message Date
Kieran Eglin 0fbf810cb6 bumped version 2025-03-06 14:41:36 -08:00
Kieran a97bb248e2 [Enhancement] Retry a download using cookies if doing so might help (#641)
* Sources that use cookies when_needed now retry downloads if we think it'd help

* tweaked error message we're checking on to match media_download_worker
2025-03-05 16:41:07 -08:00
Kieran ac895944a8 [Enhancement] Add option for a source to only use cookies when needed (#640)
* Updated model with new attribute

* Update app logic to use new cookie logic

* lots of tests

* Updated UI and renamed attribute

* Updated tests
2025-03-05 15:32:15 -08:00
Kieran Eglin 59f8aa69cd updating yt-dlp permissions, again 2025-03-04 11:08:02 -08:00
Kieran b790e05133 Testing yt-dlp binary permissions (#634) 2025-03-04 10:53:40 -08:00
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
38 changed files with 776 additions and 156 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">
<img
src="priv/static/images/originals/logo-white-wordmark-with-background.png"
+1 -1
View File
@@ -118,7 +118,7 @@ WORKDIR "/app"
# Set up data volumes
RUN mkdir -p /config /downloads /etc/elixir_tzdata_data /etc/yt-dlp/plugins && \
chmod ugo+rw /etc/elixir_tzdata_data /etc/yt-dlp /etc/yt-dlp/plugins
chmod ugo+rw /etc/elixir_tzdata_data /etc/yt-dlp /etc/yt-dlp/plugins /usr/local/bin /usr/local/bin/yt-dlp
# set runner ENV
ENV MIX_ENV="prod"
@@ -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
@@ -129,7 +129,11 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
defp action_on_error(message) do
# This will attempt re-download at the next indexing, but it won't be retried
# 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
Logger.error("yt-dlp download will not be retried: #{inspect(message)}")
+106 -21
View File
@@ -9,7 +9,9 @@ defmodule Pinchflat.Downloading.MediaDownloader do
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Sources
alias Pinchflat.Media.MediaItem
alias Pinchflat.Utils.StringUtils
alias Pinchflat.Metadata.NfoBuilder
alias Pinchflat.Metadata.MetadataParser
alias Pinchflat.Metadata.MetadataFileHelpers
@@ -20,16 +22,57 @@ 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}
# - However, if the precheck fails in a way that we think can be fixed by using cookies, we retry with cookies
# and return the result of that
# - 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
# - If we retry using cookies, all of the above return values apply. The cookie retry
# logic is handled transparently as far as the caller is concerned
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 +81,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 +113,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
@@ -113,13 +156,48 @@ defmodule Pinchflat.Downloading.MediaDownloader do
defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
use_cookies = item_with_preloads.source.use_cookies
runner_opts = [output_filepath: output_filepath, use_cookies: use_cookies]
force_use_cookies = Keyword.get(override_opts, :force_use_cookies, false)
source_uses_cookies = Sources.use_cookies?(item_with_preloads.source, :downloading)
should_use_cookies = force_use_cookies || source_uses_cookies
case YtDlpMedia.get_downloadable_status(url, use_cookies: use_cookies) do
{:ok, :downloadable} -> YtDlpMedia.download(url, options, runner_opts)
{:ok, :ignorable} -> {:error, :unsuitable_for_download}
err -> err
runner_opts = [output_filepath: output_filepath, use_cookies: should_use_cookies]
case {YtDlpMedia.get_downloadable_status(url, use_cookies: should_use_cookies), should_use_cookies} do
{{:ok, :downloadable}, _} ->
YtDlpMedia.download(url, options, runner_opts)
{{:ok, :ignorable}, _} ->
{:error, :unsuitable_for_download}
{{:error, _message, _exit_code} = err, false} ->
# If there was an error and we don't have cookies, this method will retry with cookies
# if doing so would help AND the source allows. Otherwise, it will return the error as-is
maybe_retry_with_cookies(url, item_with_preloads, output_filepath, override_opts, err)
# This gets hit if cookies are enabled which, importantly, also covers the case where we
# retry a download with cookies and it fails again
{{:error, message, exit_code}, true} ->
{:error, message, exit_code}
{err, _} ->
err
end
end
defp maybe_retry_with_cookies(url, item_with_preloads, output_filepath, override_opts, err) do
{:error, message, _} = err
source = item_with_preloads.source
message_contains_cookie_error = String.contains?(to_string(message), recoverable_cookie_errors())
if Sources.use_cookies?(source, :error_recovery) && message_contains_cookie_error do
download_with_options(
url,
item_with_preloads,
output_filepath,
Keyword.put(override_opts, :force_use_cookies, true)
)
else
err
end
end
@@ -128,4 +206,11 @@ defmodule Pinchflat.Downloading.MediaDownloader do
"Unable to communicate with SponsorBlock"
]
end
defp recoverable_cookie_errors do
[
"Sign in to confirm",
"This video is available to this channel's members"
]
end
end
@@ -12,6 +12,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Tasks
alias Pinchflat.Sources
alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.YoutubeRss
alias Pinchflat.FastIndexing.YoutubeApi
@@ -88,12 +89,16 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
defp create_media_item_from_media_id(source, media_id) do
url = "https://www.youtube.com/watch?v=#{media_id}"
# This is set to :metadata instead of :indexing since this happens _after_ the
# actual indexing process. In reality, slow indexing is the only thing that
# should be using :indexing.
should_use_cookies = Sources.use_cookies?(source, :metadata)
command_opts =
[output: DownloadOptionBuilder.build_output_path_for(source)] ++
DownloadOptionBuilder.build_quality_options_for(source)
case YtDlpMedia.get_media_attributes(url, command_opts, use_cookies: source.use_cookies) do
case YtDlpMedia.get_media_attributes(url, command_opts, use_cookies: should_use_cookies) do
{:ok, media_attrs} ->
Media.create_media_item_from_backend_attrs(source, media_attrs)
+43 -4
View File
@@ -12,6 +12,8 @@ defmodule Pinchflat.FastIndexing.YoutubeApi do
@behaviour YoutubeBehaviour
@agent_name {:global, __MODULE__.KeyIndex}
@doc """
Determines if the YouTube API is enabled for fast indexing by checking
if the user has an API key set
@@ -19,7 +21,7 @@ defmodule Pinchflat.FastIndexing.YoutubeApi do
Returns boolean()
"""
@impl YoutubeBehaviour
def enabled?(), do: is_binary(api_key())
def enabled?, do: Enum.any?(api_keys())
@doc """
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()
end
defp api_key do
Settings.get!(:youtube_api_key)
defp api_keys do
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
defp construct_api_endpoint(playlist_id) do
@@ -83,7 +122,7 @@ defmodule Pinchflat.FastIndexing.YoutubeApi do
property_type = "contentDetails"
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
defp http_client do
+2
View File
@@ -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
@@ -9,6 +9,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
needed
"""
alias Pinchflat.Sources
alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@@ -66,7 +67,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
yt_dlp_filepath = generate_filepath_for(media_item_with_preloads, "thumbnail.%(ext)s")
real_filepath = generate_filepath_for(media_item_with_preloads, "thumbnail.jpg")
command_opts = [output: yt_dlp_filepath]
addl_opts = [use_cookies: media_item_with_preloads.source.use_cookies]
addl_opts = [use_cookies: Sources.use_cookies?(media_item_with_preloads.source, :metadata)]
case YtDlpMedia.download_thumbnail(media_item_with_preloads.original_url, command_opts, addl_opts) do
{:ok, _} -> real_filepath
@@ -93,7 +93,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
defp determine_series_directory(source) do
output_path = DownloadOptionBuilder.build_output_path_for(source)
runner_opts = [output: output_path]
addl_opts = [use_cookies: source.use_cookies]
addl_opts = [use_cookies: Sources.use_cookies?(source, :metadata)]
{:ok, %{filepath: filepath}} = MediaCollection.get_source_details(source.original_url, runner_opts, addl_opts)
case MetadataFileHelpers.series_directory_from_media_filepath(filepath) do
@@ -113,6 +113,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
defp fetch_metadata_for_source(source) do
tmp_output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S"
base_opts = [convert_thumbnails: "jpg", output: tmp_output_path]
should_use_cookies = Sources.use_cookies?(source, :metadata)
opts =
if source.collection_type == :channel do
@@ -121,7 +122,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
base_opts ++ [:write_thumbnail, playlist_items: 1]
end
MediaCollection.get_source_metadata(source.original_url, opts, use_cookies: source.use_cookies)
MediaCollection.get_source_metadata(source.original_url, opts, use_cookies: should_use_cookies)
end
defp tmp_directory do
@@ -132,13 +132,14 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
{:ok, pid} = FileFollowerServer.start_link()
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
should_use_cookies = Sources.use_cookies?(source, :indexing)
command_opts =
[output: DownloadOptionBuilder.build_output_path_for(source)] ++
DownloadOptionBuilder.build_quality_options_for(source) ++
build_download_archive_options(source, was_forced)
runner_opts = [file_listener_handler: handler, use_cookies: source.use_cookies]
runner_opts = [file_listener_handler: handler, use_cookies: should_use_cookies]
result = MediaCollection.get_media_attributes_for_collection(source.original_url, command_opts, runner_opts)
FileFollowerServer.stop(pid)
+2 -2
View File
@@ -28,7 +28,7 @@ defmodule Pinchflat.Sources.Source do
series_directory
index_frequency_minutes
fast_index
use_cookies
cookie_behaviour
download_media
last_indexed_at
original_url
@@ -78,7 +78,7 @@ defmodule Pinchflat.Sources.Source do
field :collection_type, Ecto.Enum, values: [:channel, :playlist]
field :index_frequency_minutes, :integer, default: 60 * 24
field :fast_index, :boolean, default: false
field :use_cookies, :boolean, default: false
field :cookie_behaviour, Ecto.Enum, values: [:disabled, :when_needed, :all_operations], default: :disabled
field :download_media, :boolean, default: true
field :last_indexed_at, :utc_datetime
# Only download media items that were published after this date
+15 -2
View File
@@ -32,6 +32,19 @@ defmodule Pinchflat.Sources do
source.output_path_template_override || media_profile.output_path_template
end
@doc """
Returns a boolean indicating whether or not cookies should be used for a given operation.
Returns boolean()
"""
def use_cookies?(source, operation) when operation in [:indexing, :downloading, :metadata, :error_recovery] do
case source.cookie_behaviour do
:disabled -> false
:all_operations -> true
:when_needed -> operation in [:indexing, :error_recovery]
end
end
@doc """
Returns the list of sources. Returns [%Source{}, ...]
"""
@@ -181,9 +194,9 @@ defmodule Pinchflat.Sources do
defp add_source_details_to_changeset(source, changeset) do
original_url = changeset.changes.original_url
use_cookies = Ecto.Changeset.get_field(changeset, :use_cookies)
should_use_cookies = Ecto.Changeset.get_field(changeset, :cookie_behaviour) == :all_operations
# Skipping sleep interval since this is UI blocking and we want to keep this as fast as possible
addl_opts = [use_cookies: use_cookies, skip_sleep_interval: true]
addl_opts = [use_cookies: should_use_cookies, skip_sleep_interval: true]
case MediaCollection.get_source_details(original_url, [], addl_opts) do
{:ok, source_details} ->
+9
View File
@@ -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
+1 -1
View File
@@ -151,7 +151,7 @@ defmodule Pinchflat.YtDlp.Media do
#
# These don't fail if duration or aspect_ratio are missing
# due to Elixir's comparison semantics
response["duration"] <= 60 && response["aspect_ratio"] <= 0.85
response["duration"] <= 180 && response["aspect_ratio"] <= 0.85
end
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)}
@@ -34,9 +34,9 @@
<.input
field={f[:youtube_api_key]}
placeholder="ABC123"
placeholder="ABC123,DEF456"
type="text"
label="YouTube API Key"
label="YouTube API Key(s)"
help={youtube_api_help()}
html_help={true}
inputclass="font-mono text-sm mr-4"
@@ -27,6 +27,14 @@ defmodule PinchflatWeb.Sources.SourceHTML do
]
end
def friendly_cookie_behaviours do
[
{"Disabled", :disabled},
{"When Needed", :when_needed},
{"All Operations", :all_operations}
]
end
def cutoff_date_presets do
[
{"7 days", compute_date_offset(7)},
@@ -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">
@@ -87,10 +87,11 @@
/>
<.input
field={f[:use_cookies]}
type="toggle"
label="Use Cookies for Downloading"
help="Uses your YouTube cookies for this source (if configured). Used for downloading private playlists and videos. See docs for important details"
field={f[:cookie_behaviour]}
options={friendly_cookie_behaviours()}
type="select"
label="Cookie Behaviour"
help="Uses your YouTube cookies for this source (if configured). 'When Needed' tries to minimize cookie usage except for certain indexing and downloading tasks. See docs"
/>
<section x-show="advancedMode">
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do
[
app: :pinchflat,
version: "2025.1.27",
version: "2025.3.6",
elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()),
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
@@ -0,0 +1,18 @@
defmodule Pinchflat.Repo.Migrations.AddCookieBehaviourToSources do
use Ecto.Migration
def change do
alter table(:sources) do
add :cookie_behaviour, :string, null: false, default: "disabled"
end
execute(
"UPDATE sources SET cookie_behaviour = 'all_operations' WHERE use_cookies = TRUE",
"UPDATE sources SET use_cookies = TRUE WHERE cookie_behaviour = 'all_operations'"
)
alter table(:sources) do
remove :use_cookies, :boolean, null: false, default: false
end
end
end
@@ -147,6 +147,22 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
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
expect(YtDlpRunnerMock, :run, 2, fn
_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"})}
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
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}
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
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}
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}"
end
end
@@ -107,13 +108,15 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
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
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)
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
@@ -154,15 +157,16 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
{:ok, ""}
end)
source = source_fixture(%{use_cookies: true})
source = source_fixture(%{cookie_behaviour: :all_operations})
media_item = media_item_fixture(%{source_id: source.id})
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
end
test "does not set use_cookies if the source does not use cookies" do
test "does not set use_cookies if the source uses cookies when needed" do
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
_url, :get_downloadable_status, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, "{}"}
_url, :download, _opts, _ot, addl ->
@@ -173,26 +177,52 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
{:ok, ""}
end)
source = source_fixture(%{use_cookies: false})
source = source_fixture(%{cookie_behaviour: :when_needed})
media_item = media_item_fixture(%{source_id: source.id})
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
end
test "does not set use_cookies if the source does not use cookies" do
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, "{}"}
_url, :download, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
source = source_fixture(%{cookie_behaviour: :disabled})
media_item = media_item_fixture(%{source_id: source.id})
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
end
end
describe "download_for_media_item/3 when testing retries" do
describe "download_for_media_item/3 when testing non-cookie retries" do
test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock"
expect(YtDlpRunnerMock, :run, 2, fn
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{: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}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
assert {:recovered, _media_item, ^message} = MediaDownloader.download_for_media_item(media_item)
end
test "attempts to update the media item on recoverable errors", %{media_item: media_item} do
@@ -212,11 +242,121 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
{:ok, ""}
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)
assert DateTime.diff(DateTime.utc_now(), media_item.media_downloaded_at) < 2
assert String.ends_with?(media_item.media_filepath, ".mkv")
assert media_item.last_error == "Unable to communicate with SponsorBlock"
end
end
describe "download_for_media_item/3 when testing cookie retries" do
test "retries with cookies if we think it would help and the source allows" do
expect(YtDlpRunnerMock, :run, 4, fn
_url, :get_downloadable_status, _opts, _ot, [use_cookies: false] ->
{:error, "Sign in to confirm your age", 1}
_url, :get_downloadable_status, _opts, _ot, [use_cookies: true] ->
{:ok, "{}"}
_url, :download, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
source = source_fixture(%{cookie_behaviour: :when_needed})
media_item = media_item_fixture(%{source_id: source.id})
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
end
test "does not retry with cookies if we don't think it would help even the source allows" do
expect(YtDlpRunnerMock, :run, 1, fn
_url, :get_downloadable_status, _opts, _ot, [use_cookies: false] ->
{:error, "Some other error", 1}
end)
source = source_fixture(%{cookie_behaviour: :when_needed})
media_item = media_item_fixture(%{source_id: source.id})
assert {:error, :download_failed, "Some other error"} = MediaDownloader.download_for_media_item(media_item)
end
test "does not retry with cookies even if we think it would help but source doesn't allow" do
expect(YtDlpRunnerMock, :run, 1, fn
_url, :get_downloadable_status, _opts, _ot, [use_cookies: false] ->
{:error, "Sign in to confirm your age", 1}
end)
source = source_fixture(%{cookie_behaviour: :disabled})
media_item = media_item_fixture(%{source_id: source.id})
assert {:error, :download_failed, "Sign in to confirm your age"} =
MediaDownloader.download_for_media_item(media_item)
end
test "does not retry with cookies if cookies were already used" do
expect(YtDlpRunnerMock, :run, 1, fn
_url, :get_downloadable_status, _opts, _ot, [use_cookies: true] ->
{:error, "This video is available to this channel's members", 1}
end)
source = source_fixture(%{cookie_behaviour: :all_operations})
media_item = media_item_fixture(%{source_id: source.id})
assert {:error, :download_failed, "This video is available to this channel's members"} =
MediaDownloader.download_for_media_item(media_item)
end
end
@@ -324,6 +464,25 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
File.rm(updated_media_item.metadata_filepath)
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
describe "download_for_media_item/3 when testing NFO generation" do
@@ -104,34 +104,6 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
FastIndexingHelpers.index_and_kickoff_downloads(source)
end
test "sets use_cookies if the source uses cookies" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, media_attributes_return_fixture()}
end)
source = source_fixture(%{use_cookies: true})
assert [%MediaItem{}] = FastIndexingHelpers.index_and_kickoff_downloads(source)
end
test "does not set use_cookies if the source does not use cookies" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, media_attributes_return_fixture()}
end)
source = source_fixture(%{use_cookies: false})
assert [%MediaItem{}] = FastIndexingHelpers.index_and_kickoff_downloads(source)
end
test "does not enqueue a download job if the media item does not match the format rules" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
@@ -180,6 +152,50 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
end
end
describe "index_and_kickoff_downloads/1 when testing cookies" do
test "sets use_cookies if the source uses cookies" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, media_attributes_return_fixture()}
end)
source = source_fixture(%{cookie_behaviour: :all_operations})
assert [%MediaItem{}] = FastIndexingHelpers.index_and_kickoff_downloads(source)
end
test "does not set use_cookies if the source uses cookies when needed" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, media_attributes_return_fixture()}
end)
source = source_fixture(%{cookie_behaviour: :when_needed})
assert [%MediaItem{}] = FastIndexingHelpers.index_and_kickoff_downloads(source)
end
test "does not set use_cookies if the source does not use cookies" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, media_attributes_return_fixture()}
end)
source = source_fixture(%{cookie_behaviour: :disabled})
assert [%MediaItem{}] = FastIndexingHelpers.index_and_kickoff_downloads(source)
end
end
describe "index_and_kickoff_downloads/1 when testing backends" do
test "uses the YouTube API if it is enabled", %{source: source} do
expect(HTTPClientMock, :get, fn url, _headers ->
@@ -7,31 +7,67 @@ defmodule Pinchflat.FastIndexing.YoutubeApiTest do
alias Pinchflat.FastIndexing.YoutubeApi
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")
assert YoutubeApi.enabled?()
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)
refute YoutubeApi.enabled?()
end
test "returns false if only empty or whitespace keys are provided" do
Settings.set(youtube_api_key: " , ,")
refute YoutubeApi.enabled?()
end
end
describe "get_recent_media_ids/1" do
setup do
case :global.whereis_name(YoutubeApi.KeyIndex) do
:undefined -> :ok
pid -> Agent.stop(pid)
end
source = source_fixture()
Settings.set(youtube_api_key: "test_key")
Settings.set(youtube_api_key: "key1, key2")
{:ok, source: source}
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
expect(HTTPClientMock, :get, fn url, headers ->
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 headers == [accept: "application/json"]
@@ -88,13 +88,35 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
Helpers.download_and_store_thumbnail_for(media_item)
end
test "returns nil if yt-dlp fails", %{media_item: media_item} do
stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:error, "error"} end)
filepath = Helpers.download_and_store_thumbnail_for(media_item)
assert filepath == nil
end
end
describe "download_and_store_thumbnail_for/2 when testing cookie usage" do
test "sets use_cookies if the source uses cookies" do
expect(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, ""}
end)
source = source_fixture(%{use_cookies: true})
source = source_fixture(%{cookie_behaviour: :all_operations})
media_item = Repo.preload(media_item_fixture(%{source_id: source.id}), :source)
Helpers.download_and_store_thumbnail_for(media_item)
end
test "does not set use_cookies if the source uses cookies when needed" do
expect(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, ""}
end)
source = source_fixture(%{cookie_behaviour: :when_needed})
media_item = Repo.preload(media_item_fixture(%{source_id: source.id}), :source)
Helpers.download_and_store_thumbnail_for(media_item)
@@ -106,19 +128,11 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
{:ok, ""}
end)
source = source_fixture(%{use_cookies: false})
source = source_fixture(%{cookie_behaviour: :disabled})
media_item = Repo.preload(media_item_fixture(%{source_id: source.id}), :source)
Helpers.download_and_store_thumbnail_for(media_item)
end
test "returns nil if yt-dlp fails", %{media_item: media_item} do
stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:error, "error"} end)
filepath = Helpers.download_and_store_thumbnail_for(media_item)
assert filepath == nil
end
end
describe "parse_upload_date/1" do
@@ -254,7 +254,23 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
end)
profile = media_profile_fixture(%{download_source_images: true})
source = source_fixture(media_profile_id: profile.id, use_cookies: true)
source = source_fixture(media_profile_id: profile.id, cookie_behaviour: :all_operations)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
end
test "does not set use_cookies if the source uses cookies when needed" do
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_source_details, _opts, _ot, _addl ->
{:ok, source_details_return_fixture()}
_url, :get_source_metadata, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, render_metadata(:channel_source_metadata)}
end)
profile = media_profile_fixture(%{download_source_images: true})
source = source_fixture(media_profile_id: profile.id, cookie_behaviour: :when_needed)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
end
@@ -270,7 +286,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
end)
profile = media_profile_fixture(%{download_source_images: true})
source = source_fixture(media_profile_id: profile.id, use_cookies: false)
source = source_fixture(media_profile_id: profile.id, cookie_behaviour: :disabled)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
end
@@ -323,7 +339,21 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
{:ok, "{}"}
end)
source = source_fixture(%{series_directory: nil, use_cookies: true})
source = source_fixture(%{series_directory: nil, cookie_behaviour: :all_operations})
perform_job(SourceMetadataStorageWorker, %{id: source.id})
end
test "does not set use_cookies if the source uses cookies when needed" do
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_source_details, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, source_details_return_fixture()}
_url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"}
end)
source = source_fixture(%{series_directory: nil, cookie_behaviour: :when_needed})
perform_job(SourceMetadataStorageWorker, %{id: source.id})
end
@@ -337,7 +367,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
{:ok, "{}"}
end)
source = source_fixture(%{series_directory: nil, use_cookies: false})
source = source_fixture(%{series_directory: nil, cookie_behaviour: :disabled})
perform_job(SourceMetadataStorageWorker, %{id: source.id})
end
end
@@ -302,14 +302,27 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end
end
describe "index_and_enqueue_download_for_media_items/2 when testing cookies" do
test "sets use_cookies if the source uses cookies" do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
assert {:use_cookies, true} in addl_opts
{:ok, source_attributes_return_fixture()}
end)
source = source_fixture(%{use_cookies: true})
source = source_fixture(%{cookie_behaviour: :all_operations})
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end
test "sets use_cookies if the source uses cookies when needed" do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
assert {:use_cookies, true} in addl_opts
{:ok, source_attributes_return_fixture()}
end)
source = source_fixture(%{cookie_behaviour: :when_needed})
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end
@@ -320,7 +333,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
{:ok, source_attributes_return_fixture()}
end)
source = source_fixture(%{use_cookies: false})
source = source_fixture(%{cookie_behaviour: :disabled})
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end
+46 -3
View File
@@ -60,6 +60,33 @@ defmodule Pinchflat.SourcesTest do
end
end
describe "use_cookies?/2" do
test "returns true if the source has been set to use cookies" do
source = source_fixture(%{cookie_behaviour: :all_operations})
assert Sources.use_cookies?(source, :downloading)
end
test "returns false if the source has not been set to use cookies" do
source = source_fixture(%{cookie_behaviour: :disabled})
refute Sources.use_cookies?(source, :downloading)
end
test "returns true if the action is indexing and the source is set to :when_needed" do
source = source_fixture(%{cookie_behaviour: :when_needed})
assert Sources.use_cookies?(source, :indexing)
end
test "returns false if the action is downloading and the source is set to :when_needed" do
source = source_fixture(%{cookie_behaviour: :when_needed})
refute Sources.use_cookies?(source, :downloading)
end
test "returns true if the action is error_recovery and the source is set to :when_needed" do
source = source_fixture(%{cookie_behaviour: :when_needed})
assert Sources.use_cookies?(source, :error_recovery)
end
end
describe "list_sources/0" do
test "it returns all sources" do
source = source_fixture()
@@ -393,13 +420,13 @@ defmodule Pinchflat.SourcesTest do
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123",
use_cookies: true
cookie_behaviour: :all_operations
}
assert {:ok, %Source{}} = Sources.create_source(valid_attrs)
end
test "does not set use_cookies to false if the source has not been set to use cookies" do
test "does not set use_cookies if the source uses cookies when needed" do
expect(YtDlpRunnerMock, :run, fn _url, :get_source_details, _opts, _ot, addl ->
refute Keyword.get(addl, :use_cookies)
@@ -409,7 +436,23 @@ defmodule Pinchflat.SourcesTest do
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123",
use_cookies: false
cookie_behaviour: :when_needed
}
assert {:ok, %Source{}} = Sources.create_source(valid_attrs)
end
test "does not set use_cookies if the source has not been set to use cookies" do
expect(YtDlpRunnerMock, :run, fn _url, :get_source_details, _opts, _ot, addl ->
refute Keyword.get(addl, :use_cookies)
{:ok, playlist_return()}
end)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123",
cookie_behaviour: :disabled
}
assert {:ok, %Source{}} = Sources.create_source(valid_attrs)
@@ -33,4 +33,14 @@ defmodule Pinchflat.Utils.StringUtilsTest do
assert StringUtils.double_brace("hello") == "{{ hello }}"
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
+1 -1
View File
@@ -269,7 +269,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
response = %{
"original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 0.5,
"duration" => 59,
"duration" => 150,
"upload_date" => "20210101"
}