Add attribution field and handle async duration race

This commit is contained in:
Chris McCord 2021-11-10 10:10:43 -05:00
parent ff7b064660
commit eda99fa903
11 changed files with 72 additions and 21 deletions

View file

@ -49,7 +49,7 @@ defmodule LiveBeats.MediaLibrary do
stopped_query = stopped_query =
from s in Song, from s in Song,
where: s.user_id == ^song.user_id and s.status == :playing, where: s.user_id == ^song.user_id and s.status in [:playing, :paused],
update: [set: [status: :stopped]] update: [set: [status: :stopped]]
{:ok, %{now_playing: new_song}} = {:ok, %{now_playing: new_song}} =
@ -89,7 +89,12 @@ defmodule LiveBeats.MediaLibrary do
end end
def put_stats(%Ecto.Changeset{} = changeset, %MP3Stat{} = stat) do def put_stats(%Ecto.Changeset{} = changeset, %MP3Stat{} = stat) do
Ecto.Changeset.put_change(changeset, :duration, stat.duration) chset = Song.put_duration(changeset, stat.duration)
if error = chset.errors[:duration] do
{:error, %{duration: error}}
else
{:ok, chset}
end
end end
def import_songs(%Accounts.User{} = user, changesets, consume_file) def import_songs(%Accounts.User{} = user, changesets, consume_file)

View file

@ -15,6 +15,7 @@ defmodule LiveBeats.MediaLibrary.Song do
field :duration, :integer field :duration, :integer
field :status, Ecto.Enum, values: [stopped: 1, playing: 2, paused: 3] field :status, Ecto.Enum, values: [stopped: 1, playing: 2, paused: 3]
field :title, :string field :title, :string
field :attribution, :string
field :mp3_url, :string field :mp3_url, :string
field :mp3_filepath, :string field :mp3_filepath, :string
field :mp3_filename, :string field :mp3_filename, :string
@ -31,15 +32,24 @@ 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, :title, :date_recorded, :date_released]) |> cast(attrs, [:album_artist, :artist, :title, :attribution, :date_recorded, :date_released])
|> validate_required([:artist, :title]) |> validate_required([:artist, :title])
|> validate_number(:duration, greater_than: 0, less_than: 1200)
end end
def put_user(%Ecto.Changeset{} = changeset, %Accounts.User{} = user) do def put_user(%Ecto.Changeset{} = changeset, %Accounts.User{} = user) do
put_assoc(changeset, :user, user) put_assoc(changeset, :user, user)
end end
def put_duration(%Ecto.Changeset{} = changeset, duration) when is_integer(duration) do
changeset
|> Ecto.Changeset.change(%{duration: duration})
|> Ecto.Changeset.validate_number(:duration,
greater_than: 0,
less_than: 1200,
message: "must be less than 20 minutes"
)
end
def put_mp3_path(%Ecto.Changeset{} = changeset) do def put_mp3_path(%Ecto.Changeset{} = changeset) do
if changeset.valid? do if changeset.valid? do
filename = Ecto.UUID.generate() <> ".mp3" filename = Ecto.UUID.generate() <> ".mp3"

View file

@ -315,7 +315,7 @@ defmodule LiveBeatsWeb.LiveHelpers do
<%= for {row, i} <- Enum.with_index(@rows) do %> <%= for {row, i} <- Enum.with_index(@rows) do %>
<tr id={@row_id && @row_id.(row)} class="hover:bg-gray-50"> <tr id={@row_id && @row_id.(row)} class="hover:bg-gray-50">
<%= for col <- @col do %> <%= for col <- @col do %>
<td class={"px-6 py-3 whitespace-nowrap text-sm font-medium text-gray-900 #{if i == 0, do: "max-w-0 w-full"}"}> <td class={"px-6 py-3 whitespace-nowrap text-sm font-medium text-gray-900 #{if i == 0, do: "max-w-0 w-full"} #{col[:class]}"}>
<div class="flex items-center space-x-3 lg:pl-2"> <div class="flex items-center space-x-3 lg:pl-2">
<%= render_slot(col, row) %> <%= render_slot(col, row) %>
</div> </div>

View file

@ -16,7 +16,7 @@ defmodule LiveBeatsWeb.PlayerLive do
<div class="bg-white dark:bg-gray-800 p-4"> <div class="bg-white dark:bg-gray-800 p-4">
<div class="flex items-center space-x-3.5 sm:space-x-5 lg:space-x-3.5 xl:space-x-5"> <div class="flex items-center space-x-3.5 sm:space-x-5 lg:space-x-3.5 xl:space-x-5">
<div class="pr-5"> <div class="pr-5">
<div class="min-w-0 flex-col space-y-0.5"> <div class="min-w-0 max-w-xs flex-col space-y-0.5">
<h2 class="text-black dark:text-white text-sm sm:text-sm lg:text-sm xl:text-sm font-semibold truncate"> <h2 class="text-black dark:text-white text-sm sm:text-sm lg:text-sm xl:text-sm font-semibold truncate">
<%= if @song, do: @song.title, else: raw("&nbsp;") %> <%= if @song, do: @song.title, else: raw("&nbsp;") %>
</h2> </h2>

