[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:
Kieran 2024-05-02 08:43:37 -07:00 committed by GitHub
parent 8051107d32
commit 112c6a4f14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 498 additions and 70 deletions

View file

@ -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)) - Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
- Sponsorblock integration - Sponsorblock integration
- \[Advanced\] allows custom `yt-dlp` options ([docs](https://github.com/kieraneglin/pinchflat/wiki/%5BAdvanced%5D-Custom-yt%E2%80%90dlp-options)) - \[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 ## Screenshots

View file

@ -14,7 +14,7 @@ config :pinchflat,
yt_dlp_executable: System.find_executable("yt-dlp"), yt_dlp_executable: System.find_executable("yt-dlp"),
apprise_executable: System.find_executable("apprise"), apprise_executable: System.find_executable("apprise"),
yt_dlp_runner: Pinchflat.YtDlp.CommandRunner, yt_dlp_runner: Pinchflat.YtDlp.CommandRunner,
apprise_runner: Pinchflat.Notifications.CommandRunner, apprise_runner: Pinchflat.Lifecycle.Notifications.CommandRunner,
media_directory: "/downloads", media_directory: "/downloads",
# The user may or may not store metadata for their needs, but the app will always store its copy # The user may or may not store metadata for their needs, but the app will always store its copy
metadata_directory: "/config/metadata", metadata_directory: "/config/metadata",

View file

@ -7,7 +7,7 @@ FROM ${DEV_IMAGE}
# Install debian packages # Install debian packages
RUN apt-get update -qq 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 python3 python3-pip python3-setuptools python3-wheel python3-dev locales procps
# Install nodejs # Install nodejs

View file

@ -33,6 +33,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
def init(state) do def init(state) do
reset_executing_jobs() reset_executing_jobs()
create_blank_yt_dlp_files() create_blank_yt_dlp_files()
create_blank_user_script_file()
apply_default_settings() apply_default_settings()
{:ok, state} {:ok, state}
@ -65,6 +66,18 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
end) end)
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 defp apply_default_settings do
{:ok, yt_dlp_version} = yt_dlp_runner().version() {:ok, yt_dlp_version} = yt_dlp_runner().version()
{:ok, apprise_version} = apprise_runner().version() {:ok, apprise_version} = apprise_runner().version()

View file

@ -8,6 +8,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.OutputPathBuilder alias Pinchflat.Downloading.OutputPathBuilder
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
@doc """ @doc """
Builds the options for yt-dlp to download media based on the given media's profile. 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 -> Enum.reduce(filenames, [], fn filename, acc ->
filepath = Path.join(base_dir, filename) filepath = Path.join(base_dir, filename)
case File.read(filepath) do if FSUtils.exists_and_nonempty?(filepath) do
{:ok, file_data} -> [filepath | acc]
if String.trim(file_data) != "", do: [filepath | acc], else: acc else
acc
{:error, _} ->
acc
end end
end) end)

View file

