diff --git a/lib/pinchflat/media_source.ex b/lib/pinchflat/media_source.ex new file mode 100644 index 0000000..5a7fc54 --- /dev/null +++ b/lib/pinchflat/media_source.ex @@ -0,0 +1,56 @@ +defmodule Pinchflat.MediaSource do + @moduledoc """ + The MediaSource context. + """ + + import Ecto.Query, warn: false + alias Pinchflat.Repo + + alias Pinchflat.MediaSource.Channel + + @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{} + |> Channel.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a channel. Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}} + """ + def update_channel(%Channel{} = channel, attrs) do + channel + |> Channel.changeset(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 +end diff --git a/lib/pinchflat/media_source/channel.ex b/lib/pinchflat/media_source/channel.ex new file mode 100644 index 0000000..bbb3b0f --- /dev/null +++ b/lib/pinchflat/media_source/channel.ex @@ -0,0 +1,27 @@ +defmodule Pinchflat.MediaSource.Channel do + @moduledoc """ + The Channel schema. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias Pinchflat.Profiles.MediaProfile + + schema "channels" do + field :name, :string + field :channel_id, :string + + belongs_to :media_profile, MediaProfile + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(channel, attrs) do + channel + |> cast(attrs, [:name, :channel_id, :media_profile_id]) + |> validate_required([:name, :channel_id, :media_profile_id]) + |> 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_web/controllers/media_sources/channel_controller.ex b/lib/pinchflat_web/controllers/media_sources/channel_controller.ex new file mode 100644 index 0000000..41d36e3 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_controller.ex @@ -0,0 +1,62 @@ +defmodule PinchflatWeb.MediaSources.ChannelController do + use PinchflatWeb, :controller + + 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) + 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) + 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) + 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) + 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 +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..8b05cd0 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_html.ex @@ -0,0 +1,13 @@ +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 + + 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..5997de3 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_html/channel_form.html.heex @@ -0,0 +1,10 @@ +<.simple_form :let={f} for={@changeset} action={@action}> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input field={f[:name]} type="text" label="Name" /> + <.input field={f[:channel_id]} type="text" label="Channel" /> + <: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..e0c8928 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_html/edit.html.heex @@ -0,0 +1,8 @@ +<.header> + Edit Channel <%= @channel.id %> + <:subtitle>Use this form to manage channel records in your database. + + +<.channel_form changeset={@changeset} 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..8f3af0b --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_html/new.html.heex @@ -0,0 +1,8 @@ +<.header> + New Channel + <:subtitle>Use this form to manage channel records in your database. + + +<.channel_form changeset={@changeset} 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..1b44381 --- /dev/null +++ b/lib/pinchflat_web/controllers/media_sources/channel_html/show.html.heex @@ -0,0 +1,16 @@ +<.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="Name"><%= @channel.name %> + <:item title="Channel"><%= @channel.channel_id %> + + +<.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..30f02cc --- /dev/null +++ b/priv/repo/migrations/20240123174417_create_channels.exs @@ -0,0 +1,16 @@ +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 :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/media_source_test.exs b/test/pinchflat/media_source_test.exs new file mode 100644 index 0000000..2252a6d --- /dev/null +++ b/test/pinchflat/media_source_test.exs @@ -0,0 +1,94 @@ +defmodule Pinchflat.MediaSourceTest do + use Pinchflat.DataCase + + alias Pinchflat.MediaSource + + describe "channels" do + alias Pinchflat.MediaSource.Channel + + import Pinchflat.ProfilesFixtures + import Pinchflat.MediaSourceFixtures + + @invalid_attrs %{name: nil, channel_id: nil} + + test "list_channels/0 returns all channels" do + channel = channel_fixture() + assert MediaSource.list_channels() == [channel] + end + + test "get_channel!/1 returns the channel with given id" do + channel = channel_fixture() + assert MediaSource.get_channel!(channel.id) == channel + end + + test "create_channel/1 with valid data creates a channel" do + valid_attrs = %{ + name: "some name", + channel_id: "some channel_id", + media_profile_id: media_profile_fixture().id + } + + assert {:ok, %Channel{} = channel} = MediaSource.create_channel(valid_attrs) + assert channel.name == "some name" + assert channel.channel_id == "some channel_id" + end + + test "create_channel/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = MediaSource.create_channel(@invalid_attrs) + end + + test "create_channel/1 enforces uniqueness of channel_id scoped to the media_profile" do + valid_once_attrs = %{ + name: "some name", + channel_id: "abc123", + media_profile_id: media_profile_fixture().id + } + + assert {:ok, %Channel{}} = MediaSource.create_channel(valid_once_attrs) + assert {:error, %Ecto.Changeset{}} = MediaSource.create_channel(valid_once_attrs) + end + + test "create_channel/1 lets you duplicate channel_ids as long as the media profile is different" do + valid_attrs = %{ + name: "some name", + channel_id: "abc123" + } + + assert {:ok, %Channel{}} = + MediaSource.create_channel( + Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id}) + ) + + assert {:ok, %Channel{}} = + MediaSource.create_channel( + Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id}) + ) + end + + test "update_channel/2 with valid data updates the channel" do + channel = channel_fixture() + update_attrs = %{name: "some updated name", channel_id: "some updated channel_id"} + + assert {:ok, %Channel{} = channel} = MediaSource.update_channel(channel, update_attrs) + assert channel.name == "some updated name" + assert channel.channel_id == "some updated channel_id" + end + + test "update_channel/2 with invalid data returns error changeset" do + channel = channel_fixture() + assert {:error, %Ecto.Changeset{}} = MediaSource.update_channel(channel, @invalid_attrs) + assert channel == MediaSource.get_channel!(channel.id) + end + + test "delete_channel/1 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 "change_channel/1 returns a channel changeset" do + channel = channel_fixture() + assert %Ecto.Changeset{} = MediaSource.change_channel(channel) + end + 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..fad8d93 --- /dev/null +++ b/test/pinchflat_web/controllers/channel_controller_test.exs @@ -0,0 +1,102 @@ +defmodule PinchflatWeb.ChannelControllerTest do + use PinchflatWeb.ConnCase + + import Pinchflat.ProfilesFixtures + import Pinchflat.MediaSourceFixtures + + setup do + media_profile = media_profile_fixture() + + { + :ok, + %{ + create_attrs: %{ + name: "some name", + channel_id: "some channel_id", + media_profile_id: media_profile.id + }, + update_attrs: %{name: "some updated name", channel_id: "some updated channel_id"}, + invalid_attrs: %{name: nil, channel_id: nil, media_profile_id: nil} + } + } + end + + 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 + 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 + 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) =~ "some updated name" + 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 +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..191bdb0 --- /dev/null +++ b/test/support/fixtures/media_source_fixtures.ex @@ -0,0 +1,24 @@ +defmodule Pinchflat.MediaSourceFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Pinchflat.MediaSource` context. + """ + + alias Pinchflat.ProfilesFixtures + + @doc """ + Generate a channel. + """ + def channel_fixture(attrs \\ %{}) do + {:ok, channel} = + attrs + |> Enum.into(%{ + channel_id: Base.encode16(:crypto.hash(:md5, "#{:rand.uniform(1_000_000)}"), case: :lower), + name: "Channel ##{:rand.uniform(1_000_000)}", + media_profile_id: ProfilesFixtures.media_profile_fixture().id + }) + |> Pinchflat.MediaSource.create_channel() + + 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()