defmodule MusicLibraryWeb.Components.Release do use MusicLibraryWeb, :live_component require Logger alias MusicBrainz.Release alias MusicLibrary.Records.TracklistPdf alias MusicLibrary.ScrobbleActivity alias MusicLibraryWeb.Duration alias MusicLibraryWeb.ErrorMessages alias Phoenix.LiveView.AsyncResult def open(id), do: Fluxon.open_dialog(id) @impl true def mount(socket) do {:ok, socket |> assign(:can_scrobble?, ScrobbleActivity.can_scrobble?()) |> assign(:release_with_tracks, AsyncResult.loading()) |> assign(:already_scrobbled, false) |> assign(:selected_tracks, MapSet.new()) |> assign(:pending_form_params, nil)} end @impl true def update(%{release_id: release_id} = assigns, socket) do socket = assign(socket, assigns) timezone = socket.assigns.timezone current_time = DateTime.utc_now() |> DateTime.shift_zone!(timezone) {:ok, socket |> assign(:finished_at, current_time) |> assign(:form, to_form(%{"finished_at" => DateTime.to_naive(current_time)}, as: :release)) |> assign(:release_with_tracks, AsyncResult.loading()) |> start_async(:release_with_tracks, fn -> load_release_with_tracks(release_id) end)} end def update(%{already_scrobbled: _already_scrobbled} = assigns, socket) do {:ok, assign(socket, assigns)} end @impl true def handle_async(:release_with_tracks, {:ok, {:ok, release}}, socket) do notify_release_loaded(socket, release) socket = socket |> assign( :release_with_tracks, AsyncResult.ok(socket.assigns.release_with_tracks, release) ) |> apply_pending_form_params() {:noreply, socket} end def handle_async(:release_with_tracks, {:ok, {:error, reason}}, socket) do {:noreply, assign( socket, :release_with_tracks, AsyncResult.failed(socket.assigns.release_with_tracks, {:error, reason}) )} end def handle_async(:release_with_tracks, {:exit, reason}, socket) do {:noreply, assign( socket, :release_with_tracks, AsyncResult.failed(socket.assigns.release_with_tracks, {:exit, reason}) )} end defp notify_release_loaded(_socket, release) do send(self(), {__MODULE__, {:loaded, release}}) end defp apply_pending_form_params(socket) do case socket.assigns.pending_form_params do nil -> socket params -> release = socket.assigns.release_with_tracks.result new_selected = apply_form_params(release, params, socket.assigns.selected_tracks) finished_at = parse_finished_at(params["finished_at"], socket.assigns.timezone) socket |> assign(:selected_tracks, new_selected) |> assign(:finished_at, finished_at) |> assign(:pending_form_params, nil) end end @spec parse_finished_at(term(), String.t()) :: DateTime.t() | nil defp parse_finished_at(nil, _timezone), do: nil defp parse_finished_at("", _timezone), do: nil defp parse_finished_at(value, timezone) when is_binary(value) do case DateTime.from_iso8601(value) do {:ok, datetime, _offset} -> datetime {:error, _} -> case NaiveDateTime.from_iso8601(value) do {:ok, naive} -> DateTime.from_naive!(naive, timezone) {:error, _} -> nil end end end defp parse_finished_at(_, _), do: nil @impl true def render(assigns) do ~H"""
<.form for={@form} id={"#{@sheet_id}-form"} phx-target={@myself} phx-change="validate" phx-auto-recover="recover_form" class="flex min-h-0 flex-1 flex-col" >

{header_title(@release_with_tracks)}

{artist_names}

<.date_time_picker :if={@can_scrobble?} field={@form[:finished_at]} size="sm" display_format="%b %-d, %H:%M" time_format="24" placeholder={gettext("Now")} class="flex-1 sm:flex-initial" > <:inner_prefix class="pl-2 text-zinc-500 dark:text-zinc-400"> {gettext("Finished at")} <:outer_suffix class="pr-2"> <.button size="sm" type="button" phx-click="reset_to_now" phx-target={@myself} > {gettext("Now")} <.button :if={@can_scrobble? && @release_with_tracks.ok?} type="button" variant="solid" size="sm" disabled={@already_scrobbled} phx-click="scrobble_release" phx-target={@myself} phx-disable-with={gettext("Scrobbling...")} > <.icon name="hero-play" class="icon" aria-hidden="true" data-slot="icon" /> {gettext("Release")} <.dropdown :if={(@show_print? && @release_with_tracks.ok?) || !@can_scrobble?} id={"#{@sheet_id}-release-actions"} placement="bottom-end" > <:toggle> <.button type="button" variant="outline" size="sm"> {gettext("More actions")} <.icon name="hero-ellipsis-vertical" class="icon" aria-hidden="true" data-slot="icon" /> <.focus_wrap id={"#{@sheet_id}-release-actions-focus-wrap"}> <.dropdown_link :if={@show_print? && @release_with_tracks.ok?} phx-click="print_tracklist" phx-target={@myself} > <.icon name="hero-printer" class="icon" aria-hidden="true" data-slot="icon" /> {gettext("Print tracklist")} <.dropdown_link :if={!@can_scrobble?} href={LastFm.auth_url()}> <.icon name="hero-link" class="icon" aria-hidden="true" data-slot="icon" /> {gettext("Connect Last.fm")}
<.async_result :let={release_with_tracks} assign={@release_with_tracks}> <:loading>
{gettext("Loading release with tracks")} <.loading />
<:failed :let={_failure}>
<.icon name="hero-exclamation-triangle" class="size-5" aria-hidden="true" data-slot="icon" /> {gettext("Error loading tracks")} <.button type="button" variant="ghost" size="xs" phx-click={JS.push("load_release_tracks", target: @myself)} class="ml-2 cursor-pointer" > {gettext("Retry")}
<.medium :for={medium <- release_with_tracks.media} can_scrobble?={@can_scrobble?} already_scrobbled={@already_scrobbled} medium={medium} release_artists={release_with_tracks.artists} media_count={MusicBrainz.Release.media_count(release_with_tracks)} selected_tracks={@selected_tracks} myself={@myself} show_print?={@show_print?} />
<.selection_bar :if={@can_scrobble? && @release_with_tracks.ok? && MapSet.size(@selected_tracks) > 0} release={@release_with_tracks.result} selected_tracks={@selected_tracks} already_scrobbled={@already_scrobbled} myself={@myself} />
""" end attr :medium, Release.Medium, required: true attr :release_artists, :list, required: true attr :media_count, :integer, required: true attr :can_scrobble?, :boolean, required: true attr :already_scrobbled, :boolean, required: true attr :selected_tracks, :any, required: true attr :myself, :any, required: true attr :show_print?, :boolean, required: true def medium(assigns) do ~H"""
1} class="flex items-center gap-2" > <.button :if={@can_scrobble?} type="button" variant="soft" size="sm" disabled={@already_scrobbled} phx-click="scrobble_medium" phx-value-number={@medium.number} phx-target={@myself} phx-disable-with={gettext("Scrobbling...")} > <.icon name="hero-play" class="icon" aria-hidden="true" data-slot="icon" /> {medium_scrobble_label(@medium.format)} <.dropdown :if={@show_print?} id={"medium-actions-#{@medium.number}"} placement="bottom-end" > <:toggle> <.button type="button" variant="outline" size="sm"> {gettext("More actions")} <.icon name="hero-ellipsis-vertical" class="icon" aria-hidden="true" data-slot="icon" /> <.focus_wrap id={"medium-actions-#{@medium.number}-focus-wrap"}> <.dropdown_link phx-click="print_medium_tracklist" phx-value-medium-number={@medium.number} phx-target={@myself} > <.icon name="hero-printer" class="icon" aria-hidden="true" data-slot="icon" /> {gettext("Print tracklist")}
<.track_list medium_number={@medium.number} tracks={@medium.tracks} release_artists={@release_artists} selected_tracks={@selected_tracks} can_scrobble?={@can_scrobble?} /> <.separator />

{medium_duration(@medium)}

""" end defp medium_scrobble_label(format) do if format && String.contains?(format, "Vinyl") do gettext("Scrobble side") else gettext("Scrobble disc") end end attr :medium_number, :integer, required: true attr :tracks, :list, required: true attr :release_artists, :list, required: true attr :selected_tracks, :any, required: true attr :can_scrobble?, :boolean, required: true def track_list(assigns) do ~H""" """ end attr :release, :any, required: true attr :selected_tracks, :any, required: true attr :already_scrobbled, :boolean, required: true attr :myself, :any, required: true defp selection_bar(assigns) do {count, medium_count, duration_ms} = selected_tracks_summary(assigns.release, assigns.selected_tracks) assigns = assign(assigns, count: count, medium_count: medium_count, duration: Duration.format_duration(duration_ms) ) ~H"""

{ngettext("%{count} track selected", "%{count} tracks selected", @count, count: @count)}

1}> {ngettext( "across %{count} disc", "across %{count} discs", @medium_count, count: @medium_count )} ยท {@duration} {@duration}

<.button type="button" variant="solid" size="sm" disabled={@already_scrobbled} phx-click="scrobble_selected_tracks" phx-target={@myself} phx-disable-with={gettext("Scrobbling...")} > <.icon name="hero-play" class="icon" aria-hidden="true" data-slot="icon" /> {gettext("Scrobble selected")}
""" end defguardp release_loaded?(assigns) when assigns.release_with_tracks.ok? @impl true def handle_event("scrobble_release", _params, socket) when release_loaded?(socket.assigns) do release_with_tracks = socket.assigns.release_with_tracks.result finished_at = socket.assigns.finished_at case ScrobbleActivity.scrobble_release(release_with_tracks, :finished_at, finished_at) do {:ok, _} -> send_update_after(socket.assigns.myself, %{already_scrobbled: false}, 3000) put_toast!(:info, gettext("Release scrobbled successfully")) {:noreply, socket |> assign(:already_scrobbled, true)} {:error, reason} -> Logger.error("Error scrobbling release: #{inspect(reason)}") put_toast!( :error, gettext("Error scrobbling release") <> ": " <> ErrorMessages.friendly_message(reason) ) {:noreply, socket} end end def handle_event("scrobble_release", _params, socket) do put_toast!(:error, gettext("Error scrobbling release")) {:noreply, socket} end def handle_event("scrobble_medium", %{"number" => number}, socket) when release_loaded?(socket.assigns) do release_with_tracks = socket.assigns.release_with_tracks.result {number, ""} = Integer.parse(number) finished_at = socket.assigns.finished_at case ScrobbleActivity.scrobble_medium( number, release_with_tracks, :finished_at, finished_at ) do {:ok, _} -> send_update_after(socket.assigns.myself, %{already_scrobbled: false}, 3000) put_toast!(:info, gettext("Disc scrobbled successfully")) {:noreply, socket |> assign(:already_scrobbled, true)} {:error, reason} -> Logger.error("Error scrobbling medium: #{inspect(reason)}") put_toast!( :error, gettext("Error scrobbling disc") <> ": " <> ErrorMessages.friendly_message(reason) ) {:noreply, socket} end end def handle_event("scrobble_medium", _params, socket) do put_toast!(:error, gettext("Error scrobbling disc")) {:noreply, socket} end def handle_event("validate", %{"release" => params}, socket) when release_loaded?(socket.assigns) do release = socket.assigns.release_with_tracks.result new_selected = apply_form_params(release, params, socket.assigns.selected_tracks) finished_at_raw = params["finished_at"] finished_at = parse_finished_at(finished_at_raw, socket.assigns.timezone) {:noreply, socket |> assign(:selected_tracks, new_selected) |> assign(:finished_at, finished_at) |> assign(:form, to_form(%{"finished_at" => finished_at_raw}, as: :release))} end def handle_event("validate", %{"release" => params}, socket) do # Release not yet loaded (e.g., phx-auto-recover fires before async completes). # Stash the params; handle_async/3 will apply them once the release is available. {:noreply, assign(socket, :pending_form_params, params)} end def handle_event("validate", _params, socket) do {:noreply, socket} end def handle_event("recover_form", params, socket) do handle_event("validate", params, socket) end def handle_event("reset_to_now", _params, socket) do current_time = DateTime.utc_now() |> DateTime.shift_zone!(socket.assigns.timezone) {:noreply, socket |> assign(:finished_at, current_time) |> assign(:form, to_form(%{"finished_at" => DateTime.to_naive(current_time)}, as: :release))} end def handle_event("scrobble_selected_tracks", _params, socket) when release_loaded?(socket.assigns) do release_with_tracks = socket.assigns.release_with_tracks.result selected_track_ids = socket.assigns.selected_tracks if MapSet.size(selected_track_ids) == 0 do put_toast!(:error, gettext("No tracks selected")) {:noreply, socket} else finished_at = socket.assigns.finished_at case ScrobbleActivity.scrobble_tracks( selected_track_ids, release_with_tracks, :finished_at, finished_at ) do {:ok, _} -> send_update_after(socket.assigns.myself, %{already_scrobbled: false}, 3000) put_toast!(:info, gettext("Selected tracks scrobbled successfully")) {:noreply, socket |> assign(:already_scrobbled, true) |> assign(:selected_tracks, MapSet.new())} {:error, reason} -> Logger.error("Error scrobbling tracks: #{inspect(reason)}") put_toast!( :error, gettext("Error scrobbling selected tracks") <> ": " <> ErrorMessages.friendly_message(reason) ) {:noreply, socket} end end end def handle_event("scrobble_selected_tracks", _params, socket) do put_toast!(:error, gettext("Error scrobbling selected tracks")) {:noreply, socket} end def handle_event("print_tracklist", _params, socket) when release_loaded?(socket.assigns) do release = socket.assigns.release_with_tracks.result case TracklistPdf.generate(release) do {:ok, pdf_binary} -> filename = "#{release.title} - Tracklist.pdf" {:noreply, push_event(socket, "music_library:download", %{ data: Base.encode64(pdf_binary), filename: filename, content_type: "application/pdf" })} {:error, reason} -> Logger.error("Error generating tracklist PDF: #{inspect(reason)}") put_toast!( :error, gettext("Error generating tracklist PDF") <> ": " <> ErrorMessages.friendly_message(reason) ) {:noreply, socket} end end def handle_event("print_tracklist", _params, socket) do {:noreply, socket} end def handle_event("print_medium_tracklist", %{"medium-number" => number}, socket) when release_loaded?(socket.assigns) do release = socket.assigns.release_with_tracks.result {number, ""} = Integer.parse(number) case TracklistPdf.generate_medium(release, number) do {:ok, pdf_binary} -> filename = "#{release.title} - Disc #{number} - Tracklist.pdf" {:noreply, push_event(socket, "music_library:download", %{ data: Base.encode64(pdf_binary), filename: filename, content_type: "application/pdf" })} {:error, reason} -> Logger.error("Error generating medium tracklist PDF: #{inspect(reason)}") put_toast!( :error, gettext("Error generating tracklist PDF") <> ": " <> ErrorMessages.friendly_message(reason) ) {:noreply, socket} end end def handle_event("print_medium_tracklist", _params, socket) do {:noreply, socket} end def handle_event("load_release_tracks", _params, socket) do selected_release_id = socket.assigns.release_id {:noreply, socket |> assign(:release_with_tracks, AsyncResult.loading()) |> start_async(:release_with_tracks, fn -> load_release_with_tracks(selected_release_id) end)} end defp load_release_with_tracks(release_id) do with {:ok, release} <- MusicBrainz.get_release(release_id) do {:ok, MusicBrainz.Release.from_api_response(release)} end end @spec medium_selected?(MusicBrainz.Release.Medium.t(), MapSet.t()) :: boolean() defp medium_selected?(medium, selected_tracks) do medium_track_ids = medium.tracks |> Enum.map(& &1.id) |> MapSet.new() MapSet.subset?(medium_track_ids, selected_tracks) end @doc """ Applies form params from a track-selection form to produce a new `selected_tracks` MapSet. Detects master-toggle transitions (the `release[toggle_medium][]` field) by diffing the incoming params against the current server-side `current_selected` MapSet. When a medium's master toggle transitions from unchecked to checked, all of its tracks are added; when it transitions the other way, all of its tracks are removed. Used by both `Release` (as a LiveComponent) and `MusicLibraryWeb.ScrobbleLive.Show` to share identical form-driven selection logic. """ @spec apply_form_params(MusicBrainz.Release.t(), map(), MapSet.t()) :: MapSet.t() def apply_form_params(release, params, current_selected) do form_selected = (params["selected_tracks"] || []) |> Enum.reject(&(&1 == "")) |> MapSet.new() toggled_medium_numbers = (params["toggle_medium"] || []) |> Enum.reject(&(&1 == "")) |> Enum.map(&String.to_integer/1) |> MapSet.new() current_fully_selected = for medium <- release.media, medium_selected?(medium, current_selected), into: MapSet.new(), do: medium.number Enum.reduce(release.media, form_selected, fn medium, acc -> was_fully_selected? = MapSet.member?(current_fully_selected, medium.number) is_toggled_now? = MapSet.member?(toggled_medium_numbers, medium.number) medium_track_ids = medium.tracks |> Enum.map(& &1.id) |> MapSet.new() cond do not was_fully_selected? and is_toggled_now? -> MapSet.union(acc, medium_track_ids) was_fully_selected? and not is_toggled_now? -> MapSet.difference(acc, medium_track_ids) true -> acc end end) end defp medium_duration(medium) do medium |> MusicBrainz.Release.medium_duration() |> Duration.format_duration() end defp medium_title(medium) do if medium.title === "" do gettext("Disc %{no}", %{no: medium.number}) else medium.title end end defp header_title(%AsyncResult{ok?: true, result: release}), do: release.title defp header_title(_), do: "" defp header_subtitle(%AsyncResult{ok?: true, result: release}) do case release.artists do [] -> nil artists -> artists |> Enum.map_join(fn artist -> artist.name <> (artist.joinphrase || "") end) |> String.trim() |> case do "" -> nil names -> names end end end defp header_subtitle(_), do: nil @spec selected_tracks_summary(MusicBrainz.Release.t(), MapSet.t()) :: {non_neg_integer(), non_neg_integer(), non_neg_integer()} defp selected_tracks_summary(release, selected_tracks) do {count, medium_count, duration_ms} = Enum.reduce(release.media, {0, 0, 0}, fn medium, {count, medium_count, duration_ms} -> matching = Enum.filter(medium.tracks, &MapSet.member?(selected_tracks, &1.id)) case matching do [] -> {count, medium_count, duration_ms} tracks -> tracks_duration = Enum.reduce(tracks, 0, &((&1.length || 0) + &2)) {count + length(tracks), medium_count + 1, duration_ms + tracks_duration} end end) {count, medium_count, duration_ms} end end