@ -14,6 +14,8 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Downloading.MediaDownloader alias Pinchflat.Downloading.MediaDownloader
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
@doc """ @doc """
Starts the media_item media download worker and creates a task for the media_item. 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 defp download_media_and_schedule_jobs(media_item, is_redownload) do
case MediaDownloader.download_for_media_item(media_item) do case MediaDownloader.download_for_media_item(media_item) do
{:ok, updated_media_item} -> {:ok, downloaded_media_item} ->
Media.update_media_item(updated_media_item, %{ {:ok, updated_media_item} =
media_size_bytes: compute_media_filesize(updated_media_item), Media.update_media_item(downloaded_media_item, %{
media_redownloaded_at: get_redownloaded_at(is_redownload) 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} {:ok, updated_media_item}
@ -74,21 +79,13 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
defp compute_media_filesize(media_item) do defp compute_media_filesize(media_item) do
case File.stat(media_item.media_filepath) do case File.stat(media_item.media_filepath) do
{:ok, %{size: size}} -> {:ok, %{size: size}} -> size
size _ -> nil
_ ->
nil
end end
end end
defp get_redownloaded_at(is_redownload) do defp get_redownloaded_at(true), do: DateTime.utc_now()
if is_redownload do defp get_redownloaded_at(_), do: nil
DateTime.utc_now()
else
nil
end
end
defp action_on_error(message) do defp action_on_error(message) do
# This will attempt re-download at the next indexing, but it won't be retried # This will attempt re-download at the next indexing, but it won't be retried
@ -103,4 +100,12 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
{:error, :download_failed} {:error, :download_failed}
end end
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 end

View file

@ -15,7 +15,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.FastIndexingHelpers alias Pinchflat.FastIndexing.FastIndexingHelpers
alias Pinchflat.Notifications.SourceNotifications alias Pinchflat.Lifecycle.Notifications.SourceNotifications
@doc """ @doc """
Starts the source fast indexing worker and creates a task for the source. Starts the source fast indexing worker and creates a task for the source.

View file

@ -1,4 +1,4 @@
defmodule Pinchflat.Notifications.AppriseCommandRunner do defmodule Pinchflat.Lifecycle.Notifications.AppriseCommandRunner do
@moduledoc """ @moduledoc """
A behaviour for running CLI commands against a notification backend (apprise). A behaviour for running CLI commands against a notification backend (apprise).

View file

@ -1,4 +1,4 @@
defmodule Pinchflat.Notifications.CommandRunner do defmodule Pinchflat.Lifecycle.Notifications.CommandRunner do
@moduledoc """ @moduledoc """
Runs apprise commands using the `System.cmd/3` function 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.CliUtils
alias Pinchflat.Utils.FunctionUtils alias Pinchflat.Utils.FunctionUtils
alias Pinchflat.Notifications.AppriseCommandRunner alias Pinchflat.Lifecycle.Notifications.AppriseCommandRunner
@behaviour AppriseCommandRunner @behaviour AppriseCommandRunner
@ -28,10 +28,10 @@ defmodule Pinchflat.Notifications.CommandRunner do
default_opts = [:verbose] default_opts = [:verbose]
parsed_opts = CliUtils.parse_options(default_opts ++ command_opts) 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}") Logger.info("[apprise] response: #{output}")
case return_code do case exit_code do
0 -> {:ok, String.trim(output)} 0 -> {:ok, String.trim(output)}
_ -> {:error, String.trim(output)} _ -> {:error, String.trim(output)}
end end

View file

@ -1,4 +1,4 @@
defmodule Pinchflat.Notifications.SourceNotifications do defmodule Pinchflat.Lifecycle.Notifications.SourceNotifications do
@moduledoc """ @moduledoc """
Contains utilities for sending notifications about sources Contains utilities for sending notifications about sources
""" """

View 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

View file

@ -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

View file

@ -10,8 +10,10 @@ defmodule Pinchflat.Media do
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Media.MediaQuery alias Pinchflat.Media.MediaQuery
alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Utils.FilesystemUtils alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
@doc """ @doc """
Returns the list of media_items. Returns the list of media_items.
@ -180,6 +182,7 @@ defmodule Pinchflat.Media do
if delete_files do if delete_files do
{:ok, _} = do_delete_media_files(media_item) {:ok, _} = do_delete_media_files(media_item)
:ok = run_user_script(:media_deleted, media_item)
end end
# Should delete these no matter what # Should delete these no matter what
@ -202,6 +205,7 @@ defmodule Pinchflat.Media do
Tasks.delete_tasks_for(media_item) Tasks.delete_tasks_for(media_item)
{:ok, _} = do_delete_media_files(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)) update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
end end
@ -237,4 +241,10 @@ defmodule Pinchflat.Media do
|> Enum.filter(&is_binary/1) |> Enum.filter(&is_binary/1)
|> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1) |> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
end 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 end

View file

