diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 088c633f..3e59bfea 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -37,7 +37,9 @@
"mcp__tidewave__search_package_docs",
"Bash(wc:*)",
"Bash(python3:*)",
- "Bash(MIX_ENV=test mix ecto.migrate:*)"
+ "Bash(MIX_ENV=test mix ecto.migrate:*)",
+ "WebFetch(domain:api-dashboard.search.brave.com)",
+ "WebFetch(domain:api.search.brave.com)"
]
},
"enableAllProjectMcpServers": false
diff --git a/config/config.exs b/config/config.exs
index c222ebb7..432b4e60 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -49,6 +49,8 @@ config :music_library, Discogs, personal_access_token: "change me", user_agent:
config :music_library, Wikipedia, user_agent: user_agent
+config :music_library, BraveSearch, api_key: "change me", user_agent: user_agent
+
# Configure esbuild (the version is required)
config :esbuild,
version: "0.27.3",
diff --git a/config/runtime.exs b/config/runtime.exs
index 23292cdd..31ff2b09 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -28,6 +28,10 @@ if personal_access_token = System.get_env("DISCOGS_PERSONAL_ACCESS_TOKEN") do
config :music_library, Discogs, personal_access_token: personal_access_token
end
+if api_key = System.get_env("BRAVE_SEARCH_API_KEY") do
+ config :music_library, BraveSearch, api_key: api_key
+end
+
if default_timezone = System.get_env("DEFAULT_TIMEZONE") do
config :music_library, :default_timezone, default_timezone
end
diff --git a/config/test.exs b/config/test.exs
index 7d858300..6cd3a2c2 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -63,6 +63,13 @@ config :music_library, Wikipedia,
max_retries: 0
]
+config :music_library, BraveSearch,
+ api_key: "test_key",
+ req_options: [
+ plug: {Req.Test, BraveSearch.API},
+ max_retries: 0
+ ]
+
config :phoenix_test, :endpoint, MusicLibraryWeb.Endpoint
config :music_library, Oban, testing: :manual
diff --git a/lib/brave_search.ex b/lib/brave_search.ex
new file mode 100644
index 00000000..63db9316
--- /dev/null
+++ b/lib/brave_search.ex
@@ -0,0 +1,13 @@
+defmodule BraveSearch do
+ alias BraveSearch.API
+
+ def search_images(query, opts \\ []) do
+ API.search_images(query, opts, config())
+ end
+
+ def download_image(url) do
+ API.download_image(url, config())
+ end
+
+ defp config, do: BraveSearch.Config.resolve(:music_library)
+end
diff --git a/lib/brave_search/api.ex b/lib/brave_search/api.ex
new file mode 100644
index 00000000..6ead2524
--- /dev/null
+++ b/lib/brave_search/api.ex
@@ -0,0 +1,87 @@
+defmodule BraveSearch.API do
+ @moduledoc """
+ Interface to the Brave Search API.
+ """
+
+ require Logger
+
+ def search_images(query, opts, config) do
+ params = [q: query, count: Keyword.get(opts, :count, 20)]
+
+ case config
+ |> new_request()
+ |> Req.merge(url: "/res/v1/images/search", params: params)
+ |> get_request() do
+ {:ok, body} ->
+ results =
+ body
+ |> Map.get("results", [])
+ |> Enum.map(fn result ->
+ %{
+ thumbnail_url: get_in(result, ["thumbnail", "src"]),
+ image_url: get_in(result, ["properties", "url"]),
+ title: Map.get(result, "title", ""),
+ source: Map.get(result, "source", "")
+ }
+ end)
+
+ {:ok, results}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ def download_image(url, config) do
+ case Req.new(url: url, max_retries: 1, user_agent: config.user_agent)
+ |> Req.Request.merge_options(config.req_options)
+ |> Req.Request.append_request_steps(log_attempt: &log_attempt/1)
+ |> Req.Request.append_response_steps(log_error: &log_error/1)
+ |> get_request() do
+ {:ok, data} -> {:ok, data}
+ {:error, _reason} -> {:error, :download_failed}
+ end
+ end
+
+ defp new_request(config) do
+ Req.new(
+ base_url: "https://api.search.brave.com",
+ max_retries: 1,
+ user_agent: config.user_agent,
+ headers: %{"x-subscription-token" => config.api_key}
+ )
+ |> Req.Request.merge_options(config.req_options)
+ |> Req.Request.append_request_steps(log_attempt: &log_attempt/1)
+ |> Req.Request.append_response_steps(log_error: &log_error/1)
+ end
+
+ defp get_request(request) do
+ case Req.get(request) do
+ {:ok, response} when response.status == 200 ->
+ {:ok, response.body}
+
+ {:ok, response} ->
+ {:error, response.body}
+
+ error ->
+ error
+ end
+ end
+
+ defp log_attempt(request) do
+ url = URI.to_string(request.url)
+ Logger.debug("Fetching data from #{url}")
+ request
+ end
+
+ defp log_error({request, response}) do
+ if response.status in 400..499 or response.status in 500..599 do
+ Logger.error(fn ->
+ url = URI.to_string(request.url)
+ "Failed to fetch data from #{url}, reason: #{inspect(response.body)}"
+ end)
+ end
+
+ {request, response}
+ end
+end
diff --git a/lib/brave_search/config.ex b/lib/brave_search/config.ex
new file mode 100644
index 00000000..0748ce4a
--- /dev/null
+++ b/lib/brave_search/config.ex
@@ -0,0 +1,39 @@
+defmodule BraveSearch.Config do
+ @type t :: %__MODULE__{
+ api_key: String.t(),
+ user_agent: String.t(),
+ req_options: Keyword.t()
+ }
+
+ @enforce_keys [:api_key]
+ defstruct api_key: "",
+ user_agent: "change me",
+ req_options: []
+
+ @schema NimbleOptions.new!(
+ api_key: [
+ type: :string,
+ required: true
+ ],
+ user_agent: [
+ type: :string,
+ required: false,
+ default: "change me"
+ ],
+ req_options: [
+ type: :keyword_list,
+ required: false,
+ default: []
+ ]
+ )
+
+ @doc NimbleOptions.docs(@schema)
+ @spec resolve(Application.app()) :: t | no_return
+ def resolve(otp_app) do
+ app_config =
+ Application.get_env(otp_app, BraveSearch)
+ |> NimbleOptions.validate!(@schema)
+
+ struct(__MODULE__, app_config)
+ end
+end
diff --git a/lib/music_library_web/components/record_form.ex b/lib/music_library_web/components/record_form.ex
index fbaffd39..44112d3e 100644
--- a/lib/music_library_web/components/record_form.ex
+++ b/lib/music_library_web/components/record_form.ex
@@ -5,6 +5,7 @@ defmodule MusicLibraryWeb.Components.RecordForm do
only: [format_label: 1, type_label: 1, release_label: 1, release_summary: 1, record_cover: 1]
alias MusicLibrary.{Assets, Records}
+ alias MusicLibrary.Assets.Image
alias MusicLibrary.Records.Record
@impl true
@@ -297,6 +298,76 @@ defmodule MusicLibraryWeb.Components.RecordForm do
+
+ <.label>{gettext("Search for cover art online")}
+
+
+ <.input
+ type="text"
+ id="cover-search-query"
+ name="cover_search_query"
+ value={@cover_search_query}
+ phx-keyup="update_cover_search_query"
+ phx-target={@myself}
+ />
+
+ <.button
+ type="button"
+ size="sm"
+ id="cover-search-button"
+ phx-click="search_covers"
+ phx-target={@myself}
+ disabled={@cover_search_loading}
+ >
+ <.icon
+ :if={@cover_search_loading}
+ name="hero-arrow-path"
+ class="icon animate-spin"
+ aria-hidden="true"
+ data-slot="icon"
+ />
+ <.icon
+ :if={!@cover_search_loading}
+ name="hero-magnifying-glass"
+ class="icon"
+ aria-hidden="true"
+ data-slot="icon"
+ />
+ {gettext("Search")}
+
+
+
+ {@cover_search_error}
+
+
+
+
+
<:actions>
<.button variant="solid" class="w-full md:w-auto" phx-disable-with={gettext("Saving...")}>
@@ -333,7 +404,13 @@ defmodule MusicLibraryWeb.Components.RecordForm do
end)
|> assign_new(:all_genres, fn -> Records.list_genres() end)
|> assign_new(:genre_query, fn -> "" end)
- |> assign_new(:genre_suggestions, fn -> [] end)}
+ |> assign_new(:genre_suggestions, fn -> [] end)
+ |> assign_new(:cover_search_query, fn ->
+ "#{Record.artist_names(record)} #{record.title} album cover"
+ end)
+ |> assign_new(:cover_search_results, fn -> [] end)
+ |> assign_new(:cover_search_loading, fn -> false end)
+ |> assign_new(:cover_search_error, fn -> nil end)}
end
@impl true
@@ -412,6 +489,89 @@ defmodule MusicLibraryWeb.Components.RecordForm do
{:noreply, assign(socket, :form, to_form(changeset, action: :validate))}
end
+ def handle_event("update_cover_search_query", %{"value" => query}, socket) do
+ {:noreply, assign(socket, :cover_search_query, query)}
+ end
+
+ def handle_event("search_covers", _params, socket) do
+ query = socket.assigns.cover_search_query
+
+ {:noreply,
+ socket
+ |> assign(:cover_search_loading, true)
+ |> assign(:cover_search_error, nil)
+ |> start_async(:cover_search, fn -> BraveSearch.search_images(query, count: 20) end)}
+ end
+
+ def handle_event("select_cover", %{"url" => url}, socket) do
+ {:noreply,
+ socket
+ |> assign(:cover_search_loading, true)
+ |> assign(:cover_search_error, nil)
+ |> start_async(:cover_download, fn ->
+ with {:ok, data} <- BraveSearch.download_image(url),
+ {:ok, resized} <- Image.resize(data),
+ {:ok, asset} <- Assets.store_image(%{content: resized, format: "image/jpeg"}) do
+ {:ok, asset.hash}
+ end
+ end)}
+ end
+
+ @impl true
+ def handle_async(:cover_search, {:ok, {:ok, results}}, socket) do
+ {:noreply,
+ socket
+ |> assign(:cover_search_results, results)
+ |> assign(:cover_search_loading, false)}
+ end
+
+ def handle_async(:cover_search, {:ok, {:error, reason}}, socket) do
+ {:noreply,
+ socket
+ |> assign(:cover_search_error, "Search failed: #{inspect(reason)}")
+ |> assign(:cover_search_loading, false)}
+ end
+
+ def handle_async(:cover_search, {:exit, reason}, socket) do
+ {:noreply,
+ socket
+ |> assign(:cover_search_error, "Search failed: #{inspect(reason)}")
+ |> assign(:cover_search_loading, false)}
+ end
+
+ def handle_async(:cover_download, {:ok, {:ok, cover_hash}}, socket) do
+ case Records.update_record(socket.assigns.record, %{"cover_hash" => cover_hash}) do
+ {:ok, record} ->
+ notify_parent({:saved, record})
+
+ {:noreply,
+ socket
+ |> assign(:cover_search_loading, false)
+ |> put_toast(:info, gettext("Cover art updated successfully"))
+ |> push_patch(to: socket.assigns.patch)}
+
+ {:error, _changeset} ->
+ {:noreply,
+ socket
+ |> assign(:cover_search_error, gettext("Failed to save cover art"))
+ |> assign(:cover_search_loading, false)}
+ end
+ end
+
+ def handle_async(:cover_download, {:ok, {:error, reason}}, socket) do
+ {:noreply,
+ socket
+ |> assign(:cover_search_error, "Download failed: #{inspect(reason)}")
+ |> assign(:cover_search_loading, false)}
+ end
+
+ def handle_async(:cover_download, {:exit, reason}, socket) do
+ {:noreply,
+ socket
+ |> assign(:cover_search_error, "Download failed: #{inspect(reason)}")
+ |> assign(:cover_search_loading, false)}
+ end
+
defp save_record(socket, record_params, uploaded_covers) do
params =
case uploaded_covers do
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index d6ad9165..7e6a99c2 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -191,6 +191,7 @@ msgid "Saving..."
msgstr ""
#: lib/music_library_web/components/core_components.ex
+#: lib/music_library_web/components/record_form.ex
#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format
msgid "Search"
@@ -1877,3 +1878,18 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Refresh Wikipedia data"
msgstr ""
+
+#: lib/music_library_web/components/record_form.ex
+#, elixir-autogen, elixir-format
+msgid "Cover art updated successfully"
+msgstr ""
+
+#: lib/music_library_web/components/record_form.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to save cover art"
+msgstr ""
+
+#: lib/music_library_web/components/record_form.ex
+#, elixir-autogen, elixir-format
+msgid "Search for cover art online"
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index aa4bcb56..e570ffe7 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -191,6 +191,7 @@ msgid "Saving..."
msgstr ""
#: lib/music_library_web/components/core_components.ex
+#: lib/music_library_web/components/record_form.ex
#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format
msgid "Search"
@@ -1877,3 +1878,18 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy
msgid "Refresh Wikipedia data"
msgstr ""
+
+#: lib/music_library_web/components/record_form.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Cover art updated successfully"
+msgstr ""
+
+#: lib/music_library_web/components/record_form.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to save cover art"
+msgstr ""
+
+#: lib/music_library_web/components/record_form.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Search for cover art online"
+msgstr ""
diff --git a/test/brave_search/api_test.exs b/test/brave_search/api_test.exs
new file mode 100644
index 00000000..dc1d0e6c
--- /dev/null
+++ b/test/brave_search/api_test.exs
@@ -0,0 +1,89 @@
+defmodule BraveSearch.APITest do
+ use ExUnit.Case, async: true
+
+ alias BraveSearch.API
+
+ @config %BraveSearch.Config{
+ api_key: "test_key",
+ user_agent: "test_agent",
+ req_options: [plug: {Req.Test, __MODULE__}, max_retries: 0]
+ }
+
+ describe "search_images/3" do
+ test "returns parsed results on success" do
+ Req.Test.stub(__MODULE__, fn conn ->
+ assert conn.request_path == "/res/v1/images/search"
+ assert conn.params["q"] == "test query"
+
+ body = %{
+ "results" => [
+ %{
+ "thumbnail" => %{"src" => "https://example.com/thumb.jpg"},
+ "properties" => %{"url" => "https://example.com/full.jpg"},
+ "title" => "Test Image",
+ "source" => "example.com"
+ }
+ ]
+ }
+
+ Req.Test.json(conn, body)
+ end)
+
+ assert {:ok, [result]} = API.search_images("test query", [], @config)
+ assert result.thumbnail_url == "https://example.com/thumb.jpg"
+ assert result.image_url == "https://example.com/full.jpg"
+ assert result.title == "Test Image"
+ assert result.source == "example.com"
+ end
+
+ test "returns empty list when no results" do
+ Req.Test.stub(__MODULE__, fn conn ->
+ Req.Test.json(conn, %{"results" => []})
+ end)
+
+ assert {:ok, []} = API.search_images("no results", [], @config)
+ end
+
+ @tag :capture_log
+ test "returns error on non-200 response" do
+ Req.Test.stub(__MODULE__, fn conn ->
+ conn
+ |> Plug.Conn.put_status(429)
+ |> Req.Test.json(%{"error" => "rate limited"})
+ end)
+
+ assert {:error, _} = API.search_images("test", [], @config)
+ end
+
+ test "passes count option as query param" do
+ Req.Test.stub(__MODULE__, fn conn ->
+ assert conn.params["count"] == "5"
+ Req.Test.json(conn, %{"results" => []})
+ end)
+
+ assert {:ok, []} = API.search_images("test", [count: 5], @config)
+ end
+ end
+
+ describe "download_image/2" do
+ test "returns binary data on success" do
+ image_data = <<0xFF, 0xD8, 0xFF, 0xE0>>
+
+ Req.Test.stub(__MODULE__, fn conn ->
+ Plug.Conn.send_resp(conn, 200, image_data)
+ end)
+
+ assert {:ok, ^image_data} = API.download_image("http://localhost/image.jpg", @config)
+ end
+
+ @tag :capture_log
+ test "returns error on failure" do
+ Req.Test.stub(__MODULE__, fn conn ->
+ Plug.Conn.send_resp(conn, 404, "not found")
+ end)
+
+ assert {:error, :download_failed} =
+ API.download_image("http://localhost/missing.jpg", @config)
+ end
+ end
+end