Initial file uploads with copying

This commit is contained in:
Chris McCord 2021-11-01 15:57:53 -04:00
parent 5a19383137
commit 50ecdb8ced
12 changed files with 314 additions and 27 deletions

1
.gitignore vendored
View file

@ -29,3 +29,4 @@ live_beats-*.tar
npm-debug.log npm-debug.log
/assets/node_modules/ /assets/node_modules/
/priv/static/uploads

View file

@ -8,6 +8,46 @@ defmodule LiveBeats.MediaLibrary do
alias LiveBeats.MediaLibrary.{Song, Genre} 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 def create_genre(attrs \\ %{}) do
%Genre{} %Genre{}
|> Genre.changeset(attrs) |> Genre.changeset(attrs)

View file

@ -9,6 +9,8 @@ defmodule LiveBeats.MediaLibrary.Song do
field :date_released, :naive_datetime field :date_released, :naive_datetime
field :duration, :integer field :duration, :integer
field :title, :string field :title, :string
field :mp3_path, :string
field :mp3_filename, :string
belongs_to :user, LiveBeats.Accounts.User belongs_to :user, LiveBeats.Accounts.User
belongs_to :genre, LiveBeats.MediaLibrary.Genre belongs_to :genre, LiveBeats.MediaLibrary.Genre
@ -18,7 +20,19 @@ defmodule LiveBeats.MediaLibrary.Song do
@doc false @doc false
def changeset(song, attrs) do def changeset(song, attrs) do
song song
|> cast(attrs, [:album_artist, :artist, :duration, :title, :date_recorded, :date_released]) |> cast(attrs, [:album_artist, :artist, :title, :date_recorded, :date_released])
|> validate_required([:artist, :duration, :title]) |> 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
end end

View file

@ -120,7 +120,7 @@ defmodule LiveBeatsWeb.LiveHelpers do
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span> <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div <div
id={"#{@id}-content"} id={"#{@id}-content"}
class={"#{if @show, do: "fade-in-scale", else: "hidden"} sticky inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform sm:my-8 sm:align-middle sm:max-w-xl sm:w-full sm:p-6"} class={"#{if @show, do: "fade-in-scale", else: "hidden"} sticky inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform sm:my-8 sm:align-middle lg:ml-48 sm:max-w-2xl sm:w-full sm:p-6"}
phx-window-keydown={hide_modal(@id)} phx-key="escape" phx-window-keydown={hide_modal(@id)} phx-key="escape"
phx-click-away={hide_modal(@id)} phx-click-away={hide_modal(@id)}
> >

View file

@ -17,7 +17,7 @@ defmodule LiveBeatsWeb.SongLive.Index do
<%= if @live_action in [:new, :edit] do %> <%= if @live_action in [:new, :edit] do %>
<.modal show id="add-songs" return_to={Routes.song_index_path(@socket, :index)}> <.modal show id="add-songs" return_to={Routes.song_index_path(@socket, :index)}>
<.live_component <.live_component
module={LiveBeatsWeb.SongLive.FormComponent} module={LiveBeatsWeb.SongLive.UploadFormComponent}
title={@page_title} title={@page_title}
id={@song.id || :new} id={@song.id || :new}
action={@live_action} action={@live_action}

View file

@ -0,0 +1,38 @@
defmodule LiveBeatsWeb.SongLive.SongEntryComponent do
use LiveBeatsWeb, :live_component
def render(assigns) do
~H"""
<div class="sm:grid sm:grid-cols-2 sm:gap-2 sm:items-start sm:border-t sm:border-gray-200 sm:pt-2">
<div class="border border-gray-300 rounded-md px-3 py-2 mt-2 shadow-sm focus-within:ring-1 focus-within:ring-indigo-600 focus-within:border-indigo-600">
<label for="name" class="block text-xs font-medium text-gray-900">Title</label>
<input type="text" name={"songs[#{@ref}][title]"} value={@title}
class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm"/>
<%= error_tag(@errors, :title, "songs[#{@ref}][title]") %>
</div>
<div class="border border-gray-300 rounded-md px-3 py-2 mt-2 shadow-sm focus-within:ring-1 focus-within:ring-indigo-600 focus-within:border-indigo-600">
<label for="name" class="block text-xs font-medium text-gray-900">Artist</label>
<input type="text" name={"songs[#{@ref}][artist]"} value={@artist}
class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm"/>
<%= error_tag(@errors, :artist, "songs[#{@ref}][artist]") %>
</div>
<div style={"width: #{@progress}%;"} class="col-span-full bg-purple-500 dark:bg-purple-400 h-1.5 w-0 p-0">
</div>
</div>
"""
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

