Skip to content

Commit c5fcb8f

Browse files
authored
Delete media items (#20)
* Added method for deleting media files and their content * Adds controllers and methods for deleting media and files * Improved tmpfile setup and teardown for tests * Actually got tmpfile cleanup running once per suite run * Finally fixed flash messages
1 parent 58771ee commit c5fcb8f

File tree

17 files changed

+305
-33
lines changed

17 files changed

+305
-33
lines changed

config/test.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import Config
33
config :pinchflat,
44
# Specifying backend data here makes mocking and local testing SUPER easy
55
yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
6-
media_directory: Path.join([System.tmp_dir!(), "videos"]),
7-
metadata_directory: Path.join([System.tmp_dir!(), "metadata"])
6+
media_directory: Path.join([System.tmp_dir!(), "test", "videos"]),
7+
metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"])
88

99
config :pinchflat, Oban, testing: :manual
1010

lib/pinchflat/media.ex

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,22 @@ defmodule Pinchflat.Media do
8888
"""
8989
def get_media_item!(id), do: Repo.get!(MediaItem, id)
9090

91+
@doc """
92+
Produces a flat list of the filesystem paths for a media_item's downloaded files
93+
94+
Returns [binary()]
95+
"""
96+
def media_filepaths(media_item) do
97+
mapped_struct = Map.from_struct(media_item)
98+
99+
MediaItem.filepath_attributes()
100+
|> Enum.map(fn
101+
:subtitle_filepaths = field -> Enum.map(mapped_struct[field], fn [_, filepath] -> filepath end)
102+
field -> List.wrap(mapped_struct[field])
103+
end)
104+
|> List.flatten()
105+
end
106+
91107
@doc """
92108
Creates a media_item. Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}.
93109
"""
@@ -107,7 +123,7 @@ defmodule Pinchflat.Media do
107123
end
108124

109125
@doc """
110-
Deletes a media_item and its associated tasks.
126+
Deletes a media_item and its associated tasks. Will leave files on disk.
111127
112128
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}.
113129
"""
@@ -116,6 +132,35 @@ defmodule Pinchflat.Media do
116132
Repo.delete(media_item)
117133
end
118134

135+
@doc """
136+
Deletes the media_item's associated files. Will leave the media_item in the database.
137+
138+
Returns {:ok, %MediaItem{}}
139+
"""
140+
def delete_attachments(media_item) do
141+
media_item
142+
|> media_filepaths()
143+
|> Enum.each(&File.rm/1)
144+
145+
# Fails if the directory is not empty
146+
case File.rmdir(Path.dirname(media_item.media_filepath)) do
147+
:ok -> {:ok, media_item}
148+
{:error, :eexist} -> {:ok, media_item}
149+
end
150+
end
151+
152+
@doc """
153+
Deletes the media_item and all associated files. Attempts to delete the root directory
154+
but only if it is empty.
155+
156+
Returns {:ok, %MediaItem{}}
157+
"""
158+
def delete_media_item_and_attachments(media_item) do
159+
{:ok, _} = delete_attachments(media_item)
160+
161+
delete_media_item(media_item)
162+
end
163+
119164
@doc """
120165
Returns an `%Ecto.Changeset{}` for tracking media_item changes.
121166
"""

lib/pinchflat/media/media_item.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,9 @@ defmodule Pinchflat.Media.MediaItem do
6060
|> validate_required(@required_fields)
6161
|> unique_constraint([:media_id, :source_id])
6262
end
63+
64+
@doc false
65+
def filepath_attributes do
66+
~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths)a
67+
end
6368
end

lib/pinchflat/media_source.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ defmodule Pinchflat.MediaSource do
5656

5757
@doc """
5858
Deletes a source and it's associated tasks (of any state).
59+
NOTE: will fail if the source has associated media items. Intended
60+
for now, will almost certainly change in the future.
5961
6062
Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
6163
"""

lib/pinchflat_web/components/core_components.ex

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -112,24 +112,29 @@ defmodule PinchflatWeb.CoreComponents do
112112
<div
113113
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
114114
id={@id}
115-
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
115+
class="pb-8"
116116
role="alert"
117-
class={[
118-
"fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
119-
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
120-
@kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
121-
]}
122117
{@rest}
123118
>
124-
<p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
125-
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
126-
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
127-
<%= @title %>
128-
</p>
129-
<p class="mt-2 text-sm leading-5"><%= msg %></p>
130-
<button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}>
131-
<.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
132-
</button>
119+
<div class={[
120+
"flex justify-between w-full border-l-6 bg-opacity-[50%] p-5 shadow-md dark:bg-opacity-40 dark:text-white",
121+
@kind == :info && "border-[#34D399] bg-[#34D399]",
122+
@kind == :error && "border-[#F87171] bg-[#F87171]"
123+
]}>
124+
<main>
125+
<h5 :if={@title} class="mb-2 text-lg font-bold">
126+
<%= @title %>
127+
</h5>
128+
<p class="mt-2 text-md leading-5 opacity-80"><%= msg %></p>
129+
</main>
130+
<button
131+
type="button"
132+
aria-label={gettext("close")}
133+
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
134+
>
135+
<.icon name="hero-x-mark-solid" class="h-7 w-7 opacity-70 hover:opacity-100" />
136+
</button>
137+
</div>
133138
</div>
134139
"""
135140
end
@@ -146,7 +151,7 @@ defmodule PinchflatWeb.CoreComponents do
146151

