Refactor and document output path (#28)

* Updated output path builder to better use yt-dlp variables

* Added UI explaining output template options

* Fixed outdated reference in dockerfile
This commit is contained in:
Kieran
2024-02-20 13:40:13 -08:00
committed by GitHub
parent 7d62f7c7df
commit 47f0fd533f
12 changed files with 185 additions and 57 deletions
+2 -1
View File
@@ -11,7 +11,8 @@ module.exports = {
darkMode: 'class', darkMode: 'class',
theme: { theme: {
fontFamily: { fontFamily: {
satoshi: ['Satoshi', 'sans-serif'] satoshi: ['Satoshi', 'sans-serif'],
...defaultTheme.fontFamily
}, },
screens: { screens: {
'2xsm': '375px', '2xsm': '375px',
+1 -1
View File
@@ -25,7 +25,7 @@ WORKDIR /app
COPY . ./ COPY . ./
# Needs permissions to be updated AFTER the copy step # Needs permissions to be updated AFTER the copy step
RUN chmod +x ./docker-run.sh RUN chmod +x ./docker-run.dev.sh
# Install Elixir deps # Install Elixir deps
RUN mix deps.get RUN mix deps.get
+1 -1
View File
@@ -29,7 +29,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
schema "media_profiles" do schema "media_profiles" do
field :name, :string field :name, :string
field :output_path_template, :string, default: "/{{ uploader }}/{{ title }}/{{ title }}-{{ id }}.{{ ext }}" field :output_path_template, :string, default: "/{{ channel }}/{{ title }}/{{ title }} [{{ id }}].{{ ext }}"
field :download_subs, :boolean, default: true field :download_subs, :boolean, default: true
field :download_auto_subs, :boolean, default: true field :download_auto_subs, :boolean, default: true
@@ -12,50 +12,22 @@ defmodule Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder do
Translates liquid-style templates into yt-dlp-style templates, Translates liquid-style templates into yt-dlp-style templates,
leaving yt-dlp syntax intact. leaving yt-dlp syntax intact.
IDEA: apart from any custom options I've defined, I can support any yt-dlp
option by assuming `{{ identifier }}` should transform to `%(identifier)S`.
It's not doing anything huge, but it's nicer to type and more approachable IMO.
IDEA: set a default for `MediaProfile`'s `output_path_template` field
""" """
def build(template_string) do def build(template_string) do
TemplateParser.parse(template_string, full_yt_dlp_options_map()) TemplateParser.parse(template_string, custom_yt_dlp_option_map(), &identifier_fn/2)
end end
defp full_yt_dlp_options_map do # The `nil` case simply wraps the identifier in yt-dlp-style syntax. This assumes that
Map.merge( # the identifier is a valid yt-dlp option. The upside is that this gives the user
standard_yt_dlp_option_map(), # access to ALL single-word yt-dlp options in the (imo) more friendly/forgiving liquid-style syntax.
custom_yt_dlp_option_map() #
) # For all "custom" variables, we use the `Map.get/3` function to look up the value in the provided.
end # See `custom_yt_dlp_option_map` for a list of those.
defp identifier_fn(identifier, variables) do
defp standard_yt_dlp_option_map do case Map.get(variables, identifier) do
%{ nil -> "%(#{identifier})S"
# Uppercase "S" means "safe" - ie: filepath-safe value -> value
"id" => "%(id)S", end
"ext" => "%(ext)S",
"title" => "%(title)S",
"fulltitle" => "%(fulltitle)S",
"uploader" => "%(uploader)S",
"creator" => "%(creator)S",
"upload_date" => "%(upload_date)S",
"release_date" => "%(release_date)S",
"duration" => "%(duration)S",
# For videos classified as an episode of a series:
"series" => "%(series)S",
"season" => "%(season)S",
"season_number" => "%(season_number)S",
"episode" => "%(episode)S",
"episode_number" => "%(episode_number)S",
"episode_id" => "%(episode_id)S",
# For videos classified as music:
"track" => "%(track)S",
"track_number" => "%(track_number)S",
"artist" => "%(artist)S",
"album" => "%(album)S",
"album_type" => "%(album_type)S",
"genre" => "%(genre)S"
}
end end
defp custom_yt_dlp_option_map do defp custom_yt_dlp_option_map do
+12 -5
View File
@@ -8,28 +8,35 @@ defmodule Pinchflat.RenderedString.Parser do
use Pinchflat.RenderedString.Base use Pinchflat.RenderedString.Base
@doc """ @doc """
Parses a string into a rendered string, using the provided variables. Parses a string into a rendered string, using the provided variables. Optionally
takes a custom fetcher function for handling missing variables.
Variable identifiers are surrounded by {{ and }}. The variable keys MUST be strings. 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. If an identifier is not found in the provided variables, it will be removed from the string.
Returns `{:ok, binary()}` or `{:error, binary()}`.
""" """
def parse(string, variables) do def parse(string, variables, value_fetch_fn \\ &default_fetcher/2) do
# `do_parse` comes from `RenderedString.Base` # `do_parse` comes from `RenderedString.Base`
case do_parse(string) do case do_parse(string) do
{:ok, parsed, _, _, _, _} -> {:ok, parsed, _, _, _, _} ->
{:ok, build_string(parsed, variables)} {:ok, build_string(parsed, variables, value_fetch_fn)}
{:error, message, _, _, _, _} -> {:error, message, _, _, _, _} ->
{:error, message} {:error, message}
end end
end end
defp build_string(parsed, variables) do defp build_string(parsed, variables, value_fetch_fn) do
Enum.reduce(parsed, "", fn element, acc -> Enum.reduce(parsed, "", fn element, acc ->
case element do case element do
{:text, text} -> acc <> text {:text, text} -> acc <> text
{:interpolation, {:identifier, identifier}} -> acc <> to_string(variables[identifier]) {:interpolation, {:identifier, identifier}} -> acc <> value_fetch_fn.(identifier, variables)
end end
end) end)
end end
def default_fetcher(identifier, variables) do
Map.get(variables, identifier, "")
end
end end
+1
View File
@@ -88,6 +88,7 @@ defmodule PinchflatWeb do
# Core UI components and translation # Core UI components and translation
import PinchflatWeb.Gettext import PinchflatWeb.Gettext
import PinchflatWeb.CoreComponents import PinchflatWeb.CoreComponents
import PinchflatWeb.CustomComponents.TextComponents
import PinchflatWeb.CustomComponents.TableComponents import PinchflatWeb.CustomComponents.TableComponents
import PinchflatWeb.CustomComponents.ButtonComponents import PinchflatWeb.CustomComponents.ButtonComponents
@@ -256,6 +256,7 @@ defmodule PinchflatWeb.CoreComponents do
attr :prompt, :string, default: nil, doc: "the prompt for select inputs" attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs" attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :inputclass, :string, default: ""
attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step) multiple pattern placeholder readonly required rows size step)
@@ -281,7 +282,15 @@ defmodule PinchflatWeb.CoreComponents do
<div phx-feedback-for={@name}> <div phx-feedback-for={@name}>
<label class="flex items-center gap-4 text-sm leading-6"> <label class="flex items-center gap-4 text-sm leading-6">
<input type="hidden" name={@name} value="false" /> <input type="hidden" name={@name} value="false" />
<input type="checkbox" id={@id} name={@name} value="true" checked={@checked} class="rounded focus:ring-0" {@rest} /> <input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={["rounded focus:ring-0", @inputclass]}
{@rest}
/>
<%= @label %> <%= @label %>
</label> </label>
<.help :if={@help}><%= @help %></.help> <.help :if={@help}><%= @help %></.help>
@@ -315,7 +324,10 @@ defmodule PinchflatWeb.CoreComponents do
<div x-bind:class="enabled && '!bg-primary'" class="block h-8 w-14 rounded-full bg-black"></div> <div x-bind:class="enabled && '!bg-primary'" class="block h-8 w-14 rounded-full bg-black"></div>
<div <div
x-bind:class="enabled && '!right-1 !translate-x-full'" x-bind:class="enabled && '!right-1 !translate-x-full'"
class="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-white transition" class={[
"absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-white transition",
@inputclass
]}
> >
</div> </div>
</div> </div>
@@ -335,7 +347,8 @@ defmodule PinchflatWeb.CoreComponents do
name={@name} name={@name}
class={[ class={[
"relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 pl-5 pr-12 outline-none transition", "relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 pl-5 pr-12 outline-none transition",
"focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input text-black dark:text-white" "focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input text-black dark:text-white",
@inputclass
]} ]}
multiple={@multiple} multiple={@multiple}
{@rest} {@rest}
@@ -359,6 +372,7 @@ defmodule PinchflatWeb.CoreComponents do
class={[ class={[
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6", "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
"min-h-[6rem] phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400", "min-h-[6rem] phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
@inputclass,
@errors == [] && "border-zinc-300 focus:border-zinc-400", @errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400" @errors != [] && "border-rose-400 focus:border-rose-400"
]} ]}
@@ -384,6 +398,7 @@ defmodule PinchflatWeb.CoreComponents do
"w-full rounded-lg border-[1.5px] border-stroke bg-transparent px-5 py-3 font-normal text-black", "w-full rounded-lg border-[1.5px] border-stroke bg-transparent px-5 py-3 font-normal text-black",
"outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter", "outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter",
"dark:border-form-strokedark dark:bg-form-input dark:text-white dark:focus:border-primary", "dark:border-form-strokedark dark:bg-form-input dark:text-white dark:focus:border-primary",
@inputclass,
@errors != [] && "border-rose-400 focus:border-rose-400" @errors != [] && "border-rose-400 focus:border-rose-400"
]} ]}
{@rest} {@rest}
@@ -0,0 +1,31 @@
defmodule PinchflatWeb.CustomComponents.TextComponents do
@moduledoc false
use Phoenix.Component
@doc """
Renders a code block with the given content.
"""
slot :inner_block
def inline_code(assigns) do
~H"""
<code class="inline-block text-sm font-mono text-gray bg-boxdark rounded-md p-0.5 mx-0.5 text-nowrap">
<%= render_slot(@inner_block) %>
</code>
"""
end
@doc """
Renders a reference link with the given href and content.
"""
attr :href, :string, required: true
slot :inner_block
def reference_link(assigns) do
~H"""
<.link href={@href} target="_blank" class="text-blue-500 hover:text-blue-300">
<%= render_slot(@inner_block) %>
</.link>
"""
end
end
@@ -28,4 +28,21 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
{"360p", "360p"} {"360p", "360p"}
] ]
end end
def custom_output_template_options do
~w(upload_day upload_month upload_year)a
end
def common_output_template_options do
~w(
id
ext
title
fulltitle
uploader
channel
upload_date
duration_string
)a
end
end end
@@ -6,11 +6,13 @@
General Options General Options
</h3> </h3>
<.input field={f[:name]} type="text" label="Name" placeholder="New Profile" help="(required)" /> <.input field={f[:name]} type="text" label="Name" placeholder="New Profile" help="(required)" />
<.input <.input
field={f[:output_path_template]} field={f[:output_path_template]}
type="text" type="text"
inputclass="font-mono"
label="Output path template" label="Output path template"
help="TODO: provide docs (required)" help="Must end with .{{ ext }}. See below for more details. I promise the default is good for most cases (required)"
/> />
<h3 class="mb-4 mt-8 text-2xl text-black dark:text-white"> <h3 class="mb-4 mt-8 text-2xl text-black dark:text-white">
@@ -67,7 +69,7 @@
options={friendly_format_type_options()} options={friendly_format_type_options()}
type="select" type="select"
label="Include Shorts?" label="Include Shorts?"
help="Experimental" help="Experimental. Please report any issues on GitHub"
/> />
<.input <.input
field={f[:livestream_behaviour]} field={f[:livestream_behaviour]}
@@ -88,7 +90,9 @@
help="Will grab the closest available resolution if your preferred is not available" help="Will grab the closest available resolution if your preferred is not available"
/> />
<:actions> <.button class="mt-15 mb-5 sm:mb-7.5">Save Media profile</.button>
<.button class="mt-15 mb-5 sm:mb-7.5">Save Media profile</.button>
</:actions> <div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
<.output_template_help />
</div>
</.simple_form> </.simple_form>
@@ -0,0 +1,72 @@
<%!-- The heex HTML formatter is really struggling with this file - I apologize in advance --%>
<aside>
<h2 class="text-xl font-bold mb-2">Output Template Syntax</h2>
<section class="ml-2 md:ml-4 mb-4 max-w-prose">
<p>When generating an output template, you have 3 options for syntax:</p>
<ul class="list-disc list-inside ml-2 md:ml-5">
<li>
Liquid-style:
<.inline_code>/{{ channel }}/{{ title }} - {{ id }}.{{ ext }}</.inline_code>
</li>
<li>
<code class="text-sm">yt-dlp</code>-style
<.reference_link href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#output-template">
<.icon name="hero-arrow-top-right-on-square" class="h-4 w-4" />
</.reference_link>:
<.inline_code>/%(channel)s/%(duration>%H-%M-%S)s-%(id)s.%(ext)s</.inline_code>
</li>
<li>
Any bare words:
<.inline_code>/videos/1080p/{{ id }}.{{ ext }}</.inline_code>
</li>
</ul>
<p class="my-2">
Apart from custom aliases, the liquid-style syntax maps 1:1 to the <code class="text-sm">yt-dlp</code>-style syntax behind-the-scenes. This means that
<em>any</em>
single-word <code class="text-sm">yt-dlp</code>
option can be used as liquid-style and it's automatically made filepath-safe. For example, the
<.inline_code>{{ duration }}</.inline_code>
option is translated to
<.inline_code>%(duration)S</.inline_code>
</p>
<p class="my-2">
<strong>Major 🔑:</strong>
these syntaxes can be mixed and matched freely! I prefer to use liquid-style and bare words
but I'll include <code class="text-sm">yt-dlp</code>-style when I need more control. For example:
<.inline_code>/1080p/{{ channel }}/{{ title }}-(%(subtitles.en.-1.ext)s).{{ ext }}</.inline_code>
</p>
<p class="my-2">
<strong>NOTE:</strong>
Your template <em>must</em>
end with an extension option (<.inline_code>.{{ ext }}</.inline_code>
or
<.inline_code>.%(ext)S</.inline_code>).
Downloading won't work as expected without it.
</p>
</section>
<h2 class="text-xl font-bold mb-2">Template Options</h2>
<section class="ml-2 md:ml-4 mb-4 max-w-prose">
<p>
Any single-word <code class="text-sm">yt-dlp</code>
option
<.reference_link href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#output-template">
<.icon name="hero-arrow-top-right-on-square" class="h-4 w-4" />
</.reference_link>
can be used with the curly braced liquid-style syntax.
This is just a list of the most common options as well as some custom aliases
</p>
<h3 class="text-lg font-bold mb-2">Custom Aliases</h3>
<ul class="list-disc list-inside ml-2 md:ml-5">
<li :for={opt <- custom_output_template_options()}>
<.inline_code>{{ <%= opt %> }}</.inline_code>
</li>
</ul>
<h3 class="text-lg font-bold mb-2">Common Options</h3>
<ul class="list-disc list-inside ml-2 md:ml-5">
<li :for={opt <- common_output_template_options()}>
<.inline_code>{{ <%= opt %> }}</.inline_code>
</li>
</ul>
</section>
</aside>
@@ -3,7 +3,7 @@ defmodule Pinchflat.RenderedString.ParserTest do
alias Pinchflat.RenderedString.Parser alias Pinchflat.RenderedString.Parser
describe "parse/2" do describe "parse/3" do
test "it returns the rendered string when the string is valid" do test "it returns the rendered string when the string is valid" do
assert {:ok, "bar"} = Parser.parse("{{ foo }}", %{"foo" => "bar"}) assert {:ok, "bar"} = Parser.parse("{{ foo }}", %{"foo" => "bar"})
end end
@@ -36,5 +36,13 @@ defmodule Pinchflat.RenderedString.ParserTest do
test "it returns an error when the string is invalid" do test "it returns an error when the string is invalid" do
assert {:error, "expected end of string"} = Parser.parse("{{ 1-1 }", %{}) assert {:error, "expected end of string"} = Parser.parse("{{ 1-1 }", %{})
end end
test "it supports a custom fetcher function" do
custom_fetcher = fn _, _ ->
"quux"
end
assert {:ok, "quux"} = Parser.parse("{{ foo }}", %{}, custom_fetcher)
end
end end
end end