UX improvements v1 (#39)

* Removed collection_type user input instead inferring from yt-dlp response

* Updated docs

* Added delete buttons for source; Refactored the way deletion methods work

* Update source to always run an initial index

* Added deletion to the last models

* Improved clarity around deletion operation

* Improved task fetching and deletion methods

* More tests
This commit is contained in:
Kieran
2024-02-28 09:54:01 -08:00
committed by GitHub
parent 3bb7edba0b
commit 01b8856f22
22 changed files with 621 additions and 236 deletions
+29 -31
View File
@@ -12,12 +12,25 @@ defmodule Pinchflat.Media do
alias Pinchflat.Media.MediaMetadata
@doc """
Returns the list of media_items. Returns [%MediaItem{}, ...].
Returns the list of media_items.
Returns [%MediaItem{}, ...].
"""
def list_media_items do
Repo.all(MediaItem)
end
@doc """
Returns a list of media_items for a given source.
Returns [%MediaItem{}, ...].
"""
def list_media_items_for(%Source{} = source) do
MediaItem
|> where([mi], mi.source_id == ^source.id)
|> Repo.all()
end
@doc """
Returns a list of pending media_items for a given source, where
pending means the `media_filepath` is `nil` AND the media_item
@@ -138,26 +151,30 @@ defmodule Pinchflat.Media do
end
@doc """
Deletes a media_item and its associated tasks. Will leave files on disk.
Deletes a media_item and its associated tasks.
Can optionally delete the media_item's files.
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}.
"""
def delete_media_item(%MediaItem{} = media_item) do
def delete_media_item(%MediaItem{} = media_item, opts \\ []) do
delete_files = Keyword.get(opts, :delete_files, false)
if delete_files do
{:ok, _} = delete_all_attachments(media_item)
end
Tasks.delete_tasks_for(media_item)
Repo.delete(media_item)
end
@doc """
Deletes the media_item's associated files. Will leave the media_item in the database.
NOTE: this deletes the metadata files as well, but maybe it shouldn't? I'm wondering if
the metadata is more a concern of the DB record itself and should be lumped in with those
delete operations. But the metadata does come from the download operation of the file.
Food for thought but not a priority at the moment.
Returns {:ok, %MediaItem{}}
Returns an `%Ecto.Changeset{}` for tracking media_item changes.
"""
def delete_attachments(media_item) do
def change_media_item(%MediaItem{} = media_item, attrs \\ %{}) do
MediaItem.changeset(media_item, attrs)
end
defp delete_all_attachments(media_item) do
media_item = Repo.preload(media_item, :metadata)
media_item
@@ -177,25 +194,6 @@ defmodule Pinchflat.Media do
{:ok, media_item}
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.
"""
def change_media_item(%MediaItem{} = media_item, attrs \\ %{}) do
MediaItem.changeset(media_item, attrs)
end
defp build_format_clauses(media_profile) do
mapped_struct = Map.from_struct(media_profile)
+1
View File
@@ -27,6 +27,7 @@ defmodule Pinchflat.Sources.Source do
collection_id
collection_type
friendly_name
index_frequency_minutes
download_media
original_url
media_profile_id
+25 -7
View File
@@ -4,12 +4,15 @@ defmodule Pinchflat.Profiles do
"""
import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Repo
alias Pinchflat.Sources
alias Pinchflat.Profiles.MediaProfile
@doc """
Returns the list of media_profiles. Returns [%MediaProfile{}, ...]
Returns the list of media_profiles.
Returns [%MediaProfile{}, ...]
"""
def list_media_profiles do
Repo.all(MediaProfile)
@@ -23,7 +26,9 @@ defmodule Pinchflat.Profiles do
def get_media_profile!(id), do: Repo.get!(MediaProfile, id)
@doc """
Creates a media_profile. Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}}
Creates a media_profile.
Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}}
"""
def create_media_profile(attrs) do
%MediaProfile{}
@@ -32,7 +37,9 @@ defmodule Pinchflat.Profiles do
end
@doc """
Updates a media_profile. Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}}
Updates a media_profile.
Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}}
"""
def update_media_profile(%MediaProfile{} = media_profile, attrs) do
media_profile
@@ -41,14 +48,25 @@ defmodule Pinchflat.Profiles do
end
@doc """
Deletes a media_profile. Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}}
Deletes a media_profile, all its sources, and all their media items.
Can optionally delete the media files.
Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}}
"""
def delete_media_profile(%MediaProfile{} = media_profile) do
def delete_media_profile(%MediaProfile{} = media_profile, opts \\ []) do
delete_files = Keyword.get(opts, :delete_files, false)
media_profile
|> Sources.list_sources_for()
|> Enum.each(fn source ->
Sources.delete_source(source, delete_files: delete_files)
end)
Repo.delete(media_profile)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking media_profile changes.
Returns `%Ecto.Changeset{}`
"""
def change_media_profile(%MediaProfile{} = media_profile, attrs \\ %{}) do
MediaProfile.changeset(media_profile, attrs)
+41 -33
View File
@@ -6,9 +6,11 @@ defmodule Pinchflat.Sources do
import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Tasks
alias Pinchflat.Tasks.SourceTasks
alias Pinchflat.Sources.Source
alias Pinchflat.Tasks.SourceTasks
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.MediaClient.SourceDetails
@doc """
@@ -18,6 +20,15 @@ defmodule Pinchflat.Sources do
Repo.all(Source)
end
@doc """
Returns the list of sources for a media_profile.
Returns [%Source{}, ...]
"""
def list_sources_for(%MediaProfile{} = media_profile) do
Repo.all(from s in Source, where: s.media_profile_id == ^media_profile.id)
end
@doc """
Gets a single source.
@@ -55,13 +66,20 @@ defmodule Pinchflat.Sources do
end
@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.
Deletes a source, its media items, and its associated tasks (of any state).
Can optionally delete the source's media files.
Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
"""
def delete_source(%Source{} = source) do
def delete_source(%Source{} = source, opts \\ []) do
delete_files = Keyword.get(opts, :delete_files, false)
source
|> Media.list_media_items_for()
|> Enum.each(fn media_item ->
Media.delete_media_item(media_item, delete_files: delete_files)
end)
Tasks.delete_tasks_for(source)
Repo.delete(source)
end
@@ -84,10 +102,6 @@ defmodule Pinchflat.Sources do
NOTE: When operating in the ideal path, this effectively adds an API call
to the source creation/update process. Should be used only when needed.
IDEA: Maybe I could discern `collection_type` based on the original URL?
It also seems like it's a channel when the returned yt-dlp channel_id is the
same as the playlist_id - maybe could use that?
"""
def change_source_from_url(%Source{} = source, attrs) do
case change_source(source, attrs) do
@@ -118,24 +132,20 @@ defmodule Pinchflat.Sources do
defp add_source_details_by_collection_type(source, changeset, source_details) do
%Ecto.Changeset{changes: changes} = changeset
collection_type = Ecto.Changeset.get_field(changeset, :collection_type)
collection_changes =
case collection_type do
:channel ->
%{
collection_id: source_details.channel_id,
collection_name: source_details.channel_name
}
:playlist ->
%{
collection_id: source_details.playlist_id,
collection_name: source_details.playlist_name
}
_ ->
%{}
if source_details.playlist_id == source_details.channel_id do
%{
collection_type: :channel,
collection_id: source_details.channel_id,
collection_name: source_details.channel_name
}
else
%{
collection_type: :playlist,
collection_id: source_details.playlist_id,
collection_name: source_details.playlist_name
}
end
change_source(source, Map.merge(changes, collection_changes))
@@ -169,21 +179,19 @@ defmodule Pinchflat.Sources do
{:ok, source}
end
# IDEA: this uses a pattern where `kickoff_indexing_task` controls whether
# it should run based on the source, but `maybe_handle_media_tasks` handles that
# logic itself. Consider updating one or the other to be consistent (once I've
# decided which I like more)
defp maybe_run_indexing_task(changeset, source) do
case changeset.data do
# If the changeset is new (not persisted), attempt indexing no matter what
%{__meta__: %{state: :built}} ->
SourceTasks.kickoff_indexing_task(source)
# If the record has been persisted, only attempt indexing if the
# indexing frequency has been changed
# If the record has been persisted, only run indexing if the
# indexing frequency has been changed and is now greater than 0
%{__meta__: %{state: :loaded}} ->
if Map.has_key?(changeset.changes, :index_frequency_minutes) do
SourceTasks.kickoff_indexing_task(source)
case changeset.changes do
%{index_frequency_minutes: mins} when mins > 0 -> SourceTasks.kickoff_indexing_task(source)
%{index_frequency_minutes: _} -> Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker")
_ -> :ok
end
end
+28 -10
View File
@@ -19,30 +19,46 @@ defmodule Pinchflat.Tasks do
@doc """
Returns the list of tasks for a given record type and ID. Optionally allows you to specify
which job states to include.
which worker or job states to include.
Returns [%Task{}, ...]
"""
def list_tasks_for(attached_record_type, attached_record_id, job_states \\ Oban.Job.states()) do
def list_tasks_for(attached_record_type, attached_record_id, worker_name \\ nil, job_states \\ Oban.Job.states()) do
stringified_states = Enum.map(job_states, &to_string/1)
worker_name_finder =
if worker_name do
# Workers are the full module name - we want to match on the string ENDING with
# the passed worker name and it should be preceeded with a . so we aren't matching
# on a substring. You can pass in more fragments of the worker name if you need
# to disambiguate. eg: "TestWorker" or "FooBar.TestWorker"
worker_finder = "%.#{worker_name}"
dynamic([_t, j], fragment("? LIKE ?", j.worker, ^worker_finder))
else
true
end
Repo.all(
from t in Task,
join: j in assoc(t, :job),
where: field(t, ^attached_record_type) == ^attached_record_id,
where: ^worker_name_finder,
where: j.state in ^stringified_states
)
end
@doc """
Returns the list of pending tasks for a given record type and ID.
Returns the list of pending tasks for a given record type and ID. Optionally allows you to specify
which worker to include.
Returns [%Task{}, ...]
"""
def list_pending_tasks_for(attached_record_type, attached_record_id) do
def list_pending_tasks_for(attached_record_type, attached_record_id, worker_name \\ nil) do
list_tasks_for(
attached_record_type,
attached_record_id,
worker_name,
[:available, :scheduled, :retryable]
)
end
@@ -107,14 +123,15 @@ defmodule Pinchflat.Tasks do
@doc """
Deletes all tasks attached to a given record, cancelling any attached jobs.
Optionally allows you to specify which worker to include.
Returns :ok
"""
def delete_tasks_for(attached_record) do
def delete_tasks_for(attached_record, worker_name \\ nil) do
tasks =
case attached_record do
%Source{} = source -> list_tasks_for(:source_id, source.id)
%MediaItem{} = media_item -> list_tasks_for(:media_item_id, media_item.id)
%Source{} = source -> list_tasks_for(:source_id, source.id, worker_name)
%MediaItem{} = media_item -> list_tasks_for(:media_item_id, media_item.id, worker_name)
end
Enum.each(tasks, &delete_task/1)
@@ -122,14 +139,15 @@ defmodule Pinchflat.Tasks do
@doc """
Deletes all _pending_ tasks attached to a given record, cancelling any attached jobs.
Optionally allows you to specify which worker to include.
Returns :ok
"""
def delete_pending_tasks_for(attached_record) do
def delete_pending_tasks_for(attached_record, worker_name \\ nil) do
tasks =
case attached_record do
%Source{} = source -> list_pending_tasks_for(:source_id, source.id)
%MediaItem{} = media_item -> list_pending_tasks_for(:media_item_id, media_item.id)
%Source{} = source -> list_pending_tasks_for(:source_id, source.id, worker_name)
%MediaItem{} = media_item -> list_pending_tasks_for(:media_item_id, media_item.id, worker_name)
end
Enum.each(tasks, &delete_task/1)
+13 -16
View File
@@ -12,26 +12,23 @@ defmodule Pinchflat.Tasks.SourceTasks do
alias Pinchflat.Workers.VideoDownloadWorker
@doc """
Starts tasks for indexing a source's media.
Starts tasks for indexing a source's media regardless of the source's indexing
frequency. It's assumed the caller will check for that.
Returns {:ok, :should_not_index} | {:ok, %Task{}}.
Returns {:ok, %Task{}}.
"""
def kickoff_indexing_task(%Source{} = source) do
Tasks.delete_pending_tasks_for(source)
Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker")
if source.index_frequency_minutes <= 0 do
{:ok, :should_not_index}
else
source
|> Map.take([:id])
# Schedule this one immediately, but future ones will be on an interval
|> MediaIndexingWorker.new()
|> Tasks.create_job_with_task(source)
|> case do
# This should never return {:error, :duplicate_job} since we just deleted
# any pending tasks. I'm being assertive about it so it's obvious if I'm wrong
{:ok, task} -> {:ok, task}
end
source
|> Map.take([:id])
# Schedule this one immediately, but future ones will be on an interval
|> MediaIndexingWorker.new()
|> Tasks.create_job_with_task(source)
|> case do
# This should never return {:error, :duplicate_job} since we just deleted
# any pending tasks. I'm being assertive about it so it's obvious if I'm wrong
{:ok, task} -> {:ok, task}
end
end
@@ -12,19 +12,17 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
def delete(conn, %{"id" => id} = params) do
delete_files = Map.get(params, "delete_files", false)
media_item = Media.get_media_item!(id)
{:ok, _} = Media.delete_media_item(media_item, delete_files: delete_files)
if delete_files do
{:ok, _} = Media.delete_media_item_and_attachments(media_item)
flash_message =
if delete_files do
"Record and files deleted successfully."
else
"Record deleted successfully. Files were not deleted."
end
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
conn
|> put_flash(:info, flash_message)
|> redirect(to: ~p"/sources/#{media_item.source_id}")
end
end
@@ -7,17 +7,6 @@
Media Item #<%= @media_item.id %>
</h2>
</div>
<nav>
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
method="delete"
data-confirm="Are you sure?"
>
<.button color="bg-meta-1" rounding="rounded-full">
Delete Record and Files
</.button>
</.link>
</nav>
</div>
<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">
<div class="max-w-full overflow-x-auto">
@@ -25,5 +14,17 @@
<h3 class="font-bold text-xl">Attributes</h3>
<.list_items_from_map map={Map.from_struct(@media_item)} />
</div>
<section class="flex justify-center my-10">
<.link
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-full">
Delete Files
</.button>
</.link>
</section>
</div>
</div>
@@ -46,6 +46,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
def edit(conn, %{"id" => id}) do
media_profile = Profiles.get_media_profile!(id)
changeset = Profiles.change_media_profile(media_profile)
render(conn, :edit, media_profile: media_profile, changeset: changeset)
end
@@ -63,12 +64,20 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
end
end
def delete(conn, %{"id" => id}) do
def delete(conn, %{"id" => id} = params) do
delete_files = Map.get(params, "delete_files", false)
media_profile = Profiles.get_media_profile!(id)
{:ok, _media_profile} = Profiles.delete_media_profile(media_profile)
{:ok, _media_profile} = Profiles.delete_media_profile(media_profile, delete_files: delete_files)
flash_message =
if delete_files do
"Media profile, its sources, and its files deleted successfully."
else
"Media profile and its sources deleted successfully. Files were not deleted."
end
conn
|> put_flash(:info, "Media profile deleted successfully.")
|> put_flash(:info, flash_message)
|> redirect(to: ~p"/media_profiles")
end
end
@@ -22,5 +22,27 @@
<h3 class="font-bold text-xl">Attributes</h3>
<.list_items_from_map map={Map.from_struct(@media_profile)} />
</div>
<section class="flex flex-col md:flex-row items-center md:justify-around my-10">
<.link
href={~p"/media_profiles/#{@media_profile}"}
method="delete"
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-full">
Delete Profile and its Sources
</.button>
</.link>
<.link
href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
<.button color="bg-meta-1" rounding="rounded-full">
Delete Profile, Sources and Files
</.button>
</.link>
</section>
</div>
</div>
@@ -87,12 +87,20 @@ defmodule PinchflatWeb.Sources.SourceController do
end
end
def delete(conn, %{"id" => id}) do
def delete(conn, %{"id" => id} = params) do
delete_files = Map.get(params, "delete_files", false)
source = Sources.get_source!(id)
{:ok, _source} = Sources.delete_source(source)
{:ok, _source} = Sources.delete_source(source, delete_files: delete_files)
flash_message =
if delete_files do
"Source and files deleted successfully."
else
"Source deleted successfully. Files were not deleted."
end
conn
|> put_flash(:info, "Source deleted successfully.")
|> put_flash(:info, flash_message)
|> redirect(to: ~p"/sources")
end
@@ -24,11 +24,4 @@ defmodule PinchflatWeb.Sources.SourceHTML do
{"Monthly", 30 * 24 * 60}
]
end
def friendly_collection_types do
[
{"Channel", "channel"},
{"Playlist", "playlist"}
]
end
end
@@ -72,5 +72,27 @@
<p class="text-black dark:text-white">Nothing Here!</p>
<% end %>
</div>
<section class="flex flex-col md:flex-row items-center md:justify-around mt-10">
<.link
href={~p"/sources/#{@source}"}
method="delete"
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-full">
Delete Source
</.button>
</.link>
<.link
href={~p"/sources/#{@source}?delete_files=true"}
method="delete"
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
<.button color="bg-meta-1" rounding="rounded-full">
Delete Source and Files
</.button>
</.link>
</section>
</div>
</div>
@@ -19,14 +19,12 @@
label="Media Profile"
/>
<.input field={f[:collection_type]} options={friendly_collection_types()} type="select" label="Source Type" />
<.input
field={f[:index_frequency_minutes]}
options={friendly_index_frequencies()}
type="select"
label="Index Frequency"
help="The time between one index of this source finishing and the next one starting"
help="Time between one index of this source finishing and the next one starting. Setting to 'Never' will still run an initial index but no subsequent ones"
/>
<.input