From d97f21a8ef13125711f6ad3b5d9a0f5ed8ef04c2 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Mon, 23 Sep 2024 20:27:40 +0100 Subject: [PATCH] Can search and import records Requires refactor and cleanup, along with integration tests --- lib/music_library/records/music_brainz.ex | 35 ++++- lib/music_library/records/record.ex | 4 +- .../live/record_live/index.ex | 74 +++++++++++ .../live/record_live/index.html.heex | 15 +++ .../live/record_live/search_component.ex | 122 ++++++++++++++++++ lib/music_library_web/router.ex | 1 + 6 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 lib/music_library_web/live/record_live/search_component.ex diff --git a/lib/music_library/records/music_brainz.ex b/lib/music_library/records/music_brainz.ex index 90b692b4..8ee35c0b 100644 --- a/lib/music_library/records/music_brainz.ex +++ b/lib/music_library/records/music_brainz.ex @@ -43,7 +43,7 @@ defmodule MusicLibrary.Records.MusicBrainz do """ def get_release_group(id) do url = - "https://musicbrainz.org/ws/2/release-group/#{id}?fmt=json&inc=artist-credits" + "https://musicbrainz.org/ws/2/release-group/#{id}?fmt=json&inc=artist-credits+genres" json_get(url) end @@ -165,6 +165,15 @@ defmodule MusicLibrary.Records.MusicBrainz do json_get(url) end + def get_cover_art(musicbrainz_id) do + url = "https://coverartarchive.org/release-group/#{musicbrainz_id}/front" + + with {:ok, image_data} <- blob_get(url), + {:ok, thumb} = Vix.Vips.Operation.thumbnail_buffer(image_data, 400) do + Vix.Vips.Image.write_to_buffer(thumb, ".jpg") + end + end + defp json_get(url) do req = Finch.build(:get, url, [ @@ -183,4 +192,28 @@ defmodule MusicLibrary.Records.MusicBrainz do {:error, msg} end end + + defp blob_get(url) do + req = + Finch.build(:get, url, [ + {"User-Agent", "MusicLibrary/0.1.0 ( cloud8421@gmail.com )"} + ]) + + Logger.debug("Fetching data from #{url}") + + case Finch.request(req, MusicLibrary.Finch) do + {:ok, response} when response.status == 200 -> + {:ok, response.body} + + {:ok, response} when response.status in 301..308 -> + location = :proplists.get_value("location", response.headers) + Logger.debug("Following redirect to #{location}") + blob_get(location) + + other -> + msg = "Failed to fetch data from #{url}, reason: #{inspect(other)}" + Logger.error(msg) + {:error, msg} + end + end end diff --git a/lib/music_library/records/record.ex b/lib/music_library/records/record.ex index e1e3390c..a255afcd 100644 --- a/lib/music_library/records/record.ex +++ b/lib/music_library/records/record.ex @@ -26,8 +26,8 @@ defmodule MusicLibrary.Records.Record do @doc false def changeset(record, attrs) do record - |> cast(attrs, [:type, :title, :musicbrainz_id, :year, :genres, :image_url]) - |> validate_required([:type, :title, :musicbrainz_id, :year, :genres, :image_url]) + |> cast(attrs, [:type, :title, :musicbrainz_id, :year, :genres, :image_url, :image_data]) + |> validate_required([:type, :title, :musicbrainz_id, :year, :genres]) end def add_artists(record, artists_attrs) do diff --git a/lib/music_library_web/live/record_live/index.ex b/lib/music_library_web/live/record_live/index.ex index ab2b8e28..7bdb63a9 100644 --- a/lib/music_library_web/live/record_live/index.ex +++ b/lib/music_library_web/live/record_live/index.ex @@ -24,6 +24,12 @@ defmodule MusicLibraryWeb.RecordLive.Index do {:noreply, apply_action(socket, socket.assigns.live_action, params)} end + defp apply_action(socket, :search, _params) do + socket + |> assign(:page_title, "Search Records") + |> assign(:record, nil) + end + defp apply_action(socket, :edit, %{"id" => id}) do socket |> assign(:page_title, "Edit Record") @@ -62,6 +68,10 @@ defmodule MusicLibraryWeb.RecordLive.Index do {:noreply, stream_insert(socket, :records, record)} end + def handle_info({__MODULE__, {:saved, record}}, socket) do + {:noreply, push_navigate(socket, to: ~p"/records/#{record}")} + end + @impl true def handle_event("delete", %{"id" => id}, socket) do record = Records.get_record!(id) @@ -70,6 +80,70 @@ defmodule MusicLibraryWeb.RecordLive.Index do {:noreply, stream_delete(socket, :records, record)} end + def handle_event("import", %{"id" => musicbrainz_id}, socket) do + with {:ok, result} <- MusicLibrary.Records.MusicBrainz.get_release_group(musicbrainz_id), + {:ok, image_data} <- MusicLibrary.Records.MusicBrainz.get_cover_art(musicbrainz_id) do + artists_attrs = + result + |> get_in(["artist-credit", Access.all(), "artist"]) + |> Enum.map(fn artist -> + %{ + name: artist["name"], + musicbrainz_id: artist["id"], + sort_name: artist["sort-name"], + disambiguation: artist["disambiguation"] + } + end) + + record_attrs = %{ + "musicbrainz_id" => musicbrainz_id, + "title" => result["title"], + "artists" => artists_attrs, + "year" => parse_year(result["first-release-date"]), + "type" => parse_subtype(result["primary-type"]), + "genres" => Enum.map(result["genres"], fn g -> g["name"] end), + "image_url" => "https://coverartarchive.org/release-group/#{musicbrainz_id}/front", + "image_data" => image_data + } + + case Records.create_record(record_attrs) do + {:ok, record} -> + notify_parent({:saved, record}) + + {:noreply, + socket + |> put_flash(:info, "Record imported successfully") + |> push_patch(to: ~p"/records")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + else + {:error, _} -> + {:noreply, socket} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + + defp parse_year(iso_date) when is_binary(iso_date) do + case Date.from_iso8601(iso_date) do + {:ok, date} -> + date.year + + _error -> + {year, _rest} = Integer.parse(iso_date) + {:ok, year} + end + end + + defp parse_subtype("Album"), do: :album + defp parse_subtype("EP"), do: :ep + defp parse_subtype("Live"), do: :live + defp parse_subtype("Compilation"), do: :compilation + defp parse_subtype("Single"), do: :single + defp parse_subtype(_), do: :other + defp musicbrainz_url(record) do "https://musicbrainz.org/release-group/#{record.musicbrainz_id}" end diff --git a/lib/music_library_web/live/record_live/index.html.heex b/lib/music_library_web/live/record_live/index.html.heex index 398a405a..1889fd0f 100644 --- a/lib/music_library_web/live/record_live/index.html.heex +++ b/lib/music_library_web/live/record_live/index.html.heex @@ -4,6 +4,9 @@ <.link patch={~p"/records/new"}> <.button>New Record + <.link patch={~p"/records/search"}> + <.button>Search Record + @@ -55,4 +58,16 @@ /> +<.modal :if={@live_action == :search} id="record-modal" show on_cancel={JS.patch(~p"/records")}> + <.live_component + module={MusicLibraryWeb.RecordLive.SearchComponent} + id={:search} + title={@page_title} + action={@live_action} + record={@record} + patch={~p"/records"} + initial_query="" + /> + + <.pagination pagination_params={@pagination_params} /> diff --git a/lib/music_library_web/live/record_live/search_component.ex b/lib/music_library_web/live/record_live/search_component.ex new file mode 100644 index 00000000..623a6b39 --- /dev/null +++ b/lib/music_library_web/live/record_live/search_component.ex @@ -0,0 +1,122 @@ +defmodule MusicLibraryWeb.RecordLive.SearchComponent do + use MusicLibraryWeb, :live_component + + alias MusicLibrary.Records.MusicBrainz + @impl true + def render(assigns) do + ~H""" +
+ <.simple_form + for={@form} + id="search-form" + phx-target={@myself} + phx-change="search" + phx-submit="search" + > + <.input + field={@form[:query]} + type="text" + label="Search" + prompt="Search for records" + phx-debounce="500" + /> + + +
+ """ + end + + defp result(assigns) do + ~H""" +
  • +
    +
    +

    + <%= @record.title %> + + + <%= @record.year %> + +

    +

    <%= @record.artists %>

    +
    +
    + +
  • + """ + end + + attr :type, :string, required: true + + defp type_badge(assigns) do + ~H""" + + <%= @type %> + + """ + end + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:records, []) + |> assign(:form, to_form(%{"query" => ""}))} + end + + @impl true + def handle_event("search", %{"query" => query}, socket) do + {:ok, records} = search(query) + + {:noreply, + socket + |> assign(:records, records) + |> assign(:form, to_form(%{"query" => query}))} + end + + defp search(""), do: {:ok, []} + + defp search(query) do + case MusicBrainz.search_release_group(query) do + {:ok, result} -> + {:ok, + Enum.map(result["release-groups"], fn rg -> + %{ + id: rg["id"], + type: parse_subtype(rg["primary-type"]), + title: rg["title"], + artists: + rg["artist-credit"] + |> Enum.map(fn ac -> ac["artist"]["name"] end) + |> Enum.join(", "), + year: parse_year(rg["first-release-date"]) + } + end)} + + error -> + error + end + end + + defp parse_year(iso_date) do + case Date.from_iso8601(iso_date) do + {:ok, date} -> date.year + _error -> nil + end + end + + defp parse_subtype("Album"), do: :album + defp parse_subtype("EP"), do: :ep + defp parse_subtype("Live"), do: :live + defp parse_subtype("Compilation"), do: :compilation + defp parse_subtype("Single"), do: :single + defp parse_subtype(_), do: :other +end diff --git a/lib/music_library_web/router.ex b/lib/music_library_web/router.ex index f2dfb5d3..eec5afce 100644 --- a/lib/music_library_web/router.ex +++ b/lib/music_library_web/router.ex @@ -22,6 +22,7 @@ defmodule MusicLibraryWeb.Router do live "/records", RecordLive.Index, :index live "/records/new", RecordLive.Index, :new + live "/records/search", RecordLive.Index, :search live "/records/:id/edit", RecordLive.Index, :edit live "/records/:id", RecordLive.Show, :show