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
|
// Count local vs external tracks
|
||||||
foreach (var item in items.EnumerateArray())
|
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) &&
|
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
|
||||||
pathProp.ValueKind == JsonValueKind.String &&
|
pathProp.ValueKind == JsonValueKind.String &&
|
||||||
!string.IsNullOrEmpty(pathProp.GetString());
|
!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()!;
|
var id = idProp.GetString() ?? "";
|
||||||
// Local tracks have filesystem paths starting with / or containing :\
|
isExternal = id.Contains(":"); // External IDs contain provider prefix
|
||||||
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
|
|
||||||
{
|
|
||||||
localCount++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// External track (downloaded from Deezer/Qobuz/etc)
|
|
||||||
externalMatchedCount++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
if (isExternal)
|
||||||
{
|
{
|
||||||
// No path means external
|
|
||||||
externalMatchedCount++;
|
externalMatchedCount++;
|
||||||
}
|
}
|
||||||
|
else if (hasPath)
|
||||||
|
{
|
||||||
|
localCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalInJellyfin = localCount + externalMatchedCount;
|
var totalInJellyfin = localCount + externalMatchedCount;
|
||||||
@@ -422,7 +421,10 @@ public class AdminController : ControllerBase
|
|||||||
spotifyId = track.SpotifyId,
|
spotifyId = track.SpotifyId,
|
||||||
durationMs = track.DurationMs,
|
durationMs = track.DurationMs,
|
||||||
albumArtUrl = track.AlbumArtUrl,
|
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,
|
spotifyId = t.SpotifyId,
|
||||||
durationMs = t.DurationMs,
|
durationMs = t.DurationMs,
|
||||||
albumArtUrl = t.AlbumArtUrl,
|
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>();
|
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.MusicBrainzService>();
|
||||||
|
|
||||||
|
// Register genre enrichment service
|
||||||
|
builder.Services.AddSingleton<allstarr.Services.Common.GenreEnrichmentService>();
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddDefaultPolicy(policy =>
|
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
|
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);
|
_logger.LogDebug("MusicBrainz ISRC lookup: {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
@@ -77,8 +77,9 @@ public class MusicBrainzService
|
|||||||
|
|
||||||
// Return the first recording (ISRCs should be unique)
|
// Return the first recording (ISRCs should be unique)
|
||||||
var recording = result.Recordings[0];
|
var recording = result.Recordings[0];
|
||||||
_logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist}",
|
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string>();
|
||||||
isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown");
|
_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;
|
return recording;
|
||||||
}
|
}
|
||||||
@@ -106,7 +107,7 @@ public class MusicBrainzService
|
|||||||
// Build Lucene query
|
// Build Lucene query
|
||||||
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
|
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
|
||||||
var encodedQuery = Uri.EscapeDataString(query);
|
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);
|
_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>
|
/// <summary>
|
||||||
/// Rate limiting to comply with MusicBrainz API rules (1 request per second).
|
/// Rate limiting to comply with MusicBrainz API rules (1 request per second).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -214,6 +267,12 @@ public class MusicBrainzRecording
|
|||||||
|
|
||||||
[JsonPropertyName("isrcs")]
|
[JsonPropertyName("isrcs")]
|
||||||
public List<string>? Isrcs { get; set; }
|
public List<string>? Isrcs { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("genres")]
|
||||||
|
public List<MusicBrainzGenre>? Genres { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public List<MusicBrainzTag>? Tags { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -254,3 +313,30 @@ public class MusicBrainzRelease
|
|||||||
[JsonPropertyName("date")]
|
[JsonPropertyName("date")]
|
||||||
public string? Date { get; set; }
|
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) {
|
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>';
|
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) {
|
} 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
|
// Add manual map button for external tracks using data attributes
|
||||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||||
mapButton = `<button class="small map-track-btn"
|
mapButton = `<button class="small map-track-btn"
|
||||||
@@ -1742,6 +1743,7 @@
|
|||||||
<div class="track-meta">
|
<div class="track-meta">
|
||||||
${t.album ? escapeHtml(t.album) : ''}
|
${t.album ? escapeHtml(t.album) : ''}
|
||||||
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
|
${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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
Reference in New Issue
Block a user