Skip to content

Add Brave Search Images API integration for artist and album image search#13

Merged
sphildreth merged 4 commits intomainfrom
copilot/implement-brave-search-api
Dec 14, 2025
Merged

Add Brave Search Images API integration for artist and album image search#13
sphildreth merged 4 commits intomainfrom
copilot/implement-brave-search-api

Conversation

Copy link
Contributor

Copilot AI commented Dec 14, 2025

Implements Brave Search Images API as an additional metadata provider for fetching artist portraits and album cover images.

Core Components

  • BraveSearchClient - HTTP client with query encoding, header management (X-Subscription-Token), and configurable endpoints
  • BraveImageMapper - Transforms Brave API responses to ImageSearchResult with case-insensitive deduplication
  • BraveArtistImageSearchEnginePlugin - Searches with contextual queries like "artist name musician portrait"
  • BraveAlbumImageSearchEnginePlugin - Searches with "artist album title album cover" pattern

Integration

Wired into existing ArtistImageSearchEngineService and AlbumImageSearchEngineService alongside Spotify, Last.fm, Deezer, and iTunes providers. Added configuration keys to SettingRegistry:

SearchEngineBraveEnabled
SearchEngineBraveApiKey
SearchEngineBraveBaseUrl
SearchEngineBraveImageSearchPath

Configuration

BRAVE_SEARCH__ENABLED=true
BRAVE_SEARCH__APIKEY=your_api_key

Disabled by default. Requires API key from https://brave.com/search/api/

Testing