View file

@ -33,6 +33,7 @@ defmodule LiveBeatsWeb.SongLive.Index do
> >
<:col let={%{song: song}} label="Title"><%= song.title %></:col> <:col let={%{song: song}} label="Title"><%= song.title %></:col>
<:col let={%{song: song}} label="Artist"><%= song.artist %></:col> <:col let={%{song: song}} label="Artist"><%= song.artist %></:col>
<:col let={%{song: song}} label="Attribution" class="max-w-5xl break-words text-gray-600 font-light"><%= song.attribution %></:col>
<:col let={%{song: song}} label="Duration"><%= MP3Stat.to_mmss(song.duration) %></:col> <:col let={%{song: song}} label="Duration"><%= MP3Stat.to_mmss(song.duration) %></:col>
<:col let={%{song: song}} label=""> <:col let={%{song: song}} label="">
<.link phx-click={show_modal("delete-modal-#{song.id}")} class="inline-flex items-center px-3 py-2 text-sm leading-4 font-medium"> <.link phx-click={show_modal("delete-modal-#{song.id}")} class="inline-flex items-center px-3 py-2 text-sm leading-4 font-medium">

View file

@ -33,6 +33,18 @@ defmodule LiveBeatsWeb.SongLive.SongEntryComponent do
<.error input_name={"songs[#{@ref}][title]"} field={:title} errors={@errors} class="-mt-1"/> <.error input_name={"songs[#{@ref}][title]"} field={:title} errors={@errors} class="-mt-1"/>
<.error input_name={"songs[#{@ref}][artist]"} field={:artist} errors={@errors} class="-mt-1"/> <.error input_name={"songs[#{@ref}][artist]"} field={:artist} errors={@errors} class="-mt-1"/>
</div> </div>
<div class="border col-span-full 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">
License Attribution <span class="text-gray-400">(as required by artist)</span>
</label>
<textarea
name={"songs[#{@ref}][attribution]"}
class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-xs"
><%= @attribution %></textarea>
</div>
<div class="col-span-full sm:grid sm:grid-cols-2 sm:gap-2 sm:items-start">
<.error input_name={"songs[#{@ref}][attribution]"} field={:attribution} errors={@errors} class="-mt-1"/>
</div>
<div style={"width: #{@progress}%;"} class="col-span-full bg-purple-500 dark:bg-purple-400 h-1.5 w-0 p-0"> <div style={"width: #{@progress}%;"} class="col-span-full bg-purple-500 dark:bg-purple-400 h-1.5 w-0 p-0">
</div> </div>
</div> </div>
@ -51,6 +63,7 @@ defmodule LiveBeatsWeb.SongLive.SongEntryComponent do
|> assign(title: Ecto.Changeset.get_field(changeset, :title)) |> assign(title: Ecto.Changeset.get_field(changeset, :title))
|> assign(artist: Ecto.Changeset.get_field(changeset, :artist)) |> assign(artist: Ecto.Changeset.get_field(changeset, :artist))
|> assign(duration: Ecto.Changeset.get_field(changeset, :duration)) |> assign(duration: Ecto.Changeset.get_field(changeset, :duration))
|> assign(attribution: Ecto.Changeset.get_field(changeset, :attribution))
|> assign_new(:progress, fn -> 0 end)} |> assign_new(:progress, fn -> 0 end)}
end end
end end

View file

@ -10,7 +10,7 @@ defmodule LiveBeatsWeb.SongLive.SongRowComponent do
<tr id={@id} class={@class}}> <tr id={@id} class={@class}}>
<%= for {col, i} <- Enum.with_index(@col) do %> <%= for {col, i} <- Enum.with_index(@col) do %>
<td <td
class={"px-6 py-3 whitespace-nowrap text-sm font-medium text-gray-900 #{if i == 0, do: "max-w-0 w-full cursor-pointer"}"} class={"px-6 py-3 text-sm font-medium text-gray-900 #{if i == 0, do: "w-80 cursor-pointer"} #{col[:class]}"}
phx-click={JS.push("play_or_pause", value: %{id: @song.id})} phx-click={JS.push("play_or_pause", value: %{id: @song.id})}
> >
<div class="flex items-center space-x-3 lg:pl-2"> <div class="flex items-center space-x-3 lg:pl-2">

View file

