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