@ -7,6 +7,8 @@ defmodule Pinchflat.Media.MediaItem do
import Ecto.Changeset import Ecto.Changeset
import Pinchflat.Utils.ChangesetUtils import Pinchflat.Utils.ChangesetUtils
alias __MODULE__
alias Pinchflat.Repo
alias Pinchflat.Tasks.Task alias Pinchflat.Tasks.Task
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Metadata.MediaMetadata alias Pinchflat.Metadata.MediaMetadata
@ -116,4 +118,18 @@ defmodule Pinchflat.Media.MediaItem do
end) end)
|> Enum.into(%{}) |> Enum.into(%{})
end 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 end

View file

@ -6,6 +6,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias __MODULE__
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
@allowed_fields ~w( @allowed_fields ~w(
@ -84,4 +85,17 @@ defmodule Pinchflat.Profiles.MediaProfile do
def ext_regex do def ext_regex do
~r/\.({{ ?ext ?}}|%\( ?ext ?\)[sS])$/ ~r/\.({{ ?ext ?}}|%\( ?ext ?\)[sS])$/
end 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 end

View file

@ -15,7 +15,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.FastIndexingWorker alias Pinchflat.FastIndexing.FastIndexingWorker
alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.SlowIndexing.SlowIndexingHelpers
alias Pinchflat.Notifications.SourceNotifications alias Pinchflat.Lifecycle.Notifications.SourceNotifications
@doc """ @doc """
Starts the source slow indexing worker and creates a task for the source. Starts the source slow indexing worker and creates a task for the source.

View file

@ -7,6 +7,8 @@ defmodule Pinchflat.Sources.Source do
import Ecto.Changeset import Ecto.Changeset
import Pinchflat.Utils.ChangesetUtils import Pinchflat.Utils.ChangesetUtils
alias __MODULE__
alias Pinchflat.Repo
alias Pinchflat.Tasks.Task alias Pinchflat.Tasks.Task
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
@ -133,4 +135,18 @@ defmodule Pinchflat.Sources.Source do
def filepath_attributes do def filepath_attributes do
~w(nfo_filepath fanart_filepath poster_filepath banner_filepath)a ~w(nfo_filepath fanart_filepath poster_filepath banner_filepath)a
end 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 end

View file

@ -13,17 +13,22 @@ defmodule Pinchflat.Utils.CliUtils do
commands if the job runner is cancelled. commands if the job runner is cancelled.
Delegates to `System.cmd/3` and any options/output 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()} 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") wrapper_command = Path.join(:code.priv_dir(:pinchflat), "cmd_wrapper.sh")
actual_command = [command] ++ args 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 end
@doc """ @doc """

View file

@ -5,6 +5,21 @@ defmodule Pinchflat.Utils.FilesystemUtils do
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Utils.StringUtils 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 """ @doc """
Generates a temporary file and returns its path. The file is empty and has the given type. 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. Generates all the directories in the path if they don't exist.

View file

@ -81,16 +81,10 @@ defmodule Pinchflat.YtDlp.CommandRunner do
Enum.reduce(filename_options_map, [], fn {opt_name, filename}, acc -> Enum.reduce(filename_options_map, [], fn {opt_name, filename}, acc ->
filepath = Path.join(base_dir, filename) filepath = Path.join(base_dir, filename)
case File.read(filepath) do if FSUtils.exists_and_nonempty?(filepath) do
{:ok, file_data} -> [{opt_name, filepath} | acc]
if String.trim(file_data) != "" do else
[{opt_name, filepath} | acc] acc
else
acc
end
{:error, _} ->
acc
end end
end) end)
end end

View file

@ -79,7 +79,7 @@ ARG PORT=8945
RUN apt-get update -y RUN apt-get update -y
RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ 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/*_* RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
# Download and update YT-DLP # Download and update YT-DLP

View file

@ -53,6 +53,30 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
end end
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 describe "apply_default_settings" do
test "sets yt_dlp version" do test "sets yt_dlp version" do
Settings.set(yt_dlp_version: nil) Settings.set(yt_dlp_version: nil)

View file

@ -12,9 +12,8 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
setup :verify_on_exit! setup :verify_on_exit!
setup do setup do
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
{:ok, ""} stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
end)
media_item = media_item =
%{media_filepath: nil} %{media_filepath: nil}
@ -185,6 +184,20 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
assert media_item.media_redownloaded_at == nil assert media_item.media_redownloaded_at == nil
end 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 test "does not blow up if the record doesn't exist" do
assert :ok = perform_job(MediaDownloadWorker, %{id: 0}) assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
end end

View file

@ -1,13 +1,22 @@
defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Mox
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Downloading.MediaRetentionWorker alias Pinchflat.Downloading.MediaRetentionWorker
setup :verify_on_exit!
describe "perform/1" do 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 test "deletes media files that are past their retention date" do
{_source, old_media_item, new_media_item} = prepare_records() {_source, old_media_item, new_media_item} = prepare_records()

View file

@ -1,5 +1,5 @@
defmodule Pinchflat.Downloading.OutputPath.ParserTest do defmodule Pinchflat.Downloading.OutputPath.ParserTest do
use ExUnit.Case, async: true use ExUnit.Case, async: false
alias Pinchflat.Downloading.OutputPath.Parser alias Pinchflat.Downloading.OutputPath.Parser

View file

@ -1,7 +1,7 @@
defmodule Pinchflat.Notifications.CommandRunnerTest do defmodule Pinchflat.Lifecycle.Notifications.CommandRunnerTest do
use ExUnit.Case, async: true 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) @original_executable Application.compile_env(:pinchflat, :apprise_executable)

View file

@ -1,11 +1,11 @@
defmodule Pinchflat.Notifications.SourceNotificationsTest do defmodule Pinchflat.Lifecycle.Notifications.SourceNotificationsTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Mox import Mox
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
alias Pinchflat.Notifications.SourceNotifications alias Pinchflat.Lifecycle.Notifications.SourceNotifications
@apprise_servers ["server_1", "server_2"] @apprise_servers ["server_1", "server_2"]

View file

@ -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

View file

@ -29,6 +29,12 @@ defmodule Pinchflat.MediaTest do
Repo.reload!(metadata) Repo.reload!(metadata)
end end
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 end
describe "list_media_items/0" do describe "list_media_items/0" do
@ -743,6 +749,12 @@ defmodule Pinchflat.MediaTest do
end end
describe "delete_media_item/2 when testing file deletion" do 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 test "deletes the media item's files" do
media_item = media_item_with_attachments() 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.rm(Path.join([root_directory, "test.txt"]))
:ok = File.rmdir(root_directory) :ok = File.rmdir(root_directory)
end 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 end
describe "delete_media_files/2" do 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 test "does not delete the media_item" do
media_item = media_item_fixture() 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 {:ok, updated_media_item} = Media.delete_media_files(media_item, %{prevent_download: true})
assert updated_media_item.prevent_download assert updated_media_item.prevent_download
end 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 end
describe "change_media_item/1" do describe "change_media_item/1" do

View file

@ -1,6 +1,7 @@
defmodule Pinchflat.ProfilesTest do defmodule Pinchflat.ProfilesTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Mox
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures import Pinchflat.ProfilesFixtures
@ -10,6 +11,16 @@ defmodule Pinchflat.ProfilesTest do
@invalid_attrs %{name: nil, output_path_template: nil} @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 describe "list_media_profiles/0" do
test "it returns all media_profiles" do test "it returns all media_profiles" do
media_profile = media_profile_fixture() media_profile = media_profile_fixture()
@ -104,6 +115,12 @@ defmodule Pinchflat.ProfilesTest do
end end
describe "delete_media_profile/2 when deleting files" do 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 test "still deletes all the needful records" do
media_profile = media_profile_fixture() media_profile = media_profile_fixture()
source = source_fixture(media_profile_id: media_profile.id) source = source_fixture(media_profile_id: media_profile.id)

View file

@ -1,5 +1,5 @@
defmodule Pinchflat.SlowIndexing.FileFollowerServerTest do defmodule Pinchflat.SlowIndexing.FileFollowerServerTest do
use ExUnit.Case, async: true use ExUnit.Case, async: false
alias alias Pinchflat.Utils.FilesystemUtils alias alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.SlowIndexing.FileFollowerServer alias Pinchflat.SlowIndexing.FileFollowerServer

View file

@ -32,6 +32,12 @@ defmodule Pinchflat.SourcesTest do
Repo.reload!(metadata) Repo.reload!(metadata)
end end
end end
test "can be JSON encoded without error" do
source = source_fixture()
assert {:ok, _} = Phoenix.json_library().encode(source)
end
end end
describe "output_path_template/1" do describe "output_path_template/1" do
@ -612,6 +618,12 @@ defmodule Pinchflat.SourcesTest do
end end
describe "delete_source/2 when deleting files" do 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 test "deletes source and media_items" do
source = source_fixture() source = source_fixture()
media_item = media_item_with_attachments(%{source_id: source.id}) media_item = media_item_with_attachments(%{source_id: source.id})

View file

@ -1,5 +1,5 @@
defmodule Pinchflat.Utils.ChangesetUtilsTest do defmodule Pinchflat.Utils.ChangesetUtilsTest do
use ExUnit.Case, async: true use ExUnit.Case, async: false
defmodule MockSchema do defmodule MockSchema do
use Ecto.Schema use Ecto.Schema

View file

@ -1,5 +1,5 @@
defmodule Pinchflat.Utils.CliUtilsTest do defmodule Pinchflat.Utils.CliUtilsTest do
use ExUnit.Case, async: true use ExUnit.Case, async: false
alias Pinchflat.Utils.CliUtils alias Pinchflat.Utils.CliUtils

View file

@ -1,5 +1,5 @@
defmodule Pinchflat.Utils.DatetimeUtilsTest do defmodule Pinchflat.Utils.DatetimeUtilsTest do
use ExUnit.Case, async: true use ExUnit.Case, async: false
alias Pinchflat.Utils.DatetimeUtils alias Pinchflat.Utils.DatetimeUtils

View file

@ -5,6 +5,38 @@ defmodule Pinchflat.Utils.FilesystemUtilsTest do
alias Pinchflat.Utils.FilesystemUtils 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 describe "generate_metadata_tmpfile/1" do
test "creates a tmpfile and returns its path" do test "creates a tmpfile and returns its path" do
res = FilesystemUtils.generate_metadata_tmpfile(:json) res = FilesystemUtils.generate_metadata_tmpfile(:json)

View file

@ -1,5 +1,5 @@
defmodule Pinchflat.Utils.FunctionUtilsTest do defmodule Pinchflat.Utils.FunctionUtilsTest do
use ExUnit.Case, async: true use ExUnit.Case, async: false
alias Pinchflat.Utils.FunctionUtils alias Pinchflat.Utils.FunctionUtils

View file

@ -1,5 +1,5 @@
defmodule Pinchflat.Utils.NumberUtilsTest do defmodule Pinchflat.Utils.NumberUtilsTest do
use ExUnit.Case, async: true use ExUnit.Case, async: false
alias Pinchflat.Utils.NumberUtils alias Pinchflat.Utils.NumberUtils

View file

@ -1,5 +1,5 @@
defmodule Pinchflat.Utils.StringUtilsTest do defmodule Pinchflat.Utils.StringUtilsTest do
use ExUnit.Case, async: true use ExUnit.Case, async: false
alias Pinchflat.Utils.StringUtils alias Pinchflat.Utils.StringUtils

View file

@ -1,5 +1,5 @@
defmodule Pinchflat.Utils.XmlUtilsTest do defmodule Pinchflat.Utils.XmlUtilsTest do
use ExUnit.Case, async: true use ExUnit.Case, async: false
alias Pinchflat.Utils.XmlUtils alias Pinchflat.Utils.XmlUtils

View file

@ -1,5 +1,5 @@
defmodule Pinchflat.YtDlp.CommandRunnerTest do defmodule Pinchflat.YtDlp.CommandRunnerTest do
use ExUnit.Case, async: true use ExUnit.Case, async: false
alias Pinchflat.Utils.FilesystemUtils alias Pinchflat.Utils.FilesystemUtils
@ -75,6 +75,9 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
refute String.contains?(output, "--cookies") refute String.contains?(output, "--cookies")
refute String.contains?(output, cookie_file) refute String.contains?(output, cookie_file)
# Cleanup
FilesystemUtils.write_p!(cookie_file, "")
end end
end end

View file

@ -1,5 +1,5 @@
defmodule PinchflatWeb.ErrorHTMLTest do defmodule PinchflatWeb.ErrorHTMLTest do
use PinchflatWeb.ConnCase, async: true use PinchflatWeb.ConnCase, async: false
# Bring render_to_string/4 for testing custom views # Bring render_to_string/4 for testing custom views
import Phoenix.Template import Phoenix.Template

View file

@ -1,5 +1,5 @@
defmodule PinchflatWeb.ErrorJSONTest do defmodule PinchflatWeb.ErrorJSONTest do
use PinchflatWeb.ConnCase, async: true use PinchflatWeb.ConnCase, async: false
test "renders 404" do test "renders 404" do
assert PinchflatWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} assert PinchflatWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}

View file

@ -1,11 +1,14 @@
defmodule PinchflatWeb.MediaItemControllerTest do defmodule PinchflatWeb.MediaItemControllerTest do
use PinchflatWeb.ConnCase use PinchflatWeb.ConnCase
import Mox
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
setup :verify_on_exit!
describe "show media" do describe "show media" do
setup [:create_media_item] setup [:create_media_item]
@ -49,6 +52,7 @@ defmodule PinchflatWeb.MediaItemControllerTest do
describe "delete media" do describe "delete media" do
setup do setup do
media_item = media_item_with_attachments() media_item = media_item_with_attachments()
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
%{media_item: media_item} %{media_item: media_item}
end end

View file

@ -1,6 +1,7 @@
defmodule PinchflatWeb.MediaProfileControllerTest do defmodule PinchflatWeb.MediaProfileControllerTest do
use PinchflatWeb.ConnCase use PinchflatWeb.ConnCase
import Mox
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures import Pinchflat.ProfilesFixtures
@ -15,6 +16,8 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
} }
@invalid_attrs %{name: nil, output_path_template: nil} @invalid_attrs %{name: nil, output_path_template: nil}
setup :verify_on_exit!
setup do setup do
Settings.set(onboarding: false) Settings.set(onboarding: false)
@ -136,6 +139,12 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
describe "delete media_profile when deleting the records and files" do describe "delete media_profile when deleting the records and files" do
setup [:create_media_profile] 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 test "deletes chosen media_profile and its associations", %{conn: conn, media_profile: media_profile} do
source = source_fixture(media_profile_id: media_profile.id) source = source_fixture(media_profile_id: media_profile.id)
media_item = media_item_with_attachments(%{source_id: source.id}) media_item = media_item_with_attachments(%{source_id: source.id})

View file

@ -145,6 +145,12 @@ defmodule PinchflatWeb.SourceControllerTest do
describe "delete source when deleting the records and files" do describe "delete source when deleting the records and files" do
setup [:create_source] 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 test "deletes chosen source and media_items", %{conn: conn, source: source, media_item: media_item} do
delete(conn, ~p"/sources/#{source}?delete_files=true") delete(conn, ~p"/sources/#{source}?delete_files=true")

View file

@ -1,12 +1,15 @@
Mox.defmock(YtDlpRunnerMock, for: Pinchflat.YtDlp.YtDlpCommandRunner) Mox.defmock(YtDlpRunnerMock, for: Pinchflat.YtDlp.YtDlpCommandRunner)
Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock) 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) Application.put_env(:pinchflat, :apprise_runner, AppriseRunnerMock)
Mox.defmock(HTTPClientMock, for: Pinchflat.HTTP.HTTPBehaviour) Mox.defmock(HTTPClientMock, for: Pinchflat.HTTP.HTTPBehaviour)
Application.put_env(:pinchflat, :http_client, HTTPClientMock) 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() ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual)
Faker.start() Faker.start()