mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -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}"
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
117
allstarr/Services/Common/GenreEnrichmentService.cs
Normal file
117
allstarr/Services/Common/GenreEnrichmentService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user