diff --git a/config/config.exs b/config/config.exs index f920c3c..fd3d9f1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -12,7 +12,7 @@ config :pinchflat, generators: [timestamp_type: :utc_datetime], # Specifying backend data here makes mocking and local testing SUPER easy yt_dlp_executable: System.find_executable("yt-dlp"), - yt_dlp_runner: Pinchflat.DownloaderBackends.YtDlp.CommandRunner + yt_dlp_runner: Pinchflat.Downloader.Backends.YtDlp.CommandRunner # Configures the endpoint config :pinchflat, PinchflatWeb.Endpoint, diff --git a/lib/pinchflat/downloader_backends/backend_command_runner.ex b/lib/pinchflat/downloader/backends/backend_command_runner.ex similarity index 74% rename from lib/pinchflat/downloader_backends/backend_command_runner.ex rename to lib/pinchflat/downloader/backends/backend_command_runner.ex index 6e1bcb7..866008e 100644 --- a/lib/pinchflat/downloader_backends/backend_command_runner.ex +++ b/lib/pinchflat/downloader/backends/backend_command_runner.ex @@ -1,4 +1,4 @@ -defmodule Pinchflat.DownloaderBackends.BackendCommandRunner do +defmodule Pinchflat.Downloader.Backends.BackendCommandRunner do @moduledoc """ A behaviour for running CLI commands against a downloader backend """ diff --git a/lib/pinchflat/downloader_backends/yt_dlp/command_runner.ex b/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex similarity index 93% rename from lib/pinchflat/downloader_backends/yt_dlp/command_runner.ex rename to lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex index 014ae0e..545e094 100644 --- a/lib/pinchflat/downloader_backends/yt_dlp/command_runner.ex +++ b/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex @@ -1,10 +1,10 @@ -defmodule Pinchflat.DownloaderBackends.YtDlp.CommandRunner do +defmodule Pinchflat.Downloader.Backends.YtDlp.CommandRunner do @moduledoc """ Runs yt-dlp commands using the `System.cmd/3` function """ alias Pinchflat.Utils.StringUtils - alias Pinchflat.DownloaderBackends.BackendCommandRunner + alias Pinchflat.Downloader.Backends.BackendCommandRunner @behaviour BackendCommandRunner diff --git a/lib/pinchflat/downloader_backends/yt_dlp/video_collection.ex b/lib/pinchflat/downloader/backends/yt_dlp/video_collection.ex similarity index 89% rename from lib/pinchflat/downloader_backends/yt_dlp/video_collection.ex rename to lib/pinchflat/downloader/backends/yt_dlp/video_collection.ex index 324895b..ed24bdb 100644 --- a/lib/pinchflat/downloader_backends/yt_dlp/video_collection.ex +++ b/lib/pinchflat/downloader/backends/yt_dlp/video_collection.ex @@ -1,4 +1,4 @@ -defmodule Pinchflat.DownloaderBackends.YtDlp.VideoCollection do +defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollection do @moduledoc """ Contains utilities for working with collections of videos (ie: channels, playlists) """ diff --git a/lib/pinchflat/rendered_string/base.ex b/lib/pinchflat/rendered_string/base.ex new file mode 100644 index 0000000..549a676 --- /dev/null +++ b/lib/pinchflat/rendered_string/base.ex @@ -0,0 +1,52 @@ +defmodule Pinchflat.RenderedString.Base do + @moduledoc """ + A base module for parsing rendered strings, designed as a macro to be used + in other modules. See https://elixirforum.com/t/help-to-parse-a-template-with-nimbleparsec/47980 + + NOTE: if the needs here get any more complicated, look into using a Liquid + template parser. No need to reinvent the wheel any more than I already have. + + NOTE: this is effectively tested by the `Pinchflat.RenderedString.Parser`'s tests + """ + + defmacro __using__(_opts) do + quote location: :keep do + import NimbleParsec + + opening_tag = string("{{") + closing_tag = string("}}") + optional_whitespaces = ascii_string(~c[ \t\n\r], min: 0) + + # Capture everything up to the opening object + text = + lookahead_not(opening_tag) + # ... as long as it's a character + |> utf8_char([]) + # ... and there's at least one character + |> times(min: 1) + # ... and then convert it to a string + |> reduce({List, :to_string, []}) + # ... finally bag it and tag it + |> unwrap_and_tag(:text) + + identifier = + utf8_string([?a..?z, ?A..?Z, ?_, ?0..?9], min: 1) + |> reduce({Enum, :join, []}) + |> unwrap_and_tag(:identifier) + + defparsecp(:expression, identifier) + + # when spotting interpolation, ignore the opening tag and any whitespace + interpolation = + ignore(concat(opening_tag, optional_whitespaces)) + # ... then parse the expression (identifier) + |> parsec(:expression) + # ... then ignore any whitespace and the closing tags after the expression + |> ignore(concat(optional_whitespaces, closing_tag)) + # ... once again we bag it and tag it + |> unwrap_and_tag(:interpolation) + + defparsec(:do_parse, choice([interpolation, text]) |> repeat() |> eos()) + end + end +end diff --git a/lib/pinchflat/rendered_string/parser.ex b/lib/pinchflat/rendered_string/parser.ex new file mode 100644 index 0000000..ab167fa --- /dev/null +++ b/lib/pinchflat/rendered_string/parser.ex @@ -0,0 +1,35 @@ +defmodule Pinchflat.RenderedString.Parser do + @moduledoc """ + Parses liquid-ish-style strings into a rendered string + + Used for turning filepath templates into real filepaths + """ + + use Pinchflat.RenderedString.Base + + @doc """ + Parses a string into a rendered string, using the provided variables. + + Variable identifiers are surrounded by {{ and }}. The variable keys MUST be strings. + If an identifier is not found in the provided variables, it will be removed from the string. + """ + def parse(string, variables) do + # `do_parse` comes from `RenderedString.Base` + case do_parse(string) do + {:ok, parsed, _, _, _, _} -> + {:ok, build_string(parsed, variables)} + + {:error, message, _, _, _, _} -> + {:error, message} + end + end + + defp build_string(parsed, variables) do + Enum.reduce(parsed, "", fn element, acc -> + case element do + {:text, text} -> acc <> text + {:interpolation, {:identifier, identifier}} -> acc <> to_string(variables[identifier]) + end + end) + end +end diff --git a/lib/utils/string_utils.ex b/lib/pinchflat/utils/string_utils.ex similarity index 100% rename from lib/utils/string_utils.ex rename to lib/pinchflat/utils/string_utils.ex diff --git a/mix.exs b/mix.exs index 83b991b..cc91b60 100644 --- a/mix.exs +++ b/mix.exs @@ -52,7 +52,8 @@ defmodule Pinchflat.MixProject do {:dns_cluster, "~> 0.1.1"}, {:plug_cowboy, "~> 2.5"}, {:mox, "~> 1.0", only: :test}, - {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:nimble_parsec, "~> 1.4"} ] end diff --git a/mix.lock b/mix.lock index 9a2d029..2ea91e9 100644 --- a/mix.lock +++ b/mix.lock @@ -22,6 +22,7 @@ "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "phoenix": {:hex, :phoenix, "1.7.10", "02189140a61b2ce85bb633a9b6fd02dff705a5f1596869547aeb2b2b95edd729", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "cf784932e010fd736d656d7fead6a584a4498efefe5b8227e9f383bf15bb79d0"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, diff --git a/test/pinchflat/downloader_backends/yt_dlp/command_runner_test.exs b/test/pinchflat/downloader/backends/yt_dlp/command_runner_test.exs similarity index 93% rename from test/pinchflat/downloader_backends/yt_dlp/command_runner_test.exs rename to test/pinchflat/downloader/backends/yt_dlp/command_runner_test.exs index 02e18c2..2700090 100644 --- a/test/pinchflat/downloader_backends/yt_dlp/command_runner_test.exs +++ b/test/pinchflat/downloader/backends/yt_dlp/command_runner_test.exs @@ -1,7 +1,7 @@ -defmodule Pinchflat.DownloaderBackends.YtDlp.CommandRunnerTest do +defmodule Pinchflat.Downloader.Backends.YtDlp.CommandRunnerTest do use ExUnit.Case, async: true - alias Pinchflat.DownloaderBackends.YtDlp.CommandRunner, as: Runner + alias Pinchflat.Downloader.Backends.YtDlp.CommandRunner, as: Runner @original_executable Application.compile_env(:pinchflat, :yt_dlp_executable) @video_url "https://www.youtube.com/watch?v=9bZkp7q19f0" diff --git a/test/pinchflat/downloader_backends/yt_dlp/video_collection_test.exs b/test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs similarity index 89% rename from test/pinchflat/downloader_backends/yt_dlp/video_collection_test.exs rename to test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs index 08296fc..31cb840 100644 --- a/test/pinchflat/downloader_backends/yt_dlp/video_collection_test.exs +++ b/test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs @@ -1,8 +1,8 @@ -defmodule Pinchflat.DownloaderBackends.YtDlp.VideoCollectionTest do +defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollectionTest do use ExUnit.Case, async: true import Mox - alias Pinchflat.DownloaderBackends.YtDlp.VideoCollection, as: VideoCollection + alias Pinchflat.Downloader.Backends.YtDlp.VideoCollection, as: VideoCollection @channel_url "https://www.youtube.com/@TheUselessTrials" diff --git a/test/pinchflat/rendered_string/parser_test.exs b/test/pinchflat/rendered_string/parser_test.exs new file mode 100644 index 0000000..b7c4b70 --- /dev/null +++ b/test/pinchflat/rendered_string/parser_test.exs @@ -0,0 +1,40 @@ +defmodule Pinchflat.RenderedString.ParserTest do + use ExUnit.Case, async: true + + alias Pinchflat.RenderedString.Parser + + describe "parse/2" do + test "it returns the rendered string when the string is valid" do + assert {:ok, "bar"} = Parser.parse("{{ foo }}", %{"foo" => "bar"}) + end + + test "it works with filepath-like strings" do + assert {:ok, "bar/baz"} = + Parser.parse("{{ foo }}/{{ bar }}", %{"foo" => "bar", "bar" => "baz"}) + end + + test "it works when mixing text and variables" do + assert {:ok, "bar text baz"} = + Parser.parse("{{ foo }} text {{ bar }}", %{"foo" => "bar", "bar" => "baz"}) + end + + test "it removes the placeholder but doesn't blow up when the variable isn't provided" do + assert {:ok, ""} = Parser.parse("{{ foo }}", %{}) + end + + test "it accepts any number of spaces between open and closing tags" do + assert {:ok, "bar"} = Parser.parse("{{foo}}", %{"foo" => "bar"}) + assert {:ok, "bar"} = Parser.parse("{{ foo}}", %{"foo" => "bar"}) + assert {:ok, "bar"} = Parser.parse("{{foo }}", %{"foo" => "bar"}) + assert {:ok, "bar"} = Parser.parse("{{ foo }}", %{"foo" => "bar"}) + end + + test "it doesn't interpret single braces as variables" do + assert {:ok, "{foo}"} = Parser.parse("{foo}", %{}) + end + + test "it returns an error when the string is invalid" do + assert {:error, "expected end of string"} = Parser.parse("{{ 1-1 }", %{}) + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 2511d46..3a6890e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,4 +1,4 @@ -Mox.defmock(CommandRunnerMock, for: Pinchflat.DownloaderBackends.BackendCommandRunner) +Mox.defmock(CommandRunnerMock, for: Pinchflat.Downloader.Backends.BackendCommandRunner) Application.put_env(:pinchflat, :yt_dlp_runner, CommandRunnerMock) ExUnit.start()