diff --git a/README.md b/README.md
index c10dc8d..be086d3 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ If it doesn't work for your use case, please make a feature request! You can als
- Uses a novel approach to download new content more quickly than other apps
- Supports downloading audio content
- Custom rules for handling YouTube Shorts and livestreams
+- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
- Advanced options like setting cutoff dates and filtering by title
- Reliable hands-off operation
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
diff --git a/config/config.exs b/config/config.exs
index 9c60df4..8e3cd83 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -46,7 +46,13 @@ config :pinchflat, Oban,
engine: Oban.Engines.Lite,
repo: Pinchflat.Repo,
# Keep old jobs for 30 days for display in the UI
- plugins: [{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60}],
+ plugins: [
+ {Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60},
+ {Oban.Plugins.Cron,
+ crontab: [
+ {"@daily", Pinchflat.Downloading.MediaRetentionWorker}
+ ]}
+ ],
# TODO: consider making this an env var or something?
queues: [
default: 10,
diff --git a/lib/pinchflat/boot/data_backfill_worker.ex b/lib/pinchflat/boot/data_backfill_worker.ex
deleted file mode 100644
index 37cd946..0000000
--- a/lib/pinchflat/boot/data_backfill_worker.ex
+++ /dev/null
@@ -1,62 +0,0 @@
-defmodule Pinchflat.Boot.DataBackfillWorker do
- @moduledoc false
-
- use Oban.Worker,
- queue: :local_metadata,
- unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
- tags: ["media_item", "media_metadata", "local_metadata", "data_backfill"]
-
- # This one is going to be a little more self-contained
- # instead of relying on outside modules for the methods.
- # That's because, for now, these methods are not intended
- # to be used elsewhere.
- #
- # I'm just trying out that pattern and seeing if I like it better
- # so this may change.
- import Ecto.Query, warn: false
- require Logger
-
- alias __MODULE__
- alias Pinchflat.Repo
-
- @doc """
- Cancels all pending backfill jobs. Useful for ensuring worker runs immediately
- on app boot.
-
- Returns {:ok, integer()}
- """
- def cancel_pending_backfill_jobs do
- Oban.Job
- |> where(worker: "Pinchflat.Boot.DataBackfillWorker")
- |> Oban.cancel_all_jobs()
- end
-
- @impl Oban.Worker
- @doc """
- Performs one-off tasks to get data in the right shape.
- This can be needed when we add new features or change the way
- we store data. Must be idempotent. All new data should already
- conform to the expected schema so this should only be needed
- for existing data. Still runs periodically to be safe.
-
- Returns :ok
- """
- def perform(%Oban.Job{}) do
- Logger.info("Running data backfill worker")
- # Nothing to do for now - just reschedule
- # Keeping in-place because we _will_ need it in the future
-
- reschedule_backfill()
-
- :ok
- end
-
- defp reschedule_backfill do
- # Run hourly
- next_run_in = 60 * 60
-
- %{}
- |> DataBackfillWorker.new(schedule_in: next_run_in)
- |> Repo.insert_unique_job()
- end
-end
diff --git a/lib/pinchflat/boot/post_job_startup_tasks.ex b/lib/pinchflat/boot/post_job_startup_tasks.ex
index 4d11159..867c300 100644
--- a/lib/pinchflat/boot/post_job_startup_tasks.ex
+++ b/lib/pinchflat/boot/post_job_startup_tasks.ex
@@ -11,9 +11,6 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
use GenServer, restart: :temporary
import Ecto.Query, warn: false
- alias Pinchflat.Repo
- alias Pinchflat.Boot.DataBackfillWorker
-
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, %{}, opts)
end
@@ -29,16 +26,7 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
"""
@impl true
def init(state) do
- enqueue_backfill_worker()
-
+ # Empty for now, keeping because tasks _will_ be added in future
{:ok, state}
end
-
- defp enqueue_backfill_worker do
- DataBackfillWorker.cancel_pending_backfill_jobs()
-
- %{}
- |> DataBackfillWorker.new()
- |> Repo.insert_unique_job()
- end
end
diff --git a/lib/pinchflat/downloading/media_retention_worker.ex b/lib/pinchflat/downloading/media_retention_worker.ex
new file mode 100644
index 0000000..3e5c0d0
--- /dev/null
+++ b/lib/pinchflat/downloading/media_retention_worker.ex
@@ -0,0 +1,33 @@
+defmodule Pinchflat.Downloading.MediaRetentionWorker do
+ @moduledoc false
+
+ use Oban.Worker,
+ queue: :local_metadata,
+ unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
+ tags: ["media_item", "local_metadata"]
+
+ require Logger
+
+ alias Pinchflat.Media
+
+ @doc """
+ Deletes media items that are past their retention date and prevents
+ them from being re-downloaded.
+
+ This worker is scheduled to run daily via the Oban Cron plugin.
+
+ Returns :ok
+ """
+ @impl Oban.Worker
+ def perform(%Oban.Job{}) do
+ cullable_media = Media.list_cullable_media_items()
+ Logger.info("Culling #{length(cullable_media)} media items past their retention date")
+
+ Enum.each(cullable_media, fn media_item ->
+ Media.delete_media_files(media_item, %{
+ prevent_download: true,
+ culled_at: DateTime.utc_now()
+ })
+ end)
+ end
+end
diff --git a/lib/pinchflat/media/media.ex b/lib/pinchflat/media/media.ex
index 346029b..d58f657 100644
--- a/lib/pinchflat/media/media.ex
+++ b/lib/pinchflat/media/media.ex
@@ -22,13 +22,28 @@ defmodule Pinchflat.Media do
Repo.all(MediaItem)
end
+ @doc """
+ Returns a list of media_items that are cullable based on the retention period
+ of the source they belong to.
+
+ Returns [%MediaItem{}, ...]
+ """
+ def list_cullable_media_items do
+ MediaQuery.new()
+ |> MediaQuery.join_sources()
+ |> MediaQuery.with_media_filepath()
+ |> MediaQuery.with_passed_retention_period()
+ |> MediaQuery.with_no_culling_prevention()
+ |> 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
matches the format selection rules of the parent media_profile.
- See `build_format_clauses` but tl;dr is it _may_ filter based
- on shorts or livestreams depending on the media_profile settings.
+ See `matching_download_criteria_for` but tl;dr is it _may_ filter based
+ on shorts livestreams depending on the media_profile settings.
Returns [%MediaItem{}, ...].
"""
@@ -161,7 +176,7 @@ defmodule Pinchflat.Media do
Tasks.delete_tasks_for(media_item)
if delete_files do
- {:ok, _} = delete_media_files(media_item)
+ {:ok, _} = do_delete_media_files(media_item)
end
# Should delete these no matter what
@@ -169,6 +184,25 @@ defmodule Pinchflat.Media do
Repo.delete(media_item)
end
+ @doc """
+ Deletes the tasks and media files associated with a media_item but leaves the
+ media_item in the database. Does not delete anything to do with associated metadata.
+
+ Optionally accepts a second argument `addl_attrs` which will be merged into the
+ media_item before it is updated. Useful for setting things like `prevent_download`
+ and `culled_at`, if wanted
+
+ Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
+ """
+ def delete_media_files(%MediaItem{} = media_item, addl_attrs \\ %{}) do
+ filepath_attrs = MediaItem.filepath_attribute_defaults()
+
+ Tasks.delete_tasks_for(media_item)
+ {:ok, _} = do_delete_media_files(media_item)
+
+ update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
+ end
+
@doc """
Returns an `%Ecto.Changeset{}` for tracking media_item changes.
"""
@@ -176,7 +210,7 @@ defmodule Pinchflat.Media do
MediaItem.changeset(media_item, attrs)
end
- defp delete_media_files(media_item) do
+ defp do_delete_media_files(media_item) do
mapped_struct = Map.from_struct(media_item)
MediaItem.filepath_attributes()
@@ -203,6 +237,7 @@ defmodule Pinchflat.Media do
defp matching_download_criteria_for(query, source_with_preloads) do
query
+ |> MediaQuery.with_no_prevented_download()
|> MediaQuery.with_no_media_filepath()
|> MediaQuery.with_upload_date_after(source_with_preloads.download_cutoff_date)
|> MediaQuery.with_format_preference(source_with_preloads.media_profile)
diff --git a/lib/pinchflat/media/media_item.ex b/lib/pinchflat/media/media_item.ex
index 40353db..52633b2 100644
--- a/lib/pinchflat/media/media_item.ex
+++ b/lib/pinchflat/media/media_item.ex
@@ -30,7 +30,11 @@ defmodule Pinchflat.Media.MediaItem do
:subtitle_filepaths,
:thumbnail_filepath,
:metadata_filepath,
- :nfo_filepath
+ :nfo_filepath,
+ # These are user or system controlled fields
+ :prevent_download,
+ :prevent_culling,
+ :culled_at
]
# Pretty much all the fields captured at index are required.
@required_fields ~w(
@@ -42,7 +46,7 @@ defmodule Pinchflat.Media.MediaItem do
source_id
upload_date
short_form_content
- )a
+ )a
schema "media_items" do
# This is _not_ used as the primary key or internally in the database
@@ -70,6 +74,10 @@ defmodule Pinchflat.Media.MediaItem do
# Will very likely revisit because I can't leave well-enough alone.
field :subtitle_filepaths, {:array, {:array, :string}}, default: []
+ field :prevent_download, :boolean, default: false
+ field :prevent_culling, :boolean, default: false
+ field :culled_at, :utc_datetime
+
field :matching_search_term, :string, virtual: true
belongs_to :source, Source
@@ -96,4 +104,14 @@ defmodule Pinchflat.Media.MediaItem do
def filepath_attributes do
~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths nfo_filepath)a
end
+
+ @doc false
+ def filepath_attribute_defaults do
+ filepath_attributes()
+ |> Enum.map(fn
+ :subtitle_filepaths -> {:subtitle_filepaths, []}
+ field -> {field, nil}
+ end)
+ |> Enum.into(%{})
+ end
end
diff --git a/lib/pinchflat/media/media_query.ex b/lib/pinchflat/media/media_query.ex
index c43b5eb..7b7a3c7 100644
--- a/lib/pinchflat/media/media_query.ex
+++ b/lib/pinchflat/media/media_query.ex
@@ -17,6 +17,7 @@ defmodule Pinchflat.Media.MediaQuery do
# Prefixes:
# - for_* - belonging to a certain record
+ # - join_* - for joining on a certain record
# - with_* - for filtering based on full, concrete attributes
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
#
@@ -31,6 +32,27 @@ defmodule Pinchflat.Media.MediaQuery do
where(query, [mi], mi.source_id == ^source.id)
end
+ def join_sources(query) do
+ from(mi in query, join: s in assoc(mi, :source), as: :sources)
+ end
+
+ def with_passed_retention_period(query) do
+ where(
+ query,
+ [mi, sources],
+ fragment(
+ "IFNULL(?, 0) > 0 AND DATETIME('now', '-' || ? || ' day') > ?",
+ sources.retention_period_days,
+ sources.retention_period_days,
+ mi.media_downloaded_at
+ )
+ )
+ end
+
+ def with_no_culling_prevention(query) do
+ where(query, [mi], mi.prevent_culling == false)
+ end
+
def with_id(query, id) do
where(query, [mi], mi.id == ^id)
end
@@ -53,6 +75,10 @@ defmodule Pinchflat.Media.MediaQuery do
where(query, [mi], mi.upload_date >= ^date)
end
+ def with_no_prevented_download(query) do
+ where(query, [mi], mi.prevent_download == false)
+ end
+
def matching_title_regex(query, nil), do: query
def matching_title_regex(query, regex) do
diff --git a/lib/pinchflat/sources/source.ex b/lib/pinchflat/sources/source.ex
index 58e7ec0..c2ef216 100644
--- a/lib/pinchflat/sources/source.ex
+++ b/lib/pinchflat/sources/source.ex
@@ -29,6 +29,7 @@ defmodule Pinchflat.Sources.Source do
last_indexed_at
original_url
download_cutoff_date
+ retention_period_days
title_filter_regex
media_profile_id
)a
@@ -72,6 +73,7 @@ defmodule Pinchflat.Sources.Source do
field :last_indexed_at, :utc_datetime
# Only download media items that were published after this date
field :download_cutoff_date, :date
+ field :retention_period_days, :integer
field :original_url, :string
field :title_filter_regex, :string
@@ -106,6 +108,7 @@ defmodule Pinchflat.Sources.Source do
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(required_fields)
+ |> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|> unique_constraint([:collection_id, :media_profile_id, :title_filter_regex], error_key: :original_url)
end
diff --git a/lib/pinchflat_web/controllers/media_items/media_item_controller.ex b/lib/pinchflat_web/controllers/media_items/media_item_controller.ex
index 31a9ef4..bfcb3d2 100644
--- a/lib/pinchflat_web/controllers/media_items/media_item_controller.ex
+++ b/lib/pinchflat_web/controllers/media_items/media_item_controller.ex
@@ -16,20 +16,34 @@ defmodule PinchflatWeb.MediaItems.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)
+ def edit(conn, %{"id" => id}) do
media_item = Media.get_media_item!(id)
- {:ok, _} = Media.delete_media_item(media_item, delete_files: delete_files)
+ changeset = Media.change_media_item(media_item)
- flash_message =
- if delete_files do
- "Record and files deleted successfully."
- else
- "Record deleted successfully. Files were not deleted."
- end
+ render(conn, :edit, media_item: media_item, changeset: changeset)
+ end
+
+ def update(conn, %{"id" => id, "media_item" => params}) do
+ media_item = Media.get_media_item!(id)
+
+ case Media.update_media_item(media_item, params) do
+ {:ok, media_item} ->
+ conn
+ |> put_flash(:info, "Media Item updated successfully.")
+ |> redirect(to: ~p"/sources/#{media_item.source_id}/media/#{media_item}")
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ render(conn, :edit, media_item: media_item, changeset: changeset)
+ end
+ end
+
+ def delete(conn, %{"id" => id} = params) do
+ prevent_download = Map.get(params, "prevent_download", false)
+ media_item = Media.get_media_item!(id)
+ {:ok, _} = Media.delete_media_files(media_item, %{prevent_download: prevent_download})
conn
- |> put_flash(:info, flash_message)
+ |> put_flash(:info, "Files deleted successfully.")
|> redirect(to: ~p"/sources/#{media_item.source_id}")
end
diff --git a/lib/pinchflat_web/controllers/media_items/media_item_html.ex b/lib/pinchflat_web/controllers/media_items/media_item_html.ex
index 12d7ffa..659e742 100644
--- a/lib/pinchflat_web/controllers/media_items/media_item_html.ex
+++ b/lib/pinchflat_web/controllers/media_items/media_item_html.ex
@@ -3,6 +3,14 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
embed_templates "media_item_html/*"
+ @doc """
+ Renders a media item form.
+ """
+ attr :changeset, Ecto.Changeset, required: true
+ attr :action, :string, required: true
+
+ def media_item_form(assigns)
+
def media_file_exists?(media_item) do
!!media_item.media_filepath and File.exists?(media_item.media_filepath)
end
diff --git a/lib/pinchflat_web/controllers/media_items/media_item_html/edit.html.heex b/lib/pinchflat_web/controllers/media_items/media_item_html/edit.html.heex
new file mode 100644
index 0000000..5b1818c
--- /dev/null
+++ b/lib/pinchflat_web/controllers/media_items/media_item_html/edit.html.heex
@@ -0,0 +1,13 @@
+
@@ -15,13 +23,22 @@
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<.link
- href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
+ href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}"}
method="delete"
- data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
+ data-confirm="Are you sure you want to delete all files for this media item? This cannot be undone."
>
Delete Files
+ <:option>
+ <.link
+ href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?prevent_download=true"}
+ method="delete"
+ data-confirm="Are you sure you want to delete all files for this media item and prevent it from re-downloading in the future? This cannot be undone."
+ >
+ Delete and Ignore
+
+
@@ -32,6 +49,7 @@
<.media_preview media_item={@media_item} />
<% end %>
+