diff --git a/lib/pinchflat/downloading/download_option_builder.ex b/lib/pinchflat/downloading/download_option_builder.ex index dec34a7..180c4bc 100644 --- a/lib/pinchflat/downloading/download_option_builder.ex +++ b/lib/pinchflat/downloading/download_option_builder.ex @@ -3,6 +3,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do Builds the options for yt-dlp to download media based on the given media profile. """ + alias Pinchflat.Sources alias Pinchflat.Sources.Source alias Pinchflat.Media.MediaItem alias Pinchflat.Downloading.OutputPathBuilder @@ -30,12 +31,12 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do @doc """ Builds the output path for yt-dlp to download media based on the given source's - media profile. + media profile. Uses the source's override output path template if it exists. Returns binary() """ def build_output_path_for(%Source{} = source_with_preloads) do - output_path_template = source_with_preloads.media_profile.output_path_template + output_path_template = Sources.output_path_template(source_with_preloads) build_output_path(output_path_template, source_with_preloads) end @@ -184,7 +185,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do # It's dependent on the output_path_template being a string ending `.{{ ext }}` # (or equivalent), but that's validated by the MediaProfile schema. defp determine_thumbnail_location(media_item_with_preloads) do - output_path_template = media_item_with_preloads.source.media_profile.output_path_template + output_path_template = Sources.output_path_template(media_item_with_preloads.source) output_path_template |> String.split(~r{\.}, include_captures: true) diff --git a/lib/pinchflat/profiles/media_profile.ex b/lib/pinchflat/profiles/media_profile.ex index 0b351a9..be6c6a0 100644 --- a/lib/pinchflat/profiles/media_profile.ex +++ b/lib/pinchflat/profiles/media_profile.ex @@ -80,7 +80,8 @@ defmodule Pinchflat.Profiles.MediaProfile do |> unique_constraint(:name) end - defp ext_regex do + @doc false + def ext_regex do ~r/\.({{ ?ext ?}}|%\( ?ext ?\)[sS])$/ end end diff --git a/lib/pinchflat/sources/source.ex b/lib/pinchflat/sources/source.ex index c2ef216..a02c8c0 100644 --- a/lib/pinchflat/sources/source.ex +++ b/lib/pinchflat/sources/source.ex @@ -32,6 +32,7 @@ defmodule Pinchflat.Sources.Source do retention_period_days title_filter_regex media_profile_id + output_path_template_override )a # Expensive API calls are made when a source is inserted/updated so @@ -76,6 +77,7 @@ defmodule Pinchflat.Sources.Source do field :retention_period_days, :integer field :original_url, :string field :title_filter_regex, :string + field :output_path_template_override, :string field :series_directory, :string field :nfo_filepath, :string @@ -109,6 +111,8 @@ defmodule Pinchflat.Sources.Source do |> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end) |> validate_required(required_fields) |> 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 }}") |> 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/sources/sources.ex b/lib/pinchflat/sources/sources.ex index 27d16b8..b57a628 100644 --- a/lib/pinchflat/sources/sources.ex +++ b/lib/pinchflat/sources/sources.ex @@ -19,6 +19,19 @@ defmodule Pinchflat.Sources do alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.Metadata.SourceMetadataStorageWorker + @doc """ + Returns the relevant output path template for a source. + Pulls from the source's override if present, otherwise uses the media profile's. + + Returns binary() + """ + def output_path_template(source) do + source = Repo.preload(source, :media_profile) + media_profile = source.media_profile + + source.output_path_template_override || media_profile.output_path_template + end + @doc """ Returns the list of sources. Returns [%Source{}, ...] """ diff --git a/lib/pinchflat_web/controllers/sources/source_html.ex b/lib/pinchflat_web/controllers/sources/source_html.ex index b93b754..a5a7545 100644 --- a/lib/pinchflat_web/controllers/sources/source_html.ex +++ b/lib/pinchflat_web/controllers/sources/source_html.ex @@ -28,4 +28,20 @@ defmodule PinchflatWeb.Sources.SourceHTML do def rss_feed_url(conn, source) do url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml" end + + def output_path_template_override_placeholders(media_profiles) do + media_profiles + |> Enum.map(&{&1.id, &1.output_path_template}) + |> Map.new() + |> Phoenix.json_library().encode!() + end + + def output_path_template_override_help do + help_button_classes = "underline decoration-bodydark decoration-1 hover:decoration-white cursor-pointer" + help_button = ~s{Click here} + + """ + Must end with .{{ ext }}. Same rules as Media Profile output path templates. #{help_button} to load your media profile's output template + """ + end end 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 a36e2d8..33ea259 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 @@ -9,113 +9,139 @@ Oops, something went wrong! Please check the errors below. -
-

