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("Create Scrobble Rule")} +

+

+ {gettext("Search your records to map \"%{album_title}\" to a release.", + album_title: @album_title + )} +

+
+
+ <.input + type="search" + size="sm" + id="rule-picker-search-input" + name={:query} + value={@query} + placeholder={gettext("Search")} + phx-debounce="500" + autocomplete="off" + autofocus + /> +
+ +
+

+ {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