diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 26609b2..35437a2 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -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("Subsonic:MusicService") ?? _configuration.GetValue("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("Subsonic:MusicService") ?? _configuration.GetValue("Jellyfin:MusicService") ?? "Deezer", + searchQuery = $"{t.Title} {t.PrimaryArtist}" }) }); } diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 98cd9ce..93b7596 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -582,6 +582,9 @@ builder.Services.Configure(options }); builder.Services.AddSingleton(); +// Register genre enrichment service +builder.Services.AddSingleton(); + builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => diff --git a/allstarr/Services/Common/GenreEnrichmentService.cs b/allstarr/Services/Common/GenreEnrichmentService.cs new file mode 100644 index 0000000..c8e0ce5 --- /dev/null +++ b/allstarr/Services/Common/GenreEnrichmentService.cs @@ -0,0 +1,117 @@ +using allstarr.Models.Domain; +using allstarr.Services.MusicBrainz; +using allstarr.Services.Common; + +namespace allstarr.Services.Common; + +/// +/// Service for enriching songs and playlists with genre information from MusicBrainz. +/// +public class GenreEnrichmentService +{ + private readonly MusicBrainzService _musicBrainz; + private readonly RedisCacheService _cache; + private readonly ILogger _logger; + private const string GenreCachePrefix = "genre:"; + private static readonly TimeSpan GenreCacheDuration = TimeSpan.FromDays(30); + + public GenreEnrichmentService( + MusicBrainzService musicBrainz, + RedisCacheService cache, + ILogger logger) + { + _musicBrainz = musicBrainz; + _cache = cache; + _logger = logger; + } + + /// + /// Enriches a song with genre information from MusicBrainz (with caching). + /// Updates the song's Genre property with the top genre. + /// + 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(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); + } + } + + /// + /// Enriches multiple songs with genre information (batch operation). + /// + public async Task EnrichSongsGenresAsync(List songs) + { + var tasks = songs + .Where(s => string.IsNullOrEmpty(s.Genre)) + .Select(s => EnrichSongGenreAsync(s)); + + await Task.WhenAll(tasks); + } + + /// + /// Aggregates genres from a list of songs to determine playlist genres. + /// Returns the top 5 most common genres. + /// + public List AggregatePlaylistGenres(List songs) + { + var genreCounts = new Dictionary(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(); + } +} diff --git a/allstarr/Services/MusicBrainz/MusicBrainzService.cs b/allstarr/Services/MusicBrainz/MusicBrainzService.cs index 293b50e..0904970 100644 --- a/allstarr/Services/MusicBrainz/MusicBrainzService.cs +++ b/allstarr/Services/MusicBrainz/MusicBrainzService.cs @@ -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(); + _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); @@ -138,6 +139,58 @@ public class MusicBrainzService return new List(); } } + + /// + /// Enriches a song with genre information from MusicBrainz. + /// First tries ISRC lookup, then falls back to title/artist search. + /// + public async Task> GetGenresForSongAsync(string title, string artist, string? isrc = null) + { + if (!_settings.Enabled) + { + return new List(); + } + + 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(); + } + + // Extract genres (prioritize official genres over tags) + var genres = new List(); + + 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; + } /// /// Rate limiting to comply with MusicBrainz API rules (1 request per second). @@ -214,6 +267,12 @@ public class MusicBrainzRecording [JsonPropertyName("isrcs")] public List? Isrcs { get; set; } + + [JsonPropertyName("genres")] + public List? Genres { get; set; } + + [JsonPropertyName("tags")] + public List? Tags { get; set; } } /// @@ -254,3 +313,30 @@ public class MusicBrainzRelease [JsonPropertyName("date")] public string? Date { get; set; } } + +/// +/// MusicBrainz genre. +/// +public class MusicBrainzGenre +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("count")] + public int Count { get; set; } +} + +/// +/// MusicBrainz tag (folksonomy). +/// +public class MusicBrainzTag +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("count")] + public int Count { get; set; } +} diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 049b0b9..f01ba26 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -1720,7 +1720,8 @@ if (t.isLocal === true) { statusBadge = 'Local'; } else if (t.isLocal === false) { - statusBadge = 'External'; + const provider = t.externalProvider || 'External'; + statusBadge = `${escapeHtml(provider)}`; // Add manual map button for external tracks using data attributes const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : ''; mapButton = `