diff --git a/assets/js/app.js b/assets/js/app.js
index bb42d713..d9c46800 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -26,6 +26,7 @@ import { Hooks as FluxonHooks, DOM as FluxonDOM } from "fluxon";
import FormatNumberHook from "./hooks/format-number";
import UniversalSearchNavigationHook from "./hooks/universal-search-navigation";
import RecordPickerNavigationHook from "./hooks/record-picker-navigation";
+import RulePickerNavigationHook from "./hooks/rule-picker-navigation";
import SortableListHook from "./hooks/sortable-list";
import confetti from "canvas-confetti";
import { createLiveToastHook } from "live_toast";
@@ -43,6 +44,7 @@ let Hooks = FluxonHooks;
Hooks.FormatNumber = FormatNumberHook;
Hooks.UniversalSearchNavigation = UniversalSearchNavigationHook;
Hooks.RecordPickerNavigation = RecordPickerNavigationHook;
+Hooks.RulePickerNavigation = RulePickerNavigationHook;
Hooks.SortableList = SortableListHook;
Hooks.LiveToast = liveToastHook;
diff --git a/assets/js/hooks/rule-picker-navigation.js b/assets/js/hooks/rule-picker-navigation.js
new file mode 100644
index 00000000..6540d873
--- /dev/null
+++ b/assets/js/hooks/rule-picker-navigation.js
@@ -0,0 +1,13 @@
+import createNavigationHook from "./create-navigation-hook";
+
+export default createNavigationHook({
+ getContainer: (hook) => hook.el,
+ inputId: "rule-picker-search-input",
+ onSelect: (hook) => {
+ if (hook.selectedIndex === 0) {
+ hook.navigateDown();
+ } else {
+ hook.navigateUp();
+ }
+ }
+});
diff --git a/lib/music_library_web/components/scrobble_components.ex b/lib/music_library_web/components/scrobble_components.ex
index 40583bb1..160eb8f4 100644
--- a/lib/music_library_web/components/scrobble_components.ex
+++ b/lib/music_library_web/components/scrobble_components.ex
@@ -100,6 +100,7 @@ defmodule MusicLibraryWeb.ScrobbleComponents do
attr :id, :string, required: true
attr :musicbrainz_id, :string, required: true
attr :matching_records, :list, default: []
+ attr :album_title, :string, default: nil
def record_status_badges(assigns) do
assigns =
@@ -109,7 +110,16 @@ defmodule MusicLibraryWeb.ScrobbleComponents do
~H"""
- <.badge :if={@musicbrainz_id == ""}>
+ <.badge :if={@musicbrainz_id == "" and is_nil(@album_title)}>
+ {gettext("No MB ID")}
+
+ <.badge
+ :if={@musicbrainz_id == "" and @album_title}
+ class="cursor-pointer"
+ phx-click="open_rule_picker"
+ phx-value-album-title={@album_title}
+ >
+ <.icon name="hero-link" class="icon" />
{gettext("No MB ID")}
diff --git a/lib/music_library_web/components/scrobble_rule_picker.ex b/lib/music_library_web/components/scrobble_rule_picker.ex
new file mode 100644
index 00000000..5ec8d6e6
--- /dev/null
+++ b/lib/music_library_web/components/scrobble_rule_picker.ex
@@ -0,0 +1,213 @@
+defmodule MusicLibraryWeb.ScrobbleRulePicker do
+ @moduledoc false
+ use MusicLibraryWeb, :live_component
+
+ require Logger
+
+ alias MusicLibrary.Collection
+ alias MusicLibrary.Records.Record
+ alias MusicLibrary.ScrobbleRules
+ alias MusicLibrary.Wishlist
+
+ import MusicLibraryWeb.RecordComponents,
+ only: [format_label: 1, type_label: 1, release_status_tooltip: 1]
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
+
+ {gettext("No records found")}
+
+
+
+
+ {gettext("Collected")}
+
+
+ <.record_result
+ :for={record <- @collected_results}
+ record={record}
+ myself={@myself}
+ />
+
+
+
+
+
+ {gettext("Wishlisted")}
+
+
+ <.record_result
+ :for={record <- @wishlisted_results}
+ record={record}
+ myself={@myself}
+ />
+
+
+
+
+ """
+ end
+
+ defp record_result(assigns) do
+ ~H"""
+
+
+
+
+
+
+ {@record.title}
+
+
+ {Record.artist_names(@record)}
+
+
+ <.release_status_tooltip record={@record} />
+ {Record.format_release_date(@record.release_date)} · {format_label(@record.format)} · {type_label(
+ @record.type
+ )}
+
+
+
+ <.icon
+ name="hero-link"
+ class="size-5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
+ aria-hidden="true"
+ data-slot="icon"
+ />
+
+
+ """
+ end
+
+ @impl true
+ def update(assigns, socket) do
+ {:ok,
+ socket
+ |> assign(assigns)
+ |> assign(:query, "")
+ |> assign(:collected_results, [])
+ |> assign(:wishlisted_results, [])}
+ end
+
+ @impl true
+ def handle_event("search", %{"query" => query}, socket) do
+ {collected, wishlisted} =
+ if String.trim(query) == "" do
+ {[], []}
+ else
+ collected =
+ query
+ |> Collection.search_records(limit: 20)
+ |> Enum.filter(&has_selected_release?/1)
+
+ wishlisted =
+ query
+ |> Wishlist.search_records(limit: 20)
+ |> Enum.filter(&has_selected_release?/1)
+
+ {collected, wishlisted}
+ end
+
+ {:noreply,
+ socket
+ |> assign(:query, query)
+ |> assign(:collected_results, collected)
+ |> assign(:wishlisted_results, wishlisted)}
+ end
+
+ @impl true
+ def handle_event(
+ "select_record",
+ %{"record-id" => _record_id, "selected-release-id" => selected_release_id},
+ socket
+ ) do
+ album_title = socket.assigns.album_title
+
+ case ScrobbleRules.create_scrobble_rule(%{
+ type: :album,
+ match_value: album_title,
+ target_musicbrainz_id: selected_release_id
+ }) do
+ {:ok, rule} ->
+ case ScrobbleRules.apply_rule(rule) do
+ {:ok, _count} -> :ok
+ {:error, reason} -> Logger.warning("Failed to apply scrobble rule: #{inspect(reason)}")
+ end
+
+ notify_parent({:rule_created, rule})
+ put_toast!(:info, gettext("Scrobble rule created"))
+ {:noreply, socket}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ message =
+ if Keyword.has_key?(changeset.errors, :match_value) do
+ gettext("A rule for this album already exists")
+ else
+ gettext("Could not create scrobble rule")
+ end
+
+ put_toast!(:error, message)
+ {:noreply, socket}
+ end
+ end
+
+ defp has_selected_release?(record) do
+ record.selected_release_id not in [nil, ""]
+ end
+
+ defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
+end
diff --git a/lib/music_library_web/live/scrobbled_tracks_live/index.ex b/lib/music_library_web/live/scrobbled_tracks_live/index.ex
index 7370dc53..d23d6c61 100644
--- a/lib/music_library_web/live/scrobbled_tracks_live/index.ex
+++ b/lib/music_library_web/live/scrobbled_tracks_live/index.ex
@@ -149,6 +149,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
id={"status-#{track.scrobbled_at_uts}"}
musicbrainz_id={track.album.musicbrainz_id}
matching_records={matching_records}
+ album_title={track.album.title}
/>
<.import_format_dropdown
@@ -204,6 +205,18 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
patch={back_path(@track_list_params)}
/>
+
+ <.structured_modal
+ :if={@rule_picker_album_title}
+ id="rule-picker-modal"
+ on_close={JS.push("close_rule_picker")}
+ >
+ <.live_component
+ module={MusicLibraryWeb.ScrobbleRulePicker}
+ id="rule-picker"
+ album_title={@rule_picker_album_title}
+ />
+
"""
end
@@ -213,6 +226,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
socket =
socket
|> assign(:current_section, :scrobble_activity)
+ |> assign(:rule_picker_album_title, nil)
|> stream_configure(:tracks,
dom_id: fn %{track: %Track{scrobbled_at_uts: id}} -> "tracks-#{id}" end
)
@@ -266,7 +280,22 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
{:noreply, load_and_assign_tracks(socket, socket.assigns.track_list_params)}
end
+ def handle_info({MusicLibraryWeb.ScrobbleRulePicker, {:rule_created, _rule}}, socket) do
+ {:noreply,
+ socket
+ |> assign(:rule_picker_album_title, nil)
+ |> load_and_assign_tracks(socket.assigns.track_list_params)}
+ end
+
@impl true
+ def handle_event("open_rule_picker", %{"album-title" => album_title}, socket) do
+ {:noreply, assign(socket, :rule_picker_album_title, album_title)}
+ end
+
+ def handle_event("close_rule_picker", _, socket) do
+ {:noreply, assign(socket, :rule_picker_album_title, nil)}
+ end
+
def handle_event("delete", %{"scrobbled-at-uts" => scrobbled_at_uts}, socket) do
track = ListeningStats.get_track!(scrobbled_at_uts)
{:ok, _} = ListeningStats.delete_track(track)
diff --git a/lib/music_library_web/live/stats_live/index.ex b/lib/music_library_web/live/stats_live/index.ex
index f3e399eb..6d5448ba 100644
--- a/lib/music_library_web/live/stats_live/index.ex
+++ b/lib/music_library_web/live/stats_live/index.ex
@@ -47,6 +47,18 @@ defmodule MusicLibraryWeb.StatsLive.Index do
<.top_collection_genres records_by_genre={@records_by_genre} />
<.top_release_years records_by_release_year={@records_by_release_year} />
+
+ <.structured_modal
+ :if={@rule_picker_album_title}
+ id="rule-picker-modal"
+ on_close={JS.push("close_rule_picker")}
+ >
+ <.live_component
+ module={MusicLibraryWeb.ScrobbleRulePicker}
+ id="rule-picker"
+ album_title={@rule_picker_album_title}
+ />
+
"""
end
@@ -84,6 +96,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
latest_record: latest_record,
page_title: gettext("Stats"),
current_section: :stats,
+ rule_picker_album_title: nil,
records_by_artist: records_by_artists,
records_by_genre: records_by_genre,
records_by_release_year: records_by_release_year,
@@ -142,7 +155,22 @@ defmodule MusicLibraryWeb.StatsLive.Index do
end
end
+ def handle_event("open_rule_picker", %{"album-title" => album_title}, socket) do
+ {:noreply, assign(socket, :rule_picker_album_title, album_title)}
+ end
+
+ def handle_event("close_rule_picker", _, socket) do
+ {:noreply, assign(socket, :rule_picker_album_title, nil)}
+ end
+
@impl true
+ def handle_info({MusicLibraryWeb.ScrobbleRulePicker, {:rule_created, _rule}}, socket) do
+ {:noreply,
+ socket
+ |> assign(:rule_picker_album_title, nil)
+ |> assign_scrobble_activity()}
+ end
+
def handle_info(%{track_count: 0}, socket) do
{:noreply, socket}
end
@@ -318,6 +346,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
id={"status-#{album.scrobbled_at_uts}-albums"}
musicbrainz_id={album.metadata.musicbrainz_id}
matching_records={matching_records}
+ album_title={album.metadata.title}
/>
<.import_format_dropdown
@@ -398,6 +427,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
id={"status-#{track.scrobbled_at_uts}-tracks"}
musicbrainz_id={track.album.musicbrainz_id}
matching_records={matching_records}
+ album_title={track.album.title}
/>
<.import_format_dropdown
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 20ba0f96..07d92dda 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -180,6 +180,7 @@ msgstr ""
#: lib/music_library_web/components/core_components.ex
#: lib/music_library_web/components/record_form.ex
+#: lib/music_library_web/components/scrobble_rule_picker.ex
#: lib/music_library_web/live/artist_live/form.ex
#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format
@@ -234,6 +235,7 @@ msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex
#: lib/music_library_web/components/scrobble_components.ex
+#: lib/music_library_web/components/scrobble_rule_picker.ex
#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format
msgid "Collected"
@@ -241,6 +243,7 @@ msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex
#: lib/music_library_web/components/scrobble_components.ex
+#: lib/music_library_web/components/scrobble_rule_picker.ex
#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format
msgid "Wishlisted"
@@ -1559,6 +1562,7 @@ msgstr ""
msgid "No record sets yet"
msgstr ""
+#: lib/music_library_web/components/scrobble_rule_picker.ex
#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format
msgid "No records found"
@@ -2432,3 +2436,28 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "W"
msgstr ""
+
+#: lib/music_library_web/components/scrobble_rule_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Could not create scrobble rule"
+msgstr ""
+
+#: lib/music_library_web/components/scrobble_rule_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Create Scrobble Rule"
+msgstr ""
+
+#: lib/music_library_web/components/scrobble_rule_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Scrobble rule created"
+msgstr ""
+
+#: lib/music_library_web/components/scrobble_rule_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Search your records to map \"%{album_title}\" to a release."
+msgstr ""
+
+#: lib/music_library_web/components/scrobble_rule_picker.ex
+#, elixir-autogen, elixir-format
+msgid "A rule for this album already exists"
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 0f80d4ba..9f56052a 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -180,6 +180,7 @@ msgstr ""
#: lib/music_library_web/components/core_components.ex
#: lib/music_library_web/components/record_form.ex
+#: lib/music_library_web/components/scrobble_rule_picker.ex
#: lib/music_library_web/live/artist_live/form.ex
#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format
@@ -234,6 +235,7 @@ msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex
#: lib/music_library_web/components/scrobble_components.ex
+#: lib/music_library_web/components/scrobble_rule_picker.ex
#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format
msgid "Collected"
@@ -241,6 +243,7 @@ msgstr ""
#: lib/music_library_web/components/barcode_scanner.ex
#: lib/music_library_web/components/scrobble_components.ex
+#: lib/music_library_web/components/scrobble_rule_picker.ex
#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format
msgid "Wishlisted"
@@ -1559,6 +1562,7 @@ msgstr ""
msgid "No record sets yet"
msgstr ""
+#: lib/music_library_web/components/scrobble_rule_picker.ex
#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No records found"
@@ -2432,3 +2436,28 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "W"
msgstr ""
+
+#: lib/music_library_web/components/scrobble_rule_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Could not create scrobble rule"
+msgstr ""
+
+#: lib/music_library_web/components/scrobble_rule_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Create Scrobble Rule"
+msgstr ""
+
+#: lib/music_library_web/components/scrobble_rule_picker.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Scrobble rule created"
+msgstr ""
+
+#: lib/music_library_web/components/scrobble_rule_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Search your records to map \"%{album_title}\" to a release."
+msgstr ""
+
+#: lib/music_library_web/components/scrobble_rule_picker.ex
+#, elixir-autogen, elixir-format
+msgid "A rule for this album already exists"
+msgstr ""
diff --git a/test/music_library_web/live/scrobbled_tracks_live/rule_picker_test.exs b/test/music_library_web/live/scrobbled_tracks_live/rule_picker_test.exs
new file mode 100644
index 00000000..6e2a3857
--- /dev/null
+++ b/test/music_library_web/live/scrobbled_tracks_live/rule_picker_test.exs
@@ -0,0 +1,174 @@
+defmodule MusicLibraryWeb.ScrobbledTracksLive.RulePickerTest do
+ use MusicLibraryWeb.ConnCase
+
+ import MusicLibrary.Fixtures.Records
+ import MusicLibrary.ScrobbledTracksFixtures
+ import Phoenix.LiveViewTest
+
+ alias MusicLibrary.ScrobbleRules
+
+ defp create_track_without_album_mbid(_) do
+ track =
+ track_fixture(%{
+ title: "Comfortably Numb",
+ artist_name: "Pink Floyd",
+ album_title: "The Wall",
+ album_musicbrainz_id: ""
+ })
+
+ %{track: track}
+ end
+
+ defp create_collected_record(_) do
+ collected = record(%{title: "The Wall", purchased_at: DateTime.utc_now()})
+ %{collected: collected}
+ end
+
+ defp open_rule_picker(view, album_title) do
+ render_click(view, "open_rule_picker", %{"album-title" => album_title})
+ view
+ end
+
+ defp search_picker(view, query) do
+ view
+ |> form("#rule-picker-navigation form", %{query: query})
+ |> render_submit()
+ end
+
+ describe "Rule picker modal" do
+ setup [:create_track_without_album_mbid]
+
+ test "opens and shows album title", %{conn: conn, track: track} do
+ {:ok, view, _html} = live(conn, ~p"/scrobbled-tracks")
+
+ open_rule_picker(view, track.album.title)
+
+ assert has_element?(view, "#rule-picker-modal")
+ assert has_element?(view, "h1", "Create Scrobble Rule")
+ assert render(view) =~ track.album.title
+ end
+
+ test "closes when close event is triggered", %{conn: conn, track: track} do
+ {:ok, view, _html} = live(conn, ~p"/scrobbled-tracks")
+
+ open_rule_picker(view, track.album.title)
+ assert has_element?(view, "#rule-picker-modal")
+
+ render_click(view, "close_rule_picker")
+
+ refute has_element?(view, "#rule-picker-modal")
+ end
+ end
+
+ describe "Rule picker search" do
+ setup [:create_track_without_album_mbid, :create_collected_record]
+
+ test "searches and displays collected records", %{
+ conn: conn,
+ track: track,
+ collected: collected
+ } do
+ {:ok, view, _html} = live(conn, ~p"/scrobbled-tracks")
+
+ open_rule_picker(view, track.album.title)
+ html = search_picker(view, collected.title)
+
+ assert html =~ collected.title
+ assert html =~ "Collected"
+ end
+
+ test "filters out records without selected_release_id", %{conn: conn, track: track} do
+ no_release =
+ record(%{
+ title: "No Release Zzzzzz",
+ selected_release_id: nil,
+ musicbrainz_data: %{}
+ })
+
+ assert is_nil(no_release.selected_release_id)
+
+ {:ok, view, _html} = live(conn, ~p"/scrobbled-tracks")
+
+ open_rule_picker(view, track.album.title)
+ search_picker(view, "No Release Zzzzzz")
+
+ refute has_element?(
+ view,
+ "li[phx-click='select_record'][phx-value-record-id='#{no_release.id}']"
+ )
+ end
+
+ test "shows wishlisted records", %{conn: conn, track: track} do
+ wishlisted = record(%{title: "Wish You Were Here", purchased_at: nil})
+
+ {:ok, view, _html} = live(conn, ~p"/scrobbled-tracks")
+
+ open_rule_picker(view, track.album.title)
+ html = search_picker(view, wishlisted.title)
+
+ assert html =~ wishlisted.title
+ assert html =~ "Wishlisted"
+ end
+
+ test "empty search returns no results", %{conn: conn, track: track} do
+ {:ok, view, _html} = live(conn, ~p"/scrobbled-tracks")
+
+ open_rule_picker(view, track.album.title)
+ html = search_picker(view, " ")
+
+ refute html =~ "Collected"
+ refute html =~ "Wishlisted"
+ end
+ end
+
+ describe "Rule creation" do
+ setup [:create_track_without_album_mbid, :create_collected_record]
+
+ test "creates scrobble rule when record is selected", %{
+ conn: conn,
+ track: track,
+ collected: collected
+ } do
+ {:ok, view, _html} = live(conn, ~p"/scrobbled-tracks")
+
+ open_rule_picker(view, track.album.title)
+ search_picker(view, collected.title)
+
+ view
+ |> element("li[phx-click='select_record'][phx-value-record-id='#{collected.id}']")
+ |> render_click()
+
+ refute has_element?(view, "#rule-picker-modal")
+
+ [rule] = ScrobbleRules.list_scrobble_rules(type: :album)
+ assert rule.match_value == track.album.title
+ assert rule.target_musicbrainz_id == collected.selected_release_id
+ assert rule.type == :album
+ assert rule.enabled == true
+ end
+
+ test "shows error for duplicate rule", %{conn: conn, track: track, collected: collected} do
+ {:ok, _rule} =
+ ScrobbleRules.create_scrobble_rule(%{
+ type: :album,
+ match_value: track.album.title,
+ target_musicbrainz_id: Ecto.UUID.generate()
+ })
+
+ {:ok, view, _html} = live(conn, ~p"/scrobbled-tracks")
+
+ open_rule_picker(view, track.album.title)
+ search_picker(view, collected.title)
+
+ view
+ |> element("li[phx-click='select_record'][phx-value-record-id='#{collected.id}']")
+ |> render_click()
+
+ # Modal stays open after error
+ assert has_element?(view, "#rule-picker-modal")
+
+ # Only the original rule exists, no duplicate was created
+ assert length(ScrobbleRules.list_scrobble_rules(type: :album)) == 1
+ end
+ end
+end