39 unit tests covering:

  • HTTP client: URL construction, header injection, count clamping (1-50), error states
  • Mapper: null handling, deduplication, ordering preservation
  • Plugins: query validation, disabled state handling, result transformation
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:

  1. Understand existing patterns

    1. Scan src/Melodee.Common for:
      • IArtistImageSearchEnginePlugin
      • IAlbumImageSearchEnginePlugin
    2. Find and study:
      • ArtistQuery
      • AlbumQuery
      • ImageSearchResult
      • OperationResult<T>
    3. Locate existing image search engine plugins in:
      • src/Melodee.Common/Plugins/SearchEngine and any subfolders.
      • Focus on any existing image plugins (e.g., Last.fm, Spotify, Deezer).
    4. Identify:
      • Typical plugin class location and naming.
      • How queries are composed from ArtistQuery / AlbumQuery.
      • How OperationResult<T> is created on success/failure.
      • How image results are normalized/deduplicated.
      • Any base classes or helpers used.
    5. Design rule: For all Brave implementations, mirror the style, error handling, DI patterns, and coding conventions of the closest existing image search plugin.
  2. Add Brave configuration and DTOs

    1. Create folder src/Melodee.Common/SearchEngines/Brave/ if it does not exist.
    2. Add BraveSearchOptions in src/Melodee.Common/SearchEngines/Brave/BraveSearchOptions.cs with:
      • namespace Melodee.Common.SearchEngines.Brave;
      • Properties:
        • 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;
      • Use the same options/IOptions<T> pattern as other search engines in the repo.
    3. Add Brave image DTOs in src/Melodee.Common/SearchEngines/Brave/BraveImageSearchDtos.cs:
      • namespace Melodee.Common.SearchEngines.Brave;
      • Define at least:
        • public sealed class BraveImageSearchResponse with List<BraveImageResult> Results { get; set; } = new();
        • public sealed class BraveImageResult with fields:
          • Title, Url, ThumbnailUrl, Source, PageUrl
          • Optional: Width, Height, ContentType if useful and supported.
      • Ensure DTOs are compatible with Brave Images API JSON and deserialization is case-insensitive.
  3. Implement BraveSearchClient

    1. Create src/Melodee.Common/SearchEngines/Brave/BraveSearchClient.cs with:

      • namespace Melodee.Common.SearchEngines.Brave;
      • Constructor dependencies:
        • HttpClient httpClient
        • IOptions<BraveSearchOptions> (or the equivalent used elsewhere).
      • Private fields:
        • _httpClient, _options, _jsonOptions (for System.Text.Json with case-insensitive property names).
    2. Implement:

      Task<BraveImageSearchResponse?> SearchImagesAsync(
          string query,
          int count,
          CancellationToken cancellationToken = default);
    3. Behavior:

      • If !_options.Value.Enabled, return null without making a request.
      • If ApiKey is missing/whitespace, handle in the same way other HTTP clients in this repo do:
        • Either throw InvalidOperationException with a clear message, or log+return null. Match existing pattern.
      • Build URI from BaseUrl + ImageSearchPath with query parameters:
        • q = query
        • count = clamped 1..50 (or repo-appropriate max)
        • Optionally add safe-search parameter as used elsewhere.
      • Create HttpRequestMessage GET:
        • Accept: application/json
        • X-Subscription-Token: <ApiKey>
      • Use _httpClient.SendAsync(request, cancellationToken).
      • Exception handling:
        • Let OperationCanceledException bubble up.
        • Handle HttpRequestException consistently with other search clients (log/convert to null or error).
      • On non-2xx status:
        • Follow the same pattern as other HTTP clients (commonly returning null and logging).
      • On success:
        • Deserialize JSON into BraveImageSearchResponse using System.Net.Http.Json or JsonSerializer.
        • Return the deserialized response.
  4. Implement Brave image mapper

    1. Create src/Melodee.Common/SearchEngines/Brave/BraveImageMapper.cs with:

      • namespace Melodee.Common.SearchEngines.Brave;
    2. Implement:

      internal static class BraveImageMapper
      {
          public static ImageSearchResult? ToImageSearchResult(BraveImageResult source);
      
          public static ImageSearchResult[] MapResults(IEnumerable<BraveImageResult> results, int maxResults);
      }
    3. Mapping rules:

      • If source is null or `source.Ur...

💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI and others added 3 commits December 14, 2025 02:30
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>
Copilot AI changed the title [WIP] Implement Brave Search Images API integration with unit tests Add Brave Search Images API integration for artist and album image search Dec 14, 2025
Copilot AI requested a review from sphildreth December 14, 2025 02:45
@sphildreth sphildreth marked this pull request as ready for review December 14, 2025 02:49
Copilot AI review requested due to automatic review settings December 14, 2025 02:49
@sphildreth sphildreth merged commit 9a8f1f7 into main Dec 14, 2025
5 checks passed
@sphildreth sphildreth deleted the copilot/implement-brave-search-api branch December 14, 2025 02:49
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
BRAVE_SEARCH__IMAGESEARCHPATH=/res/v1/images/search
BRAVE_SEARCH__IMAGESEARCHPATH=/res/v1/images/search

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +63
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Add("Accept", "application/json");
request.Headers.Add("X-Subscription-Token", apiKey);

try
{
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable 'HttpRequestMessage' is created but not disposed.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +118
var mockHttpFactory = new Mock<IHttpClientFactory>();
mockHttpFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(new HttpClient(handlerStub));
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable 'HttpClient' is created but not disposed.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +137
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);
}
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable 'HttpClient' is created but not disposed.

Suggested change
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);
}

Copilot uses AI. Check for mistakes.
Comment on lines +162 to +180
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);
}
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable 'HttpClient' is created but not disposed.

Suggested change
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();
}

Copilot uses AI. Check for mistakes.
Comment on lines +136 to +140
var response = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonSerializer.Serialize(new BraveImageSearchResponse()))
};
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable 'HttpResponseMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
Comment on lines +171 to +175
var response = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonSerializer.Serialize(new BraveImageSearchResponse()))
};
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable 'HttpResponseMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
Comment on lines +206 to +210
var response = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonSerializer.Serialize(new BraveImageSearchResponse()))
};
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable 'HttpResponseMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
Comment on lines +243 to +247
var response = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(JsonSerializer.Serialize(new BraveImageSearchResponse()))
};
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable 'HttpResponseMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
var handlerStub = new HttpHandlerStubDelegate((request, cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();
var response = new HttpResponseMessage { StatusCode = HttpStatusCode.OK };
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable 'HttpResponseMessage' is created but not disposed.

Copilot uses AI. Check for mistakes.
Copilot AI mentioned this pull request Dec 21, 2025
34 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants