From de195aa97051399ed1bfcb4093273d68cec813f0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Nov 2025 08:09:28 +0000 Subject: [PATCH] Add track selection and group scrobbling to release sheet (#62) * Initial plan * Add track selection and scrobble functionality Co-authored-by: cloud8421 <537608+cloud8421@users.noreply.github.com> * Add translations for track selection feature Co-authored-by: cloud8421 <537608+cloud8421@users.noreply.github.com> * Format code * Temporarily set selected tracks in scrobble anything view --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: cloud8421 <537608+cloud8421@users.noreply.github.com> Co-authored-by: Claudio Ortolina --- lib/music_library/scrobble_activity.ex | 48 ++++++++++ lib/music_library_web/components/release.ex | 89 ++++++++++++++++++- .../live/scrobble_live/show.html.heex | 1 + priv/gettext/default.pot | 20 +++++ priv/gettext/en/LC_MESSAGES/default.po | 20 +++++ 5 files changed, 175 insertions(+), 3 deletions(-) diff --git a/lib/music_library/scrobble_activity.ex b/lib/music_library/scrobble_activity.ex index 45eaef91..36839e14 100644 --- a/lib/music_library/scrobble_activity.ex +++ b/lib/music_library/scrobble_activity.ex @@ -97,6 +97,54 @@ defmodule MusicLibrary.ScrobbleActivity do end end + def scrobble_tracks(selected_track_ids, release_with_tracks, opts) when is_list(opts) do + case Enum.sort(opts) do + [finished_at: _, started_at: _] -> + raise ArgumentError, """ + Cannot scrobble tracks with both started_at and finished_at. + Remove either of them. + """ + + [started_at: started_at] -> + scrobble_tracks(selected_track_ids, release_with_tracks, {:started_at, started_at}) + + [finished_at: finished_at] -> + scrobble_tracks(selected_track_ids, release_with_tracks, {:finished_at, finished_at}) + end + end + + def scrobble_tracks(selected_track_ids, release_with_tracks, {:finished_at, finished_at}) do + all_tracks = Release.tracks(release_with_tracks) + + selected_tracks = + Enum.filter(all_tracks, fn track -> MapSet.member?(selected_track_ids, track.id) end) + + tracks_duration = Enum.sum_by(selected_tracks, fn track -> track.length || 0 end) + started_at = DateTime.add(finished_at, -tracks_duration, :millisecond) + scrobble_tracks(selected_track_ids, release_with_tracks, {:started_at, started_at}) + end + + def scrobble_tracks(selected_track_ids, release_with_tracks, {:started_at, started_at}) do + all_tracks = Release.tracks(release_with_tracks) + + selected_tracks = + Enum.filter(all_tracks, fn track -> MapSet.member?(selected_track_ids, track.id) end) + + tracks_duration = Enum.sum_by(selected_tracks, fn track -> track.length || 0 end) + + if tracks_duration == 0 do + {:error, :no_duration} + else + session_key = Secrets.get!("last_fm_session_key").value + + {scrobbles, _finished_at} = + selected_tracks + |> to_scrobbles(release_with_tracks, started_at) + + LastFm.scrobble(scrobbles, session_key) + end + end + defp to_scrobbles(tracks, release_with_tracks, started_at) do tracks |> Enum.map_reduce(started_at, fn track, time -> diff --git a/lib/music_library_web/components/release.ex b/lib/music_library_web/components/release.ex index 5fa9b1ca..7e6548de 100644 --- a/lib/music_library_web/components/release.ex +++ b/lib/music_library_web/components/release.ex @@ -14,7 +14,8 @@ defmodule MusicLibraryWeb.Components.Release do socket |> assign(:can_scrobble?, ScrobbleActivity.can_scrobble?()) |> assign(:release_with_tracks, AsyncResult.loading()) - |> assign(:already_scrobbled, false)} + |> assign(:already_scrobbled, false) + |> assign(:selected_tracks, MapSet.new())} end @impl true @@ -49,11 +50,15 @@ defmodule MusicLibraryWeb.Components.Release do :if={@can_scrobble? && @release_with_tracks.ok?} size="sm" disabled={@already_scrobbled} - phx-click="scrobble_release" + phx-click={ + if MapSet.size(@selected_tracks) > 0, + do: "scrobble_selected_tracks", + else: "scrobble_release" + } phx-target={@myself} phx-disable-with={gettext("Scrobbling...")} > - {gettext("Scrobble release")} + {scrobble_button_label(@selected_tracks)} <.button :if={!@can_scrobble?} size="sm" href={LastFm.auth_url()}> {gettext("Connect your Last.fm account")} @@ -86,6 +91,7 @@ defmodule MusicLibraryWeb.Components.Release do medium={medium} release_artists={release_with_tracks.artists} media_count={MusicBrainz.Release.media_count(release_with_tracks)} + selected_tracks={@selected_tracks} myself={@myself} /> @@ -100,6 +106,7 @@ defmodule MusicLibraryWeb.Components.Release do 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 def medium(assigns) do @@ -127,6 +134,9 @@ defmodule MusicLibraryWeb.Components.Release do medium_number={@medium.number} tracks={@medium.tracks} release_artists={@release_artists} + selected_tracks={@selected_tracks} + can_scrobble?={@can_scrobble?} + myself={@myself} /> <.separator />

@@ -146,6 +156,9 @@ defmodule MusicLibraryWeb.Components.Release do 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 + attr :myself, :any, required: true def track_list(assigns) do ~H""" @@ -155,6 +168,17 @@ defmodule MusicLibraryWeb.Components.Release do class="contents leading-5 text-zinc-700 dark:text-zinc-300 list-none" >

+ + + {track.number || track.position} @@ -169,6 +193,7 @@ defmodule MusicLibraryWeb.Components.Release do :if={@release_artists !== track.artists} class="table-row text-xs md:text-sm" > + {Enum.map_join(track.artists, ", ", fn artist -> artist.name end)} @@ -238,6 +263,56 @@ defmodule MusicLibraryWeb.Components.Release do {:noreply, socket |> put_toast(:error, gettext("Error scrobbling disc"))} end + def handle_event("toggle_track", %{"track-id" => track_id}, socket) do + selected_tracks = socket.assigns.selected_tracks + + updated_tracks = + if MapSet.member?(selected_tracks, track_id) do + MapSet.delete(selected_tracks, track_id) + else + MapSet.put(selected_tracks, track_id) + end + + {:noreply, assign(socket, :selected_tracks, updated_tracks)} + 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 + {:noreply, socket |> put_toast(:error, gettext("No tracks selected"))} + else + case ScrobbleActivity.scrobble_tracks( + selected_track_ids, + release_with_tracks, + finished_at: DateTime.utc_now() + ) do + {:ok, _} -> + send_update_after(socket.assigns.myself, %{already_scrobbled: false}, 3000) + + {:noreply, + socket + |> assign(:already_scrobbled, true) + |> assign(:selected_tracks, MapSet.new()) + |> put_toast(:info, gettext("Selected tracks scrobbled successfully"))} + + {:error, reason} -> + {:noreply, + socket + |> put_toast( + :error, + gettext("Error scrobbling selected tracks") <> "," <> inspect(reason) + )} + end + end + end + + def handle_event("scrobble_selected_tracks", _params, socket) do + {:noreply, socket |> put_toast(:error, gettext("Error scrobbling selected tracks"))} + end + defp medium_duration(medium) do medium |> MusicBrainz.Release.medium_duration() @@ -251,4 +326,12 @@ defmodule MusicLibraryWeb.Components.Release do medium.title end end + + defp scrobble_button_label(selected_tracks) do + if MapSet.size(selected_tracks) > 0 do + gettext("Scrobble selected tracks") + else + gettext("Scrobble release") + end + end end diff --git a/lib/music_library_web/live/scrobble_live/show.html.heex b/lib/music_library_web/live/scrobble_live/show.html.heex index d9dc5d24..b29dd409 100644 --- a/lib/music_library_web/live/scrobble_live/show.html.heex +++ b/lib/music_library_web/live/scrobble_live/show.html.heex @@ -94,6 +94,7 @@ already_scrobbled={false} medium={medium} release_artists={@release.artists} + selected_tracks={MapSet.new()} media_count={MusicBrainz.Release.media_count(@release)} myself={nil} /> diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 3fc7b093..6b6debb5 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -766,6 +766,26 @@ msgstr "" msgid "Scrobble release" msgstr "" +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Scrobble selected tracks" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Selected tracks scrobbled successfully" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Error scrobbling selected tracks" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "No tracks selected" +msgstr "" + #: lib/music_library_web/components/release.ex #: lib/music_library_web/live/scrobble_live/show.ex #, elixir-autogen, elixir-format diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 5398091d..27cd3766 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -766,6 +766,26 @@ msgstr "" msgid "Scrobble release" msgstr "" +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Scrobble selected tracks" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Selected tracks scrobbled successfully" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Error scrobbling selected tracks" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "No tracks selected" +msgstr "" + #: lib/music_library_web/components/release.ex #: lib/music_library_web/live/scrobble_live/show.ex #, elixir-autogen, elixir-format