From 4252c27f87d0b5079901c2d22b38519f4f47cd92 Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 22 Jan 2024 20:51:28 -0800 Subject: [PATCH] Initial implementation of media profiles (#7) * [WIP] Added basic video download method * [WIP] Very-WIP first steps at parsing options and downloading * Made my options safe by default and removed special safe versions * Ran html generator for mediaprofile model - leaving as-is for now * Addressed a bunch of TODO comments --- config/config.exs | 4 +- config/dev.exs | 3 + config/test.exs | 3 +- ideas.md | 3 + .../backends/yt_dlp/command_runner.ex | 4 + .../downloader/backends/yt_dlp/video.ex | 22 +++++ lib/pinchflat/downloader/video_downloader.ex | 38 ++++++++ lib/pinchflat/profiles.ex | 56 ++++++++++++ lib/pinchflat/profiles/media_profile.ex | 23 +++++ .../profiles/options/yt_dlp/option_builder.ex | 42 +++++++++ .../options/yt_dlp/output_path_builder.ex | 64 +++++++++++++ .../media_profile_controller.ex | 62 +++++++++++++ .../media_profiles/media_profile_html.ex | 13 +++ .../media_profile_html/edit.html.heex | 8 ++ .../media_profile_html/index.html.heex | 30 +++++++ .../media_profile_form.html.heex | 10 +++ .../media_profile_html/new.html.heex | 8 ++ .../media_profile_html/show.html.heex | 16 ++++ lib/pinchflat_web/router.ex | 2 + .../20240122030944_create_media_profiles.exs | 14 +++ .../yt_dlp/output_path_builder_test.exs | 25 ++++++ .../backends/yt_dlp/video_collection_test.exs | 2 +- .../downloader/backends/yt_dlp/video_test.exs | 44 +++++++++ .../downloader/video_downloader_test.exs | 35 ++++++++ .../options/yt_dlp/option_builder_test.exs | 18 ++++ test/pinchflat/profiles_test.exs | 70 +++++++++++++++ test/pinchflat/utils/string_utils_test.exs | 2 +- .../media_profile_controller_test.exs | 90 +++++++++++++++++++ test/support/fixtures/profiles_fixtures.ex | 21 +++++ 29 files changed, 728 insertions(+), 4 deletions(-) create mode 100644 ideas.md create mode 100644 lib/pinchflat/downloader/backends/yt_dlp/video.ex create mode 100644 lib/pinchflat/downloader/video_downloader.ex create mode 100644 lib/pinchflat/profiles.ex create mode 100644 lib/pinchflat/profiles/media_profile.ex create mode 100644 lib/pinchflat/profiles/options/yt_dlp/option_builder.ex create mode 100644 lib/pinchflat/profiles/options/yt_dlp/output_path_builder.ex create mode 100644 lib/pinchflat_web/controllers/media_profiles/media_profile_controller.ex create mode 100644 lib/pinchflat_web/controllers/media_profiles/media_profile_html.ex create mode 100644 lib/pinchflat_web/controllers/media_profiles/media_profile_html/edit.html.heex create mode 100644 lib/pinchflat_web/controllers/media_profiles/media_profile_html/index.html.heex create mode 100644 lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex create mode 100644 lib/pinchflat_web/controllers/media_profiles/media_profile_html/new.html.heex create mode 100644 lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex create mode 100644 priv/repo/migrations/20240122030944_create_media_profiles.exs create mode 100644 test/pinchflat/downloader/backends/yt_dlp/output_path_builder_test.exs create mode 100644 test/pinchflat/downloader/backends/yt_dlp/video_test.exs create mode 100644 test/pinchflat/downloader/video_downloader_test.exs create mode 100644 test/pinchflat/profiles/options/yt_dlp/option_builder_test.exs create mode 100644 test/pinchflat/profiles_test.exs create mode 100644 test/pinchflat_web/controllers/media_profile_controller_test.exs create mode 100644 test/support/fixtures/profiles_fixtures.ex diff --git a/config/config.exs b/config/config.exs index fd3d9f1..92bd240 100644 --- a/config/config.exs +++ b/config/config.exs @@ -12,7 +12,9 @@ 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.Downloader.Backends.YtDlp.CommandRunner + yt_dlp_runner: Pinchflat.Downloader.Backends.YtDlp.CommandRunner, + # TODO: figure this out + media_directory: :not_implemented # Configures the endpoint config :pinchflat, PinchflatWeb.Endpoint, diff --git a/config/dev.exs b/config/dev.exs index a3bc3e4..847ef3c 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -1,5 +1,8 @@ import Config +config :pinchflat, + media_directory: Path.join([System.tmp_dir!(), "yt-dlp"]) + # Configure your database config :pinchflat, Pinchflat.Repo, username: System.get_env("POSTGRES_USER"), diff --git a/config/test.exs b/config/test.exs index 20d03ff..1ca2159 100644 --- a/config/test.exs +++ b/config/test.exs @@ -2,7 +2,8 @@ import Config config :pinchflat, # Specifying backend data here makes mocking and local testing SUPER easy - yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]) + yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]), + media_directory: Path.join([System.tmp_dir!(), "yt-dlp"]) # Configure your database # diff --git a/ideas.md b/ideas.md new file mode 100644 index 0000000..fabc001 --- /dev/null +++ b/ideas.md @@ -0,0 +1,3 @@ +- Write media datbase ID as metadata/to file/whatever so it gives us an option to retroactively match media to the DB down the line. Useful if someone moves the media without informing the UI + - Use a UUID for the media database ID (or at least alongside it) +- Look into this and its recommended plugins https://hexdocs.pm/ex_check/readme.html diff --git a/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex b/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex index 545e094..b452dfd 100644 --- a/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex +++ b/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex @@ -10,6 +10,10 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.CommandRunner do @doc """ Runs a yt-dlp command and returns the string output + + # IDEA: deduplicate command opts, keeping the last one on conflict + although possibly not needed (and a LOT easier) if yt-dlp + just ignores duplicate options (ie: look into that) """ @impl BackendCommandRunner def run(url, command_opts) do diff --git a/lib/pinchflat/downloader/backends/yt_dlp/video.ex b/lib/pinchflat/downloader/backends/yt_dlp/video.ex new file mode 100644 index 0000000..5d95e21 --- /dev/null +++ b/lib/pinchflat/downloader/backends/yt_dlp/video.ex @@ -0,0 +1,22 @@ +defmodule Pinchflat.Downloader.Backends.YtDlp.Video do + @moduledoc """ + Contains utilities for working with singular videos + """ + + @doc """ + Downloads a single video (and possible metadata) directly to its + final destination. Returns the parsed JSON output from yt-dlp. + """ + def download(url, command_opts \\ []) do + opts = [:no_simulate, :dump_json] ++ command_opts + + case backend_runner().run(url, opts) do + {:ok, output} -> Phoenix.json_library().decode(output) + err -> err + end + end + + defp backend_runner do + Application.get_env(:pinchflat, :yt_dlp_runner) + end +end diff --git a/lib/pinchflat/downloader/video_downloader.ex b/lib/pinchflat/downloader/video_downloader.ex new file mode 100644 index 0000000..d5166b7 --- /dev/null +++ b/lib/pinchflat/downloader/video_downloader.ex @@ -0,0 +1,38 @@ +defmodule Pinchflat.Downloader.VideoDownloader do + @moduledoc """ + This is the integration layer for actually downloading videos. + It takes into account the media profile's settings in order + to download the video with the desired options. + + Technically hardcodes the yt-dlp backend for now, but should leave + it open-ish for future expansion (just in case). + """ + + alias Pinchflat.Profiles.MediaProfile + + alias Pinchflat.Downloader.Backends.YtDlp.Video, as: YtDlpVideo + alias Pinchflat.Profiles.Options.YtDlp.OptionBuilder, as: YtDlpOptionBuilder + + @doc """ + Downloads a single video based on the settings in the given media profile. + """ + def download_for_media_profile(url, %MediaProfile{} = media_profile, backend \\ :yt_dlp) do + option_builder = option_builder(backend) + video_backend = video_backend(backend) + {:ok, options} = option_builder.build(media_profile) + + video_backend.download(url, options) + end + + defp option_builder(backend) do + case backend do + :yt_dlp -> YtDlpOptionBuilder + end + end + + defp video_backend(backend) do + case backend do + :yt_dlp -> YtDlpVideo + end + end +end diff --git a/lib/pinchflat/profiles.ex b/lib/pinchflat/profiles.ex new file mode 100644 index 0000000..856f931 --- /dev/null +++ b/lib/pinchflat/profiles.ex @@ -0,0 +1,56 @@ +defmodule Pinchflat.Profiles do + @moduledoc """ + The Profiles context. + """ + + import Ecto.Query, warn: false + alias Pinchflat.Repo + + alias Pinchflat.Profiles.MediaProfile + + @doc """ + Returns the list of media_profiles. Returns [%MediaProfile{}, ...] + """ + def list_media_profiles do + Repo.all(MediaProfile) + end + + @doc """ + Gets a single media_profile. + + Returns %MediaProfile{}. Raises `Ecto.NoResultsError` if the Media profile does not exist. + """ + def get_media_profile!(id), do: Repo.get!(MediaProfile, id) + + @doc """ + Creates a media_profile. Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}} + """ + def create_media_profile(attrs \\ %{}) do + %MediaProfile{} + |> MediaProfile.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a media_profile. Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}} + """ + def update_media_profile(%MediaProfile{} = media_profile, attrs) do + media_profile + |> MediaProfile.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a media_profile. Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}} + """ + def delete_media_profile(%MediaProfile{} = media_profile) do + Repo.delete(media_profile) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking media_profile changes. + """ + def change_media_profile(%MediaProfile{} = media_profile, attrs \\ %{}) do + MediaProfile.changeset(media_profile, attrs) + end +end diff --git a/lib/pinchflat/profiles/media_profile.ex b/lib/pinchflat/profiles/media_profile.ex new file mode 100644 index 0000000..ed66992 --- /dev/null +++ b/lib/pinchflat/profiles/media_profile.ex @@ -0,0 +1,23 @@ +defmodule Pinchflat.Profiles.MediaProfile do + @moduledoc """ + A media profile is a set of settings that can be applied to many media sources + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "media_profiles" do + field :name, :string + field :output_path_template, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(media_profile, attrs) do + media_profile + |> cast(attrs, [:name, :output_path_template]) + |> validate_required([:name, :output_path_template]) + |> unique_constraint(:name) + end +end diff --git a/lib/pinchflat/profiles/options/yt_dlp/option_builder.ex b/lib/pinchflat/profiles/options/yt_dlp/option_builder.ex new file mode 100644 index 0000000..ead342f --- /dev/null +++ b/lib/pinchflat/profiles/options/yt_dlp/option_builder.ex @@ -0,0 +1,42 @@ +defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilder do + @moduledoc """ + Builds the options for yt-dlp based on the given media profile. + + IDEA: consider making this a behaviour so I can add other backends later + """ + + alias Pinchflat.Profiles.MediaProfile + alias Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder + + @doc """ + Builds the options for yt-dlp based on the given media profile. + + IDEA: consider adding the ability to pass in a second argument to override + these options + """ + def build(%MediaProfile{} = media_profile) do + {:ok, output_path} = OutputPathBuilder.build(media_profile.output_path_template) + + # NOTE: I'll be hardcoding most things for now (esp. options to help me test) - + # add more configuration later as I build out the models. Walk before you can run! + + {:ok, + [ + :write_thumbnail, + :write_subs, + :embed_metadata, + :embed_thumbnail, + :embed_subs, + :write_info_json, + :write_auto_subs, + :no_progress, + convert_thumbnails: "jpg", + sub_langs: "en.*", + output: Path.join(base_directory(), output_path) + ]} + end + + defp base_directory do + Application.get_env(:pinchflat, :media_directory) + end +end diff --git a/lib/pinchflat/profiles/options/yt_dlp/output_path_builder.ex b/lib/pinchflat/profiles/options/yt_dlp/output_path_builder.ex new file mode 100644 index 0000000..b058784 --- /dev/null +++ b/lib/pinchflat/profiles/options/yt_dlp/output_path_builder.ex @@ -0,0 +1,64 @@ +defmodule Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder do + @moduledoc """ + Builds yt-dlp-friendly output paths for downloaded media + + IDEA: consider making this a behaviour so I can add other backends later + """ + + alias Pinchflat.RenderedString.Parser, as: TemplateParser + + @doc """ + Builds the actual final filepath from a given template. + + Translates liquid-style templates into yt-dlp-style templates, + leaving yt-dlp syntax intact. + """ + def build(template_string) do + TemplateParser.parse(template_string, full_yt_dlp_options_map()) + end + + defp full_yt_dlp_options_map do + Map.merge( + standard_yt_dlp_option_map(), + custom_yt_dlp_option_map() + ) + end + + defp standard_yt_dlp_option_map do + %{ + # Uppercase "S" means "safe" - ie: filepath-safe + "id" => "%(id)S", + "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 + + defp custom_yt_dlp_option_map do + %{ + # Individual parts of the upload date + "upload_year" => "%(upload_date>%Y)S", + "upload_month" => "%(upload_date>%m)S", + "upload_day" => "%(upload_date>%d)S" + } + end +end diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_controller.ex b/lib/pinchflat_web/controllers/media_profiles/media_profile_controller.ex new file mode 100644 index 0000000..895b219 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_controller.ex @@ -0,0 +1,62 @@ +defmodule PinchflatWeb.MediaProfiles.MediaProfileController do + use PinchflatWeb, :controller + + alias Pinchflat.Profiles + alias Pinchflat.Profiles.MediaProfile + + def index(conn, _params) do + media_profiles = Profiles.list_media_profiles() + render(conn, :index, media_profiles: media_profiles) + end + + def new(conn, _params) do + changeset = Profiles.change_media_profile(%MediaProfile{}) + render(conn, :new, changeset: changeset) + end + + def create(conn, %{"media_profile" => media_profile_params}) do + case Profiles.create_media_profile(media_profile_params) do + {:ok, media_profile} -> + conn + |> put_flash(:info, "Media profile created successfully.") + |> redirect(to: ~p"/media_profiles/#{media_profile}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + end + + def show(conn, %{"id" => id}) do + media_profile = Profiles.get_media_profile!(id) + render(conn, :show, media_profile: media_profile) + end + + def edit(conn, %{"id" => id}) do + media_profile = Profiles.get_media_profile!(id) + changeset = Profiles.change_media_profile(media_profile) + render(conn, :edit, media_profile: media_profile, changeset: changeset) + end + + def update(conn, %{"id" => id, "media_profile" => media_profile_params}) do + media_profile = Profiles.get_media_profile!(id) + + case Profiles.update_media_profile(media_profile, media_profile_params) do + {:ok, media_profile} -> + conn + |> put_flash(:info, "Media profile updated successfully.") + |> redirect(to: ~p"/media_profiles/#{media_profile}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :edit, media_profile: media_profile, changeset: changeset) + end + end + + def delete(conn, %{"id" => id}) do + media_profile = Profiles.get_media_profile!(id) + {:ok, _media_profile} = Profiles.delete_media_profile(media_profile) + + conn + |> put_flash(:info, "Media profile deleted successfully.") + |> redirect(to: ~p"/media_profiles") + end +end diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html.ex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html.ex new file mode 100644 index 0000000..edb33ee --- /dev/null +++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html.ex @@ -0,0 +1,13 @@ +defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do + use PinchflatWeb, :html + + embed_templates "media_profile_html/*" + + @doc """ + Renders a media_profile form. + """ + attr :changeset, Ecto.Changeset, required: true + attr :action, :string, required: true + + def media_profile_form(assigns) +end diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/edit.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/edit.html.heex new file mode 100644 index 0000000..ca35b19 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/edit.html.heex @@ -0,0 +1,8 @@ +<.header> + Edit Media profile <%= @media_profile.id %> + <:subtitle>Use this form to manage media_profile records in your database. + + +<.media_profile_form changeset={@changeset} action={~p"/media_profiles/#{@media_profile}"} /> + +<.back navigate={~p"/media_profiles"}>Back to media_profiles diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/index.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/index.html.heex new file mode 100644 index 0000000..eec319c --- /dev/null +++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/index.html.heex @@ -0,0 +1,30 @@ +<.header> + Listing Media profiles + <:actions> + <.link href={~p"/media_profiles/new"}> + <.button>New Media profile + + + + +<.table + id="media_profiles" + rows={@media_profiles} + row_click={&JS.navigate(~p"/media_profiles/#{&1}")} +> + <:col :let={media_profile} label="Name"><%= media_profile.name %> + <:col :let={media_profile} label="Output path template"> + <%= media_profile.output_path_template %> + + <:action :let={media_profile}> +
+ <.link navigate={~p"/media_profiles/#{media_profile}"}>Show +
+ <.link navigate={~p"/media_profiles/#{media_profile}/edit"}>Edit + + <:action :let={media_profile}> + <.link href={~p"/media_profiles/#{media_profile}"} method="delete" data-confirm="Are you sure?"> + Delete + + + diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex new file mode 100644 index 0000000..2a3ab66 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex @@ -0,0 +1,10 @@ +<.simple_form :let={f} for={@changeset} action={@action}> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input field={f[:name]} type="text" label="Name" /> + <.input field={f[:output_path_template]} type="text" label="Output path template" /> + <:actions> + <.button>Save Media profile + + diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/new.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/new.html.heex new file mode 100644 index 0000000..0887e17 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/new.html.heex @@ -0,0 +1,8 @@ +<.header> + New Media profile + <:subtitle>Use this form to manage media_profile records in your database. + + +<.media_profile_form changeset={@changeset} action={~p"/media_profiles"} /> + +<.back navigate={~p"/media_profiles"}>Back to media_profiles diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex new file mode 100644 index 0000000..80ea07f --- /dev/null +++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex @@ -0,0 +1,16 @@ +<.header> + Media profile <%= @media_profile.id %> + <:subtitle>This is a media_profile record from your database. + <:actions> + <.link href={~p"/media_profiles/#{@media_profile}/edit"}> + <.button>Edit media_profile + + + + +<.list> + <:item title="Name"><%= @media_profile.name %> + <:item title="Output path template"><%= @media_profile.output_path_template %> + + +<.back navigate={~p"/media_profiles"}>Back to media_profiles diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index 186fcb3..6b62a82 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -18,6 +18,8 @@ defmodule PinchflatWeb.Router do pipe_through :browser get "/", PageController, :home + + resources "/media_profiles", MediaProfiles.MediaProfileController end # Other scopes may use custom stacks. diff --git a/priv/repo/migrations/20240122030944_create_media_profiles.exs b/priv/repo/migrations/20240122030944_create_media_profiles.exs new file mode 100644 index 0000000..5997a44 --- /dev/null +++ b/priv/repo/migrations/20240122030944_create_media_profiles.exs @@ -0,0 +1,14 @@ +defmodule Pinchflat.Repo.Migrations.CreateMediaProfiles do + use Ecto.Migration + + def change do + create table(:media_profiles) do + add :name, :string, null: false + add :output_path_template, :string, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:media_profiles, [:name]) + end +end diff --git a/test/pinchflat/downloader/backends/yt_dlp/output_path_builder_test.exs b/test/pinchflat/downloader/backends/yt_dlp/output_path_builder_test.exs new file mode 100644 index 0000000..4098726 --- /dev/null +++ b/test/pinchflat/downloader/backends/yt_dlp/output_path_builder_test.exs @@ -0,0 +1,25 @@ +defmodule Pinchflat.Downloader.Backends.YtDlp.OutputPathBuilderTest do + use ExUnit.Case, async: true + + alias Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder + + describe "build/1" do + test "it expands 'standard' curly brace variables in the template" do + assert {:ok, res} = OutputPathBuilder.build("/videos/{{ title }}.{{ ext }}") + + assert res == "/videos/%(title)S.%(ext)S" + end + + test "it expands 'custom' curly brace variables in the template" do + assert {:ok, res} = OutputPathBuilder.build("/videos/{{ upload_year }}.{{ ext }}") + + assert res == "/videos/%(upload_date>%Y)S.%(ext)S" + end + + test "it leaves yt-dlp variables alone" do + assert {:ok, res} = OutputPathBuilder.build("/videos/%(title)s.%(ext)s") + + assert res == "/videos/%(title)s.%(ext)s" + end + end +end diff --git a/test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs b/test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs index 31cb840..06c55e8 100644 --- a/test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs +++ b/test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs @@ -2,7 +2,7 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollectionTest do use ExUnit.Case, async: true import Mox - alias Pinchflat.Downloader.Backends.YtDlp.VideoCollection, as: VideoCollection + alias Pinchflat.Downloader.Backends.YtDlp.VideoCollection @channel_url "https://www.youtube.com/@TheUselessTrials" diff --git a/test/pinchflat/downloader/backends/yt_dlp/video_test.exs b/test/pinchflat/downloader/backends/yt_dlp/video_test.exs new file mode 100644 index 0000000..00a6430 --- /dev/null +++ b/test/pinchflat/downloader/backends/yt_dlp/video_test.exs @@ -0,0 +1,44 @@ +defmodule Pinchflat.Downloader.Backends.YtDlp.VideoTest do + use ExUnit.Case, async: true + import Mox + + alias Pinchflat.Downloader.Backends.YtDlp.Video + + @video_url "https://www.youtube.com/watch?v=TiZPUDkDYbk" + + setup :verify_on_exit! + + describe "download/2" do + test "it calls the backend runner with the expected arguments" do + expect(CommandRunnerMock, :run, fn @video_url, opts -> + assert opts == [:no_simulate, :dump_json] + + {:ok, "{}"} + end) + + assert {:ok, _} = Video.download(@video_url) + end + + test "it passes along additional options" do + expect(CommandRunnerMock, :run, fn _url, opts -> + assert opts == [:no_simulate, :dump_json, :custom_arg] + + {:ok, "{}"} + end) + + assert {:ok, _} = Video.download(@video_url, [:custom_arg]) + end + + test "it parses the output as JSON" do + expect(CommandRunnerMock, :run, fn _url, _opts -> {:ok, "{\"title\": \"Test\"}"} end) + + assert {:ok, %{"title" => "Test"}} = Video.download(@video_url) + end + + test "it directly passes along any errors" do + expect(CommandRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end) + + assert {:error, "Big issue", 1} = Video.download(@video_url) + end + end +end diff --git a/test/pinchflat/downloader/video_downloader_test.exs b/test/pinchflat/downloader/video_downloader_test.exs new file mode 100644 index 0000000..36ad4a8 --- /dev/null +++ b/test/pinchflat/downloader/video_downloader_test.exs @@ -0,0 +1,35 @@ +defmodule Pinchflat.Downloader.VideoDownloaderTest do + use ExUnit.Case, async: true + import Mox + + alias Pinchflat.Profiles.MediaProfile + alias Pinchflat.Downloader.VideoDownloader + + @video_url "https://www.youtube.com/watch?v=TiZPUDkDYbk" + @media_profile %MediaProfile{ + output_path_template: "videos/{{ title }}.%(ext)s" + } + + setup :verify_on_exit! + + describe "download_for_media_profile/3" do + test "it calls the backend runner with the arguments built from the media profile" do + expect(CommandRunnerMock, :run, fn @video_url, opts -> + assert :no_simulate in opts + assert :dump_json in opts + assert {:output, "/tmp/yt-dlp/videos/%(title)S.%(ext)s"} in opts + + {:ok, "{}"} + end) + + assert {:ok, _} = VideoDownloader.download_for_media_profile(@video_url, @media_profile) + end + + test "it returns the parsed JSON output" do + expect(CommandRunnerMock, :run, fn _url, _opts -> {:ok, "{\"title\": \"Test\"}"} end) + + assert {:ok, %{"title" => "Test"}} = + VideoDownloader.download_for_media_profile(@video_url, @media_profile) + end + end +end diff --git a/test/pinchflat/profiles/options/yt_dlp/option_builder_test.exs b/test/pinchflat/profiles/options/yt_dlp/option_builder_test.exs new file mode 100644 index 0000000..eefb92d --- /dev/null +++ b/test/pinchflat/profiles/options/yt_dlp/option_builder_test.exs @@ -0,0 +1,18 @@ +defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilderTest do + use ExUnit.Case, async: true + + alias Pinchflat.Profiles.MediaProfile + alias Pinchflat.Profiles.Options.YtDlp.OptionBuilder + + @media_profile %MediaProfile{ + output_path_template: "videos/{{ title }}.%(ext)s" + } + + describe "build/1" do + test "it generates an expanded output path based on the given template" do + assert {:ok, res} = OptionBuilder.build(@media_profile) + + assert {:output, "/tmp/yt-dlp/videos/%(title)S.%(ext)s"} in res + end + end +end diff --git a/test/pinchflat/profiles_test.exs b/test/pinchflat/profiles_test.exs new file mode 100644 index 0000000..9f54b39 --- /dev/null +++ b/test/pinchflat/profiles_test.exs @@ -0,0 +1,70 @@ +defmodule Pinchflat.ProfilesTest do + use Pinchflat.DataCase + + alias Pinchflat.Profiles + + describe "media_profiles" do + alias Pinchflat.Profiles.MediaProfile + + import Pinchflat.ProfilesFixtures + + @invalid_attrs %{name: nil, output_path_template: nil} + + test "list_media_profiles/0 returns all media_profiles" do + media_profile = media_profile_fixture() + assert Profiles.list_media_profiles() == [media_profile] + end + + test "get_media_profile!/1 returns the media_profile with given id" do + media_profile = media_profile_fixture() + assert Profiles.get_media_profile!(media_profile.id) == media_profile + end + + test "create_media_profile/1 with valid data creates a media_profile" do + valid_attrs = %{name: "some name", output_path_template: "some output_path_template"} + + assert {:ok, %MediaProfile{} = media_profile} = Profiles.create_media_profile(valid_attrs) + assert media_profile.name == "some name" + assert media_profile.output_path_template == "some output_path_template" + end + + test "create_media_profile/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Profiles.create_media_profile(@invalid_attrs) + end + + test "update_media_profile/2 with valid data updates the media_profile" do + media_profile = media_profile_fixture() + + update_attrs = %{ + name: "some updated name", + output_path_template: "some updated output_path_template" + } + + assert {:ok, %MediaProfile{} = media_profile} = + Profiles.update_media_profile(media_profile, update_attrs) + + assert media_profile.name == "some updated name" + assert media_profile.output_path_template == "some updated output_path_template" + end + + test "update_media_profile/2 with invalid data returns error changeset" do + media_profile = media_profile_fixture() + + assert {:error, %Ecto.Changeset{}} = + Profiles.update_media_profile(media_profile, @invalid_attrs) + + assert media_profile == Profiles.get_media_profile!(media_profile.id) + end + + test "delete_media_profile/1 deletes the media_profile" do + media_profile = media_profile_fixture() + assert {:ok, %MediaProfile{}} = Profiles.delete_media_profile(media_profile) + assert_raise Ecto.NoResultsError, fn -> Profiles.get_media_profile!(media_profile.id) end + end + + test "change_media_profile/1 returns a media_profile changeset" do + media_profile = media_profile_fixture() + assert %Ecto.Changeset{} = Profiles.change_media_profile(media_profile) + end + end +end diff --git a/test/pinchflat/utils/string_utils_test.exs b/test/pinchflat/utils/string_utils_test.exs index 59ebbfc..60071fb 100644 --- a/test/pinchflat/utils/string_utils_test.exs +++ b/test/pinchflat/utils/string_utils_test.exs @@ -1,7 +1,7 @@ defmodule Pinchflat.Utils.StringUtilsTest do use ExUnit.Case, async: true - alias Pinchflat.Utils.StringUtils, as: StringUtils + alias Pinchflat.Utils.StringUtils describe "to_kebab_case/1" do test "converts a space-delimited string to kebab-case" do diff --git a/test/pinchflat_web/controllers/media_profile_controller_test.exs b/test/pinchflat_web/controllers/media_profile_controller_test.exs new file mode 100644 index 0000000..b5bac1e --- /dev/null +++ b/test/pinchflat_web/controllers/media_profile_controller_test.exs @@ -0,0 +1,90 @@ +defmodule PinchflatWeb.MediaProfileControllerTest do + use PinchflatWeb.ConnCase + + import Pinchflat.ProfilesFixtures + + @create_attrs %{name: "some name", output_path_template: "some output_path_template"} + @update_attrs %{ + name: "some updated name", + output_path_template: "some updated output_path_template" + } + @invalid_attrs %{name: nil, output_path_template: nil} + + describe "index" do + test "lists all media_profiles", %{conn: conn} do + conn = get(conn, ~p"/media_profiles") + assert html_response(conn, 200) =~ "Listing Media profiles" + end + end + + describe "new media_profile" do + test "renders form", %{conn: conn} do + conn = get(conn, ~p"/media_profiles/new") + assert html_response(conn, 200) =~ "New Media profile" + end + end + + describe "create media_profile" do + test "redirects to show when data is valid", %{conn: conn} do + conn = post(conn, ~p"/media_profiles", media_profile: @create_attrs) + + assert %{id: id} = redirected_params(conn) + assert redirected_to(conn) == ~p"/media_profiles/#{id}" + + conn = get(conn, ~p"/media_profiles/#{id}") + assert html_response(conn, 200) =~ "Media profile #{id}" + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/media_profiles", media_profile: @invalid_attrs) + assert html_response(conn, 200) =~ "New Media profile" + end + end + + describe "edit media_profile" do + setup [:create_media_profile] + + test "renders form for editing chosen media_profile", %{ + conn: conn, + media_profile: media_profile + } do + conn = get(conn, ~p"/media_profiles/#{media_profile}/edit") + assert html_response(conn, 200) =~ "Edit Media profile" + end + end + + describe "update media_profile" do + setup [:create_media_profile] + + test "redirects when data is valid", %{conn: conn, media_profile: media_profile} do + conn = put(conn, ~p"/media_profiles/#{media_profile}", media_profile: @update_attrs) + assert redirected_to(conn) == ~p"/media_profiles/#{media_profile}" + + conn = get(conn, ~p"/media_profiles/#{media_profile}") + assert html_response(conn, 200) =~ "some updated name" + end + + test "renders errors when data is invalid", %{conn: conn, media_profile: media_profile} do + conn = put(conn, ~p"/media_profiles/#{media_profile}", media_profile: @invalid_attrs) + assert html_response(conn, 200) =~ "Edit Media profile" + end + end + + describe "delete media_profile" do + setup [:create_media_profile] + + test "deletes chosen media_profile", %{conn: conn, media_profile: media_profile} do + conn = delete(conn, ~p"/media_profiles/#{media_profile}") + assert redirected_to(conn) == ~p"/media_profiles" + + assert_error_sent 404, fn -> + get(conn, ~p"/media_profiles/#{media_profile}") + end + end + end + + defp create_media_profile(_) do + media_profile = media_profile_fixture() + %{media_profile: media_profile} + end +end diff --git a/test/support/fixtures/profiles_fixtures.ex b/test/support/fixtures/profiles_fixtures.ex new file mode 100644 index 0000000..ef846f5 --- /dev/null +++ b/test/support/fixtures/profiles_fixtures.ex @@ -0,0 +1,21 @@ +defmodule Pinchflat.ProfilesFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Pinchflat.Profiles` context. + """ + + @doc """ + Generate a media_profile. + """ + def media_profile_fixture(attrs \\ %{}) do + {:ok, media_profile} = + attrs + |> Enum.into(%{ + name: "some name", + output_path_template: "some output_path_template" + }) + |> Pinchflat.Profiles.create_media_profile() + + media_profile + end +end