diff --git a/config/test.exs b/config/test.exs index b2d8614..94e1919 100644 --- a/config/test.exs +++ b/config/test.exs @@ -3,8 +3,8 @@ import Config config :pinchflat, # Specifying backend data here makes mocking and local testing SUPER easy yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]), - media_directory: Path.join([System.tmp_dir!(), "videos"]), - metadata_directory: Path.join([System.tmp_dir!(), "metadata"]) + media_directory: Path.join([System.tmp_dir!(), "test", "videos"]), + metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]) config :pinchflat, Oban, testing: :manual diff --git a/lib/pinchflat/media.ex b/lib/pinchflat/media.ex index 6bd21ea..14ae494 100644 --- a/lib/pinchflat/media.ex +++ b/lib/pinchflat/media.ex @@ -88,6 +88,22 @@ defmodule Pinchflat.Media do """ def get_media_item!(id), do: Repo.get!(MediaItem, id) + @doc """ + Produces a flat list of the filesystem paths for a media_item's downloaded files + + Returns [binary()] + """ + def media_filepaths(media_item) do + mapped_struct = Map.from_struct(media_item) + + MediaItem.filepath_attributes() + |> Enum.map(fn + :subtitle_filepaths = field -> Enum.map(mapped_struct[field], fn [_, filepath] -> filepath end) + field -> List.wrap(mapped_struct[field]) + end) + |> List.flatten() + end + @doc """ Creates a media_item. Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}. """ @@ -107,7 +123,7 @@ defmodule Pinchflat.Media do end @doc """ - Deletes a media_item and its associated tasks. + Deletes a media_item and its associated tasks. Will leave files on disk. Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}. """ @@ -116,6 +132,35 @@ defmodule Pinchflat.Media do Repo.delete(media_item) end + @doc """ + Deletes the media_item's associated files. Will leave the media_item in the database. + + Returns {:ok, %MediaItem{}} + """ + def delete_attachments(media_item) do + media_item + |> media_filepaths() + |> Enum.each(&File.rm/1) + + # Fails if the directory is not empty + case File.rmdir(Path.dirname(media_item.media_filepath)) do + :ok -> {:ok, media_item} + {:error, :eexist} -> {:ok, media_item} + end + end + + @doc """ + Deletes the media_item and all associated files. Attempts to delete the root directory + but only if it is empty. + + Returns {:ok, %MediaItem{}} + """ + def delete_media_item_and_attachments(media_item) do + {:ok, _} = delete_attachments(media_item) + + delete_media_item(media_item) + end + @doc """ Returns an `%Ecto.Changeset{}` for tracking media_item changes. """ diff --git a/lib/pinchflat/media/media_item.ex b/lib/pinchflat/media/media_item.ex index 838cc14..b43a1a2 100644 --- a/lib/pinchflat/media/media_item.ex +++ b/lib/pinchflat/media/media_item.ex @@ -60,4 +60,9 @@ defmodule Pinchflat.Media.MediaItem do |> validate_required(@required_fields) |> unique_constraint([:media_id, :source_id]) end + + @doc false + def filepath_attributes do + ~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths)a + end end diff --git a/lib/pinchflat/media_source.ex b/lib/pinchflat/media_source.ex index e7c8abb..46fab79 100644 --- a/lib/pinchflat/media_source.ex +++ b/lib/pinchflat/media_source.ex @@ -56,6 +56,8 @@ defmodule Pinchflat.MediaSource do @doc """ Deletes a source and it's associated tasks (of any state). + NOTE: will fail if the source has associated media items. Intended + for now, will almost certainly change in the future. Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}} """ diff --git a/lib/pinchflat_web/components/core_components.ex b/lib/pinchflat_web/components/core_components.ex index 5c7c073..bcf8e02 100644 --- a/lib/pinchflat_web/components/core_components.ex +++ b/lib/pinchflat_web/components/core_components.ex @@ -112,24 +112,29 @@ defmodule PinchflatWeb.CoreComponents do
hide("##{@id}")} + class="pb-8" role="alert" - class={[ - "fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1", - @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900", - @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900" - ]} {@rest} > -

- <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" /> - <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" /> - <%= @title %> -

-

<%= msg %>

- +
+
+
+ <%= @title %> +
+

<%= msg %>

