Files
allstarr/allstarr/Services/MusicBrainz/MusicBrainzService.cs
Josh Patra bf02dc5a57 Add MusicBrainz genre enrichment and improve track counting
- Fixed external track detection (check for provider prefix in ID)
- Added genre support to MusicBrainz service (inc=genres+tags)
- Created GenreEnrichmentService for async genre lookup with caching
- Show provider name and search query for external tracks in admin UI
- Display search query that will be used for external track streaming
- Aggregate playlist genres from track genres
- All 225 tests passing
2026-02-04 16:43:17 -05:00

343 lines
10 KiB
C#

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+genres+tags";
_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];
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string>();
_logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist} (Genres: {Genres})",
isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
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}&inc=genres+tags";
_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>
/// Enriches a song with genre information from MusicBrainz.
/// First tries ISRC lookup, then falls back to title/artist search.
/// </summary>
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
{
if (!_settings.Enabled)
{
return new List<string>();
}
MusicBrainzRecording? recording = null;
// Try ISRC lookup first (most accurate)
if (!string.IsNullOrEmpty(isrc))
{
recording = await LookupByIsrcAsync(isrc);
}
// Fall back to search if ISRC lookup failed or no ISRC provided
if (recording == null)
{
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
recording = recordings.FirstOrDefault();
}
if (recording == null)
{
return new List<string>();
}
// Extract genres (prioritize official genres over tags)
var genres = new List<string>();
if (recording.Genres != null && recording.Genres.Count > 0)
{
// Get top genres by vote count
genres.AddRange(recording.Genres
.OrderByDescending(g => g.Count)
.Take(5)
.Select(g => g.Name)
.Where(n => !string.IsNullOrEmpty(n))
.Select(n => n!)
.ToList());
}
_logger.LogInformation("Found {Count} genres for {Title} - {Artist}: {Genres}",
genres.Count, title, artist, string.Join(", ", genres));
return genres;
}
/// <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; }
[JsonPropertyName("genres")]
public List<MusicBrainzGenre>? Genres { get; set; }
[JsonPropertyName("tags")]
public List<MusicBrainzTag>? Tags { 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; }
}
/// <summary>
/// MusicBrainz genre.
/// </summary>
public class MusicBrainzGenre
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("count")]
public int Count { get; set; }
}
/// <summary>
/// MusicBrainz tag (folksonomy).
/// </summary>
public class MusicBrainzTag
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("count")]
public int Count { get; set; }
}