View file

@ -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

View file

@ -0,0 +1,81 @@
<div>
<h2><%= @title %></h2>
<.form
let={f}
for={:songs}
id="song-form"
class="space-y-8"
phx-target={@myself}
phx-change="validate"
phx-submit="save">
<div class="space-y-8 divide-y divide-gray-200 sm:space-y-5">
<div class="space-y-2 sm:space-y-2">
<%= for {ref, changeset} <- @changesets do %>
<.live_component id={ref} module={SongEntryComponent} changeset={changeset} />
<% end %>
<!-- upload -->
<div class="sm:grid sm:border-t sm:border-gray-200 sm:pt-5">
<div class="mt-1 sm:mt-0" phx-drop-target={@uploads.mp3.ref}>
<%= if Enum.any?(@uploads.mp3.errors) do %>
<div class="rounded-md bg-red-50 p-4 mb-2">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" x-description="Heroicon name: solid/x-circle" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
Oops!
</h3>
<div class="mt-2 text-sm text-red-700">
<ul role="list" class="list-disc pl-5 space-y-1">
<%= for {_ref, error} <- @uploads.mp3.errors do %>
<li><.file_error kind={error} /></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div class="max-w-lg flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<div class="flex text-sm text-gray-600">
<label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
<span phx-click={JS.dispatch("click", to: "##{@uploads.mp3.ref}")}>Upload files</span>
<%= live_file_input @uploads.mp3, class: "sr-only" %>
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500">
MP3s up to 20MB
</p>
</div>
</div>
</div>
</div>
<!-- /upload -->
</div>
</div>
<div class="pt-5">
<div class="flex justify-end">
<button type="button" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Cancel
</button>
<button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Save
</button>
</div>
</div>
</.form>
</div>

View file

