From 50ecdb8ced0ce7386fb6663223cdd8c8c0ec0477 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 1 Nov 2021 15:57:53 -0400 Subject: [PATCH] Initial file uploads with copying --- .gitignore | 1 + lib/live_beats/media_library.ex | 40 +++++++ lib/live_beats/media_library/song.ex | 18 ++- lib/live_beats_web/live/live_helpers.ex | 2 +- lib/live_beats_web/live/song_live/index.ex | 2 +- .../live/song_live/song_entry_component.ex | 38 +++++++ .../live/song_live/upload_form_component.ex | 107 ++++++++++++++++++ .../song_live/upload_form_component.html.heex | 81 +++++++++++++ .../templates/layout/root.html.heex | 24 ++-- lib/live_beats_web/views/error_helpers.ex | 10 +- .../20211027201102_create_songs.exs | 2 + priv/repo/seeds.exs | 16 +-- 12 files changed, 314 insertions(+), 27 deletions(-) create mode 100644 lib/live_beats_web/live/song_live/song_entry_component.ex create mode 100644 lib/live_beats_web/live/song_live/upload_form_component.ex create mode 100644 lib/live_beats_web/live/song_live/upload_form_component.html.heex diff --git a/.gitignore b/.gitignore index 74e6812..830a9fa 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ live_beats-*.tar npm-debug.log /assets/node_modules/ +/priv/static/uploads diff --git a/lib/live_beats/media_library.ex b/lib/live_beats/media_library.ex index 9ea7532..5aaad0d 100644 --- a/lib/live_beats/media_library.ex +++ b/lib/live_beats/media_library.ex @@ -8,6 +8,46 @@ defmodule LiveBeats.MediaLibrary do alias LiveBeats.MediaLibrary.{Song, Genre} + def store_mp3(%Song{} = song, tmp_path) do + File.mkdir_p!("priv/static/uploads/songs") + File.cp!(tmp_path, song.mp3_path) + end + + def import_songs(changesets, consome_file) + when is_map(changesets) and is_function(consome_file, 2) do + changesets + |> Enum.reduce(Ecto.Multi.new(), fn {ref, chset}, acc -> + chset = + chset + |> Song.put_mp3_path() + |> Map.put(:action, nil) + + Ecto.Multi.insert(acc, {:song, ref}, chset) + end) + |> LiveBeats.Repo.transaction() + |> case do + {:ok, results} -> + {:ok, + results + |> Enum.filter(&match?({{:song, _ref}, _}, &1)) + |> Enum.map(fn {{:song, ref}, song} -> + consome_file.(ref, fn tmp_path -> store_mp3(song, tmp_path) end) + {ref, song} + end) + |> Enum.into(%{})} + + {:error, _failed_op, _failed_val, _changes} -> + {:error, :invalid} + end + end + + def parse_file_name(name) do + case Regex.split(~r/[-–]/, Path.rootname(name), parts: 2) do + [title] -> %{title: String.trim(title), artist: nil} + [title, artist] -> %{title: String.trim(title), artist: String.trim(artist)} + end + end + def create_genre(attrs \\ %{}) do %Genre{} |> Genre.changeset(attrs) diff --git a/lib/live_beats/media_library/song.ex b/lib/live_beats/media_library/song.ex index 57f7e5c..064041b 100644 --- a/lib/live_beats/media_library/song.ex +++ b/lib/live_beats/media_library/song.ex @@ -9,6 +9,8 @@ defmodule LiveBeats.MediaLibrary.Song do field :date_released, :naive_datetime field :duration, :integer field :title, :string + field :mp3_path, :string + field :mp3_filename, :string belongs_to :user, LiveBeats.Accounts.User belongs_to :genre, LiveBeats.MediaLibrary.Genre @@ -18,7 +20,19 @@ defmodule LiveBeats.MediaLibrary.Song do @doc false def changeset(song, attrs) do song - |> cast(attrs, [:album_artist, :artist, :duration, :title, :date_recorded, :date_released]) - |> validate_required([:artist, :duration, :title]) + |> cast(attrs, [:album_artist, :artist, :title, :date_recorded, :date_released]) + |> validate_required([:artist, :title]) + end + + def put_mp3_path(%Ecto.Changeset{} = changeset) do + if changeset.valid? do + filename = Ecto.UUID.generate() <> ".mp3" + + changeset + |> Ecto.Changeset.put_change(:mp3_filename, filename) + |> Ecto.Changeset.put_change(:mp3_path, "priv/uploads/songs/#{filename}") + else + changeset + end end end diff --git a/lib/live_beats_web/live/live_helpers.ex b/lib/live_beats_web/live/live_helpers.ex index 38d3df1..abdcc6f 100644 --- a/lib/live_beats_web/live/live_helpers.ex +++ b/lib/live_beats_web/live/live_helpers.ex @@ -120,7 +120,7 @@ defmodule LiveBeatsWeb.LiveHelpers do
diff --git a/lib/live_beats_web/live/song_live/index.ex b/lib/live_beats_web/live/song_live/index.ex index 7de1b18..9e75232 100644 --- a/lib/live_beats_web/live/song_live/index.ex +++ b/lib/live_beats_web/live/song_live/index.ex @@ -17,7 +17,7 @@ defmodule LiveBeatsWeb.SongLive.Index do <%= if @live_action in [:new, :edit] do %> <.modal show id="add-songs" return_to={Routes.song_index_path(@socket, :index)}> <.live_component - module={LiveBeatsWeb.SongLive.FormComponent} + module={LiveBeatsWeb.SongLive.UploadFormComponent} title={@page_title} id={@song.id || :new} action={@live_action} diff --git a/lib/live_beats_web/live/song_live/song_entry_component.ex b/lib/live_beats_web/live/song_live/song_entry_component.ex new file mode 100644 index 0000000..430aa83 --- /dev/null +++ b/lib/live_beats_web/live/song_live/song_entry_component.ex @@ -0,0 +1,38 @@ +defmodule LiveBeatsWeb.SongLive.SongEntryComponent do + use LiveBeatsWeb, :live_component + + def render(assigns) do + ~H""" +
+
+ + + <%= error_tag(@errors, :title, "songs[#{@ref}][title]") %> +
+
+ + + <%= error_tag(@errors, :artist, "songs[#{@ref}][artist]") %> +
+
+
+
+ """ + end + + def update(%{progress: progress}, socket) do + {:ok, assign(socket, progress: progress)} + end + + def update(%{changeset: changeset, id: id}, socket) do + {:ok, + socket + |> assign(ref: id) + |> assign(:errors, changeset.errors) + |> assign(title: Ecto.Changeset.get_field(changeset, :title)) + |> assign(artist: Ecto.Changeset.get_field(changeset, :artist)) + |> assign_new(:progress, fn -> 0 end)} + end +end diff --git a/lib/live_beats_web/live/song_live/upload_form_component.ex b/lib/live_beats_web/live/song_live/upload_form_component.ex new file mode 100644 index 0000000..39af6cd --- /dev/null +++ b/lib/live_beats_web/live/song_live/upload_form_component.ex @@ -0,0 +1,107 @@ +defmodule LiveBeatsWeb.SongLive.UploadFormComponent do + use LiveBeatsWeb, :live_component + + alias LiveBeats.{MediaLibrary, ID3} + alias LiveBeatsWeb.SongLive.SongEntryComponent + + @max_songs 10 + + @impl true + def update(%{song: song} = assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign(changesets: %{}) + |> allow_upload(:mp3, + song_id: song.id, + auto_upload: true, + progress: &handle_progress/3, + accept: ~w(.mp3), + max_entries: @max_songs, + max_file_size: 20_000_000 + )} + end + + @impl true + def handle_event("validate", %{"_target" => ["mp3"]}, socket) do + {:noreply, socket} + end + + def handle_event("validate", %{"songs" => songs_params, "_target" => ["songs", _, _]}, socket) do + new_socket = + Enum.reduce(songs_params, socket, fn {ref, song_params}, acc -> + new_changeset = + acc + |> get_changeset(ref) + |> Ecto.Changeset.apply_changes() + |> MediaLibrary.change_song(song_params) + |> Map.put(:action, :validate) + + update_changeset(acc, new_changeset, ref) + end) + + {:noreply, new_socket} + end + + defp consume_entry(socket, ref, store_func) when is_function(store_func) do + {entries, []} = uploaded_entries(socket, :mp3) + entry = Enum.find(entries, fn entry -> entry.ref == ref end) + consume_uploaded_entry(socket, entry, fn meta -> store_func.(meta.path) end) + end + + def handle_event("save", %{"songs" => song_params}, socket) do + changesets = socket.assigns.changesets + + case MediaLibrary.import_songs(changesets, &consume_entry(socket, &1, &2)) do + {:ok, songs} -> + {:noreply, + socket + |> put_flash(:info, "#{map_size(songs)} song(s) uploaded") + |> push_redirect(to: Routes.song_index_path(socket, :index))} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "There were problems uploading your songs")} + end + end + + defp get_changeset(socket, entry_ref) do + case Enum.find(socket.assigns.changesets, fn {ref, _changeset} -> ref === entry_ref end) do + {^entry_ref, changeset} -> changeset + nil -> nil + end + end + + defp put_new_changeset(socket, entry) do + if get_changeset(socket, entry.ref) do + socket + else + if Enum.count(socket.assigns.changesets) > @max_songs do + raise RuntimeError, "file upload limited exceeded" + end + + attrs = MediaLibrary.parse_file_name(entry.client_name) + changeset = MediaLibrary.change_song(%MediaLibrary.Song{}, attrs) + + update_changeset(socket, changeset, entry.ref) + end + end + + defp update_changeset(socket, %Ecto.Changeset{} = changeset, entry_ref) do + update(socket, :changesets, &Map.put(&1, entry_ref, changeset)) + end + + defp handle_progress(:mp3, entry, socket) do + send_update(SongEntryComponent, id: entry.ref, progress: entry.progress) + {:noreply, put_new_changeset(socket, entry)} + end + + defp put_tmp_mp3(changeset, path) do + {:ok, tmp_path} = Plug.Upload.random_file("tmp_mp3") + File.cp!(path, tmp_path) + Ecto.Changeset.put_change(changeset, :tmp_mp3_path, tmp_path) + end + + defp file_error(%{kind: :too_large} = assigns), do: ~H|larger than 10MB| + defp file_error(%{kind: :not_accepted} = assigns), do: ~H|not a valid MP3 file| + defp file_error(%{kind: :too_many_files} = assigns), do: ~H|too many files| +end diff --git a/lib/live_beats_web/live/song_live/upload_form_component.html.heex b/lib/live_beats_web/live/song_live/upload_form_component.html.heex new file mode 100644 index 0000000..0325925 --- /dev/null +++ b/lib/live_beats_web/live/song_live/upload_form_component.html.heex @@ -0,0 +1,81 @@ +
+

<%= @title %>

+ + <.form + let={f} + for={:songs} + id="song-form" + class="space-y-8" + phx-target={@myself} + phx-change="validate" + phx-submit="save"> + +
+
+ <%= for {ref, changeset} <- @changesets do %> + <.live_component id={ref} module={SongEntryComponent} changeset={changeset} /> + <% end %> + + +
+
+ <%= if Enum.any?(@uploads.mp3.errors) do %> +
+
+
+ +
+
+

+ Oops! +

+
+
    + <%= for {_ref, error} <- @uploads.mp3.errors do %> +
  • <.file_error kind={error} />
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+
+ +
+ +

or drag and drop

+
+

+ MP3s up to 20MB +

+
+
+
+
+ + +
+
+ +
+
+ + +
+
+ +
diff --git a/lib/live_beats_web/templates/layout/root.html.heex b/lib/live_beats_web/templates/layout/root.html.heex index d8c86d3..a5ab878 100644 --- a/lib/live_beats_web/templates/layout/root.html.heex +++ b/lib/live_beats_web/templates/layout/root.html.heex @@ -65,7 +65,6 @@
@@ -159,7 +158,6 @@