diff --git a/allstarr.Tests/JellyfinResponseStructureTests.cs b/allstarr.Tests/JellyfinResponseStructureTests.cs
new file mode 100644
index 0000000..e69de29
diff --git a/allstarr/Models/Settings/MusicBrainzSettings.cs b/allstarr/Models/Settings/MusicBrainzSettings.cs
new file mode 100644
index 0000000..56397c2
--- /dev/null
+++ b/allstarr/Models/Settings/MusicBrainzSettings.cs
@@ -0,0 +1,21 @@
+namespace allstarr.Models.Settings;
+
+///
+/// Settings for MusicBrainz API integration.
+///
+public class MusicBrainzSettings
+{
+ public bool Enabled { get; set; } = true;
+ public string? Username { get; set; }
+ public string? Password { get; set; }
+
+ ///
+ /// Base URL for MusicBrainz API.
+ ///
+ public string BaseUrl { get; set; } = "https://musicbrainz.org/ws/2";
+
+ ///
+ /// Rate limit: 1 request per second for unauthenticated, 1 per second for authenticated.
+ ///
+ public int RateLimitMs { get; set; } = 1000;
+}
diff --git a/allstarr/Program.cs b/allstarr/Program.cs
index d2c0bae..98cd9ce 100644
--- a/allstarr/Program.cs
+++ b/allstarr/Program.cs
@@ -556,6 +556,32 @@ builder.Services.AddHostedService();
builder.Services.AddHostedService(sp => sp.GetRequiredService());
+// Register MusicBrainz service for metadata enrichment
+builder.Services.Configure(options =>
+{
+ builder.Configuration.GetSection("MusicBrainz").Bind(options);
+
+ // Override from environment variables
+ var enabled = builder.Configuration.GetValue("MusicBrainz:Enabled");
+ if (!string.IsNullOrEmpty(enabled))
+ {
+ options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
+ }
+
+ var username = builder.Configuration.GetValue("MusicBrainz:Username");
+ if (!string.IsNullOrEmpty(username))
+ {
+ options.Username = username;
+ }
+
+ var password = builder.Configuration.GetValue("MusicBrainz:Password");
+ if (!string.IsNullOrEmpty(password))
+ {
+ options.Password = password;
+ }
+});
+builder.Services.AddSingleton();
+
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
diff --git a/allstarr/Services/MusicBrainz/MusicBrainzService.cs b/allstarr/Services/MusicBrainz/MusicBrainzService.cs
new file mode 100644
index 0000000..293b50e
--- /dev/null
+++ b/allstarr/Services/MusicBrainz/MusicBrainzService.cs
@@ -0,0 +1,256 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using allstarr.Models.Domain;
+using allstarr.Models.Settings;
+using Microsoft.Extensions.Options;
+
+namespace allstarr.Services.MusicBrainz;
+
+///
+/// Service for querying MusicBrainz API for metadata enrichment.
+///
+public class MusicBrainzService
+{
+ private readonly HttpClient _httpClient;
+ private readonly MusicBrainzSettings _settings;
+ private readonly ILogger _logger;
+ private DateTime _lastRequestTime = DateTime.MinValue;
+ private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1);
+
+ public MusicBrainzService(
+ IHttpClientFactory httpClientFactory,
+ IOptions settings,
+ ILogger logger)
+ {
+ _httpClient = httpClientFactory.CreateClient();
+ _httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
+ _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+ _settings = settings.Value;
+ _logger = logger;
+
+ // Set up digest authentication if credentials provided
+ if (!string.IsNullOrEmpty(_settings.Username) && !string.IsNullOrEmpty(_settings.Password))
+ {
+ var credentials = Convert.ToBase64String(
+ Encoding.ASCII.GetBytes($"{_settings.Username}:{_settings.Password}"));
+ _httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Basic", credentials);
+ }
+ }
+
+ ///
+ /// Looks up a recording by ISRC code.
+ ///
+ public async Task LookupByIsrcAsync(string isrc)
+ {
+ if (!_settings.Enabled)
+ {
+ return null;
+ }
+
+ await RateLimitAsync();
+
+ try
+ {
+ var url = $"{_settings.BaseUrl}/isrc/{isrc}?fmt=json&inc=artists+releases+release-groups";
+ _logger.LogDebug("MusicBrainz ISRC lookup: {Url}", url);
+
+ var response = await _httpClient.GetAsync(url);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("MusicBrainz ISRC lookup failed: {StatusCode}", response.StatusCode);
+ return null;
+ }
+
+ var json = await response.Content.ReadAsStringAsync();
+ var result = JsonSerializer.Deserialize(json, JsonOptions);
+
+ if (result?.Recordings == null || result.Recordings.Count == 0)
+ {
+ _logger.LogDebug("No MusicBrainz recordings found for ISRC: {Isrc}", isrc);
+ return null;
+ }
+
+ // Return the first recording (ISRCs should be unique)
+ var recording = result.Recordings[0];
+ _logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist}",
+ isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown");
+
+ return recording;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error looking up ISRC {Isrc} in MusicBrainz", isrc);
+ return null;
+ }
+ }
+
+ ///
+ /// Searches for recordings by title and artist.
+ ///
+ public async Task> SearchRecordingsAsync(string title, string artist, int limit = 5)
+ {
+ if (!_settings.Enabled)
+ {
+ return new List();
+ }
+
+ await RateLimitAsync();
+
+ try
+ {
+ // Build Lucene query
+ var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
+ var encodedQuery = Uri.EscapeDataString(query);
+ var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}";
+
+ _logger.LogDebug("MusicBrainz search: {Url}", url);
+
+ var response = await _httpClient.GetAsync(url);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("MusicBrainz search failed: {StatusCode}", response.StatusCode);
+ return new List();
+ }
+
+ var json = await response.Content.ReadAsStringAsync();
+ var result = JsonSerializer.Deserialize(json, JsonOptions);
+
+ if (result?.Recordings == null || result.Recordings.Count == 0)
+ {
+ _logger.LogDebug("No MusicBrainz recordings found for: {Title} - {Artist}", title, artist);
+ return new List();
+ }
+
+ _logger.LogInformation("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
+ result.Recordings.Count, title, artist);
+
+ return result.Recordings;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error searching MusicBrainz for: {Title} - {Artist}", title, artist);
+ return new List();
+ }
+ }
+
+ ///
+ /// Rate limiting to comply with MusicBrainz API rules (1 request per second).
+ ///
+ private async Task RateLimitAsync()
+ {
+ await _rateLimitSemaphore.WaitAsync();
+ try
+ {
+ var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime;
+ var minInterval = TimeSpan.FromMilliseconds(_settings.RateLimitMs);
+
+ if (timeSinceLastRequest < minInterval)
+ {
+ var delay = minInterval - timeSinceLastRequest;
+ await Task.Delay(delay);
+ }
+
+ _lastRequestTime = DateTime.UtcNow;
+ }
+ finally
+ {
+ _rateLimitSemaphore.Release();
+ }
+ }
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+}
+
+///
+/// MusicBrainz ISRC lookup response.
+///
+public class MusicBrainzIsrcResponse
+{
+ [JsonPropertyName("recordings")]
+ public List? Recordings { get; set; }
+}
+
+///
+/// MusicBrainz search response.
+///
+public class MusicBrainzSearchResponse
+{
+ [JsonPropertyName("recordings")]
+ public List? Recordings { get; set; }
+
+ [JsonPropertyName("count")]
+ public int Count { get; set; }
+}
+
+///
+/// MusicBrainz recording.
+///
+public class MusicBrainzRecording
+{
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ [JsonPropertyName("length")]
+ public int? Length { get; set; } // in milliseconds
+
+ [JsonPropertyName("artist-credit")]
+ public List? ArtistCredit { get; set; }
+
+ [JsonPropertyName("releases")]
+ public List? Releases { get; set; }
+
+ [JsonPropertyName("isrcs")]
+ public List? Isrcs { get; set; }
+}
+
+///
+/// MusicBrainz artist credit.
+///
+public class MusicBrainzArtistCredit
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("artist")]
+ public MusicBrainzArtist? Artist { get; set; }
+}
+
+///
+/// MusicBrainz artist.
+///
+public class MusicBrainzArtist
+{
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+}
+
+///
+/// MusicBrainz release.
+///
+public class MusicBrainzRelease
+{
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ [JsonPropertyName("date")]
+ public string? Date { get; set; }
+}
diff --git a/allstarr/appsettings.json b/allstarr/appsettings.json
index ab6e01a..fdb962d 100644
--- a/allstarr/appsettings.json
+++ b/allstarr/appsettings.json
@@ -58,5 +58,12 @@
"CacheDurationMinutes": 60,
"RateLimitDelayMs": 100,
"PreferIsrcMatching": true
+ },
+ "MusicBrainz": {
+ "Enabled": true,
+ "Username": "",
+ "Password": "",
+ "BaseUrl": "https://musicbrainz.org/ws/2",
+ "RateLimitMs": 1000
}
}