147152
def flash_group(assigns) do
148153
~H"""
149-
<div id={@id}>
154+
<div class="flex flex-col gap-7.5" id={@id}>
150155
<.flash kind={:info} title="Success!" flash={@flash} />
151156
<.flash kind={:error} title="Error!" flash={@flash} />
152157
<.flash
@@ -632,19 +637,23 @@ defmodule PinchflatWeb.CoreComponents do
632637
def show(js \\ %JS{}, selector) do
633638
JS.show(js,
634639
to: selector,
635-
transition:
636-
{"transition-all transform ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
637-
"opacity-100 translate-y-0 sm:scale-100"}
640+
transition: {
641+
"transition-all transform ease-out duration-300",
642+
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
643+
"opacity-100 translate-y-0 sm:scale-100"
644+
}
638645
)
639646
end
640647

641648
def hide(js \\ %JS{}, selector) do
642649
JS.hide(js,
643650
to: selector,
644651
time: 200,
645-
transition:
646-
{"transition-all transform ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
647-
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
652+
transition: {
653+
"transition-all transform ease-in duration-200",
654+
"opacity-100 translate-y-0 sm:scale-100",
655+
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
656+
}
648657
)
649658
end
650659

lib/pinchflat_web/controllers/media/media_item_controller.ex

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,23 @@ defmodule PinchflatWeb.Media.MediaItemController do
88

99
render(conn, :show, media_item: media_item)
1010
end
11+
12+
def delete(conn, %{"id" => id} = params) do
13+
delete_files = Map.get(params, "delete_files", false)
14+
media_item = Media.get_media_item!(id)
15+
16+
if delete_files do
17+
{:ok, _} = Media.delete_media_item_and_attachments(media_item)
18+
19+
conn
20+
|> put_flash(:info, "Record and files deleted successfully.")
21+
|> redirect(to: ~p"/sources/#{media_item.source_id}")
22+
else
23+
{:ok, _} = Media.delete_media_item(media_item)
24+
25+
conn
26+
|> put_flash(:info, "Record deleted successfully. Files were not deleted.")
27+
|> redirect(to: ~p"/sources/#{media_item.source_id}")
28+
end
29+
end
1130
end

lib/pinchflat_web/controllers/media/media_item_html/show.html.heex

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
22
<div class="flex gap-3 items-center">
3-
<.link :if={@conn.params["source_id"]} navigate={~p"/sources/#{@media_item.source_id}"}>
3+
<.link navigate={~p"/sources/#{@media_item.source_id}"}>
44
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
55
</.link>
66
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
77
Media Item #<%= @media_item.id %>
88
</h2>
99
</div>
10+
<nav>
11+
<.link
12+
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
13+
method="delete"
14+
data-confirm="Are you sure?"
15+
>
16+
<.button color="bg-meta-1" rounding="rounded-full">
17+
Delete Record and Files
18+
</.button>
19+
</.link>
20+
</nav>
1021
</div>
1122
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
1223
<div class="max-w-full overflow-x-auto">

lib/pinchflat_web/controllers/searches/search_html/show.html.heex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
<.highlight_search_terms text={result.matching_search_term} />
1717
</:col>
1818
<:col :let={result} label="" class="flex place-content-evenly">
19-
<.link navigate={~p"/media/#{result.id}"} class="hover:text-secondary duration-200 ease-in-out mx-0.5">
19+
<.link
20+
navigate={~p"/sources/#{result.source_id}/media/#{result.id}"}
21+
class="hover:text-secondary duration-200 ease-in-out mx-0.5"
22+
>
2023
<.icon name="hero-eye" />
2124
</.link>
2225
</:col>

lib/pinchflat_web/router.ex

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,10 @@ defmodule PinchflatWeb.Router do
2020
get "/", PageController, :home
2121

2222
resources "/media_profiles", MediaProfiles.MediaProfileController
23-
resources "/media", Media.MediaItemController, only: [:show]
2423
resources "/search", Searches.SearchController, only: [:show], singleton: true
2524

2625
resources "/sources", MediaSources.SourceController do
27-
resources "/media", Media.MediaItemController, only: [:show]
26+
resources "/media", Media.MediaItemController, only: [:show, :delete]
2827
end
2928
end
3029

test/pinchflat/media_test.exs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,24 @@ defmodule Pinchflat.MediaTest do
221221
end
222222
end
223223

224+
describe "media_filepaths/1" do
225+
test "returns filepaths in a flat list" do
226+
filepaths = %{
227+
media_filepath: "/video/test.mp4",
228+
thumbnail_filepath: "/video/test.jpg",
229+
subtitle_filepaths: [["en", "video/test.srt"]]
230+
}
231+
232+
media_item = media_item_fixture(filepaths)
233+
234+
assert Media.media_filepaths(media_item) == [
235+
"/video/test.mp4",
236+
"/video/test.jpg",
237+
"video/test.srt"
238+
]
239+
end
240+
end
241+
224242
describe "create_media_item/1" do
225243
test "creating with valid data creates a media_item" do
226244
valid_attrs = %{
@@ -282,6 +300,61 @@ defmodule Pinchflat.MediaTest do
282300
end
283301
end
284302

303+
describe "delete_attachments/1" do
304+
test "deletes the media item's files" do
305+
media_item = media_item_with_attachments()
306+
307+
assert {:ok, _} = Media.delete_attachments(media_item)
308+
refute File.exists?(media_item.media_filepath)
309+
end
310+
311+
test "does not delete the media item" do
312+
media_item = media_item_with_attachments()
313+
314+
assert {:ok, _} = Media.delete_attachments(media_item)
315+
316+
assert Repo.reload!(media_item)
317+
end
318+
319+
test "deletes the parent folder if it is empty" do
320+
media_item = media_item_with_attachments()
321+
root_directory = Path.dirname(media_item.media_filepath)
322+
323+
assert {:ok, _} = Media.delete_attachments(media_item)
324+
refute File.exists?(root_directory)
325+
end
326+
327+
test "does not delete the parent folder if it is not empty" do
328+
media_item = media_item_with_attachments()
329+
root_directory = Path.dirname(media_item.media_filepath)
330+
File.touch(Path.join([root_directory, "test.txt"]))
331+
332+
assert {:ok, _} = Media.delete_attachments(media_item)
333+
assert File.exists?(root_directory)
334+
335+
:ok = File.rm(Path.join([root_directory, "test.txt"]))
336+
:ok = File.rmdir(root_directory)
337+
end
338+
end
339+
340+
describe "delete_media_item_and_attachments/1" do
341+
setup do
342+
media_item = media_item_with_attachments()
343+
{:ok, media_item: media_item}
344+
end
345+
346+
test "deletes the media item", %{media_item: media_item} do
347+
assert {:ok, _} = Media.delete_media_item_and_attachments(media_item)
348+
assert_raise Ecto.NoResultsError, fn -> Media.get_media_item!(media_item.id) end
349+
end
350+
351+
test "deletes associated files", %{media_item: media_item} do
352+
assert File.exists?(media_item.media_filepath)
353+
assert {:ok, _} = Media.delete_media_item_and_attachments(media_item)
354+
refute File.exists?(media_item.media_filepath)
355+
end
356+
end
357+
285358
describe "change_media_item/1" do
286359
test "change_media_item/1 returns a media_item changeset" do
287360
media_item = media_item_fixture()

0 commit comments

Comments
 (0)