[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))
- 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
defmodule Pinchflat.Notifications.SourceNotifications do
defmodule Pinchflat.Lifecycle.Notifications.SourceNotifications do
@moduledoc """
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.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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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