+ 234 - <% end %>
+ 234 +
+
+⏺ Now let's update the LiveView modules to set the current_section properly:
+
+⏺ Update(lib/music_library_web/live/scrobble_live/index.ex)
+ ⎿ Updated lib/music_library_web/live/scrobble_live/index.ex with 1 addition
+ 7 def mount(_params, _session, socket) do
+ 8 {:ok,
+ 9 assign(socket,
+ 10 + current_section: :scrobble,
+ 11 search_query: "",
+ 12 search_results: [],
+ 13 selected_release_group: nil,
+
+⏺ Update(lib/music_library_web/live/scrobble_live/show.ex)
+ ⎿ Updated lib/music_library_web/live/scrobble_live/show.ex with 1 addition
+ 7 def mount(%{"release_id" => release_id}, _session, socket) do
+ 8 {:ok,
+ 9 assign(socket,
+ 10 + current_section: :scrobble,
+ 11 release_id: release_id,
+ 12 release: nil,
+ 13 loading: true,
+
+⏺ Now let's test compilation:
+
+⏺ Bash(mix compile)
+ ⎿ warning: undefined attribute "options" for component Fluxon.Components.Input.input/1
+ │
+ 174 │ options={[
+ … +28 lines (ctrl+o to expand)
+
+⏺ I need to fix these issues. Let me check the MusicBrainz API and fix the components:
+
+⏺ tidewave - get_source_location (MCP)(reference: "MusicBrainz.get_releases")
+ ⎿ lib/music_brainz.ex:19
+
+⏺ Read(lib/music_brainz.ex)
+ ⎿ Read 10 lines (ctrl+o to expand)
+
+⏺ Now let me fix the issues:
+
+⏺ Update(lib/music_library_web/live/scrobble_live/index.ex)
+ ⎿ Updated lib/music_library_web/live/scrobble_live/index.ex with 1 addition and 1 removal
+ 77 end
+ 78
+ 79 def handle_info({:fetch_releases, release_group}, socket) do
+ 80 - case MusicBrainz.get_releases(release_group.id, [], limit: 50) do
+ 80 + case MusicBrainz.get_releases(release_group.id, limit: 50) do
+ 81 {:ok, releases} ->
+ 82 {:noreply, assign(socket, releases: releases, loading: false)}
+ 83
+
+⏺ Update(lib/music_library_web/live/scrobble_live/index.html.heex)
+ ⎿ Updated lib/music_library_web/live/scrobble_live/index.html.heex with 1 addition and 1 removal
+ 83 <.button
+ 84 variant="ghost"
+ 85 size="sm"
+ 86 - phx-click={JS.assign(%{selected_release_group: nil, releases: []})}
+ 86 + phx-click="clear_selection"
+ 87 >
+ 88 <.icon name="hero-arrow-left" class="h-4 w-4" />
+ 89
+
+⏺ Update(lib/music_library_web/live/scrobble_live/index.ex)
+ ⎿ Updated lib/music_library_web/live/scrobble_live/index.ex with 4 additions
+ 56 end
+ 57 end
+ 58
+ 59 + def handle_event("clear_selection", _params, socket) do
+ 60 + {:noreply, assign(socket, selected_release_group: nil, releases: [])}
+ 61 + end
+ 62 +
+ 63 @impl true
+ 64 def handle_info({:perform_search, query}, socket) do
+ 65 case MusicBrainz.search_release_group(query, limit: 20) do
+
+⏺ Now let me fix the select component in the show template. Let me first check how selects are used in other templates:
+
+⏺ Search(pattern: "type="select"", glob: "**/*.heex", output_mode: "content", head_limit: 5)
+ ⎿ Found 1 line (ctrl+o to expand)
+
+⏺ Let me check if there are select components used elsewhere:
+
+⏺ Search(pattern: "\.select"", glob: "**/*.heex", output_mode: "content", head_limit: 5)
+ ⎿ Found 0 lines
+
+⏺ Let me check the Fluxon documentation for the correct select syntax:
+
+⏺ Update(lib/music_library_web/live/scrobble_live/show.html.heex)
+ ⎿ Updated lib/music_library_web/live/scrobble_live/show.html.heex with 5 additions and 7 removals
+ 166 phx-change="update_scrobble_form"
+ 167 class="space-y-4"
+ 168 >
+ 169 - <.input
+ 170 - type="select"
+ 169 + <.select
+ 170 name="type"
+ 171 label="Scrobble timing"
+ 172 value={@scrobble_form["type"]}
+ 173 - options={[
+ 174 - {"Just finished listening", "finished_at"},
+ 175 - {"Started listening at", "started_at"}
+ 176 - ]}
+ 177 - />
+ 173 + >
+ 174 + <:option value="finished_at">Just finished listening
+ 175 + <:option value="started_at">Started listening at
+ 176 +
+ 177
+ 178 <%= if @scrobble_form["type"] == "finished_at" do %>
+ 179
diff --git a/lib/music_library_web/live/scrobble_live/index.ex b/lib/music_library_web/live/scrobble_live/index.ex
new file mode 100644
index 00000000..8ee119f6
--- /dev/null
+++ b/lib/music_library_web/live/scrobble_live/index.ex
@@ -0,0 +1,99 @@
+defmodule MusicLibraryWeb.ScrobbleLive.Index do
+ use MusicLibraryWeb, :live_view
+
+ alias MusicLibrary.ScrobbleActivity
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok,
+ assign(socket,
+ current_section: :scrobble,
+ search_query: "",
+ search_results: [],
+ selected_release_group: nil,
+ releases: [],
+ loading: false,
+ can_scrobble: ScrobbleActivity.can_scrobble?(),
+ page_title: "Scrobble"
+ )}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ {:noreply, apply_action(socket, socket.assigns.live_action, params)}
+ end
+
+ defp apply_action(socket, :index, _params) do
+ socket
+ |> assign(:page_title, "Scrobble")
+ end
+
+ @impl true
+ def handle_event("search", %{"query" => query}, socket) do
+ if String.trim(query) == "" do
+ {:noreply,
+ assign(socket,
+ search_query: query,
+ search_results: [],
+ selected_release_group: nil,
+ releases: []
+ )}
+ else
+ send(self(), {:perform_search, query})
+ {:noreply, assign(socket, search_query: query, loading: true)}
+ end
+ end
+
+ def handle_event("select_release_group", %{"release_group_id" => release_group_id}, socket) do
+ selected_release_group =
+ Enum.find(socket.assigns.search_results, &(&1.id == release_group_id))
+
+ if selected_release_group do
+ send(self(), {:fetch_releases, selected_release_group})
+ {:noreply, assign(socket, selected_release_group: selected_release_group, loading: true)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("clear_selection", _params, socket) do
+ {:noreply, assign(socket, selected_release_group: nil, releases: [])}
+ end
+
+ @impl true
+ def handle_info({:perform_search, query}, socket) do
+ case MusicBrainz.search_release_group(query, limit: 20) do
+ {:ok, results} ->
+ {:noreply,
+ assign(socket,
+ search_results: results.release_groups,
+ loading: false,
+ selected_release_group: nil,
+ releases: []
+ )}
+
+ {:error, _reason} ->
+ {:noreply,
+ socket
+ |> put_flash(:error, "Failed to search for release groups")
+ |> assign(loading: false)}
+ end
+ end
+
+ def handle_info({:fetch_releases, release_group}, socket) do
+ case MusicBrainz.get_releases(release_group.id, limit: 50) do
+ {:ok, %{"releases" => releases}} ->
+ releases =
+ releases
+ |> Enum.map(&MusicBrainz.Release.from_api_response/1)
+
+ {:noreply, assign(socket, releases: releases, loading: false)}
+
+ {:error, _reason} ->
+ {:noreply,
+ socket
+ |> put_flash(:error, "Failed to fetch releases for this release group")
+ |> assign(loading: false)}
+ end
+ end
+end
diff --git a/lib/music_library_web/live/scrobble_live/index.html.heex b/lib/music_library_web/live/scrobble_live/index.html.heex
new file mode 100644
index 00000000..dee2eb89
--- /dev/null
+++ b/lib/music_library_web/live/scrobble_live/index.html.heex
@@ -0,0 +1,165 @@
+
+
+
+ {gettext("Scrobble to Last.fm")}
+
+
+ Search for MusicBrainz releases and scrobble them to Last.fm
+
+
+ <%= if not @can_scrobble do %>
+
+
+ <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />
+
+
+ You need to connect your Last.fm account to scrobble. Please set up your Last.fm session key in the settings.
+
+
+
+
+ <% end %>
+
+
+ <.form for={%{}} as={:search} phx-submit="search" class="space-y-4">
+
+ <.input
+ type="text"
+ name="query"
+ value={@search_query}
+ placeholder="Search for release groups (e.g., artist name, album title)..."
+ class="flex-1"
+ phx-debounce="300"
+ />
+ <.button type="submit" disabled={@loading}>
+ <%= if @loading do %>
+ <.icon name="hero-arrow-path" class="h-4 w-4 animate-spin mr-2" /> Searching...
+ <% else %>
+ <.icon name="hero-magnifying-glass" class="h-4 w-4 mr-2" /> Search
+ <% end %>
+
+
+
+
+ <%= if @search_results != [] && @selected_release_group == nil do %>
+
+
Release Groups
+
+ <%= for release_group <- @search_results do %>
+
+
+
+
+
+ {release_group.title}
+
+
+ {release_group.artists}
+
+ <%= if release_group.release_date do %>
+
+ First released: {release_group.release_date}
+
+ <% end %>
+
+ <.icon name="hero-chevron-right" class="h-4 w-4 text-gray-400" />
+
+
+
+ <% end %>
+
+
+ <% end %>
+
+ <%= if @selected_release_group && @releases != [] do %>
+
+
+ <.button
+ variant="ghost"
+ size="sm"
+ phx-click="clear_selection"
+ >
+ <.icon name="hero-arrow-left" class="h-4 w-4" />
+
+
+ Releases for "{@selected_release_group.title}"
+
+
+
+
+ <%= for release <- @releases do %>
+
+ <.link
+ navigate={~p"/scrobble/#{release.id}"}
+ class="block p-4"
+ >
+
+
+
+ {release.title}
+
+
+ <%= if release.date do %>
+
Released: {release.date}
+ <% end %>
+ <%= if release.country do %>
+
Country: {release.country}
+ <% end %>
+ <%= if release.barcode do %>
+
Barcode: {release.barcode}
+ <% end %>
+ <%= if release.catalog_number do %>
+
Catalog: {release.catalog_number}
+ <% end %>
+ <%= if release.media != [] do %>
+
+ {length(release.media)} medium(s) • {release.media
+ |> Enum.map(&length(&1.tracks))
+ |> Enum.sum()} tracks
+
+ <% end %>
+
+
+ <.icon name="hero-chevron-right" class="h-4 w-4 text-gray-400" />
+
+
+
+ <% end %>
+
+
+ <% end %>
+
+ <%= if @loading && @search_query != "" do %>
+
+ <.icon name="hero-arrow-path" class="h-8 w-8 animate-spin mx-auto text-gray-400" />
+
+ <%= if @selected_release_group do %>
+ Loading releases...
+ <% else %>
+ Searching...
+ <% end %>
+
+
+ <% end %>
+
+ <%= if @search_query != "" && @search_results == [] && not @loading do %>
+
+ <.icon
+ name="hero-magnifying-glass"
+ class="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600"
+ />
+
+ No release groups found for "{@search_query}"
+
+
+ Try a different search term or check the spelling
+
+
+ <% end %>
+
+
+
diff --git a/lib/music_library_web/live/scrobble_live/show.ex b/lib/music_library_web/live/scrobble_live/show.ex
new file mode 100644
index 00000000..4eb78916
--- /dev/null
+++ b/lib/music_library_web/live/scrobble_live/show.ex
@@ -0,0 +1,145 @@
+defmodule MusicLibraryWeb.ScrobbleLive.Show do
+ use MusicLibraryWeb, :live_view
+
+ alias MusicLibrary.ScrobbleActivity
+
+ @impl true
+ def mount(%{"release_id" => release_id}, _session, socket) do
+ {:ok,
+ assign(socket,
+ current_section: :scrobble,
+ release_id: release_id,
+ release: nil,
+ loading: true,
+ can_scrobble: ScrobbleActivity.can_scrobble?(),
+ scrobble_form: %{
+ "type" => "finished_at",
+ "finished_at_date" => Date.utc_today() |> Date.to_string(),
+ "finished_at_time" => Time.utc_now() |> Time.truncate(:second) |> Time.to_string(),
+ "started_at_date" => Date.utc_today() |> Date.to_string(),
+ "started_at_time" => Time.utc_now() |> Time.truncate(:second) |> Time.to_string()
+ },
+ page_title: "Scrobble Release"
+ )}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ {:noreply, apply_action(socket, socket.assigns.live_action, params)}
+ end
+
+ defp apply_action(socket, :show, _params) do
+ send(self(), :fetch_release)
+ socket
+ end
+
+ @impl true
+ def handle_event("update_scrobble_form", %{"scrobble" => scrobble_params}, socket) do
+ {:noreply, assign(socket, scrobble_form: scrobble_params)}
+ end
+
+ def handle_event("scrobble_release", _params, socket) do
+ case perform_scrobble(socket.assigns.release, :all, socket.assigns.scrobble_form) do
+ {:ok, _result} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Release scrobbled successfully!")
+ |> push_navigate(to: ~p"/scrobble")}
+
+ {:error, reason} ->
+ {:noreply, put_flash(socket, :error, "Failed to scrobble release: #{inspect(reason)}")}
+ end
+ end
+
+ def handle_event("scrobble_medium", %{"medium_number" => medium_number_str}, socket) do
+ {medium_number, ""} = Integer.parse(medium_number_str)
+
+ case perform_scrobble(
+ socket.assigns.release,
+ {:medium, medium_number},
+ socket.assigns.scrobble_form
+ ) do
+ {:ok, _result} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Medium scrobbled successfully!")
+ |> push_navigate(to: ~p"/scrobble")}
+
+ {:error, reason} ->
+ {:noreply, put_flash(socket, :error, "Failed to scrobble medium: #{inspect(reason)}")}
+ end
+ end
+
+ @impl true
+ def handle_info(:fetch_release, socket) do
+ case MusicBrainz.get_release(socket.assigns.release_id) do
+ {:ok, release} ->
+ release = MusicBrainz.Release.from_api_response(release)
+
+ {:noreply,
+ assign(socket,
+ release: release,
+ loading: false,
+ page_title: "Scrobble: #{release.title}"
+ )}
+
+ {:error, _reason} ->
+ {:noreply,
+ socket
+ |> put_flash(:error, "Failed to fetch release details")
+ |> push_navigate(to: ~p"/scrobble")}
+ end
+ end
+
+ defp perform_scrobble(release, scope, scrobble_form) do
+ datetime_opts = build_datetime_opts(scrobble_form)
+
+ case scope do
+ :all ->
+ ScrobbleActivity.scrobble_release(release, datetime_opts)
+
+ {:medium, medium_number} ->
+ ScrobbleActivity.scrobble_medium(medium_number, release, datetime_opts)
+ end
+ end
+
+ defp build_datetime_opts(%{
+ "type" => "finished_at",
+ "finished_at_date" => date,
+ "finished_at_time" => time
+ }) do
+ with {:ok, date} <- Date.from_iso8601(date),
+ {:ok, time} <- Time.from_iso8601(time),
+ datetime <- DateTime.new!(date, time, "Etc/UTC") do
+ [finished_at: datetime]
+ else
+ _ -> [finished_at: DateTime.utc_now()]
+ end
+ end
+
+ defp build_datetime_opts(%{
+ "type" => "started_at",
+ "started_at_date" => date,
+ "started_at_time" => time
+ }) do
+ with {:ok, date} <- Date.from_iso8601(date),
+ {:ok, time} <- Time.from_iso8601(time),
+ datetime <- DateTime.new!(date, time, "Etc/UTC") do
+ [started_at: datetime]
+ else
+ _ -> [started_at: DateTime.utc_now()]
+ end
+ end
+
+ defp build_datetime_opts(_), do: [finished_at: DateTime.utc_now()]
+
+ defp format_duration(nil), do: "Unknown"
+
+ defp format_duration(milliseconds) when is_integer(milliseconds) do
+ seconds = div(milliseconds, 1000)
+ minutes = div(seconds, 60)
+ remaining_seconds = rem(seconds, 60)
+
+ "#{minutes}:#{String.pad_leading(Integer.to_string(remaining_seconds), 2, "0")}"
+ 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
new file mode 100644
index 00000000..031360e0
--- /dev/null
+++ b/lib/music_library_web/live/scrobble_live/show.html.heex
@@ -0,0 +1,229 @@
+
+
+ <%= if @loading do %>
+
+ <.icon name="hero-arrow-path" class="h-8 w-8 animate-spin mx-auto text-gray-400" />
+
Loading release details...
+
+ <% else %>
+
+
+ <.link navigate={~p"/scrobble"} class="flex-shrink-0">
+ <.button variant="ghost" size="sm">
+ <.icon name="hero-arrow-left" class="h-4 w-4 mr-2" /> Back to Search
+
+
+
+
+
+ {@release.title}
+
+
+ <%= if @release.artists != [] do %>
+ by {@release.artists |> Enum.map(& &1.name) |> Enum.join(", ")}
+ <% end %>
+ <%= if @release.date do %>
+ • Released {Date.from_iso8601!(@release.date) |> Calendar.strftime("%Y")}
+ <% end %>
+
+
+
+
+ <%= if not @can_scrobble do %>
+
+
+ <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />
+
+
+ You need to connect your Last.fm account to scrobble. Please set up your Last.fm session key in the settings.
+
+
+
+
+ <% end %>
+
+
+
+
+
+
Release Information
+
+ <%= if @release.date do %>
+
+
+ Release Date:
+
+
{@release.date}
+
+ <% end %>
+ <%= if @release.country do %>
+
+ Country:
+
{@release.country}
+
+ <% end %>
+ <%= if @release.barcode do %>
+
+ Barcode:
+
{@release.barcode}
+
+ <% end %>
+ <%= if @release.catalog_number do %>
+
+
+ Catalog Number:
+
+
{@release.catalog_number}
+
+ <% end %>
+
+
+
+
+ <%= if @release.media != [] do %>
+
+
+
Tracks
+
+ <%= for medium <- @release.media do %>
+
+
+
+ <%= if length(@release.media) > 1 do %>
+ Medium {medium.number}
+ <%= if medium.title do %>
+ - {medium.title}
+ <% end %>
+ <%= if medium.format do %>
+ ({medium.format})
+ <% end %>
+ <% else %>
+ Tracks
+ <%= if medium.format do %>
+ ({medium.format})
+ <% end %>
+ <% end %>
+
+ <%= if @can_scrobble do %>
+ <.button
+ size="sm"
+ variant="outline"
+ phx-click="scrobble_medium"
+ phx-value-medium_number={medium.number}
+ >
+ <.icon name="hero-play" class="h-3 w-3 mr-1" /> Scrobble Medium
+
+ <% end %>
+
+
+
+ <%= for track <- medium.tracks do %>
+
+
+
+ {track.position}
+
+
+
+ {track.title}
+
+ <%= if track.artists != [] and track.artists != @release.artists do %>
+
+ {track.artists |> Enum.map(& &1.name) |> Enum.join(", ")}
+
+ <% end %>
+
+
+ <%= if track.length do %>
+
+ {format_duration(track.length)}
+
+ <% end %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%= if @can_scrobble do %>
+
+
+
+
Scrobble Options
+
+ <.form
+ for={%{}}
+ as={:scrobble}
+ phx-change="update_scrobble_form"
+ class="space-y-4"
+ >
+ <.select
+ name="type"
+ label="Scrobble timing"
+ value={@scrobble_form["type"]}
+ options={[
+ {"Just finished listening", "finished_at"},
+ {"Started listening at", "started_at"}
+ ]}
+ />
+
+ <%= if @scrobble_form["type"] == "finished_at" do %>
+
+ <.input
+ type="date"
+ name="finished_at_date"
+ label="Finished Date"
+ value={@scrobble_form["finished_at_date"]}
+ />
+ <.input
+ type="time"
+ name="finished_at_time"
+ label="Finished Time"
+ value={@scrobble_form["finished_at_time"]}
+ />
+
+ <% else %>
+
+ <.input
+ type="date"
+ name="started_at_date"
+ label="Started Date"
+ value={@scrobble_form["started_at_date"]}
+ />
+ <.input
+ type="time"
+ name="started_at_time"
+ label="Started Time"
+ value={@scrobble_form["started_at_time"]}
+ />
+
+ <% end %>
+
+
+
+ <.button
+ phx-click="scrobble_release"
+ class="w-full"
+ size="lg"
+ >
+ <.icon name="hero-play" class="h-4 w-4 mr-2" /> Scrobble Entire Release
+
+
+
+ This will scrobble all tracks from all media in sequence
+
+
+
+
+
+ <% end %>
+
+
+ <% end %>
+
+
diff --git a/lib/music_library_web/router.ex b/lib/music_library_web/router.ex
index 390543c5..3e341743 100644
--- a/lib/music_library_web/router.ex
+++ b/lib/music_library_web/router.ex
@@ -71,6 +71,9 @@ defmodule MusicLibraryWeb.Router do
live "/scrobbled-tracks", ScrobbledTracksLive.Index, :index
live "/scrobbled-tracks/:scrobbled_at_uts/edit", ScrobbledTracksLive.Index, :edit
+
+ live "/scrobble", ScrobbleLive.Index, :index
+ live "/scrobble/:release_id", ScrobbleLive.Show, :show
end
end
end
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 8b018fc1..6dc00de0 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -1399,3 +1399,13 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "90d"
msgstr ""
+
+#: lib/music_library_web/components/layouts/app.html.heex
+#, elixir-autogen, elixir-format
+msgid "Scrobble"
+msgstr ""
+
+#: lib/music_library_web/live/scrobble_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Scrobble to Last.fm"
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 44ce05f1..8c728245 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -1399,3 +1399,13 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy
msgid "90d"
msgstr ""
+
+#: lib/music_library_web/components/layouts/app.html.heex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Scrobble"
+msgstr ""
+
+#: lib/music_library_web/live/scrobble_live/index.html.heex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Scrobble to Last.fm"
+msgstr ""