+
+ +
""" end @@ -146,7 +151,7 @@ defmodule PinchflatWeb.CoreComponents do def flash_group(assigns) do ~H""" -
+
<.flash kind={:info} title="Success!" flash={@flash} /> <.flash kind={:error} title="Error!" flash={@flash} /> <.flash @@ -632,9 +637,11 @@ defmodule PinchflatWeb.CoreComponents do def show(js \\ %JS{}, selector) do JS.show(js, to: selector, - transition: - {"transition-all transform ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", - "opacity-100 translate-y-0 sm:scale-100"} + transition: { + "transition-all transform ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100" + } ) end @@ -642,9 +649,11 @@ defmodule PinchflatWeb.CoreComponents do JS.hide(js, to: selector, time: 200, - transition: - {"transition-all transform ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", - "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + transition: { + "transition-all transform ease-in duration-200", + "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" + } ) end diff --git a/lib/pinchflat_web/controllers/media/media_item_controller.ex b/lib/pinchflat_web/controllers/media/media_item_controller.ex index 3db81d4..398bd32 100644 --- a/lib/pinchflat_web/controllers/media/media_item_controller.ex +++ b/lib/pinchflat_web/controllers/media/media_item_controller.ex @@ -8,4 +8,23 @@ defmodule PinchflatWeb.Media.MediaItemController do render(conn, :show, media_item: media_item) end + + def delete(conn, %{"id" => id} = params) do + delete_files = Map.get(params, "delete_files", false) + media_item = Media.get_media_item!(id) + + if delete_files do + {:ok, _} = Media.delete_media_item_and_attachments(media_item) + + conn + |> put_flash(:info, "Record and files deleted successfully.") + |> redirect(to: ~p"/sources/#{media_item.source_id}") + else + {:ok, _} = Media.delete_media_item(media_item) + + conn + |> put_flash(:info, "Record deleted successfully. Files were not deleted.") + |> redirect(to: ~p"/sources/#{media_item.source_id}") + end + end end diff --git a/lib/pinchflat_web/controllers/media/media_item_html/show.html.heex b/lib/pinchflat_web/controllers/media/media_item_html/show.html.heex index ac65a28..f23de2c 100644 --- a/lib/pinchflat_web/controllers/media/media_item_html/show.html.heex +++ b/lib/pinchflat_web/controllers/media/media_item_html/show.html.heex @@ -1,12 +1,23 @@
- <.link :if={@conn.params["source_id"]} navigate={~p"/sources/#{@media_item.source_id}"}> + <.link navigate={~p"/sources/#{@media_item.source_id}"}> <.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />

Media Item #<%= @media_item.id %>

+
diff --git a/lib/pinchflat_web/controllers/searches/search_html/show.html.heex b/lib/pinchflat_web/controllers/searches/search_html/show.html.heex index 996701e..4d2bfd5 100644 --- a/lib/pinchflat_web/controllers/searches/search_html/show.html.heex +++ b/lib/pinchflat_web/controllers/searches/search_html/show.html.heex @@ -16,7 +16,10 @@ <.highlight_search_terms text={result.matching_search_term} /> <:col :let={result} label="" class="flex place-content-evenly"> - <.link navigate={~p"/media/#{result.id}"} class="hover:text-secondary duration-200 ease-in-out mx-0.5"> + <.link + navigate={~p"/sources/#{result.source_id}/media/#{result.id}"} + class="hover:text-secondary duration-200 ease-in-out mx-0.5" + > <.icon name="hero-eye" /> diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index 55c9fcb..0470029 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -20,11 +20,10 @@ defmodule PinchflatWeb.Router do get "/", PageController, :home resources "/media_profiles", MediaProfiles.MediaProfileController - resources "/media", Media.MediaItemController, only: [:show] resources "/search", Searches.SearchController, only: [:show], singleton: true resources "/sources", MediaSources.SourceController do - resources "/media", Media.MediaItemController, only: [:show] + resources "/media", Media.MediaItemController, only: [:show, :delete] end end diff --git a/test/pinchflat/media_test.exs b/test/pinchflat/media_test.exs index 8fbe8d7..77d267d 100644 --- a/test/pinchflat/media_test.exs +++ b/test/pinchflat/media_test.exs @@ -221,6 +221,24 @@ defmodule Pinchflat.MediaTest do end end + describe "media_filepaths/1" do + test "returns filepaths in a flat list" do + filepaths = %{ + media_filepath: "/video/test.mp4", + thumbnail_filepath: "/video/test.jpg", + subtitle_filepaths: [["en", "video/test.srt"]] + } + + media_item = media_item_fixture(filepaths) + + assert Media.media_filepaths(media_item) == [ + "/video/test.mp4", + "/video/test.jpg", + "video/test.srt" + ] + end + end + describe "create_media_item/1" do test "creating with valid data creates a media_item" do valid_attrs = %{ @@ -282,6 +300,61 @@ defmodule Pinchflat.MediaTest do end end + describe "delete_attachments/1" do + test "deletes the media item's files" do + media_item = media_item_with_attachments() + + assert {:ok, _} = Media.delete_attachments(media_item) + refute File.exists?(media_item.media_filepath) + end + + test "does not delete the media item" do + media_item = media_item_with_attachments() + + assert {:ok, _} = Media.delete_attachments(media_item) + + assert Repo.reload!(media_item) + end + + test "deletes the parent folder if it is empty" do + media_item = media_item_with_attachments() + root_directory = Path.dirname(media_item.media_filepath) + + assert {:ok, _} = Media.delete_attachments(media_item) + refute File.exists?(root_directory) + end + + test "does not delete the parent folder if it is not empty" do + media_item = media_item_with_attachments() + root_directory = Path.dirname(media_item.media_filepath) + File.touch(Path.join([root_directory, "test.txt"])) + + assert {:ok, _} = Media.delete_attachments(media_item) + assert File.exists?(root_directory) + + :ok = File.rm(Path.join([root_directory, "test.txt"])) + :ok = File.rmdir(root_directory) + end + end + + describe "delete_media_item_and_attachments/1" do + setup do + media_item = media_item_with_attachments() + {:ok, media_item: media_item} + end + + test "deletes the media item", %{media_item: media_item} do + assert {:ok, _} = Media.delete_media_item_and_attachments(media_item) + assert_raise Ecto.NoResultsError, fn -> Media.get_media_item!(media_item.id) end + end + + test "deletes associated files", %{media_item: media_item} do + assert File.exists?(media_item.media_filepath) + assert {:ok, _} = Media.delete_media_item_and_attachments(media_item) + refute File.exists?(media_item.media_filepath) + end + end + describe "change_media_item/1" do test "change_media_item/1 returns a media_item changeset" do media_item = media_item_fixture() diff --git a/test/pinchflat/profiles/options/yt_dlp/download_option_builder_test.exs b/test/pinchflat/profiles/options/yt_dlp/download_option_builder_test.exs index 32d3ffa..56942ce 100644 --- a/test/pinchflat/profiles/options/yt_dlp/download_option_builder_test.exs +++ b/test/pinchflat/profiles/options/yt_dlp/download_option_builder_test.exs @@ -12,7 +12,7 @@ defmodule Pinchflat.Profiles.Options.YtDlp.DownloadOptionBuilderTest do test "it generates an expanded output path based on the given template" do assert {:ok, res} = DownloadOptionBuilder.build(@media_profile) - assert {:output, "/tmp/videos/%(title)S.%(ext)s"} in res + assert {:output, "/tmp/test/videos/%(title)S.%(ext)s"} in res end end diff --git a/test/pinchflat_web/controllers/media_item_controller_test.exs b/test/pinchflat_web/controllers/media_item_controller_test.exs index eb0eab2..002f3bd 100644 --- a/test/pinchflat_web/controllers/media_item_controller_test.exs +++ b/test/pinchflat_web/controllers/media_item_controller_test.exs @@ -3,15 +3,74 @@ defmodule PinchflatWeb.MediaItemControllerTest do import Pinchflat.MediaFixtures + alias Pinchflat.Repo + alias Pinchflat.Media + describe "show media" do setup [:create_media_item] test "renders the page", %{conn: conn, media_item: media_item} do - conn = get(conn, ~p"/media/#{media_item}") + conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}") assert html_response(conn, 200) =~ "Media Item ##{media_item.id}" end end + describe "delete media when just deleting the records" do + setup do + media_item = media_item_with_attachments() + + on_exit(fn -> + Media.delete_attachments(media_item) + end) + + %{media_item: media_item} + end + + test "the media item is deleted", %{conn: conn, media_item: media_item} do + delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}") + + assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end + end + + test "the files are not deleted", %{conn: conn, media_item: media_item} do + delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}") + + assert File.exists?(media_item.media_filepath) + end + + test "redirects to the source page", %{conn: conn, media_item: media_item} do + conn = delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}") + + assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}" + end + end + + describe "delete media when deleting the records and files" do + setup do + media_item = media_item_with_attachments() + + %{media_item: media_item} + end + + test "the media item is deleted", %{conn: conn, media_item: media_item} do + delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true") + + assert_raise Ecto.NoResultsError, fn -> Repo.reload!(media_item) end + end + + test "the files are deleted", %{conn: conn, media_item: media_item} do + delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true") + + refute File.exists?(media_item.media_filepath) + end + + test "redirects to the source page", %{conn: conn, media_item: media_item} do + conn = delete(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item.id}?delete_files=true") + + assert redirected_to(conn) == ~p"/sources/#{media_item.source_id}" + end + end + defp create_media_item(_) do media_item = media_item_fixture() %{media_item: media_item} diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index ff478a0..3aa5fce 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -34,6 +34,7 @@ defmodule PinchflatWeb.ConnCase do setup tags do Pinchflat.DataCase.setup_sandbox(tags) + Pinchflat.DataCase.setup_temp_filepaths() {:ok, conn: Phoenix.ConnTest.build_conn()} end end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index d217749..5bf7f10 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -32,6 +32,20 @@ defmodule Pinchflat.DataCase do setup tags do Pinchflat.DataCase.setup_sandbox(tags) + Pinchflat.DataCase.setup_temp_filepaths() + :ok + end + + @doc """ + Sets up the temp filepaths for the media and metadata directories. + """ + def setup_temp_filepaths do + File.rm_rf!(Application.get_env(:pinchflat, :media_directory)) + File.rm_rf!(Application.get_env(:pinchflat, :metadata_directory)) + + File.mkdir_p!(Application.get_env(:pinchflat, :media_directory)) + File.mkdir_p!(Application.get_env(:pinchflat, :metadata_directory)) + :ok end diff --git a/test/support/files/media.mkv b/test/support/files/media.mkv new file mode 100644 index 0000000..b0b02c1 Binary files /dev/null and b/test/support/files/media.mkv differ diff --git a/test/support/fixtures/media_fixtures.ex b/test/support/fixtures/media_fixtures.ex index 66bb1da..f998824 100644 --- a/test/support/fixtures/media_fixtures.ex +++ b/test/support/fixtures/media_fixtures.ex @@ -31,9 +31,9 @@ defmodule Pinchflat.MediaFixtures do def media_item_with_metadata(attrs \\ %{}) do json_filepath = Path.join([ - Path.dirname(__ENV__.file), + File.cwd!(), + "test", "support", - "fixtures", "files", "media_metadata.json" ]) @@ -44,4 +44,28 @@ defmodule Pinchflat.MediaFixtures do media_item_fixture(merged_attrs) end + + def media_item_with_attachments(attrs \\ %{}) do + stored_media_filepath = + Path.join([ + Application.get_env(:pinchflat, :media_directory), + "#{:rand.uniform(1_000_000)}", + "#{:rand.uniform(1_000_000)}_media.mkv" + ]) + + fixture_media_filepath = + Path.join([ + File.cwd!(), + "test", + "support", + "files", + "media.mkv" + ]) + + :ok = File.mkdir_p(Path.dirname(stored_media_filepath)) + :ok = File.cp(fixture_media_filepath, stored_media_filepath) + + merged_attrs = Map.merge(attrs, %{media_filepath: stored_media_filepath}) + media_item_fixture(merged_attrs) + end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 40d1202..b0bcf23 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -4,3 +4,11 @@ Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock) ExUnit.start() Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual) Faker.start() + +ExUnit.after_suite(fn _ -> + File.rm_rf!(Application.get_env(:pinchflat, :media_directory)) + File.rm_rf!(Application.get_env(:pinchflat, :metadata_directory)) + + File.mkdir_p!(Application.get_env(:pinchflat, :media_directory)) + File.mkdir_p!(Application.get_env(:pinchflat, :metadata_directory)) +end)