- General Options -

- - Editing Mode: - -
+
+
+

+ General Options +

+ + Editing Mode: + +
- <.input - field={f[:custom_name]} - type="text" - label="Custom Name" - help="Something descriptive. Does not impact indexing or downloading" - /> - - <.input field={f[:original_url]} type="text" label="Source URL" help="URL of a channel or playlist (required)" /> - - <.input - field={f[:media_profile_id]} - options={Enum.map(@media_profiles, &{&1.name, &1.id})} - type="select" - label="Media Profile" - help="Sets your preferences for what media to look for and how to store it" - /> - -

- Indexing Options -

- -
<.input - field={f[:index_frequency_minutes]} - options={friendly_index_frequencies()} - type="select" - label="Index Frequency" - x-bind:disabled="fastIndexingEnabled == true" - x-init="$watch('fastIndexingEnabled', v => v && ($el.value = 30 * 24 * 60))" - help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting" + field={f[:custom_name]} + type="text" + label="Custom Name" + help="Something descriptive. Does not impact indexing or downloading" /> -
+ <.input field={f[:original_url]} type="text" label="Source URL" help="URL of a channel or playlist (required)" /> + + <.input + field={f[:media_profile_id]} + options={Enum.map(@media_profiles, &{&1.name, &1.id})} + type="select" + label="Media Profile" + help="Sets your preferences for what media to look for and how to store it" + x-model.fill="mediaProfileId" + /> + +

+ Indexing Options +

+ +
<.input - field={f[:fast_index]} - type="toggle" - label="Use Fast Indexing" - label_suffix="(pro)" - help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. Does not work with private playlists. See below for more info" - x-init=" + field={f[:index_frequency_minutes]} + options={friendly_index_frequencies()} + type="select" + label="Index Frequency" + x-bind:disabled="fastIndexingEnabled == true" + x-init="$watch('fastIndexingEnabled', v => v && ($el.value = 30 * 24 * 60))" + help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting" + /> + +
+ <.input + field={f[:fast_index]} + type="toggle" + label="Use Fast Indexing" + label_suffix="(pro)" + help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. Does not work with private playlists. See below for more info" + x-init=" // `enabled` is the data attribute that the toggle uses internally fastIndexingEnabled = enabled $watch('enabled', value => fastIndexingEnabled = !!value) " - /> -
-
+ /> +
+
-

- Downloading Options -

- - <.input - field={f[:download_media]} - type="toggle" - label="Download Media" - help="Unchecking still indexes media but it won't be downloaded until you enable this option" - /> - - <.input - field={f[:download_cutoff_date]} - type="text" - label="Download Cutoff Date" - placeholder="YYYY-MM-DD" - maxlength="10" - pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])" - title="YYYY-MM-DD" - help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format" - /> - - <.input - field={f[:retention_period_days]} - type="number" - label="Retention Period (days)" - min="0" - help="Days between when media is *downloaded* and when it's deleted. Leave blank to keep media indefinitely" - /> - -

- Advanced Options + Downloading Options

-

- Tread carefully -

<.input - field={f[:title_filter_regex]} - type="text" - label="Title Filter Regex" - placeholder="(?i)^How to Bike$" - help="A PCRE-compatible regex. Only media with titles that match this regex will be downloaded. Look up 'SQLean Regex docs' for more" + field={f[:download_media]} + type="toggle" + label="Download Media" + help="Unchecking still indexes media but it won't be downloaded until you enable this option" /> + + <.input + field={f[:download_cutoff_date]} + type="text" + label="Download Cutoff Date" + placeholder="YYYY-MM-DD" + maxlength="10" + pattern="((?:19|20)[0-9][0-9])-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])" + title="YYYY-MM-DD" + help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format" + /> + + <.input + field={f[:retention_period_days]} + type="number" + label="Retention Period (days)" + min="0" + help="Days between when media is *downloaded* and when it's deleted. Leave blank to keep media indefinitely" + /> + +
+

+ Advanced Options +

+

+ Tread carefully +

