From 480af45527b3aadeb87ee6ff8fdc7610cce40daa Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 24 Jan 2024 14:07:39 -0800 Subject: [PATCH] Add "channel" type Media Source (#8) * [WIP] Working on fetching channel metadata in yt-dlp backend * Finished first draft of methods to do with querying channels * Renamed CommandRunnerMock to have a more descriptive name * Ran the phx generator for the channel model * Renamed Downloader namespace to MediaClient * [WIP] saving before attempting LiveView * LiveView did not work out but here's a working controller how about --- config/config.exs | 2 +- ideas.md | 2 +- .../backends/yt_dlp/video_collection.ex | 21 -- .../backends/backend_command_runner.ex | 2 +- .../media_client/backends/yt_dlp/channel.ex | 32 +++ .../backends/yt_dlp/command_runner.ex | 6 +- .../backends/yt_dlp/video.ex | 12 +- .../backends/yt_dlp/video_collection.ex | 28 +++ lib/pinchflat/media_client/channel_details.ex | 32 +++ .../video_downloader.ex | 6 +- lib/pinchflat/media_source.ex | 99 ++++++++ lib/pinchflat/media_source/channel.ex | 33 +++ lib/pinchflat/profiles/media_profile.ex | 4 + .../profiles/options/yt_dlp/option_builder.ex | 2 + .../media_sources/channel_controller.ex | 75 ++++++ .../controllers/media_sources/channel_html.ex | 14 ++ .../channel_html/channel_form.html.heex | 18 ++ .../media_sources/channel_html/edit.html.heex | 12 + .../channel_html/index.html.heex | 28 +++ .../media_sources/channel_html/new.html.heex | 12 + .../media_sources/channel_html/show.html.heex | 17 ++ lib/pinchflat_web/router.ex | 4 + .../20240123174417_create_channels.exs | 17 ++ .../backends/yt_dlp/video_collection_test.exs | 44 ---- .../downloader/backends/yt_dlp/video_test.exs | 44 ---- .../backends/yt_dlp/channel_test.exs | 44 ++++ .../backends/yt_dlp/command_runner_test.exs | 6 +- .../yt_dlp/output_path_builder_test.exs | 2 +- .../backends/yt_dlp/video_collection_test.exs | 48 ++++ .../backends/yt_dlp/video_test.exs | 50 ++++ .../media_client/channel_details_test.exs | 38 +++ .../video_downloader_test.exs | 10 +- test/pinchflat/media_source_test.exs | 220 ++++++++++++++++++ .../controllers/channel_controller_test.exs | 119 ++++++++++ .../support/fixtures/media_source_fixtures.ex | 29 +++ test/support/fixtures/profiles_fixtures.ex | 4 +- test/test_helper.exs | 4 +- 37 files changed, 1007 insertions(+), 133 deletions(-) delete mode 100644 lib/pinchflat/downloader/backends/yt_dlp/video_collection.ex rename lib/pinchflat/{downloader => media_client}/backends/backend_command_runner.ex (73%) create mode 100644 lib/pinchflat/media_client/backends/yt_dlp/channel.ex rename lib/pinchflat/{downloader => media_client}/backends/yt_dlp/command_runner.ex (91%) rename lib/pinchflat/{downloader => media_client}/backends/yt_dlp/video.ex (54%) create mode 100644 lib/pinchflat/media_client/backends/yt_dlp/video_collection.ex create mode 100644 lib/pinchflat/media_client/channel_details.ex rename lib/pinchflat/{downloader => media_client}/video_downloader.ex (85%) create mode 100644 lib/pinchflat/media_source.ex create mode 100644 lib/pinchflat/media_source/channel.ex create mode 100644 lib/pinchflat_web/controllers/media_sources/channel_controller.ex create mode 100644 lib/pinchflat_web/controllers/media_sources/channel_html.ex create mode 100644 lib/pinchflat_web/controllers/media_sources/channel_html/channel_form.html.heex create mode 100644 lib/pinchflat_web/controllers/media_sources/channel_html/edit.html.heex create mode 100644 lib/pinchflat_web/controllers/media_sources/channel_html/index.html.heex create mode 100644 lib/pinchflat_web/controllers/media_sources/channel_html/new.html.heex create mode 100644 lib/pinchflat_web/controllers/media_sources/channel_html/show.html.heex create mode 100644 priv/repo/migrations/20240123174417_create_channels.exs delete mode 100644 test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs delete mode 100644 test/pinchflat/downloader/backends/yt_dlp/video_test.exs create mode 100644 test/pinchflat/media_client/backends/yt_dlp/channel_test.exs rename test/pinchflat/{downloader => media_client}/backends/yt_dlp/command_runner_test.exs (91%) rename test/pinchflat/{downloader => media_client}/backends/yt_dlp/output_path_builder_test.exs (91%) create mode 100644 test/pinchflat/media_client/backends/yt_dlp/video_collection_test.exs create mode 100644 test/pinchflat/media_client/backends/yt_dlp/video_test.exs create mode 100644 test/pinchflat/media_client/channel_details_test.exs rename test/pinchflat/{downloader => media_client}/video_downloader_test.exs (74%) create mode 100644 test/pinchflat/media_source_test.exs create mode 100644 test/pinchflat_web/controllers/channel_controller_test.exs create mode 100644 test/support/fixtures/media_source_fixtures.ex diff --git a/config/config.exs b/config/config.exs index 92bd240..a859fb3 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.Downloader.Backends.YtDlp.CommandRunner, + yt_dlp_runner: Pinchflat.MediaClient.Backends.YtDlp.CommandRunner, # TODO: figure this out media_directory: :not_implemented diff --git a/ideas.md b/ideas.md index fabc001..2cb74e3 100644 --- a/ideas.md +++ b/ideas.md @@ -1,3 +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) + - 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/video_collection.ex b/lib/pinchflat/downloader/backends/yt_dlp/video_collection.ex deleted file mode 100644 index ed24bdb..0000000 --- a/lib/pinchflat/downloader/backends/yt_dlp/video_collection.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollection do - @moduledoc """ - Contains utilities for working with collections of videos (ie: channels, playlists) - """ - - @doc """ - Returns a list of strings representing the video ids in the collection - """ - def get_video_ids(url, command_opts \\ []) do - opts = command_opts ++ [:simulate, :skip_download, :get_id] - - case backend_runner().run(url, opts) do - {:ok, output} -> {:ok, String.split(output, "\n", trim: true)} - res -> res - end - end - - defp backend_runner do - Application.get_env(:pinchflat, :yt_dlp_runner) - end -end diff --git a/lib/pinchflat/downloader/backends/backend_command_runner.ex b/lib/pinchflat/media_client/backends/backend_command_runner.ex similarity index 73% rename from lib/pinchflat/downloader/backends/backend_command_runner.ex rename to lib/pinchflat/media_client/backends/backend_command_runner.ex index 866008e..0a26a79 100644 --- a/lib/pinchflat/downloader/backends/backend_command_runner.ex +++ b/lib/pinchflat/media_client/backends/backend_command_runner.ex @@ -1,4 +1,4 @@ -defmodule Pinchflat.Downloader.Backends.BackendCommandRunner do +defmodule Pinchflat.MediaClient.Backends.BackendCommandRunner do @moduledoc """ A behaviour for running CLI commands against a downloader backend """ diff --git a/lib/pinchflat/media_client/backends/yt_dlp/channel.ex b/lib/pinchflat/media_client/backends/yt_dlp/channel.ex new file mode 100644 index 0000000..39d7348 --- /dev/null +++ b/lib/pinchflat/media_client/backends/yt_dlp/channel.ex @@ -0,0 +1,32 @@ +defmodule Pinchflat.MediaClient.Backends.YtDlp.Channel do + @moduledoc """ + Contains utilities for working with a channel's videos + """ + + use Pinchflat.MediaClient.Backends.YtDlp.VideoCollection + alias Pinchflat.MediaClient.ChannelDetails + + @doc """ + Gets a channel's ID and name from its URL. + + yt-dlp does not _really_ have channel-specific functions, so + instead we're fetching just the first video (using playlist_end: 1) + and parsing the channel ID and name from _its_ metadata + + Returns {:ok, %ChannelDetails{}} | {:error, any, ...}. + """ + def get_channel_info(channel_url) do + opts = [print: "%(.{channel,channel_id})j", playlist_end: 1] + + with {:ok, output} <- backend_runner().run(channel_url, opts), + {:ok, parsed_json} <- Phoenix.json_library().decode(output) do + {:ok, ChannelDetails.new(parsed_json["channel_id"], parsed_json["channel"])} + else + err -> err + end + end + + defp backend_runner do + Application.get_env(:pinchflat, :yt_dlp_runner) + end +end diff --git a/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex b/lib/pinchflat/media_client/backends/yt_dlp/command_runner.ex similarity index 91% rename from lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex rename to lib/pinchflat/media_client/backends/yt_dlp/command_runner.ex index b452dfd..f803b02 100644 --- a/lib/pinchflat/downloader/backends/yt_dlp/command_runner.ex +++ b/lib/pinchflat/media_client/backends/yt_dlp/command_runner.ex @@ -1,16 +1,18 @@ -defmodule Pinchflat.Downloader.Backends.YtDlp.CommandRunner do +defmodule Pinchflat.MediaClient.Backends.YtDlp.CommandRunner do @moduledoc """ Runs yt-dlp commands using the `System.cmd/3` function """ alias Pinchflat.Utils.StringUtils - alias Pinchflat.Downloader.Backends.BackendCommandRunner + alias Pinchflat.MediaClient.Backends.BackendCommandRunner @behaviour BackendCommandRunner @doc """ Runs a yt-dlp command and returns the string output + Returns {:ok, binary()} | {:error, output, status}. + # 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) diff --git a/lib/pinchflat/downloader/backends/yt_dlp/video.ex b/lib/pinchflat/media_client/backends/yt_dlp/video.ex similarity index 54% rename from lib/pinchflat/downloader/backends/yt_dlp/video.ex rename to lib/pinchflat/media_client/backends/yt_dlp/video.ex index 5d95e21..bea5b3b 100644 --- a/lib/pinchflat/downloader/backends/yt_dlp/video.ex +++ b/lib/pinchflat/media_client/backends/yt_dlp/video.ex @@ -1,4 +1,4 @@ -defmodule Pinchflat.Downloader.Backends.YtDlp.Video do +defmodule Pinchflat.MediaClient.Backends.YtDlp.Video do @moduledoc """ Contains utilities for working with singular videos """ @@ -6,12 +6,16 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.Video do @doc """ Downloads a single video (and possible metadata) directly to its final destination. Returns the parsed JSON output from yt-dlp. + + Returns {:ok, map()} | {:error, any, ...}. """ def download(url, command_opts \\ []) do - opts = [:no_simulate, :dump_json] ++ command_opts + opts = [:no_simulate, print: "%()j"] ++ command_opts - case backend_runner().run(url, opts) do - {:ok, output} -> Phoenix.json_library().decode(output) + with {:ok, output} <- backend_runner().run(url, opts), + {:ok, parsed_json} <- Phoenix.json_library().decode(output) do + {:ok, parsed_json} + else err -> err end end diff --git a/lib/pinchflat/media_client/backends/yt_dlp/video_collection.ex b/lib/pinchflat/media_client/backends/yt_dlp/video_collection.ex new file mode 100644 index 0000000..5bff0b8 --- /dev/null +++ b/lib/pinchflat/media_client/backends/yt_dlp/video_collection.ex @@ -0,0 +1,28 @@ +defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollection do + @moduledoc """ + Contains utilities for working with collections of videos (ie: channels, playlists). + + Meant to be included in other modules but can be used on its own. Channels and playlists + will have many of their own methods, but also share a lot of methods. This module is for + those shared methods. + """ + + defmacro __using__(_) do + quote do + @doc """ + Returns a list of strings representing the video ids in the collection. + + Returns {:ok, [binary()]} | {:error, any, ...}. + """ + def get_video_ids(url, command_opts \\ []) do + runner = Application.get_env(:pinchflat, :yt_dlp_runner) + opts = command_opts ++ [:simulate, :skip_download, print: :id] + + case runner.run(url, opts) do + {:ok, output} -> {:ok, String.split(output, "\n", trim: true)} + res -> res + end + end + end + end +end diff --git a/lib/pinchflat/media_client/channel_details.ex b/lib/pinchflat/media_client/channel_details.ex new file mode 100644 index 0000000..3e952bf --- /dev/null +++ b/lib/pinchflat/media_client/channel_details.ex @@ -0,0 +1,32 @@ +defmodule Pinchflat.MediaClient.ChannelDetails do + @moduledoc """ + This is the integration layer for actually working with channels. + + Technically hardcodes the yt-dlp backend for now, but should leave + it open-ish for future expansion (just in case). + """ + @enforce_keys [:id, :name] + defstruct [:id, :name] + + alias Pinchflat.MediaClient.Backends.YtDlp.Channel, as: YtDlpChannel + + @doc false + def new(id, name) do + %__MODULE__{id: id, name: name} + end + + @doc """ + Gets a channel's ID and name from its URL, using the given backend. + + Returns {:ok, map()} | {:error, any, ...}. + """ + def get_channel_details(channel_url, backend \\ :yt_dlp) do + channel_module(backend).get_channel_info(channel_url) + end + + defp channel_module(backend) do + case backend do + :yt_dlp -> YtDlpChannel + end + end +end diff --git a/lib/pinchflat/downloader/video_downloader.ex b/lib/pinchflat/media_client/video_downloader.ex similarity index 85% rename from lib/pinchflat/downloader/video_downloader.ex rename to lib/pinchflat/media_client/video_downloader.ex index d5166b7..8d526d7 100644 --- a/lib/pinchflat/downloader/video_downloader.ex +++ b/lib/pinchflat/media_client/video_downloader.ex @@ -1,4 +1,4 @@ -defmodule Pinchflat.Downloader.VideoDownloader do +defmodule Pinchflat.MediaClient.VideoDownloader do @moduledoc """ This is the integration layer for actually downloading videos. It takes into account the media profile's settings in order @@ -10,11 +10,13 @@ defmodule Pinchflat.Downloader.VideoDownloader do alias Pinchflat.Profiles.MediaProfile - alias Pinchflat.Downloader.Backends.YtDlp.Video, as: YtDlpVideo + alias Pinchflat.MediaClient.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. + + Returns {:ok, %ChannelDetails{}} | {:error, any, ...}. """ def download_for_media_profile(url, %MediaProfile{} = media_profile, backend \\ :yt_dlp) do option_builder = option_builder(backend) diff --git a/lib/pinchflat/media_source.ex b/lib/pinchflat/media_source.ex new file mode 100644 index 0000000..6b98bf7 --- /dev/null +++ b/lib/pinchflat/media_source.ex @@ -0,0 +1,99 @@ +defmodule Pinchflat.MediaSource do + @moduledoc """ + The MediaSource context. + """ + + import Ecto.Query, warn: false + alias Pinchflat.Repo + + alias Pinchflat.MediaSource.Channel + alias Pinchflat.MediaClient.ChannelDetails + + @doc """ + Returns the list of channels. Returns [%Channel{}, ...] + """ + def list_channels do + Repo.all(Channel) + end + + @doc """ + Gets a single channel. + + Returns %Channel{}. Raises `Ecto.NoResultsError` if the Channel does not exist. + """ + def get_channel!(id), do: Repo.get!(Channel, id) + + @doc """ + Creates a channel. Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}} + """ + def create_channel(attrs \\ %{}) do + %Channel{} + |> change_channel_from_url(attrs) + |> Repo.insert() + end + + @doc """ + Updates a channel. Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}} + """ + def update_channel(%Channel{} = channel, attrs) do + channel + |> change_channel_from_url(attrs) + |> Repo.update() + end + + @doc """ + Deletes a channel. Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}} + """ + def delete_channel(%Channel{} = channel) do + Repo.delete(channel) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking channel changes. + """ + def change_channel(%Channel{} = channel, attrs \\ %{}) do + Channel.changeset(channel, attrs) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking channel changes and additionally + fetches channel details from the original_url (if provided). If the channel + details cannot be fetched, an error is added to the changeset. + + Note that this fetches channel details as long as the `original_url` is present. + This means that it'll go for it even if a changeset is otherwise invalid. This + is pretty easy to change, but for MVP I'm not concerned. + """ + def change_channel_from_url(%Channel{} = channel, attrs \\ %{}) do + case change_channel(channel, attrs) do + %Ecto.Changeset{changes: %{original_url: _}} = changeset -> + add_channel_details_to_changeset(channel, changeset) + + changeset -> + changeset + end + end + + defp add_channel_details_to_changeset(channel, changeset) do + %Ecto.Changeset{changes: changes} = changeset + + case ChannelDetails.get_channel_details(changes.original_url) do + {:ok, %ChannelDetails{} = channel_details} -> + change_channel( + channel, + Map.merge(changes, %{ + name: channel_details.name, + channel_id: channel_details.id + }) + ) + + {:error, runner_error, _status_code} -> + Ecto.Changeset.add_error( + changeset, + :original_url, + "could not fetch channel details from URL", + error: runner_error + ) + end + end +end diff --git a/lib/pinchflat/media_source/channel.ex b/lib/pinchflat/media_source/channel.ex new file mode 100644 index 0000000..4861f65 --- /dev/null +++ b/lib/pinchflat/media_source/channel.ex @@ -0,0 +1,33 @@ +defmodule Pinchflat.MediaSource.Channel do + @moduledoc """ + The Channel schema. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias Pinchflat.Profiles.MediaProfile + + @required_fields ~w(name channel_id original_url media_profile_id)a + @allowed_fields @required_fields + + schema "channels" do + field :name, :string + field :channel_id, :string + # This should only be used for user reference going forward + # as the channel_id should be used for all API calls + field :original_url, :string + + belongs_to :media_profile, MediaProfile + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(channel, attrs) do + channel + |> cast(attrs, @allowed_fields) + |> validate_required(@required_fields) + |> unique_constraint([:channel_id, :media_profile_id]) + end +end diff --git a/lib/pinchflat/profiles/media_profile.ex b/lib/pinchflat/profiles/media_profile.ex index ed66992..34b3fdf 100644 --- a/lib/pinchflat/profiles/media_profile.ex +++ b/lib/pinchflat/profiles/media_profile.ex @@ -6,10 +6,14 @@ defmodule Pinchflat.Profiles.MediaProfile do use Ecto.Schema import Ecto.Changeset + alias Pinchflat.MediaSource.Channel + schema "media_profiles" do field :name, :string field :output_path_template, :string + has_many :channels, Channel + timestamps(type: :utc_datetime) end diff --git a/lib/pinchflat/profiles/options/yt_dlp/option_builder.ex b/lib/pinchflat/profiles/options/yt_dlp/option_builder.ex index ead342f..783be1e 100644 --- a/lib/pinchflat/profiles/options/yt_dlp/option_builder.ex +++ b/lib/pinchflat/profiles/options/yt_dlp/option_builder.ex @@ -20,6 +20,8 @@ defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilder do # 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! + # NOTE: Looks like you can put different media types in different directories. + # see: https://github.com/yt-dlp/yt-dlp#output-template {:ok, [ :write_thumbnail, diff --git a/lib/pinchflat_web/controllers/media_sources/channel_controller.ex b/lib/pinchflat_web/controllers/media_sources/channel_controller.ex new file mode 100644 index 0000000..08de4f4 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_controller.ex @@ -0,0 +1,75 @@ +defmodule PinchflatWeb.MediaSources.ChannelController do + use PinchflatWeb, :controller + + alias Pinchflat.Profiles + alias Pinchflat.MediaSource + alias Pinchflat.MediaSource.Channel + + def index(conn, _params) do + channels = MediaSource.list_channels() + + render(conn, :index, channels: channels) + end + + def new(conn, _params) do + changeset = MediaSource.change_channel(%Channel{}) + + render(conn, :new, changeset: changeset, media_profiles: media_profiles()) + end + + def create(conn, %{"channel" => channel_params}) do + case MediaSource.create_channel(channel_params) do + {:ok, channel} -> + conn + |> put_flash(:info, "Channel created successfully.") + |> redirect(to: ~p"/media_sources/channels/#{channel}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset, media_profiles: media_profiles()) + end + end + + def show(conn, %{"id" => id}) do + channel = MediaSource.get_channel!(id) + + render(conn, :show, channel: channel) + end + + def edit(conn, %{"id" => id}) do + channel = MediaSource.get_channel!(id) + changeset = MediaSource.change_channel(channel) + + render(conn, :edit, channel: channel, changeset: changeset, media_profiles: media_profiles()) + end + + def update(conn, %{"id" => id, "channel" => channel_params}) do + channel = MediaSource.get_channel!(id) + + case MediaSource.update_channel(channel, channel_params) do + {:ok, channel} -> + conn + |> put_flash(:info, "Channel updated successfully.") + |> redirect(to: ~p"/media_sources/channels/#{channel}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :edit, + channel: channel, + changeset: changeset, + media_profiles: media_profiles() + ) + end + end + + def delete(conn, %{"id" => id}) do + channel = MediaSource.get_channel!(id) + {:ok, _channel} = MediaSource.delete_channel(channel) + + conn + |> put_flash(:info, "Channel deleted successfully.") + |> redirect(to: ~p"/media_sources/channels") + end + + defp media_profiles do + Profiles.list_media_profiles() + end +end diff --git a/lib/pinchflat_web/controllers/media_sources/channel_html.ex b/lib/pinchflat_web/controllers/media_sources/channel_html.ex new file mode 100644 index 0000000..37bc279 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_html.ex @@ -0,0 +1,14 @@ +defmodule PinchflatWeb.MediaSources.ChannelHTML do + use PinchflatWeb, :html + + embed_templates "channel_html/*" + + @doc """ + Renders a channel form. + """ + attr :changeset, Ecto.Changeset, required: true + attr :action, :string, required: true + attr :media_profiles, :list, required: true + + def channel_form(assigns) +end diff --git a/lib/pinchflat_web/controllers/media_sources/channel_html/channel_form.html.heex b/lib/pinchflat_web/controllers/media_sources/channel_html/channel_form.html.heex new file mode 100644 index 0000000..00ad253 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_html/channel_form.html.heex @@ -0,0 +1,18 @@ +<.simple_form :let={f} for={@changeset} action={@action}> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + + <.input + field={f[:media_profile_id]} + options={Enum.map(@media_profiles, &{&1.name, &1.id})} + type="select" + label="Media Profile" + /> + + <.input field={f[:original_url]} type="text" label="Channel URL" /> + + <:actions> + <.button>Save Channel + + diff --git a/lib/pinchflat_web/controllers/media_sources/channel_html/edit.html.heex b/lib/pinchflat_web/controllers/media_sources/channel_html/edit.html.heex new file mode 100644 index 0000000..c65d810 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_html/edit.html.heex @@ -0,0 +1,12 @@ +<.header> + Edit Channel <%= @channel.id %> + <:subtitle>Use this form to manage channel records in your database. + + +<.channel_form + changeset={@changeset} + media_profiles={@media_profiles} + action={~p"/media_sources/channels/#{@channel}"} +/> + +<.back navigate={~p"/media_sources/channels"}>Back to channels diff --git a/lib/pinchflat_web/controllers/media_sources/channel_html/index.html.heex b/lib/pinchflat_web/controllers/media_sources/channel_html/index.html.heex new file mode 100644 index 0000000..b78a61b --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_html/index.html.heex @@ -0,0 +1,28 @@ +<.header> + Listing Channels + <:actions> + <.link href={~p"/media_sources/channels/new"}> + <.button>New Channel + + + + +<.table id="channels" rows={@channels} row_click={&JS.navigate(~p"/media_sources/channels/#{&1}")}> + <:col :let={channel} label="Name"><%= channel.name %> + <:col :let={channel} label="Channel"><%= channel.channel_id %> + <:action :let={channel}> +
+ <.link navigate={~p"/media_sources/channels/#{channel}"}>Show +
+ <.link navigate={~p"/media_sources/channels/#{channel}/edit"}>Edit + + <:action :let={channel}> + <.link + href={~p"/media_sources/channels/#{channel}"} + method="delete" + data-confirm="Are you sure?" + > + Delete + + + diff --git a/lib/pinchflat_web/controllers/media_sources/channel_html/new.html.heex b/lib/pinchflat_web/controllers/media_sources/channel_html/new.html.heex new file mode 100644 index 0000000..b09a477 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_html/new.html.heex @@ -0,0 +1,12 @@ +<.header> + New Channel + <:subtitle>Use this form to manage channel records in your database. + + +<.channel_form + changeset={@changeset} + media_profiles={@media_profiles} + action={~p"/media_sources/channels"} +/> + +<.back navigate={~p"/media_sources/channels"}>Back to channels diff --git a/lib/pinchflat_web/controllers/media_sources/channel_html/show.html.heex b/lib/pinchflat_web/controllers/media_sources/channel_html/show.html.heex new file mode 100644 index 0000000..0f59e3b --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_html/show.html.heex @@ -0,0 +1,17 @@ +<.header> + Channel <%= @channel.id %> + <:subtitle>This is a channel record from your database. + <:actions> + <.link href={~p"/media_sources/channels/#{@channel}/edit"}> + <.button>Edit channel + + + + +<.list> + <:item title="Channel Name"><%= @channel.name %> + <:item title="Channel ID"><%= @channel.channel_id %> + <:item title="Original URL"><%= @channel.original_url %> + + +<.back navigate={~p"/media_sources/channels"}>Back to channels diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index 6b62a82..e0775ea 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -20,6 +20,10 @@ defmodule PinchflatWeb.Router do get "/", PageController, :home resources "/media_profiles", MediaProfiles.MediaProfileController + + scope "/media_sources", MediaSources do + resources "/channels", ChannelController + end end # Other scopes may use custom stacks. diff --git a/priv/repo/migrations/20240123174417_create_channels.exs b/priv/repo/migrations/20240123174417_create_channels.exs new file mode 100644 index 0000000..4430c3d --- /dev/null +++ b/priv/repo/migrations/20240123174417_create_channels.exs @@ -0,0 +1,17 @@ +defmodule Pinchflat.Repo.Migrations.CreateChannels do + use Ecto.Migration + + def change do + create table(:channels) do + add :name, :string, null: false + add :channel_id, :string, null: false + add :original_url, :string, null: false + add :media_profile_id, references(:media_profiles, on_delete: :restrict), null: false + + timestamps(type: :utc_datetime) + end + + create index(:channels, [:media_profile_id]) + create unique_index(:channels, [:channel_id, :media_profile_id]) + 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 deleted file mode 100644 index 06c55e8..0000000 --- a/test/pinchflat/downloader/backends/yt_dlp/video_collection_test.exs +++ /dev/null @@ -1,44 +0,0 @@ -defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollectionTest do - use ExUnit.Case, async: true - import Mox - - alias Pinchflat.Downloader.Backends.YtDlp.VideoCollection - - @channel_url "https://www.youtube.com/@TheUselessTrials" - - setup :verify_on_exit! - - describe "get_video_ids/2" do - test "returns a list of video ids with no blank elements" do - expect(CommandRunnerMock, :run, fn _url, _opts -> {:ok, "id1\nid2\n\nid3\n"} end) - - assert {:ok, ["id1", "id2", "id3"]} = VideoCollection.get_video_ids(@channel_url) - end - - test "it passes the expected default args" do - expect(CommandRunnerMock, :run, fn _url, opts -> - assert opts == [:simulate, :skip_download, :get_id] - - {:ok, ""} - end) - - assert {:ok, _} = VideoCollection.get_video_ids(@channel_url) - end - - test "it passes the expected custom args" do - expect(CommandRunnerMock, :run, fn _url, opts -> - assert opts == [:custom_arg, :simulate, :skip_download, :get_id] - - {:ok, ""} - end) - - assert {:ok, _} = VideoCollection.get_video_ids(@channel_url, [:custom_arg]) - end - - test "returns the error straight through when the command fails" do - expect(CommandRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end) - - assert {:error, "Big issue", 1} = VideoCollection.get_video_ids(@channel_url) - end - end -end diff --git a/test/pinchflat/downloader/backends/yt_dlp/video_test.exs b/test/pinchflat/downloader/backends/yt_dlp/video_test.exs deleted file mode 100644 index 00a6430..0000000 --- a/test/pinchflat/downloader/backends/yt_dlp/video_test.exs +++ /dev/null @@ -1,44 +0,0 @@ -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/media_client/backends/yt_dlp/channel_test.exs b/test/pinchflat/media_client/backends/yt_dlp/channel_test.exs new file mode 100644 index 0000000..3e12a6c --- /dev/null +++ b/test/pinchflat/media_client/backends/yt_dlp/channel_test.exs @@ -0,0 +1,44 @@ +defmodule Pinchflat.MediaClient.Backends.YtDlp.ChannelTest do + use ExUnit.Case, async: true + import Mox + + alias Pinchflat.MediaClient.ChannelDetails + alias Pinchflat.MediaClient.Backends.YtDlp.Channel + + @channel_url "https://www.youtube.com/c/TheUselessTrials" + + setup :verify_on_exit! + + describe "get_channel_info/1" do + test "it returns a %ChannelDetails{} with data on success" do + expect(YtDlpRunnerMock, :run, fn _url, _opts -> + {:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"} + end) + + assert {:ok, res} = Channel.get_channel_info(@channel_url) + assert %ChannelDetails{id: "UCQH2", name: "TheUselessTrials"} = res + end + + test "it passes the expected args to the backend runner" do + expect(YtDlpRunnerMock, :run, fn @channel_url, opts -> + assert opts == [{:print, "%(.{channel,channel_id})j"}, {:playlist_end, 1}] + + {:ok, "{}"} + end) + + assert {:ok, _} = Channel.get_channel_info(@channel_url) + end + + test "it returns an error if the runner returns an error" do + expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end) + + assert {:error, "Big issue", 1} = Channel.get_channel_info(@channel_url) + end + + test "it returns an error if the output is not JSON" do + expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "Not JSON"} end) + + assert {:error, %Jason.DecodeError{}} = Channel.get_channel_info(@channel_url) + end + end +end diff --git a/test/pinchflat/downloader/backends/yt_dlp/command_runner_test.exs b/test/pinchflat/media_client/backends/yt_dlp/command_runner_test.exs similarity index 91% rename from test/pinchflat/downloader/backends/yt_dlp/command_runner_test.exs rename to test/pinchflat/media_client/backends/yt_dlp/command_runner_test.exs index 2700090..6ba8801 100644 --- a/test/pinchflat/downloader/backends/yt_dlp/command_runner_test.exs +++ b/test/pinchflat/media_client/backends/yt_dlp/command_runner_test.exs @@ -1,10 +1,10 @@ -defmodule Pinchflat.Downloader.Backends.YtDlp.CommandRunnerTest do +defmodule Pinchflat.MediaClient.Backends.YtDlp.CommandRunnerTest do use ExUnit.Case, async: true - alias Pinchflat.Downloader.Backends.YtDlp.CommandRunner, as: Runner + alias Pinchflat.MediaClient.Backends.YtDlp.CommandRunner, as: Runner @original_executable Application.compile_env(:pinchflat, :yt_dlp_executable) - @video_url "https://www.youtube.com/watch?v=9bZkp7q19f0" + @video_url "https://www.youtube.com/watch?v=-LHXuyzpex0" setup do on_exit(&reset_executable/0) diff --git a/test/pinchflat/downloader/backends/yt_dlp/output_path_builder_test.exs b/test/pinchflat/media_client/backends/yt_dlp/output_path_builder_test.exs similarity index 91% rename from test/pinchflat/downloader/backends/yt_dlp/output_path_builder_test.exs rename to test/pinchflat/media_client/backends/yt_dlp/output_path_builder_test.exs index 4098726..4c67a44 100644 --- a/test/pinchflat/downloader/backends/yt_dlp/output_path_builder_test.exs +++ b/test/pinchflat/media_client/backends/yt_dlp/output_path_builder_test.exs @@ -1,4 +1,4 @@ -defmodule Pinchflat.Downloader.Backends.YtDlp.OutputPathBuilderTest do +defmodule Pinchflat.MediaClient.Backends.YtDlp.OutputPathBuilderTest do use ExUnit.Case, async: true alias Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder diff --git a/test/pinchflat/media_client/backends/yt_dlp/video_collection_test.exs b/test/pinchflat/media_client/backends/yt_dlp/video_collection_test.exs new file mode 100644 index 0000000..787bcd2 --- /dev/null +++ b/test/pinchflat/media_client/backends/yt_dlp/video_collection_test.exs @@ -0,0 +1,48 @@ +defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollectionTest do + use ExUnit.Case, async: true + import Mox + + alias Pinchflat.MediaClient.Backends.YtDlp.VideoCollection + + @channel_url "https://www.youtube.com/@TheUselessTrials" + + defmodule VideoCollectionUser do + use VideoCollection + end + + setup :verify_on_exit! + + describe "get_video_ids/2" do + test "returns a list of video ids with no blank elements" do + expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "id1\nid2\n\nid3\n"} end) + + assert {:ok, ["id1", "id2", "id3"]} = VideoCollectionUser.get_video_ids(@channel_url) + end + + test "it passes the expected default args" do + expect(YtDlpRunnerMock, :run, fn _url, opts -> + assert opts == [:simulate, :skip_download, {:print, :id}] + + {:ok, ""} + end) + + assert {:ok, _} = VideoCollectionUser.get_video_ids(@channel_url) + end + + test "it passes the expected custom args" do + expect(YtDlpRunnerMock, :run, fn _url, opts -> + assert opts == [:custom_arg, :simulate, :skip_download, {:print, :id}] + + {:ok, ""} + end) + + assert {:ok, _} = VideoCollectionUser.get_video_ids(@channel_url, [:custom_arg]) + end + + test "returns the error straight through when the command fails" do + expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end) + + assert {:error, "Big issue", 1} = VideoCollectionUser.get_video_ids(@channel_url) + end + end +end diff --git a/test/pinchflat/media_client/backends/yt_dlp/video_test.exs b/test/pinchflat/media_client/backends/yt_dlp/video_test.exs new file mode 100644 index 0000000..bee9a28 --- /dev/null +++ b/test/pinchflat/media_client/backends/yt_dlp/video_test.exs @@ -0,0 +1,50 @@ +defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoTest do + use ExUnit.Case, async: true + import Mox + + alias Pinchflat.MediaClient.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(YtDlpRunnerMock, :run, fn @video_url, opts -> + assert opts == [:no_simulate, {:print, "%()j"}] + + {:ok, "{}"} + end) + + assert {:ok, _} = Video.download(@video_url) + end + + test "it passes along additional options" do + expect(YtDlpRunnerMock, :run, fn _url, opts -> + assert opts == [:no_simulate, {:print, "%()j"}, :custom_arg] + + {:ok, "{}"} + end) + + assert {:ok, _} = Video.download(@video_url, [:custom_arg]) + end + + test "it parses the output as JSON" do + expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "{\"title\": \"Test\"}"} end) + + assert {:ok, %{"title" => "Test"}} = Video.download(@video_url) + end + + test "it returns an error if the output is not JSON" do + expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "Not JSON"} end) + + assert {:error, %Jason.DecodeError{}} = Video.download(@video_url) + end + + test "it directly passes along any errors" do + expect(YtDlpRunnerMock, :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/media_client/channel_details_test.exs b/test/pinchflat/media_client/channel_details_test.exs new file mode 100644 index 0000000..012161e --- /dev/null +++ b/test/pinchflat/media_client/channel_details_test.exs @@ -0,0 +1,38 @@ +defmodule Pinchflat.MediaClient.ChannelDetailsTest do + use ExUnit.Case, async: true + import Mox + + alias Pinchflat.MediaClient.ChannelDetails + + @channel_url "https://www.youtube.com/c/TheUselessTrials" + + setup :verify_on_exit! + + describe "new/2" do + test "it returns a struct with the given values" do + assert %ChannelDetails{id: "UCQH2", name: "TheUselessTrials"} = + ChannelDetails.new("UCQH2", "TheUselessTrials") + end + end + + describe "get_channel_details/2" do + test "it passes the expected arguments to the backend" do + expect(YtDlpRunnerMock, :run, fn @channel_url, opts -> + assert opts == [{:print, "%(.{channel,channel_id})j"}, {:playlist_end, 1}] + + {:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"} + end) + + assert {:ok, _} = ChannelDetails.get_channel_details(@channel_url) + end + + test "it returns a struct composed of the returned data" do + expect(YtDlpRunnerMock, :run, fn _url, _opts -> + {:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"} + end) + + assert {:ok, res} = ChannelDetails.get_channel_details(@channel_url) + assert %ChannelDetails{id: "UCQH2", name: "TheUselessTrials"} = res + end + end +end diff --git a/test/pinchflat/downloader/video_downloader_test.exs b/test/pinchflat/media_client/video_downloader_test.exs similarity index 74% rename from test/pinchflat/downloader/video_downloader_test.exs rename to test/pinchflat/media_client/video_downloader_test.exs index 36ad4a8..99ead4e 100644 --- a/test/pinchflat/downloader/video_downloader_test.exs +++ b/test/pinchflat/media_client/video_downloader_test.exs @@ -1,9 +1,9 @@ -defmodule Pinchflat.Downloader.VideoDownloaderTest do +defmodule Pinchflat.MediaClient.VideoDownloaderTest do use ExUnit.Case, async: true import Mox alias Pinchflat.Profiles.MediaProfile - alias Pinchflat.Downloader.VideoDownloader + alias Pinchflat.MediaClient.VideoDownloader @video_url "https://www.youtube.com/watch?v=TiZPUDkDYbk" @media_profile %MediaProfile{ @@ -14,9 +14,9 @@ defmodule Pinchflat.Downloader.VideoDownloaderTest do 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 -> + expect(YtDlpRunnerMock, :run, fn @video_url, opts -> assert :no_simulate in opts - assert :dump_json in opts + assert {:print, "%()j"} in opts assert {:output, "/tmp/yt-dlp/videos/%(title)S.%(ext)s"} in opts {:ok, "{}"} @@ -26,7 +26,7 @@ defmodule Pinchflat.Downloader.VideoDownloaderTest do end test "it returns the parsed JSON output" do - expect(CommandRunnerMock, :run, fn _url, _opts -> {:ok, "{\"title\": \"Test\"}"} end) + expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "{\"title\": \"Test\"}"} end) assert {:ok, %{"title" => "Test"}} = VideoDownloader.download_for_media_profile(@video_url, @media_profile) diff --git a/test/pinchflat/media_source_test.exs b/test/pinchflat/media_source_test.exs new file mode 100644 index 0000000..4fcc8a7 --- /dev/null +++ b/test/pinchflat/media_source_test.exs @@ -0,0 +1,220 @@ +defmodule Pinchflat.MediaSourceTest do + use Pinchflat.DataCase + import Mox + + alias Pinchflat.MediaSource + alias Pinchflat.MediaSource.Channel + + import Pinchflat.ProfilesFixtures + import Pinchflat.MediaSourceFixtures + + @invalid_channel_attrs %{name: nil, channel_id: nil} + + setup :verify_on_exit! + + describe "list_channels/0" do + test "it returns all channels" do + channel = channel_fixture() + assert MediaSource.list_channels() == [channel] + end + end + + describe "get_channel!/1" do + test "it returns the channel with given id" do + channel = channel_fixture() + assert MediaSource.get_channel!(channel.id) == channel + end + end + + describe "create_channel/1" do + test "creates a channel and adds name + ID from runner response" do + expect(YtDlpRunnerMock, :run, &runner_function_mock/2) + + valid_attrs = %{ + media_profile_id: media_profile_fixture().id, + original_url: "https://www.youtube.com/channel/abc123" + } + + assert {:ok, %Channel{} = channel} = MediaSource.create_channel(valid_attrs) + assert channel.name == "some name" + assert String.starts_with?(channel.channel_id, "some_channel_id_") + end + + test "creation with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = MediaSource.create_channel(@invalid_channel_attrs) + end + + test "creation enforces uniqueness of channel_id scoped to the media_profile" do + expect(YtDlpRunnerMock, :run, 2, fn _url, _opts -> + {:ok, + Phoenix.json_library().encode!(%{ + channel: "some name", + channel_id: "some_channel_id_12345678" + })} + end) + + valid_once_attrs = %{ + media_profile_id: media_profile_fixture().id, + original_url: "https://www.youtube.com/channel/abc123" + } + + assert {:ok, %Channel{}} = MediaSource.create_channel(valid_once_attrs) + assert {:error, %Ecto.Changeset{}} = MediaSource.create_channel(valid_once_attrs) + end + + test "creation lets you duplicate channel_ids as long as the media profile is different" do + expect(YtDlpRunnerMock, :run, 2, fn _url, _opts -> + {:ok, + Phoenix.json_library().encode!(%{ + channel: "some name", + channel_id: "some_channel_id_12345678" + })} + end) + + valid_attrs = %{ + name: "some name", + original_url: "https://www.youtube.com/channel/abc123" + } + + channel_1_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id}) + channel_2_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id}) + + assert {:ok, %Channel{}} = MediaSource.create_channel(channel_1_attrs) + assert {:ok, %Channel{}} = MediaSource.create_channel(channel_2_attrs) + end + end + + describe "update_channel/2" do + test "updates with valid data updates the channel" do + channel = channel_fixture() + update_attrs = %{name: "some updated name"} + + assert {:ok, %Channel{} = channel} = MediaSource.update_channel(channel, update_attrs) + assert channel.name == "some updated name" + end + + test "updating the original_url will re-fetch the channel details" do + expect(YtDlpRunnerMock, :run, &runner_function_mock/2) + + channel = channel_fixture() + update_attrs = %{original_url: "https://www.youtube.com/channel/abc123"} + + assert {:ok, %Channel{} = channel} = MediaSource.update_channel(channel, update_attrs) + assert channel.name == "some name" + assert String.starts_with?(channel.channel_id, "some_channel_id_") + end + + test "not updating the original_url will not re-fetch the channel details" do + expect(YtDlpRunnerMock, :run, 0, &runner_function_mock/2) + + channel = channel_fixture() + update_attrs = %{name: "some updated name"} + + assert {:ok, %Channel{}} = MediaSource.update_channel(channel, update_attrs) + end + + test "updates with invalid data returns error changeset" do + channel = channel_fixture() + + assert {:error, %Ecto.Changeset{}} = + MediaSource.update_channel(channel, @invalid_channel_attrs) + + assert channel == MediaSource.get_channel!(channel.id) + end + end + + describe "delete_channel/1" do + test "it deletes the channel" do + channel = channel_fixture() + assert {:ok, %Channel{}} = MediaSource.delete_channel(channel) + assert_raise Ecto.NoResultsError, fn -> MediaSource.get_channel!(channel.id) end + end + + test "it returns a channel changeset" do + channel = channel_fixture() + assert %Ecto.Changeset{} = MediaSource.change_channel(channel) + end + end + + describe "change_channel/2" do + test "it returns a changeset" do + channel = channel_fixture() + + assert %Ecto.Changeset{} = MediaSource.change_channel(channel) + end + end + + describe "change_channel_from_url/2" do + test "it returns a changeset" do + stub(YtDlpRunnerMock, :run, &runner_function_mock/2) + channel = channel_fixture() + + assert %Ecto.Changeset{} = MediaSource.change_channel_from_url(channel) + end + + test "it does not fetch channel details if the original_url isn't in the changeset" do + expect(YtDlpRunnerMock, :run, 0, &runner_function_mock/2) + + changeset = MediaSource.change_channel_from_url(%Channel{}, %{name: "some updated name"}) + + assert %Ecto.Changeset{} = changeset + end + + test "it fetches channel details if the original_url is in the changeset" do + expect(YtDlpRunnerMock, :run, &runner_function_mock/2) + + changeset = + MediaSource.change_channel_from_url(%Channel{}, %{ + original_url: "https://www.youtube.com/channel/abc123" + }) + + assert %Ecto.Changeset{} = changeset + end + + test "it adds channel details to the changeset, keeping the orignal details" do + expect(YtDlpRunnerMock, :run, &runner_function_mock/2) + + media_profile = media_profile_fixture() + media_profile_id = media_profile.id + + changeset = + MediaSource.change_channel_from_url(%Channel{}, %{ + original_url: "https://www.youtube.com/channel/abc123", + media_profile_id: media_profile.id + }) + + assert %Ecto.Changeset{} = changeset + assert String.starts_with?(changeset.changes.channel_id, "some_channel_id_") + + assert %{ + name: "some name", + media_profile_id: ^media_profile_id, + original_url: "https://www.youtube.com/channel/abc123" + } = changeset.changes + end + + test "it adds an error to the changeset if the runner fails" do + expect(YtDlpRunnerMock, :run, 1, fn _url, _opts -> + {:error, "some error", 1} + end) + + changeset = + MediaSource.change_channel_from_url(%Channel{}, %{ + original_url: "https://www.youtube.com/channel/abc123" + }) + + assert %Ecto.Changeset{} = changeset + assert errors_on(changeset).original_url == ["could not fetch channel details from URL"] + end + end + + defp runner_function_mock(_url, _opts) do + { + :ok, + Phoenix.json_library().encode!(%{ + channel: "some name", + channel_id: "some_channel_id_#{:rand.uniform(1_000_000)}" + }) + } + end +end diff --git a/test/pinchflat_web/controllers/channel_controller_test.exs b/test/pinchflat_web/controllers/channel_controller_test.exs new file mode 100644 index 0000000..40215a5 --- /dev/null +++ b/test/pinchflat_web/controllers/channel_controller_test.exs @@ -0,0 +1,119 @@ +defmodule PinchflatWeb.ChannelControllerTest do + use PinchflatWeb.ConnCase + import Mox + + import Pinchflat.ProfilesFixtures + import Pinchflat.MediaSourceFixtures + + setup do + media_profile = media_profile_fixture() + + { + :ok, + %{ + create_attrs: %{ + media_profile_id: media_profile.id, + original_url: "https://www.youtube.com/channel/abc123" + }, + update_attrs: %{ + original_url: "https://www.youtube.com/channel/321xyz" + }, + invalid_attrs: %{original_url: nil, media_profile_id: nil} + } + } + end + + setup :verify_on_exit! + + describe "index" do + test "lists all channels", %{conn: conn} do + conn = get(conn, ~p"/media_sources/channels") + assert html_response(conn, 200) =~ "Listing Channels" + end + end + + describe "new channel" do + test "renders form", %{conn: conn} do + conn = get(conn, ~p"/media_sources/channels/new") + assert html_response(conn, 200) =~ "New Channel" + end + end + + describe "create channel" do + test "redirects to show when data is valid", %{conn: conn, create_attrs: create_attrs} do + expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/2) + conn = post(conn, ~p"/media_sources/channels", channel: create_attrs) + + assert %{id: id} = redirected_params(conn) + assert redirected_to(conn) == ~p"/media_sources/channels/#{id}" + + conn = get(conn, ~p"/media_sources/channels/#{id}") + assert html_response(conn, 200) =~ "Channel #{id}" + end + + test "renders errors when data is invalid", %{conn: conn, invalid_attrs: invalid_attrs} do + conn = post(conn, ~p"/media_sources/channels", channel: invalid_attrs) + assert html_response(conn, 200) =~ "New Channel" + end + end + + describe "edit channel" do + setup [:create_channel] + + test "renders form for editing chosen channel", %{conn: conn, channel: channel} do + conn = get(conn, ~p"/media_sources/channels/#{channel}/edit") + assert html_response(conn, 200) =~ "Edit Channel" + end + end + + describe "update channel" do + setup [:create_channel] + + test "redirects when data is valid", %{conn: conn, channel: channel, update_attrs: update_attrs} do + expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/2) + + conn = put(conn, ~p"/media_sources/channels/#{channel}", channel: update_attrs) + assert redirected_to(conn) == ~p"/media_sources/channels/#{channel}" + + conn = get(conn, ~p"/media_sources/channels/#{channel}") + assert html_response(conn, 200) =~ "https://www.youtube.com/channel/321xyz" + end + + test "renders errors when data is invalid", %{ + conn: conn, + channel: channel, + invalid_attrs: invalid_attrs + } do + conn = put(conn, ~p"/media_sources/channels/#{channel}", channel: invalid_attrs) + assert html_response(conn, 200) =~ "Edit Channel" + end + end + + describe "delete channel" do + setup [:create_channel] + + test "deletes chosen channel", %{conn: conn, channel: channel} do + conn = delete(conn, ~p"/media_sources/channels/#{channel}") + assert redirected_to(conn) == ~p"/media_sources/channels" + + assert_error_sent 404, fn -> + get(conn, ~p"/media_sources/channels/#{channel}") + end + end + end + + defp create_channel(_) do + channel = channel_fixture() + %{channel: channel} + end + + defp runner_function_mock(_url, _opts) do + { + :ok, + Phoenix.json_library().encode!(%{ + channel: "some name", + channel_id: "some_channel_id_#{:rand.uniform(1_000_000)}" + }) + } + end +end diff --git a/test/support/fixtures/media_source_fixtures.ex b/test/support/fixtures/media_source_fixtures.ex new file mode 100644 index 0000000..5be9bd3 --- /dev/null +++ b/test/support/fixtures/media_source_fixtures.ex @@ -0,0 +1,29 @@ +defmodule Pinchflat.MediaSourceFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Pinchflat.MediaSource` context. + """ + + alias Pinchflat.Repo + alias Pinchflat.ProfilesFixtures + alias Pinchflat.MediaSource.Channel + + @doc """ + Generate a channel. + """ + def channel_fixture(attrs \\ %{}) do + {:ok, channel} = + %Channel{} + |> Channel.changeset( + Enum.into(attrs, %{ + name: "Channel ##{:rand.uniform(1_000_000)}", + channel_id: Base.encode16(:crypto.hash(:md5, "#{:rand.uniform(1_000_000)}")), + original_url: "https://www.youtube.com/channel/#{:rand.uniform(1_000_000)}", + media_profile_id: ProfilesFixtures.media_profile_fixture().id + }) + ) + |> Repo.insert() + + channel + end +end diff --git a/test/support/fixtures/profiles_fixtures.ex b/test/support/fixtures/profiles_fixtures.ex index ef846f5..6bf5bfc 100644 --- a/test/support/fixtures/profiles_fixtures.ex +++ b/test/support/fixtures/profiles_fixtures.ex @@ -11,8 +11,8 @@ defmodule Pinchflat.ProfilesFixtures do {:ok, media_profile} = attrs |> Enum.into(%{ - name: "some name", - output_path_template: "some output_path_template" + name: "Media Profile ##{:rand.uniform(1_000_000)}", + output_path_template: "/video/{{title}}.{{ext}}" }) |> Pinchflat.Profiles.create_media_profile() diff --git a/test/test_helper.exs b/test/test_helper.exs index 3a6890e..54cb003 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,5 +1,5 @@ -Mox.defmock(CommandRunnerMock, for: Pinchflat.Downloader.Backends.BackendCommandRunner) -Application.put_env(:pinchflat, :yt_dlp_runner, CommandRunnerMock) +Mox.defmock(YtDlpRunnerMock, for: Pinchflat.MediaClient.Backends.BackendCommandRunner) +Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock) ExUnit.start() Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual)