mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Add MusicBrainz API integration for metadata enrichment
- Added MusicBrainzSettings model with username/password authentication - Created MusicBrainzService with ISRC lookup and recording search - Implements proper rate limiting (1 req/sec) per MusicBrainz rules - Added meaningful User-Agent header as required - Registered service in Program.cs with configuration - Added MusicBrainz section to appsettings.json - Credentials stored in .env (MUSICBRAINZ_USERNAME/PASSWORD) Next: Add to admin UI and implement import/export for .env
This commit is contained in:
0
allstarr.Tests/JellyfinResponseStructureTests.cs
Normal file
0
allstarr.Tests/JellyfinResponseStructureTests.cs
Normal file
21
allstarr/Models/Settings/MusicBrainzSettings.cs
Normal file
21
allstarr/Models/Settings/MusicBrainzSettings.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace allstarr.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for MusicBrainz API integration.
|
||||
/// </summary>
|
||||
public class MusicBrainzSettings
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for MusicBrainz API.
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "https://musicbrainz.org/ws/2";
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit: 1 request per second for unauthenticated, 1 per second for authenticated.
|
||||
/// </summary>
|
||||
public int RateLimitMs { get; set; } = 1000;
|
||||
}
|
||||
@@ -556,6 +556,32 @@ builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracks
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
|
||||
|
||||
// Register MusicBrainz service for metadata enrichment
|
||||
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
|
||||
{
|
||||
builder.Configuration.GetSection("MusicBrainz").Bind(options);
|
||||
|
||||
// Override from environment variables
|
||||
var enabled = builder.Configuration.GetValue<string>("MusicBrainz:Enabled");
|
||||
if (!string.IsNullOrEmpty(enabled))
|
||||
{
|
||||
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var username = builder.Configuration.GetValue<string>("MusicBrainz:Username");
|
||||
if (!string.IsNullOrEmpty(username))
|
||||
{
|
||||
options.Username = username;
|
||||
}
|
||||
|
||||
var password = builder.Configuration.GetValue<string>("MusicBrainz:Password");
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
options.Password = password;
|
||||
}
|
||||
});
|
||||
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.MusicBrainzService>();
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
|
||||
256
allstarr/Services/MusicBrainz/MusicBrainzService.cs
Normal file
256
allstarr/Services/MusicBrainz/MusicBrainzService.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying MusicBrainz API for metadata enrichment.
|
||||
/// </summary>
|
||||
public class MusicBrainzService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly MusicBrainzSettings _settings;
|
||||
private readonly ILogger<MusicBrainzService> _logger;
|
||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||
private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1);
|
||||
|
||||
public MusicBrainzService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<MusicBrainzSettings> settings,
|
||||
ILogger<MusicBrainzService> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a recording by ISRC code.
|
||||
/// </summary>
|
||||
public async Task<MusicBrainzRecording?> 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<MusicBrainzIsrcResponse>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for recordings by title and artist.
|
||||
/// </summary>
|
||||
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
|
||||
{
|
||||
if (!_settings.Enabled)
|
||||
{
|
||||
return new List<MusicBrainzRecording>();
|
||||
}
|
||||
|
||||
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<MusicBrainzRecording>();
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<MusicBrainzSearchResponse>(json, JsonOptions);
|
||||
|
||||
if (result?.Recordings == null || result.Recordings.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No MusicBrainz recordings found for: {Title} - {Artist}", title, artist);
|
||||
return new List<MusicBrainzRecording>();
|
||||
}
|
||||
|
||||
_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<MusicBrainzRecording>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rate limiting to comply with MusicBrainz API rules (1 request per second).
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz ISRC lookup response.
|
||||
/// </summary>
|
||||
public class MusicBrainzIsrcResponse
|
||||
{
|
||||
[JsonPropertyName("recordings")]
|
||||
public List<MusicBrainzRecording>? Recordings { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz search response.
|
||||
/// </summary>
|
||||
public class MusicBrainzSearchResponse
|
||||
{
|
||||
[JsonPropertyName("recordings")]
|
||||
public List<MusicBrainzRecording>? Recordings { get; set; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz recording.
|
||||
/// </summary>
|
||||
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<MusicBrainzArtistCredit>? ArtistCredit { get; set; }
|
||||
|
||||
[JsonPropertyName("releases")]
|
||||
public List<MusicBrainzRelease>? Releases { get; set; }
|
||||
|
||||
[JsonPropertyName("isrcs")]
|
||||
public List<string>? Isrcs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz artist credit.
|
||||
/// </summary>
|
||||
public class MusicBrainzArtistCredit
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("artist")]
|
||||
public MusicBrainzArtist? Artist { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz artist.
|
||||
/// </summary>
|
||||
public class MusicBrainzArtist
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz release.
|
||||
/// </summary>
|
||||
public class MusicBrainzRelease
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("date")]
|
||||
public string? Date { get; set; }
|
||||
}
|
||||
@@ -58,5 +58,12 @@
|
||||
"CacheDurationMinutes": 60,
|
||||
"RateLimitDelayMs": 100,
|
||||
"PreferIsrcMatching": true
|
||||
},
|
||||
"MusicBrainz": {
|
||||
"Enabled": true,
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"BaseUrl": "https://musicbrainz.org/ws/2",
|
||||
"RateLimitMs": 1000
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user