diff --git a/lib/pinchflat/media/media_query.ex b/lib/pinchflat/media/media_query.ex index e038683a..dd1f5c6a 100644 --- a/lib/pinchflat/media/media_query.ex +++ b/lib/pinchflat/media/media_query.ex @@ -75,6 +75,14 @@ defmodule Pinchflat.Media.MediaQuery do ) end + def meets_min_and_max_duration do + dynamic( + [mi, source], + (is_nil(source.min_duration_seconds) or fragment("duration_seconds >= ?", source.min_duration_seconds)) and + (is_nil(source.max_duration_seconds) or fragment("duration_seconds <= ?", source.max_duration_seconds)) + ) + end + def past_retention_period do dynamic( [mi, source], @@ -123,7 +131,8 @@ defmodule Pinchflat.Media.MediaQuery do not (^download_prevented()) and ^upload_date_after_source_cutoff() and ^format_matching_profile_preference() and - ^matches_source_title_regex() + ^matches_source_title_regex() and + ^meets_min_and_max_duration() ) end diff --git a/lib/pinchflat/sources/source.ex b/lib/pinchflat/sources/source.ex index 3a23db86..99ff2d4a 100644 --- a/lib/pinchflat/sources/source.ex +++ b/lib/pinchflat/sources/source.ex @@ -37,6 +37,8 @@ defmodule Pinchflat.Sources.Source do media_profile_id output_path_template_override marked_for_deletion_at + min_duration_seconds + max_duration_seconds )a # Expensive API calls are made when a source is inserted/updated so @@ -84,6 +86,9 @@ defmodule Pinchflat.Sources.Source do field :title_filter_regex, :string field :output_path_template_override, :string + field :min_duration_seconds, :integer + field :max_duration_seconds, :integer + field :series_directory, :string field :nfo_filepath, :string field :poster_filepath, :string @@ -118,6 +123,7 @@ defmodule Pinchflat.Sources.Source do |> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end) |> validate_required(required_fields) |> validate_title_regex() + |> validate_min_and_max_durations() |> validate_number(:retention_period_days, greater_than_or_equal_to: 0) # Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room) |> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}") @@ -164,6 +170,17 @@ defmodule Pinchflat.Sources.Source do defp validate_title_regex(changeset), do: changeset + defp validate_min_and_max_durations(changeset) do + min_duration = get_change(changeset, :min_duration_seconds) + max_duration = get_change(changeset, :max_duration_seconds) + + case {min_duration, max_duration} do + {min, max} when is_nil(min) or is_nil(max) -> changeset + {min, max} when min >= max -> add_error(changeset, :max_duration_seconds, "must be greater than minumum duration") + _ -> changeset + end + end + defimpl Jason.Encoder, for: Source do def encode(value, opts) do value diff --git a/lib/pinchflat_web/controllers/sources/source_controller.ex b/lib/pinchflat_web/controllers/sources/source_controller.ex index ef003921..15737624 100644 --- a/lib/pinchflat_web/controllers/sources/source_controller.ex +++ b/lib/pinchflat_web/controllers/sources/source_controller.ex @@ -62,6 +62,7 @@ defmodule PinchflatWeb.Sources.SourceController do | id: nil, uuid: nil, custom_name: nil, + description: nil, collection_name: nil, collection_id: nil, collection_type: nil, diff --git a/lib/pinchflat_web/controllers/sources/source_html/source_form.html.heex b/lib/pinchflat_web/controllers/sources/source_html/source_form.html.heex index c30efed7..50055f15 100644 --- a/lib/pinchflat_web/controllers/sources/source_html/source_form.html.heex +++ b/lib/pinchflat_web/controllers/sources/source_html/source_form.html.heex @@ -93,6 +93,24 @@ help="Uses your YouTube cookies for this source (if configured). Used for downloading private playlists and videos. See docs for important details" /> +
+ <.input + field={f[:min_duration_seconds]} + type="number" + label="Minimum Duration (seconds)" + min="0" + help="Minimum duration of the media to be downloaded. Can be blank" + /> + + <.input + field={f[:max_duration_seconds]} + type="number" + label="Maximum Duration (seconds)" + min="0" + help="Maximum duration of the media to be downloaded. Can be blank" + /> +
+ <.input field={f[:download_cutoff_date]} type="text" diff --git a/priv/repo/erd.png b/priv/repo/erd.png index 958d3d57..deed712a 100644 Binary files a/priv/repo/erd.png and b/priv/repo/erd.png differ diff --git a/priv/repo/migrations/20240814193939_add_duration_limits_to_sources.exs b/priv/repo/migrations/20240814193939_add_duration_limits_to_sources.exs new file mode 100644 index 00000000..75b55f49 --- /dev/null +++ b/priv/repo/migrations/20240814193939_add_duration_limits_to_sources.exs @@ -0,0 +1,10 @@ +defmodule Pinchflat.Repo.Migrations.AddDurationLimitsToSources do + use Ecto.Migration + + def change do + alter table(:sources) do + add :min_duration_seconds, :integer + add :max_duration_seconds, :integer + end + end +end diff --git a/test/pinchflat/media_test.exs b/test/pinchflat/media_test.exs index 9cf3c2a0..9e6387b0 100644 --- a/test/pinchflat/media_test.exs +++ b/test/pinchflat/media_test.exs @@ -355,6 +355,48 @@ defmodule Pinchflat.MediaTest do end end + describe "list_pending_media_items_for/1 when min and max durations" do + test "returns media items that meet the min and max duration" do + source = source_fixture(%{min_duration_seconds: 10, max_duration_seconds: 20}) + + _short_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 5}) + normal_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15}) + _long_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 25}) + + assert Media.list_pending_media_items_for(source) == [normal_media_item] + end + + test "does not apply a min duration if none is specified" do + source = source_fixture(%{min_duration_seconds: nil, max_duration_seconds: 20}) + + short_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 5}) + normal_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15}) + _long_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 25}) + + assert Media.list_pending_media_items_for(source) == [short_media_item, normal_media_item] + end + + test "does not apply a max duration if none is specified" do + source = source_fixture(%{min_duration_seconds: 10, max_duration_seconds: nil}) + + _short_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 5}) + normal_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15}) + long_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 25}) + + assert Media.list_pending_media_items_for(source) == [normal_media_item, long_media_item] + end + + test "does not apply a min or max duration if none are specified" do + source = source_fixture(%{min_duration_seconds: nil, max_duration_seconds: nil}) + + short_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 5}) + normal_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15}) + long_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 25}) + + assert Media.list_pending_media_items_for(source) == [short_media_item, normal_media_item, long_media_item] + end + end + describe "list_pending_media_items_for/1 when testing download prevention" do test "returns only media items that are not prevented from downloading" do source = source_fixture() @@ -434,6 +476,34 @@ defmodule Pinchflat.MediaTest do assert Media.pending_download?(media_item) end + test "returns true if the duration is between the min and max" do + source = source_fixture(%{min_duration_seconds: 10, max_duration_seconds: 20}) + media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15}) + + assert Media.pending_download?(media_item) + end + + test "returns false if the duration is below the min" do + source = source_fixture(%{min_duration_seconds: 10, max_duration_seconds: 20}) + media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 5}) + + refute Media.pending_download?(media_item) + end + + test "returns false if the duration is above the max" do + source = source_fixture(%{min_duration_seconds: 10, max_duration_seconds: 20}) + media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 25}) + + refute Media.pending_download?(media_item) + end + + test "returns true if there is no min or max duration" do + source = source_fixture(%{min_duration_seconds: nil, max_duration_seconds: nil}) + media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, duration_seconds: 15}) + + assert Media.pending_download?(media_item) + end + test "returns true if the media item is not prevented from downloading" do media_item = media_item_fixture(%{media_filepath: nil, prevent_download: false}) diff --git a/test/pinchflat/sources_test.exs b/test/pinchflat/sources_test.exs index 662d6971..cc8a63ee 100644 --- a/test/pinchflat/sources_test.exs +++ b/test/pinchflat/sources_test.exs @@ -681,6 +681,34 @@ defmodule Pinchflat.SourcesTest do end end + describe "change_source/3 when testing min/max duration validations" do + test "succeeds if min and max are nil" do + source = source_fixture() + + assert %{errors: []} = Sources.change_source(source, %{min_duration_seconds: nil, max_duration_seconds: nil}) + end + + test "succeeds if either min or max is nil" do + source = source_fixture() + + assert %{errors: []} = Sources.change_source(source, %{min_duration_seconds: nil, max_duration_seconds: 100}) + assert %{errors: []} = Sources.change_source(source, %{min_duration_seconds: 100, max_duration_seconds: nil}) + end + + test "succeeds if min is less than max" do + source = source_fixture() + + assert %{errors: []} = Sources.change_source(source, %{min_duration_seconds: 100, max_duration_seconds: 200}) + end + + test "fails if min is greater than or equal to max" do + source = source_fixture() + + assert %{errors: [_]} = Sources.change_source(source, %{min_duration_seconds: 200, max_duration_seconds: 100}) + assert %{errors: [_]} = Sources.change_source(source, %{min_duration_seconds: 100, max_duration_seconds: 100}) + end + end + describe "change_source/3 when testing original_url validation" do test "succeeds when an original URL is valid" do source = source_fixture()