+ + <.input + field={f[:title_filter_regex]} + type="text" + label="Title Filter Regex" + placeholder="(?i)^How to Bike$" + help="A PCRE-compatible regex. Only media with titles that match this regex will be downloaded. Look up 'SQLean Regex docs' for more" + /> + +
+ <.input + field={f[:output_path_template_override]} + type="text" + inputclass="font-mono" + label="Output path template override" + help={output_path_template_override_help()} + html_help={true} + x-bind:placeholder="placeholders[mediaProfileId]" + x-model.fill="inputValue" + /> +
+
+ + <.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Source + +
+ <.fast_indexing_help /> +
- - <.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Source - -
- <.fast_indexing_help /> -
diff --git a/priv/repo/migrations/20240411154245_add_output_template_to_sources.exs b/priv/repo/migrations/20240411154245_add_output_template_to_sources.exs new file mode 100644 index 0000000..8237996 --- /dev/null +++ b/priv/repo/migrations/20240411154245_add_output_template_to_sources.exs @@ -0,0 +1,9 @@ +defmodule Pinchflat.Repo.Migrations.AddOutputTemplateToSources do + use Ecto.Migration + + def change do + alter table(:sources) do + add :output_path_template_override, :string + end + end +end diff --git a/test/pinchflat/downloading/download_option_builder_test.exs b/test/pinchflat/downloading/download_option_builder_test.exs index a7a30cb..5314f89 100644 --- a/test/pinchflat/downloading/download_option_builder_test.exs +++ b/test/pinchflat/downloading/download_option_builder_test.exs @@ -4,6 +4,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do import Pinchflat.SourcesFixtures import Pinchflat.ProfilesFixtures + alias Pinchflat.Sources alias Pinchflat.Profiles alias Pinchflat.Utils.FilesystemUtils alias Pinchflat.Downloading.DownloadOptionBuilder @@ -31,6 +32,20 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do assert {:output, "/tmp/test/media/#{media_item.source.custom_name}.%(ext)s"} in res end + + test "uses source's output override if present", %{media_item: media_item} do + source = media_item.source + {:ok, _} = Sources.update_source(source, %{output_path_template_override: "override.%(ext)s"}) + + media_item = + media_item + |> Repo.reload() + |> Repo.preload(source: :media_profile) + + assert {:ok, res} = DownloadOptionBuilder.build(media_item) + + assert {:output, "/tmp/test/media/override.%(ext)s"} in res + end end describe "build/1 when testing default options" do @@ -135,6 +150,20 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do assert {:output, "thumbnail:/tmp/test/media/%(title)S-thumb.%(ext)s"} in res end + test "appends -thumb to source's output path override, if present", %{media_item: media_item} do + media_item = update_media_profile_attribute(media_item, %{download_thumbnail: true}) + {:ok, _} = Sources.update_source(media_item.source, %{output_path_template_override: "override.%(ext)s"}) + + media_item = + media_item + |> Repo.reload() + |> Repo.preload(source: :media_profile) + + assert {:ok, res} = DownloadOptionBuilder.build(media_item) + + assert {:output, "thumbnail:/tmp/test/media/override-thumb.%(ext)s"} in res + end + test "converts thumbnail to jpg when download_thumbnail is true", %{media_item: media_item} do media_item = update_media_profile_attribute(media_item, %{download_thumbnail: true}) @@ -359,6 +388,15 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do assert path == "/tmp/test/media/%(title)S.%(ext)s" end + + test "uses source's output override if present", %{media_item: media_item} do + source = media_item.source + {:ok, source} = Sources.update_source(source, %{output_path_template_override: "override.%(ext)s"}) + + path = DownloadOptionBuilder.build_output_path_for(source) + + assert path == "/tmp/test/media/override.%(ext)s" + end end defp update_media_profile_attribute(media_item_with_preloads, attrs) do diff --git a/test/pinchflat/sources_test.exs b/test/pinchflat/sources_test.exs index 175a945..3b8091d 100644 --- a/test/pinchflat/sources_test.exs +++ b/test/pinchflat/sources_test.exs @@ -35,6 +35,28 @@ defmodule Pinchflat.SourcesTest do end end + describe "output_path_template/1" do + test "returns the source's override if present" do + source = source_fixture(%{output_path_template_override: "/override/{{ title }}.{{ ext }}"}) + + assert Sources.output_path_template(source) == "/override/{{ title }}.{{ ext }}" + end + + test "returns the media profile's template if no override is present" do + media_profile = media_profile_fixture(%{output_path_template: "/profile/{{ title }}.{{ ext }}"}) + source = source_fixture(%{media_profile_id: media_profile.id}) + + assert Sources.output_path_template(source) == "/profile/{{ title }}.{{ ext }}" + end + + test "Treats empty strings as being blank" do + media_profile = media_profile_fixture(%{output_path_template: "/profile/{{ title }}.{{ ext }}"}) + source = source_fixture(%{media_profile_id: media_profile.id, output_path_template_override: " "}) + + assert Sources.output_path_template(source) == "/profile/{{ title }}.{{ ext }}" + end + end + describe "list_sources/0" do test "it returns all sources" do source = source_fixture()