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
This commit is contained in:
2026-02-04 16:43:17 -05:00
parent 7938871556
commit bf02dc5a57
5 changed files with 234 additions and 22 deletions

View File

@@ -252,30 +252,29 @@ public class AdminController : ControllerBase
// Count local vs external tracks
foreach (var item in items.EnumerateArray())
{
// Check if track has a real file path (local) or is external
// External tracks from allstarr have ExternalProvider in ProviderIds
// Local tracks have real filesystem paths
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
pathProp.ValueKind == JsonValueKind.String &&
!string.IsNullOrEmpty(pathProp.GetString());
if (hasPath)
// Check if it's an external track by looking at the ID format
// External tracks have IDs like "deezer:123456" or "qobuz:123456"
var isExternal = false;
if (item.TryGetProperty("Id", out var idProp))
{
var pathStr = pathProp.GetString()!;
// Local tracks have filesystem paths starting with / or containing :\
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
{
localCount++;
}
else
{
// External track (downloaded from Deezer/Qobuz/etc)
externalMatchedCount++;
}
var id = idProp.GetString() ?? "";
isExternal = id.Contains(":"); // External IDs contain provider prefix
}
else
if (isExternal)
{
// No path means external
externalMatchedCount++;
}
else if (hasPath)
{
localCount++;
}
}
var totalInJellyfin = localCount + externalMatchedCount;
@@ -422,7 +421,10 @@ public class AdminController : ControllerBase
spotifyId = track.SpotifyId,
durationMs = track.DurationMs,
albumArtUrl = track.AlbumArtUrl,
isLocal = isLocal
isLocal = isLocal,
// For external tracks, show what will be searched
externalProvider = isLocal ? null : _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
searchQuery = isLocal ? null : $"{track.Title} {track.PrimaryArtist}"
});
}
@@ -456,7 +458,9 @@ public class AdminController : ControllerBase
spotifyId = t.SpotifyId,
durationMs = t.DurationMs,
albumArtUrl = t.AlbumArtUrl,
isLocal = (bool?)null // Unknown
isLocal = (bool?)null, // Unknown
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
searchQuery = $"{t.Title} {t.PrimaryArtist}"
})
});
}

View File

@@ -582,6 +582,9 @@ builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options
});
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.MusicBrainzService>();
// Register genre enrichment service
builder.Services.AddSingleton<allstarr.Services.Common.GenreEnrichmentService>();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>

View File

@@ -0,0 +1,117 @@
using allstarr.Models.Domain;
using allstarr.Services.MusicBrainz;
using allstarr.Services.Common;
namespace allstarr.Services.Common;
/// <summary>
/// Service for enriching songs and playlists with genre information from MusicBrainz.
/// </summary>
public class GenreEnrichmentService
{
private readonly MusicBrainzService _musicBrainz;
private readonly RedisCacheService _cache;
private readonly ILogger<GenreEnrichmentService> _logger;
private const string GenreCachePrefix = "genre:";
private static readonly TimeSpan GenreCacheDuration = TimeSpan.FromDays(30);
public GenreEnrichmentService(
MusicBrainzService musicBrainz,
RedisCacheService cache,
ILogger<GenreEnrichmentService> logger)
{
_musicBrainz = musicBrainz;
_cache = cache;
_logger = logger;
}
/// <summary>
/// Enriches a song with genre information from MusicBrainz (with caching).
/// Updates the song's Genre property with the top genre.
/// </summary>
public async Task EnrichSongGenreAsync(Song song)
{
// Skip if song already has a genre
if (!string.IsNullOrEmpty(song.Genre))
{
return;
}
// Check cache first
var cacheKey = $"{GenreCachePrefix}{song.Title}:{song.Artist}";
var cachedGenre = await _cache.GetAsync<string>(cacheKey);
if (cachedGenre != null)
{
song.Genre = cachedGenre;
_logger.LogDebug("Using cached genre for {Title} - {Artist}: {Genre}",
song.Title, song.Artist, cachedGenre);
return;
}
// Fetch from MusicBrainz
try
{
var genres = await _musicBrainz.GetGenresForSongAsync(song.Title, song.Artist, song.Isrc);
if (genres.Count > 0)
{
// Use the top genre
song.Genre = genres[0];
// Cache the result
await _cache.SetAsync(cacheKey, song.Genre, GenreCacheDuration);
_logger.LogInformation("Enriched {Title} - {Artist} with genre: {Genre}",
song.Title, song.Artist, song.Genre);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enrich genre for {Title} - {Artist}",
song.Title, song.Artist);
}
}
/// <summary>
/// Enriches multiple songs with genre information (batch operation).
/// </summary>
public async Task EnrichSongsGenresAsync(List<Song> songs)
{
var tasks = songs
.Where(s => string.IsNullOrEmpty(s.Genre))
.Select(s => EnrichSongGenreAsync(s));
await Task.WhenAll(tasks);
}
/// <summary>
/// Aggregates genres from a list of songs to determine playlist genres.
/// Returns the top 5 most common genres.
/// </summary>
public List<string> AggregatePlaylistGenres(List<Song> songs)
{
var genreCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var song in songs)
{
if (!string.IsNullOrEmpty(song.Genre))
{
if (genreCounts.ContainsKey(song.Genre))
{
genreCounts[song.Genre]++;
}
else
{
genreCounts[song.Genre] = 1;
}
}
}
return genreCounts
.OrderByDescending(kvp => kvp.Value)
.Take(5)
.Select(kvp => kvp.Key)
.ToList();
}
}

View File

@@ -55,7 +55,7 @@ public class MusicBrainzService
try
{
var url = $"{_settings.BaseUrl}/isrc/{isrc}?fmt=json&inc=artists+releases+release-groups";
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);
@@ -77,8 +77,9 @@ public class MusicBrainzService
// 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");
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;
}
@@ -106,7 +107,7 @@ public class MusicBrainzService
// 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}";
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}&inc=genres+tags";
_logger.LogDebug("MusicBrainz search: {Url}", url);
@@ -139,6 +140,58 @@ public class MusicBrainzService
}
}
/// <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>
@@ -214,6 +267,12 @@ public class MusicBrainzRecording
[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>
@@ -254,3 +313,30 @@ public class MusicBrainzRelease
[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; }
}

View File

@@ -1720,7 +1720,8 @@
if (t.isLocal === true) {
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
} else if (t.isLocal === false) {
statusBadge = '<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>External</span>';
const provider = t.externalProvider || 'External';
statusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
// Add manual map button for external tracks using data attributes
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
mapButton = `<button class="small map-track-btn"
@@ -1742,6 +1743,7 @@
<div class="track-meta">
${t.album ? escapeHtml(t.album) : ''}
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
${t.isLocal === false && t.searchQuery ? '<br><small style="color:var(--accent)">🔍 Search: ' + escapeHtml(t.searchQuery) + '</small>' : ''}
</div>
</div>
`;