mirror of
https://github.com/kieraneglin/pinchflat.git
synced 2026-01-23 02:24:24 +00:00
[Enhancement] Custom media lifecycle scripts (#219)
* Namespaced notification modules under lifecycle * Added a JSON encoder for all the main model types * Added startup task to create user script file * Hook up user script event to media download * Hooked up media deletion user script * Added jq to docker deps * Updated README
This commit is contained in:
parent
8051107d32
commit
112c6a4f14
47 changed files with 498 additions and 70 deletions
|
|
@ -66,6 +66,7 @@ If it doesn't work for your use case, please make a feature request! You can als
|
|||
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
||||
- Sponsorblock integration
|
||||
- \[Advanced\] allows custom `yt-dlp` options ([docs](https://github.com/kieraneglin/pinchflat/wiki/%5BAdvanced%5D-Custom-yt%E2%80%90dlp-options))
|
||||
- \[Advanced\] supports running custom scripts when after downloading/deleting media (alpha - [docs](https://github.com/kieraneglin/pinchflat/wiki/%5BAdvanced%5D-Custom-lifecycle-scripts))
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ config :pinchflat,
|
|||
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||
apprise_executable: System.find_executable("apprise"),
|
||||
yt_dlp_runner: Pinchflat.YtDlp.CommandRunner,
|
||||
apprise_runner: Pinchflat.Notifications.CommandRunner,
|
||||
apprise_runner: Pinchflat.Lifecycle.Notifications.CommandRunner,
|
||||
media_directory: "/downloads",
|
||||
# The user may or may not store metadata for their needs, but the app will always store its copy
|
||||
metadata_directory: "/config/metadata",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ FROM ${DEV_IMAGE}
|
|||
|
||||
# Install debian packages
|
||||
RUN apt-get update -qq
|
||||
RUN apt-get install -y inotify-tools ffmpeg curl git openssh-client \
|
||||
RUN apt-get install -y inotify-tools ffmpeg curl git openssh-client jq \
|
||||
python3 python3-pip python3-setuptools python3-wheel python3-dev locales procps
|
||||
|
||||
# Install nodejs
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
|||
def init(state) do
|
||||
reset_executing_jobs()
|
||||
create_blank_yt_dlp_files()
|
||||
create_blank_user_script_file()
|
||||
apply_default_settings()
|
||||
|
||||
{:ok, state}
|
||||
|
|
@ -65,6 +66,18 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
|||
end)
|
||||
end
|
||||
|
||||
defp create_blank_user_script_file do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
|
||||
if !File.exists?(filepath) do
|
||||
Logger.info("Creating blank file and making it executable: #{filepath}")
|
||||
|
||||
FilesystemUtils.write_p!(filepath, "")
|
||||
File.chmod(filepath, 0o755)
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_default_settings do
|
||||
{:ok, yt_dlp_version} = yt_dlp_runner().version()
|
||||
{:ok, apprise_version} = apprise_runner().version()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Downloading.OutputPathBuilder
|
||||
|
||||
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
||||
|
||||
@doc """
|
||||
Builds the options for yt-dlp to download media based on the given media's profile.
|
||||
|
||||
|
|
@ -154,12 +156,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||
Enum.reduce(filenames, [], fn filename, acc ->
|
||||
filepath = Path.join(base_dir, filename)
|
||||
|
||||
case File.read(filepath) do
|
||||
{:ok, file_data} ->
|
||||
if String.trim(file_data) != "", do: [filepath | acc], else: acc
|
||||
|
||||
{:error, _} ->
|
||||
acc
|
||||
if FSUtils.exists_and_nonempty?(filepath) do
|
||||
[filepath | acc]
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||
alias Pinchflat.Media
|
||||
alias Pinchflat.Downloading.MediaDownloader
|
||||
|
||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||
|
||||
@doc """
|
||||
Starts the media_item media download worker and creates a task for the media_item.
|
||||
|
||||
|
|
@ -56,11 +58,14 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||
|
||||
defp download_media_and_schedule_jobs(media_item, is_redownload) do
|
||||
case MediaDownloader.download_for_media_item(media_item) do
|
||||
{:ok, updated_media_item} ->
|
||||
Media.update_media_item(updated_media_item, %{
|
||||
media_size_bytes: compute_media_filesize(updated_media_item),
|
||||
media_redownloaded_at: get_redownloaded_at(is_redownload)
|
||||
})
|
||||
{:ok, downloaded_media_item} ->
|
||||
{:ok, updated_media_item} =
|
||||
Media.update_media_item(downloaded_media_item, %{
|
||||
media_size_bytes: compute_media_filesize(downloaded_media_item),
|
||||
media_redownloaded_at: get_redownloaded_at(is_redownload)
|
||||
})
|
||||
|
||||
:ok = run_user_script(updated_media_item)
|
||||
|
||||
{:ok, updated_media_item}
|
||||
|
||||
|
|
@ -74,21 +79,13 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||
|
||||
defp compute_media_filesize(media_item) do
|
||||
case File.stat(media_item.media_filepath) do
|
||||
{:ok, %{size: size}} ->
|
||||
size
|
||||
|
||||
_ ->
|
||||
nil
|
||||
{:ok, %{size: size}} -> size
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_redownloaded_at(is_redownload) do
|
||||
if is_redownload do
|
||||
DateTime.utc_now()
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
defp get_redownloaded_at(true), do: DateTime.utc_now()
|
||||
defp get_redownloaded_at(_), do: nil
|
||||
|
||||
defp action_on_error(message) do
|
||||
# This will attempt re-download at the next indexing, but it won't be retried
|
||||
|
|
@ -103,4 +100,12 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
|||
{:error, :download_failed}
|
||||
end
|
||||
end
|
||||
|
||||
# NOTE: I like this pattern of using the default value so that I don't have to
|
||||
# define it in config.exs (and friends). Consider using this elsewhere.
|
||||
defp run_user_script(media_item) do
|
||||
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
||||
|
||||
runner.run(:media_downloaded, media_item)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
|||
alias Pinchflat.Settings
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||
alias Pinchflat.Notifications.SourceNotifications
|
||||
alias Pinchflat.Lifecycle.Notifications.SourceNotifications
|
||||
|
||||
@doc """
|
||||
Starts the source fast indexing worker and creates a task for the source.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Pinchflat.Notifications.AppriseCommandRunner do
|
||||
defmodule Pinchflat.Lifecycle.Notifications.AppriseCommandRunner do
|
||||
@moduledoc """
|
||||
A behaviour for running CLI commands against a notification backend (apprise).
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Pinchflat.Notifications.CommandRunner do
|
||||
defmodule Pinchflat.Lifecycle.Notifications.CommandRunner do
|
||||
@moduledoc """
|
||||
Runs apprise commands using the `System.cmd/3` function
|
||||
"""
|
||||
|
|
@ -7,7 +7,7 @@ defmodule Pinchflat.Notifications.CommandRunner do
|
|||
|
||||
alias Pinchflat.Utils.CliUtils
|
||||
alias Pinchflat.Utils.FunctionUtils
|
||||
alias Pinchflat.Notifications.AppriseCommandRunner
|
||||
alias Pinchflat.Lifecycle.Notifications.AppriseCommandRunner
|
||||
|
||||
@behaviour AppriseCommandRunner
|
||||
|
||||
|
|
@ -28,10 +28,10 @@ defmodule Pinchflat.Notifications.CommandRunner do
|
|||
default_opts = [:verbose]
|
||||
parsed_opts = CliUtils.parse_options(default_opts ++ command_opts)
|
||||
|
||||
{output, return_code} = CliUtils.wrap_cmd(backend_executable(), parsed_opts ++ endpoints)
|
||||
{output, exit_code} = CliUtils.wrap_cmd(backend_executable(), parsed_opts ++ endpoints)
|
||||
Logger.info("[apprise] response: #{output}")
|
||||
|
||||
case return_code do
|
||||
case exit_code do
|
||||
0 -> {:ok, String.trim(output)}
|
||||
_ -> {:error, String.trim(output)}
|
||||
end
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Pinchflat.Notifications.SourceNotifications do
|
||||
defmodule Pinchflat.Lifecycle.Notifications.SourceNotifications do
|
||||
@moduledoc """
|
||||
Contains utilities for sending notifications about sources
|
||||
"""
|
||||
76
lib/pinchflat/lifecycle/user_scripts/command_runner.ex
Normal file
76
lib/pinchflat/lifecycle/user_scripts/command_runner.ex
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
||||
@moduledoc """
|
||||
Runs custom user commands commands using the `System.cmd/3` function
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pinchflat.Utils.CliUtils
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
alias Pinchflat.Lifecycle.UserScripts.UserScriptCommandRunner
|
||||
|
||||
@behaviour UserScriptCommandRunner
|
||||
|
||||
@event_types [
|
||||
:media_downloaded,
|
||||
:media_deleted
|
||||
]
|
||||
|
||||
@doc """
|
||||
Runs the user script command for the given event type. Passes the event
|
||||
and the encoded data to the user script command.
|
||||
|
||||
This function will succeed in almost all cases, even if the user script command
|
||||
failed - this is because I don't want bad scripts to stop the whole process.
|
||||
If something fails, it'll be logged.
|
||||
|
||||
The only things that can cause a true failure are passing in an invalid event
|
||||
type or if the passed data cannot be encoded into JSON - both indicative of
|
||||
failures in the development process.
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
@impl UserScriptCommandRunner
|
||||
def run(event_type, encodable_data) when event_type in @event_types do
|
||||
case backend_executable() do
|
||||
{:ok, :no_executable} ->
|
||||
:ok
|
||||
|
||||
{:ok, executable_path} ->
|
||||
{:ok, encoded_data} = Phoenix.json_library().encode(encodable_data)
|
||||
|
||||
{output, exit_code} =
|
||||
CliUtils.wrap_cmd(
|
||||
executable_path,
|
||||
[to_string(event_type), encoded_data],
|
||||
[],
|
||||
logging_arg_override: "[suppressed]"
|
||||
)
|
||||
|
||||
handle_output(output, exit_code)
|
||||
end
|
||||
end
|
||||
|
||||
def run(event_type, _encodable_data) do
|
||||
raise ArgumentError, "Invalid event type: #{inspect(event_type)}"
|
||||
end
|
||||
|
||||
defp handle_output(output, exit_code) do
|
||||
Logger.debug("Custom lifecycle script exit code: #{exit_code} with output: #{output}")
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp backend_executable do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
|
||||
if FilesystemUtils.exists_and_nonempty?(filepath) do
|
||||
{:ok, filepath}
|
||||
else
|
||||
Logger.warning("User scripts lifecyle file either not present or is empty. Skipping.")
|
||||
|
||||
{:ok, :no_executable}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
defmodule Pinchflat.Lifecycle.UserScripts.UserScriptCommandRunner do
|
||||
@moduledoc """
|
||||
A behaviour for running custom user scripts on certain events.
|
||||
|
||||
Used so we can implement Mox for testing without actually running the
|
||||
user's command.
|
||||
"""
|
||||
|
||||
@callback run(atom(), map()) :: :ok | {:error, binary()}
|
||||
end
|
||||
|
|
@ -10,8 +10,10 @@ defmodule Pinchflat.Media do
|
|||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
|
||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||
|
||||
@doc """
|
||||
Returns the list of media_items.
|
||||
|
|
@ -180,6 +182,7 @@ defmodule Pinchflat.Media do
|
|||
|
||||
if delete_files do
|
||||
{:ok, _} = do_delete_media_files(media_item)
|
||||
:ok = run_user_script(:media_deleted, media_item)
|
||||
end
|
||||
|
||||
# Should delete these no matter what
|
||||
|
|
@ -202,6 +205,7 @@ defmodule Pinchflat.Media do
|
|||
|
||||
Tasks.delete_tasks_for(media_item)
|
||||
{:ok, _} = do_delete_media_files(media_item)
|
||||
:ok = run_user_script(:media_deleted, media_item)
|
||||
|
||||
update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
|
||||
end
|
||||
|
|
@ -237,4 +241,10 @@ defmodule Pinchflat.Media do
|
|||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
|
||||
end
|
||||
|
||||
defp run_user_script(event, media_item) do
|
||||
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
||||
|
||||
runner.run(event, media_item)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ defmodule Pinchflat.Media.MediaItem do
|
|||
import Ecto.Changeset
|
||||
import Pinchflat.Utils.ChangesetUtils
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Tasks.Task
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
|
|
@ -116,4 +118,18 @@ defmodule Pinchflat.Media.MediaItem do
|
|||
end)
|
||||
|> Enum.into(%{})
|
||||
end
|
||||
|
||||
@doc false
|
||||
def json_exluded_fields do
|
||||
~w(__meta__ __struct__ metadata tasks media_items_search_index)a
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: MediaItem do
|
||||
def encode(value, opts) do
|
||||
value
|
||||
|> Repo.preload(:source)
|
||||
|> Map.drop(MediaItem.json_exluded_fields())
|
||||
|> Jason.Encode.map(opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Sources.Source
|
||||
|
||||
@allowed_fields ~w(
|
||||
|
|
@ -84,4 +85,17 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||
def ext_regex do
|
||||
~r/\.({{ ?ext ?}}|%\( ?ext ?\)[sS])$/
|
||||
end
|
||||
|
||||
@doc false
|
||||
def json_exluded_fields do
|
||||
~w(__meta__ __struct__ sources)a
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: MediaProfile do
|
||||
def encode(value, opts) do
|
||||
value
|
||||
|> Map.drop(MediaProfile.json_exluded_fields())
|
||||
|> Jason.Encode.map(opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
|||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||
alias Pinchflat.Notifications.SourceNotifications
|
||||
alias Pinchflat.Lifecycle.Notifications.SourceNotifications
|
||||
|
||||
@doc """
|
||||
Starts the source slow indexing worker and creates a task for the source.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ defmodule Pinchflat.Sources.Source do
|
|||
import Ecto.Changeset
|
||||
import Pinchflat.Utils.ChangesetUtils
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Tasks.Task
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
|
|
@ -133,4 +135,18 @@ defmodule Pinchflat.Sources.Source do
|
|||
def filepath_attributes do
|
||||
~w(nfo_filepath fanart_filepath poster_filepath banner_filepath)a
|
||||
end
|
||||
|
||||
@doc false
|
||||
def json_exluded_fields do
|
||||
~w(__meta__ __struct__ metadata tasks media_items)a
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Source do
|
||||
def encode(value, opts) do
|
||||
value
|
||||
|> Repo.preload(:media_profile)
|
||||
|> Map.drop(Source.json_exluded_fields())
|
||||
|> Jason.Encode.map(opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,17 +13,22 @@ defmodule Pinchflat.Utils.CliUtils do
|
|||
commands if the job runner is cancelled.
|
||||
|
||||
Delegates to `System.cmd/3` and any options/output
|
||||
are passed through.
|
||||
are passed through. Custom options can be passed in.
|
||||
|
||||
Custom options:
|
||||
- logging_arg_override: if set, the passed value will be logged in place of
|
||||
the actual arguments passed to the command
|
||||
|
||||
Returns {binary(), integer()}
|
||||
"""
|
||||
def wrap_cmd(command, args, opts \\ []) do
|
||||
def wrap_cmd(command, args, passthrough_opts \\ [], opts \\ []) do
|
||||
wrapper_command = Path.join(:code.priv_dir(:pinchflat), "cmd_wrapper.sh")
|
||||
actual_command = [command] ++ args
|
||||
logging_arg_override = Keyword.get(opts, :logging_arg_override, Enum.join(args, " "))
|
||||
|
||||
Logger.info("[command_wrapper]: #{command} called with: #{Enum.join(args, " ")}")
|
||||
Logger.info("[command_wrapper]: #{command} called with: #{logging_arg_override}")
|
||||
|
||||
System.cmd(wrapper_command, actual_command, opts)
|
||||
System.cmd(wrapper_command, actual_command, passthrough_opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -5,6 +5,21 @@ defmodule Pinchflat.Utils.FilesystemUtils do
|
|||
alias Pinchflat.Media
|
||||
alias Pinchflat.Utils.StringUtils
|
||||
|
||||
@doc """
|
||||
Checks if a file exists and has non-whitespace contents.
|
||||
|
||||
Returns boolean()
|
||||
"""
|
||||
def exists_and_nonempty?(filepath) do
|
||||
case File.read(filepath) do
|
||||
{:ok, contents} ->
|
||||
String.trim(contents) != ""
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a temporary file and returns its path. The file is empty and has the given type.
|
||||
Generates all the directories in the path if they don't exist.
|
||||
|
|
|
|||
|
|
@ -81,16 +81,10 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
|||
Enum.reduce(filename_options_map, [], fn {opt_name, filename}, acc ->
|
||||
filepath = Path.join(base_dir, filename)
|
||||
|
||||
case File.read(filepath) do
|
||||
{:ok, file_data} ->
|
||||
if String.trim(file_data) != "" do
|
||||
[{opt_name, filepath} | acc]
|
||||
else
|
||||
acc
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
acc
|
||||
if FSUtils.exists_and_nonempty?(filepath) do
|
||||
[{opt_name, filepath} | acc]
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ ARG PORT=8945
|
|||
|
||||
RUN apt-get update -y
|
||||
RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
|
||||
ffmpeg curl git openssh-client nano python3 python3-pip
|
||||
ffmpeg curl git openssh-client nano python3 python3-pip jq procps
|
||||
RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# Download and update YT-DLP
|
||||
|
|
|
|||
|
|
@ -53,6 +53,30 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "create_blank_user_script_file" do
|
||||
test "creates a blank script file" do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
File.rm(filepath)
|
||||
|
||||
refute File.exists?(filepath)
|
||||
|
||||
PreJobStartupTasks.init(%{})
|
||||
|
||||
assert File.exists?(filepath)
|
||||
end
|
||||
|
||||
test "gives it 755 permissions" do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
File.rm(filepath)
|
||||
|
||||
PreJobStartupTasks.init(%{})
|
||||
|
||||
assert File.stat!(filepath).mode == 0o100755
|
||||
end
|
||||
end
|
||||
|
||||
describe "apply_default_settings" do
|
||||
test "sets yt_dlp version" do
|
||||
Settings.set(yt_dlp_version: nil)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||
setup :verify_on_exit!
|
||||
|
||||
setup do
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts ->
|
||||
{:ok, ""}
|
||||
end)
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||
|
||||
media_item =
|
||||
%{media_filepath: nil}
|
||||
|
|
@ -185,6 +184,20 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
|||
assert media_item.media_redownloaded_at == nil
|
||||
end
|
||||
|
||||
test "calls the user script runner", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||
{:ok, render_metadata(:media_metadata)}
|
||||
end)
|
||||
|
||||
expect(UserScriptRunnerMock, :run, fn :media_downloaded, data ->
|
||||
assert data.id == media_item.id
|
||||
|
||||
:ok
|
||||
end)
|
||||
|
||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||
end
|
||||
|
||||
test "does not blow up if the record doesn't exist" do
|
||||
assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
import Mox
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Downloading.MediaRetentionWorker
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "perform/1" do
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "deletes media files that are past their retention date" do
|
||||
{_source, old_media_item, new_media_item} = prepare_records()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Pinchflat.Downloading.OutputPath.ParserTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Downloading.OutputPath.Parser
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
defmodule Pinchflat.Notifications.CommandRunnerTest do
|
||||
use ExUnit.Case, async: true
|
||||
defmodule Pinchflat.Lifecycle.Notifications.CommandRunnerTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Notifications.CommandRunner, as: Runner
|
||||
alias Pinchflat.Lifecycle.Notifications.CommandRunner, as: Runner
|
||||
|
||||
@original_executable Application.compile_env(:pinchflat, :apprise_executable)
|
||||
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
defmodule Pinchflat.Notifications.SourceNotificationsTest do
|
||||
defmodule Pinchflat.Lifecycle.Notifications.SourceNotificationsTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
import Mox
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
|
||||
alias Pinchflat.Notifications.SourceNotifications
|
||||
alias Pinchflat.Lifecycle.Notifications.SourceNotifications
|
||||
|
||||
@apprise_servers ["server_1", "server_2"]
|
||||
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
defmodule Pinchflat.Lifecycle.UserScripts.CommandRunnerTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: Runner
|
||||
|
||||
setup do
|
||||
FilesystemUtils.write_p!(filepath(), "")
|
||||
File.chmod(filepath(), 0o755)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "run/2" do
|
||||
test "runs the provided lifecycle file if present" do
|
||||
# We *love* indirectly testing side effects
|
||||
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
File.write(filepath(), "#!/bin/bash\ntouch #{tmp_dir}/test_file\n")
|
||||
|
||||
refute File.exists?("#{tmp_dir}/test_file")
|
||||
assert :ok = Runner.run(:media_downloaded, %{})
|
||||
assert File.exists?("#{tmp_dir}/test_file")
|
||||
end
|
||||
|
||||
test "passes the event name to the script" do
|
||||
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
File.write(filepath(), "#!/bin/bash\necho $1 > #{tmp_dir}/event_name\n")
|
||||
|
||||
assert :ok = Runner.run(:media_downloaded, %{})
|
||||
assert File.read!("#{tmp_dir}/event_name") == "media_downloaded\n"
|
||||
end
|
||||
|
||||
test "passes the encoded data to the script" do
|
||||
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
File.write(filepath(), "#!/bin/bash\necho $2 > #{tmp_dir}/encoded_data\n")
|
||||
|
||||
assert :ok = Runner.run(:media_downloaded, %{foo: "bar"})
|
||||
assert File.read!("#{tmp_dir}/encoded_data") == "{\"foo\":\"bar\"}\n"
|
||||
end
|
||||
|
||||
test "does nothing if the lifecycle file is not present" do
|
||||
:ok = File.rm(filepath())
|
||||
|
||||
assert :ok = Runner.run(:media_downloaded, %{})
|
||||
end
|
||||
|
||||
test "does nothing if the lifecycle file is empty" do
|
||||
File.write(filepath(), "")
|
||||
|
||||
assert :ok = Runner.run(:media_downloaded, %{})
|
||||
end
|
||||
|
||||
test "returns :ok if the command exits with a non-zero status" do
|
||||
File.write(filepath(), "#!/bin/bash\nexit 1\n")
|
||||
|
||||
assert :ok = Runner.run(:media_downloaded, %{})
|
||||
end
|
||||
|
||||
test "gets upset if you pass an invalid event type" do
|
||||
assert_raise ArgumentError, "Invalid event type: :invalid_event", fn ->
|
||||
Runner.run(:invalid_event, %{})
|
||||
end
|
||||
end
|
||||
|
||||
test "gets upset if the record cannot be decoded" do
|
||||
File.write(filepath(), "#!/bin/bash")
|
||||
|
||||
assert_raise MatchError, fn ->
|
||||
Runner.run(:media_downloaded, %Ecto.Changeset{})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp filepath do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
|
||||
Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
end
|
||||
end
|
||||
|
|
@ -29,6 +29,12 @@ defmodule Pinchflat.MediaTest do
|
|||
Repo.reload!(metadata)
|
||||
end
|
||||
end
|
||||
|
||||
test "can be JSON encoded without error" do
|
||||
media_item = media_item_fixture()
|
||||
|
||||
assert {:ok, _} = Phoenix.json_library().encode(media_item)
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_media_items/0" do
|
||||
|
|
@ -743,6 +749,12 @@ defmodule Pinchflat.MediaTest do
|
|||
end
|
||||
|
||||
describe "delete_media_item/2 when testing file deletion" do
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "deletes the media item's files" do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
|
|
@ -793,9 +805,27 @@ defmodule Pinchflat.MediaTest do
|
|||
:ok = File.rm(Path.join([root_directory, "test.txt"]))
|
||||
:ok = File.rmdir(root_directory)
|
||||
end
|
||||
|
||||
test "calls the user script runner" do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
|
||||
assert data.id == media_item.id
|
||||
|
||||
:ok
|
||||
end)
|
||||
|
||||
assert {:ok, _} = Media.delete_media_item(media_item, delete_files: true)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_media_files/2" do
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "does not delete the media_item" do
|
||||
media_item = media_item_fixture()
|
||||
|
||||
|
|
@ -848,6 +878,18 @@ defmodule Pinchflat.MediaTest do
|
|||
assert {:ok, updated_media_item} = Media.delete_media_files(media_item, %{prevent_download: true})
|
||||
assert updated_media_item.prevent_download
|
||||
end
|
||||
|
||||
test "calls the user script runner" do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
|
||||
assert data.id == media_item.id
|
||||
|
||||
:ok
|
||||
end)
|
||||
|
||||
assert {:ok, _} = Media.delete_media_files(media_item)
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_media_item/1" do
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule Pinchflat.ProfilesTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
import Mox
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
import Pinchflat.ProfilesFixtures
|
||||
|
|
@ -10,6 +11,16 @@ defmodule Pinchflat.ProfilesTest do
|
|||
|
||||
@invalid_attrs %{name: nil, output_path_template: nil}
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "schema" do
|
||||
test "can be JSON encoded without error" do
|
||||
profile = media_profile_fixture()
|
||||
|
||||
assert {:ok, _} = Phoenix.json_library().encode(profile)
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_media_profiles/0" do
|
||||
test "it returns all media_profiles" do
|
||||
media_profile = media_profile_fixture()
|
||||
|
|
@ -104,6 +115,12 @@ defmodule Pinchflat.ProfilesTest do
|
|||
end
|
||||
|
||||
describe "delete_media_profile/2 when deleting files" do
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "still deletes all the needful records" do
|
||||
media_profile = media_profile_fixture()
|
||||
source = source_fixture(media_profile_id: media_profile.id)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Pinchflat.SlowIndexing.FileFollowerServerTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias alias Pinchflat.Utils.FilesystemUtils
|
||||
alias Pinchflat.SlowIndexing.FileFollowerServer
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ defmodule Pinchflat.SourcesTest do
|
|||
Repo.reload!(metadata)
|
||||
end
|
||||
end
|
||||
|
||||
test "can be JSON encoded without error" do
|
||||
source = source_fixture()
|
||||
|
||||
assert {:ok, _} = Phoenix.json_library().encode(source)
|
||||
end
|
||||
end
|
||||
|
||||
describe "output_path_template/1" do
|
||||
|
|
@ -612,6 +618,12 @@ defmodule Pinchflat.SourcesTest do
|
|||
end
|
||||
|
||||
describe "delete_source/2 when deleting files" do
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "deletes source and media_items" do
|
||||
source = source_fixture()
|
||||
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Pinchflat.Utils.ChangesetUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
defmodule MockSchema do
|
||||
use Ecto.Schema
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Pinchflat.Utils.CliUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.CliUtils
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Pinchflat.Utils.DatetimeUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.DatetimeUtils
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,38 @@ defmodule Pinchflat.Utils.FilesystemUtilsTest do
|
|||
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
|
||||
describe "exists_and_nonempty?" do
|
||||
test "returns true if a file exists and has contents" do
|
||||
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
File.write(filepath, "{}")
|
||||
|
||||
assert FilesystemUtils.exists_and_nonempty?(filepath)
|
||||
|
||||
File.rm!(filepath)
|
||||
end
|
||||
|
||||
test "returns false if a file doesn't exist" do
|
||||
refute FilesystemUtils.exists_and_nonempty?("/nonexistent/file.json")
|
||||
end
|
||||
|
||||
test "returns false if a file exists but is empty" do
|
||||
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
|
||||
refute FilesystemUtils.exists_and_nonempty?(filepath)
|
||||
|
||||
File.rm!(filepath)
|
||||
end
|
||||
|
||||
test "trims the contents before checking" do
|
||||
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
File.write(filepath, " \n\n \r\n ")
|
||||
|
||||
refute FilesystemUtils.exists_and_nonempty?(filepath)
|
||||
|
||||
File.rm!(filepath)
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_metadata_tmpfile/1" do
|
||||
test "creates a tmpfile and returns its path" do
|
||||
res = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Pinchflat.Utils.FunctionUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.FunctionUtils
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Pinchflat.Utils.NumberUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.NumberUtils
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Pinchflat.Utils.StringUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.StringUtils
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Pinchflat.Utils.XmlUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.XmlUtils
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Pinchflat.YtDlp.CommandRunnerTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
|
||||
|
|
@ -75,6 +75,9 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
|
|||
|
||||
refute String.contains?(output, "--cookies")
|
||||
refute String.contains?(output, cookie_file)
|
||||
|
||||
# Cleanup
|
||||
FilesystemUtils.write_p!(cookie_file, "")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule PinchflatWeb.ErrorHTMLTest do
|
||||
use PinchflatWeb.ConnCase, async: true
|
||||
use PinchflatWeb.ConnCase, async: false
|
||||
|
||||
# Bring render_to_string/4 for testing custom views
|
||||
import Phoenix.Template
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule PinchflatWeb.ErrorJSONTest do
|
||||
use PinchflatWeb.ConnCase, async: true
|
||||
use PinchflatWeb.ConnCase, async: false
|
||||
|
||||
test "renders 404" do
|
||||
assert PinchflatWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
defmodule PinchflatWeb.MediaItemControllerTest do
|
||||
use PinchflatWeb.ConnCase
|
||||
|
||||
import Mox
|
||||
import Pinchflat.MediaFixtures
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "show media" do
|
||||
setup [:create_media_item]
|
||||
|
||||
|
|
@ -49,6 +52,7 @@ defmodule PinchflatWeb.MediaItemControllerTest do
|
|||
describe "delete media" do
|
||||
setup do
|
||||
media_item = media_item_with_attachments()
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
%{media_item: media_item}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule PinchflatWeb.MediaProfileControllerTest do
|
||||
use PinchflatWeb.ConnCase
|
||||
|
||||
import Mox
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
import Pinchflat.ProfilesFixtures
|
||||
|
|
@ -15,6 +16,8 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
|
|||
}
|
||||
@invalid_attrs %{name: nil, output_path_template: nil}
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
setup do
|
||||
Settings.set(onboarding: false)
|
||||
|
||||
|
|
@ -136,6 +139,12 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
|
|||
describe "delete media_profile when deleting the records and files" do
|
||||
setup [:create_media_profile]
|
||||
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "deletes chosen media_profile and its associations", %{conn: conn, media_profile: media_profile} do
|
||||
source = source_fixture(media_profile_id: media_profile.id)
|
||||
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||
|
|
|
|||
|
|
@ -145,6 +145,12 @@ defmodule PinchflatWeb.SourceControllerTest do
|
|||
describe "delete source when deleting the records and files" do
|
||||
setup [:create_source]
|
||||
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "deletes chosen source and media_items", %{conn: conn, source: source, media_item: media_item} do
|
||||
delete(conn, ~p"/sources/#{source}?delete_files=true")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
Mox.defmock(YtDlpRunnerMock, for: Pinchflat.YtDlp.YtDlpCommandRunner)
|
||||
Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock)
|
||||
|
||||
Mox.defmock(AppriseRunnerMock, for: Pinchflat.Notifications.AppriseCommandRunner)
|
||||
Mox.defmock(AppriseRunnerMock, for: Pinchflat.Lifecycle.Notifications.AppriseCommandRunner)
|
||||
Application.put_env(:pinchflat, :apprise_runner, AppriseRunnerMock)
|
||||
|
||||
Mox.defmock(HTTPClientMock, for: Pinchflat.HTTP.HTTPBehaviour)
|
||||
Application.put_env(:pinchflat, :http_client, HTTPClientMock)
|
||||
|
||||
Mox.defmock(UserScriptRunnerMock, for: Pinchflat.Lifecycle.UserScripts.UserScriptCommandRunner)
|
||||
Application.put_env(:pinchflat, :user_script_runner, UserScriptRunnerMock)
|
||||
|
||||
ExUnit.start()
|
||||
Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual)
|
||||
Faker.start()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue