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)