Add Brave Search Images API integration for artist and album image search#13
Conversation
Co-authored-by: sphildreth <193334+sphildreth@users.noreply.github.com>
Co-authored-by: sphildreth <193334+sphildreth@users.noreply.github.com>
Co-authored-by: sphildreth <193334+sphildreth@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR implements Brave Search Images API integration as an additional metadata provider for fetching artist portraits and album cover images. The implementation includes a complete HTTP client with proper error handling, result mapping with deduplication, and two plugin implementations following existing project patterns.
- Added BraveSearchClient with configurable endpoints and API key authentication via X-Subscription-Token header
- Implemented BraveImageMapper with case-insensitive URL deduplication and result transformation
- Created plugins for both artist and album image searches with contextual query building
- Integrated with existing search engine services alongside Spotify, Last.fm, Deezer, and iTunes
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 35 comments.
Show a summary per file
| File | Description |
|---|---|
| BraveSearchClientTests.cs | Comprehensive unit tests for HTTP client covering configuration, URL construction, header management, clamping, and error scenarios |
| BraveImageMapperTests.cs | Unit tests for result mapping including null handling, deduplication (case-insensitive), and order preservation |
| BraveArtistImageSearchEnginePluginTests.cs | Tests for artist image search plugin covering query validation, disabled state, result mapping, and exception handling |
| BraveAlbumImageSearchEnginePluginTests.cs | Tests for album image search plugin with artist name inclusion, result clamping, and error resilience |
| ArtistImageSearchEngineService.cs | Integration of BraveArtistImageSearchEnginePlugin into existing artist image search service |
| AlbumImageSearchEngineService.cs | Integration of BraveAlbumImageSearchEnginePlugin into existing album image search service |
| BraveSearchOptions.cs | Configuration options class with API key, base URL, and image search path properties |
| BraveSearchClient.cs | HTTP client implementation with query encoding, header injection, count clamping (1-50), and graceful error handling |
| BraveImageSearchDtos.cs | DTOs for Brave API responses with JsonPropertyName attributes for proper deserialization |
| BraveImageMapper.cs | Static mapper class transforming Brave API results to ImageSearchResult with URL-based deduplication |
| BraveArtistImageSearchEnginePlugin.cs | Artist image search plugin with "musician portrait" query enhancement |
| BraveAlbumImageSearchEnginePlugin.cs | Album image search plugin with "album cover" query enhancement and optional artist name inclusion |
| SettingRegistry.cs | Added four configuration keys for Brave Search integration (enabled, API key, base URL, image search path) |
| example.env | Environment variable examples for Brave Search API configuration |
| configuration.md | Documentation of Brave Search API setup and configuration options |
| README.md | Updated metadata sources list to include Brave Search |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| BRAVE_SEARCH__ENABLED=false | ||
| BRAVE_SEARCH__APIKEY=your_brave_api_key_here | ||
| BRAVE_SEARCH__BASEURL=https://api.search.brave.com | ||
| BRAVE_SEARCH__IMAGESEARCHPATH=/res/v1/images/search No newline at end of file |
There was a problem hiding this comment.
Trailing whitespace detected at the end of this line. Remove the trailing space after the path to maintain consistency with other configuration values in this file.
| BRAVE_SEARCH__IMAGESEARCHPATH=/res/v1/images/search | |
| BRAVE_SEARCH__IMAGESEARCHPATH=/res/v1/images/search |
| var request = new HttpRequestMessage(HttpMethod.Get, requestUri); | ||
| request.Headers.Add("Accept", "application/json"); | ||
| request.Headers.Add("X-Subscription-Token", apiKey); | ||
|
|
||
| try | ||
| { |
There was a problem hiding this comment.
Disposable 'HttpRequestMessage' is created but not disposed.
| var request = new HttpRequestMessage(HttpMethod.Get, requestUri); | |
| request.Headers.Add("Accept", "application/json"); | |
| request.Headers.Add("X-Subscription-Token", apiKey); | |
| try | |
| { | |
| try | |
| { | |
| using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); | |
| request.Headers.Add("Accept", "application/json"); | |
| request.Headers.Add("X-Subscription-Token", apiKey); |
| var mockHttpFactory = new Mock<IHttpClientFactory>(); | ||
| mockHttpFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(new HttpClient(handlerStub)); |
There was a problem hiding this comment.
Disposable 'HttpClient' is created but not disposed.
| var mockHttpFactory = new Mock<IHttpClientFactory>(); | |
| mockHttpFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(new HttpClient(handlerStub)); | |
| using var httpClient = new HttpClient(handlerStub); | |
| var mockHttpFactory = new Mock<IHttpClientFactory>(); | |
| mockHttpFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient); |
| var mockHttpFactory = new Mock<IHttpClientFactory>(); | ||
| mockHttpFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(new HttpClient(handlerStub)); | ||
|
|
||
| var plugin = new BraveAlbumImageSearchEnginePlugin( | ||
| mockLogger.Object, | ||
| mockHttpFactory.Object, | ||
| mockConfig.Object); | ||
|
|
||
| var query = new AlbumQuery { Name = "Abbey Road", Artist = "The Beatles", Year = 1969 }; | ||
|
|
||
| // Act | ||
| var result = await plugin.DoAlbumImageSearch(query, 10); | ||
|
|
||
| // Assert | ||
| Assert.NotNull(result); | ||
| Assert.True(result.IsSuccess); | ||
| Assert.NotNull(result.Data); | ||
| Assert.Equal(2, result.Data.Length); | ||
| Assert.Equal("Abbey Road Cover 1", result.Data[0].Title); | ||
| Assert.Equal("Abbey Road Cover 2", result.Data[1].Title); | ||
| } |
There was a problem hiding this comment.
Disposable 'HttpClient' is created but not disposed.
| var mockHttpFactory = new Mock<IHttpClientFactory>(); | |
| mockHttpFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(new HttpClient(handlerStub)); | |
| var plugin = new BraveAlbumImageSearchEnginePlugin( | |
| mockLogger.Object, | |
| mockHttpFactory.Object, | |
| mockConfig.Object); | |
| var query = new AlbumQuery { Name = "Abbey Road", Artist = "The Beatles", Year = 1969 }; | |
| // Act | |
| var result = await plugin.DoAlbumImageSearch(query, 10); | |
| // Assert | |
| Assert.NotNull(result); | |
| Assert.True(result.IsSuccess); | |
| Assert.NotNull(result.Data); | |
| Assert.Equal(2, result.Data.Length); | |
| Assert.Equal("Abbey Road Cover 1", result.Data[0].Title); | |
| Assert.Equal("Abbey Road Cover 2", result.Data[1].Title); | |
| } | |
| using (var httpClient = new HttpClient(handlerStub)) | |
| { | |
| var mockHttpFactory = new Mock<IHttpClientFactory>(); | |
| mockHttpFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient); | |
| var plugin = new BraveAlbumImageSearchEnginePlugin( | |
| mockLogger.Object, | |
| mockHttpFactory.Object, | |
| mockConfig.Object); | |
| var query = new AlbumQuery { Name = "Abbey Road", Artist = "The Beatles", Year = 1969 }; | |
| // Act | |
| var result = await plugin.DoAlbumImageSearch(query, 10); | |
| // Assert | |
| Assert.NotNull(result); | |
| Assert.True(result.IsSuccess); | |
| Assert.NotNull(result.Data); | |
| Assert.Equal(2, result.Data.Length); | |
| Assert.Equal("Abbey Road Cover 1", result.Data[0].Title); | |
| Assert.Equal("Abbey Road Cover 2", result.Data[1].Title); | |
| } |
| var mockHttpFactory = new Mock<IHttpClientFactory>(); | ||
| mockHttpFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(new HttpClient(handlerStub)); | ||
|
|
||
| var plugin = new BraveArtistImageSearchEnginePlugin( | ||
| mockLogger.Object, | ||
| mockHttpFactory.Object, | ||
| mockConfig.Object); | ||
|
|
||
| var query = new ArtistQuery { Name = "Unknown Artist" }; | ||
|
|
||
| // Act | ||
| var result = await plugin.DoArtistImageSearch(query, 10); | ||
|
|
||
| // Assert | ||
| Assert.NotNull(result); | ||
| Assert.True(result.IsSuccess); | ||
| Assert.NotNull(result.Data); | ||
| Assert.Empty(result.Data); | ||
| } |
There was a problem hiding this comment.
Disposable 'HttpClient' is created but not disposed.
| var mockHttpFactory = new Mock<IHttpClientFactory>(); | |
| mockHttpFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(new HttpClient(handlerStub)); | |
| var plugin = new BraveArtistImageSearchEnginePlugin( | |
| mockLogger.Object, | |
| mockHttpFactory.Object, | |
| mockConfig.Object); | |
| var query = new ArtistQuery { Name = "Unknown Artist" }; | |
| // Act | |
| var result = await plugin.DoArtistImageSearch(query, 10); | |
| // Assert | |
| Assert.NotNull(result); | |
| Assert.True(result.IsSuccess); | |
| Assert.NotNull(result.Data); | |
| Assert.Empty(result.Data); | |
| } | |
| var httpClient = new HttpClient(handlerStub); | |
| var mockHttpFactory = new Mock<IHttpClientFactory>(); | |
| mockHttpFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient); | |
| try | |
| { | |
| var plugin = new BraveArtistImageSearchEnginePlugin( | |
| mockLogger.Object, | |
| mockHttpFactory.Object, | |
| mockConfig.Object); | |
| var query = new ArtistQuery { Name = "Unknown Artist" }; | |
| // Act | |
| var result = await plugin.DoArtistImageSearch(query, 10); | |
| // Assert | |
| Assert.NotNull(result); | |
| Assert.True(result.IsSuccess); | |
| Assert.NotNull(result.Data); | |
| Assert.Empty(result.Data); | |
| } | |
| finally | |
| { | |
| httpClient.Dispose(); | |
| } |
| var response = new HttpResponseMessage | ||
| { | ||
| StatusCode = HttpStatusCode.OK, | ||
| Content = new StringContent(JsonSerializer.Serialize(new BraveImageSearchResponse())) | ||
| }; |
There was a problem hiding this comment.
Disposable 'HttpResponseMessage' is created but not disposed.
| var response = new HttpResponseMessage | ||
| { | ||
| StatusCode = HttpStatusCode.OK, | ||
| Content = new StringContent(JsonSerializer.Serialize(new BraveImageSearchResponse())) | ||
| }; |
There was a problem hiding this comment.
Disposable 'HttpResponseMessage' is created but not disposed.
| var response = new HttpResponseMessage | ||
| { | ||
| StatusCode = HttpStatusCode.OK, | ||
| Content = new StringContent(JsonSerializer.Serialize(new BraveImageSearchResponse())) | ||
| }; |
There was a problem hiding this comment.
Disposable 'HttpResponseMessage' is created but not disposed.
| var response = new HttpResponseMessage | ||
| { | ||
| StatusCode = HttpStatusCode.OK, | ||
| Content = new StringContent(JsonSerializer.Serialize(new BraveImageSearchResponse())) | ||
| }; |
There was a problem hiding this comment.
Disposable 'HttpResponseMessage' is created but not disposed.
| var handlerStub = new HttpHandlerStubDelegate((request, cancellationToken) => | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| var response = new HttpResponseMessage { StatusCode = HttpStatusCode.OK }; |
There was a problem hiding this comment.
Disposable 'HttpResponseMessage' is created but not disposed.
Implements Brave Search Images API as an additional metadata provider for fetching artist portraits and album cover images.
Core Components
Integration
Wired into existing
ArtistImageSearchEngineServiceandAlbumImageSearchEngineServicealongside Spotify, Last.fm, Deezer, and iTunes providers. Added configuration keys toSettingRegistry:Configuration
Disabled by default. Requires API key from https://brave.com/search/api/
Testing
39 unit tests covering:
Original prompt
You are an automated coding agent working in a C# / JavaScript solution called Melodee. Your task is to fully implement Brave Search Images API integration as specified in the document in
prompts/BRAVE_SEARCH_API.md, including unit tests, following existing project conventions.Follow these instructions exactly:
Understand existing patterns
src/Melodee.Commonfor:IArtistImageSearchEnginePluginIAlbumImageSearchEnginePluginArtistQueryAlbumQueryImageSearchResultOperationResult<T>src/Melodee.Common/Plugins/SearchEngineand any subfolders.ArtistQuery/AlbumQuery.OperationResult<T>is created on success/failure.Add Brave configuration and DTOs
src/Melodee.Common/SearchEngines/Brave/if it does not exist.BraveSearchOptionsinsrc/Melodee.Common/SearchEngines/Brave/BraveSearchOptions.cswith:namespace Melodee.Common.SearchEngines.Brave;public string ApiKey { get; set; } = string.Empty;public string BaseUrl { get; set; } = "https://api.search.brave.com";public string ImageSearchPath { get; set; } = "/res/v1/images/search";public bool Enabled { get; set; } = true;IOptions<T>pattern as other search engines in the repo.src/Melodee.Common/SearchEngines/Brave/BraveImageSearchDtos.cs:namespace Melodee.Common.SearchEngines.Brave;public sealed class BraveImageSearchResponsewithList<BraveImageResult> Results { get; set; } = new();public sealed class BraveImageResultwith fields:Title,Url,ThumbnailUrl,Source,PageUrlWidth,Height,ContentTypeif useful and supported.Implement
BraveSearchClientCreate
src/Melodee.Common/SearchEngines/Brave/BraveSearchClient.cswith:namespace Melodee.Common.SearchEngines.Brave;HttpClient httpClientIOptions<BraveSearchOptions>(or the equivalent used elsewhere)._httpClient,_options,_jsonOptions(forSystem.Text.Jsonwith case-insensitive property names).Implement:
Behavior:
!_options.Value.Enabled, returnnullwithout making a request.ApiKeyis missing/whitespace, handle in the same way other HTTP clients in this repo do:InvalidOperationExceptionwith a clear message, or log+returnnull. Match existing pattern.BaseUrl+ImageSearchPathwith query parameters:q= querycount= clamped1..50(or repo-appropriate max)HttpRequestMessageGET:Accept: application/jsonX-Subscription-Token: <ApiKey>_httpClient.SendAsync(request, cancellationToken).OperationCanceledExceptionbubble up.HttpRequestExceptionconsistently with other search clients (log/convert tonullor error).nulland logging).BraveImageSearchResponseusingSystem.Net.Http.JsonorJsonSerializer.Implement Brave image mapper
Create
src/Melodee.Common/SearchEngines/Brave/BraveImageMapper.cswith:namespace Melodee.Common.SearchEngines.Brave;Implement:
Mapping rules:
sourceisnullor `source.Ur...💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.