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 } }