@ -46,17 +46,25 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
%{current_user: current_user} = socket.assigns %{current_user: current_user} = socket.assigns
changesets = socket.assigns.changesets changesets = socket.assigns.changesets
if pending_stats?(socket) do
{:noreply, socket}
else
case MediaLibrary.import_songs(current_user, changesets, &consume_entry(socket, &1, &2)) do case MediaLibrary.import_songs(current_user, changesets, &consume_entry(socket, &1, &2)) do
{:ok, songs} -> {:ok, songs} ->
{:noreply, {:noreply,
socket socket
|> put_flash(:info, "#{map_size(songs)} song(s) uploaded") |> put_flash(:info, "#{map_size(songs)} song(s) uploaded")
|> push_redirect(to: Routes.song_index_path(socket, :index))} |> push_redirect(to: home_path(socket))}
{:error, _reason} -> {:error, _reason} ->
{:noreply, socket} {:noreply, socket}
end end
end end
end
defp pending_stats?(socket) do
Enum.find(socket.assigns.changesets, fn {_ref, chset} -> !chset.changes[:duration] end)
end
defp consume_entry(socket, ref, store_func) when is_function(store_func) do defp consume_entry(socket, ref, store_func) when is_function(store_func) do
{entries, []} = uploaded_entries(socket, :mp3) {entries, []} = uploaded_entries(socket, :mp3)
@ -121,6 +129,7 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
consume_uploaded_entry(socket, entry, fn %{path: path} -> consume_uploaded_entry(socket, entry, fn %{path: path} ->
Task.Supervisor.start_child(LiveBeats.TaskSupervisor, fn -> Task.Supervisor.start_child(LiveBeats.TaskSupervisor, fn ->
Process.sleep(5000)
send_update(lv, __MODULE__, send_update(lv, __MODULE__,
id: socket.assigns.id, id: socket.assigns.id,
action: {:duration, entry.ref, LiveBeats.MP3Stat.parse(path)} action: {:duration, entry.ref, LiveBeats.MP3Stat.parse(path)}
@ -136,9 +145,20 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
defp file_error(%{kind: :not_accepted} = assigns), do: ~H|not a valid MP3 file| 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| defp file_error(%{kind: :too_many_files} = assigns), do: ~H|too many files|
defp file_error(%{kind: {msg, opts}} = assigns) when is_binary(msg) and is_list(opts) do
~H|<%= LiveBeatsWeb.ErrorHelpers.translate_error(@kind) %>|
end
defp put_stats(socket, entry_ref, %MP3Stat{} = stat) do defp put_stats(socket, entry_ref, %MP3Stat{} = stat) do
if changeset = get_changeset(socket, entry_ref) do if changeset = get_changeset(socket, entry_ref) do
update_changeset(socket, MediaLibrary.put_stats(changeset, stat), entry_ref) case MediaLibrary.put_stats(changeset, stat) do
{:ok, new_changeset} ->
update_changeset(socket, new_changeset, entry_ref)
{:error, %{duration: error}} ->
IO.inspect({:duration, error})
cancel_changeset_upload(socket, entry_ref, error)
end
else else
socket socket
end end

View file

@ -1,4 +1,10 @@
<main> <main>
<.live_component module={LiveBeatsWeb.LayoutComponent} id="layout" />
<%= if @current_user do %>
<%= live_render(@socket, LiveBeatsWeb.PlayerLive, id: "player", session: %{}, sticky: true) %>
<% end %>
<p class="alert alert-info fade-in-scale" role="alert" <p class="alert alert-info fade-in-scale" role="alert"
phx-click="lv:clear-flash" phx-click="lv:clear-flash"
phx-value-key="info"><%= live_flash(@flash, :info) %></p> phx-value-key="info"><%= live_flash(@flash, :info) %></p>
@ -7,7 +13,5 @@
phx-click="lv:clear-flash" phx-click="lv:clear-flash"
phx-value-key="error"><%= live_flash(@flash, :error) %></p> phx-value-key="error"><%= live_flash(@flash, :error) %></p>
<.live_component module={LiveBeatsWeb.LayoutComponent} id="layout" />
<%= @inner_content %> <%= @inner_content %>
</main> </main>

View file

@ -301,9 +301,6 @@
</div> </div>
<main class="flex-1 relative z-0 overflow-y-auto focus:outline-none"> <main class="flex-1 relative z-0 overflow-y-auto focus:outline-none">
<%= if @current_user do %>
<%= live_render(@conn, LiveBeatsWeb.PlayerLive, session: %{}) %>
<% end %>
<%= @inner_content %> <%= @inner_content %>
</main> </main>
</div> </div>

View file

@ -10,6 +10,7 @@ defmodule LiveBeats.Repo.Migrations.CreateSongs do
add :played_at, :utc_datetime add :played_at, :utc_datetime
add :paused_at, :utc_datetime add :paused_at, :utc_datetime
add :title, :string, null: false add :title, :string, null: false
add :attribution, :string
add :mp3_url, :string, null: false add :mp3_url, :string, null: false
add :mp3_filename, :string, null: false add :mp3_filename, :string, null: false
add :mp3_filepath, :string, null: false add :mp3_filepath, :string, null: false