Video filepath parser (#6)

* Restructured files; Added parser placeholder

* More restructuring

* Added basic parser for hydrating template strings

* Improved docs

* More docs
This commit is contained in:
Kieran 2024-01-20 19:40:55 -08:00 committed by GitHub
parent bad3e10fea
commit a4f5024d8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 140 additions and 11 deletions

View file

@ -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,

View file

@ -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
"""

View file

@ -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

View file

@ -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)
"""

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"},

View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -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()