@ -65,7 +65,6 @@
<div> <div>
<button type="button" <button type="button"
class="group w-full bg-gray-100 rounded-md px-3.5 py-2 text-sm text-left font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-purple-500" class="group w-full bg-gray-100 rounded-md px-3.5 py-2 text-sm text-left font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-purple-500"
id="options-menu-button"
phx-click={show_dropdown("#account-dropdown-mobile")}> phx-click={show_dropdown("#account-dropdown-mobile")}>
<span class="flex w-full justify-between items-center"> <span class="flex w-full justify-between items-center">
<span class="flex min-w-0 items-center justify-between space-x-3"> <span class="flex min-w-0 items-center justify-between space-x-3">
@ -89,16 +88,16 @@
</div> </div>
<div id="account-dropdown-mobile" phx-click-away={hide_dropdown("#account-dropdown-mobile")} class="hidden z-10 mx-3 origin-top absolute right-0 left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200 focus:outline-none" role="menu"> <div id="account-dropdown-mobile" phx-click-away={hide_dropdown("#account-dropdown-mobile")} class="hidden z-10 mx-3 origin-top absolute right-0 left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200 focus:outline-none" role="menu">
<div class="py-1" role="none"> <div class="py-1" role="none">
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-0">View profile</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">View profile</a>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-1">Settings</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Settings</a>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-2">Notifications</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Notifications</a>
</div> </div>
<div class="py-1" role="none"> <div class="py-1" role="none">
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-3">Get desktop app</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Get desktop app</a>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-4">Support</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Support</a>
</div> </div>
<div class="py-1" role="none"> <div class="py-1" role="none">
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-5">Logout</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Logout</a>
</div> </div>
</div> </div>
</div> </div>
@ -159,7 +158,6 @@
<div> <div>
<button type="button" <button type="button"
class="group w-full bg-gray-100 rounded-md px-3.5 py-2 text-sm text-left font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-purple-500" class="group w-full bg-gray-100 rounded-md px-3.5 py-2 text-sm text-left font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-purple-500"
id="options-menu-button"
phx-click={show_dropdown("#account-dropdown")}> phx-click={show_dropdown("#account-dropdown")}>
<span class="flex w-full justify-between items-center"> <span class="flex w-full justify-between items-center">
<span class="flex min-w-0 items-center justify-between space-x-3"> <span class="flex min-w-0 items-center justify-between space-x-3">
@ -183,13 +181,13 @@
</div> </div>
<div id="account-dropdown" phx-click-away={hide_dropdown("#account-dropdown")} class="hidden z-10 mx-3 origin-top absolute right-0 left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200" role="menu" aria-expanded="true"> <div id="account-dropdown" phx-click-away={hide_dropdown("#account-dropdown")} class="hidden z-10 mx-3 origin-top absolute right-0 left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200" role="menu" aria-expanded="true">
<div class="py-1" role="none"> <div class="py-1" role="none">
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-0">View profile</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">View profile</a>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-1">Settings</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Settings</a>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-2">Notifications</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Notifications</a>
</div> </div>
<div class="py-1" role="none"> <div class="py-1" role="none">
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-3">Get desktop app</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Get desktop app</a>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-4">Support</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Support</a>
</div> </div>
<div class="py-1" role="none"> <div class="py-1" role="none">
<%= link "Logout", to: Routes.o_auth_callback_path(@conn, :sign_out), method: :delete, role: "menuitem", id: "options-menu-item-5", class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %> <%= link "Logout", to: Routes.o_auth_callback_path(@conn, :sign_out), method: :delete, role: "menuitem", id: "options-menu-item-5", class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>

View file

@ -9,14 +9,20 @@ defmodule LiveBeatsWeb.ErrorHelpers do
Generates tag for inlined form input errors. Generates tag for inlined form input errors.
""" """
def error_tag(form, field) do def error_tag(form, field) do
Enum.map(Keyword.get_values(form.errors, field), fn error -> error_tag(form.errors, field, input_name(form, field))
end
def error_tag(errors, field, input_name) do
Enum.map(Keyword.get_values(errors, field), fn error ->
content_tag(:div, translate_error(error), content_tag(:div, translate_error(error),
class: "invalid-feedback mt-0 text-sm text-red-600 text-right", class: "invalid-feedback mt-0 text-sm text-red-600 text-right",
phx_feedback_for: input_name(form, field) phx_feedback_for: input_name
) )
end) end)
end end
@doc """ @doc """
Translates an error message using gettext. Translates an error message using gettext.
""" """

View file

@ -7,6 +7,8 @@ defmodule LiveBeats.Repo.Migrations.CreateSongs do
add :artist, :string add :artist, :string
add :duration, :integer add :duration, :integer
add :title, :string add :title, :string
add :mp3_path, :string
add :mp3_filename, :string
add :date_recorded, :naive_datetime add :date_recorded, :naive_datetime
add :date_released, :naive_datetime add :date_released, :naive_datetime
add :user_id, references(:users, on_delete: :nothing) add :user_id, references(:users, on_delete: :nothing)

View file

@ -14,11 +14,11 @@ for title <- ~w(Chill Pop Hip-hop Electronic) do
{:ok, _} = LiveBeats.MediaLibrary.create_genre(%{title: title}) {:ok, _} = LiveBeats.MediaLibrary.create_genre(%{title: title})
end end
for i <- 1..20 do # for i <- 1..20 do
{:ok, _} = # {:ok, _} =
LiveBeats.MediaLibrary.create_song(%{ # LiveBeats.MediaLibrary.create_song(%{
artist: "Bonobo", # artist: "Bonobo",
title: "Black Sands #{i}", # title: "Black Sands #{i}",
duration: 180_000 # duration: 180_000
}) # })
end # end