From 02640b8a3aa5a8a1efae2f8fdbdb202b617a5716 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 10 Feb 2026 10:25:17 -0500 Subject: [PATCH] feat: implement MusicBrainz genre enrichment for all external sources - Add automatic genre enrichment for Deezer, Qobuz, and SquidWTF tracks - Use ISRC codes for exact matching, fallback to title/artist search - Cache results in Redis (30 days) and file cache for performance - Remove unused Spotify API ClientId and ClientSecret settings - Respect MusicBrainz rate limits (1 req/sec) This ensures all external tracks have genre metadata, even when the source provider doesn't include it (especially SquidWTF/Tidal). --- .env.example | 7 ------- allstarr/Models/Settings/SpotifyApiSettings.cs | 12 ------------ allstarr/Program.cs | 16 ++-------------- .../Services/Deezer/DeezerMetadataService.cs | 14 +++++++++++++- .../Services/Lyrics/LyricsStartupValidator.cs | 8 +++----- allstarr/Services/Qobuz/QobuzMetadataService.cs | 16 ++++++++++++++-- .../Services/SquidWTF/SquidWTFMetadataService.cs | 11 ++++++++++- allstarr/appsettings.json | 2 -- docker-compose.yml | 2 -- 9 files changed, 42 insertions(+), 46 deletions(-) diff --git a/.env.example b/.env.example index dc1b120..94b83ba 100644 --- a/.env.example +++ b/.env.example @@ -143,13 +143,6 @@ SPOTIFY_IMPORT_PLAYLISTS=[] # Enable direct Spotify API access (default: false) SPOTIFY_API_ENABLED=false -# Spotify Client ID from https://developer.spotify.com/dashboard -# Create an app in the Spotify Developer Dashboard to get this -SPOTIFY_API_CLIENT_ID= - -# Spotify Client Secret (optional - only needed for certain OAuth flows) -SPOTIFY_API_CLIENT_SECRET= - # Spotify session cookie (sp_dc) - REQUIRED for editorial playlists # Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication # via session cookie because they're not accessible through the official API. diff --git a/allstarr/Models/Settings/SpotifyApiSettings.cs b/allstarr/Models/Settings/SpotifyApiSettings.cs index 88f23d4..fcf81b8 100644 --- a/allstarr/Models/Settings/SpotifyApiSettings.cs +++ b/allstarr/Models/Settings/SpotifyApiSettings.cs @@ -18,18 +18,6 @@ public class SpotifyApiSettings /// public bool Enabled { get; set; } - /// - /// Spotify Client ID from https://developer.spotify.com/dashboard - /// Used for OAuth token refresh and API access. - /// - public string ClientId { get; set; } = string.Empty; - - /// - /// Spotify Client Secret from https://developer.spotify.com/dashboard - /// Optional - only needed for certain OAuth flows. - /// - public string ClientSecret { get; set; } = string.Empty; - /// /// Spotify session cookie (sp_dc). /// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly. diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 4b0c407..15acbc3 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -473,7 +473,8 @@ else if (musicService == MusicService.SquidWTF) sp.GetRequiredService>(), sp.GetRequiredService>(), sp.GetRequiredService(), - squidWtfApiUrls)); + squidWtfApiUrls, + sp.GetRequiredService())); builder.Services.AddSingleton(sp => new SquidWTFDownloadService( sp.GetRequiredService(), @@ -537,18 +538,6 @@ builder.Services.Configure(options options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase); } - var clientId = builder.Configuration.GetValue("SpotifyApi:ClientId"); - if (!string.IsNullOrEmpty(clientId)) - { - options.ClientId = clientId; - } - - var clientSecret = builder.Configuration.GetValue("SpotifyApi:ClientSecret"); - if (!string.IsNullOrEmpty(clientSecret)) - { - options.ClientSecret = clientSecret; - } - var sessionCookie = builder.Configuration.GetValue("SpotifyApi:SessionCookie"); if (!string.IsNullOrEmpty(sessionCookie)) { @@ -576,7 +565,6 @@ builder.Services.Configure(options // Log configuration (mask sensitive values) Console.WriteLine($"SpotifyApi Configuration:"); Console.WriteLine($" Enabled: {options.Enabled}"); - Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}"); Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}"); Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}"); Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}"); diff --git a/allstarr/Services/Deezer/DeezerMetadataService.cs b/allstarr/Services/Deezer/DeezerMetadataService.cs index 434b6b5..cf04c38 100644 --- a/allstarr/Services/Deezer/DeezerMetadataService.cs +++ b/allstarr/Services/Deezer/DeezerMetadataService.cs @@ -3,6 +3,7 @@ using allstarr.Models.Settings; using allstarr.Models.Download; using allstarr.Models.Search; using allstarr.Models.Subsonic; +using allstarr.Services.Common; using System.Text.Json; using Microsoft.Extensions.Options; @@ -15,12 +16,17 @@ public class DeezerMetadataService : IMusicMetadataService { private readonly HttpClient _httpClient; private readonly SubsonicSettings _settings; + private readonly GenreEnrichmentService _genreEnrichment; private const string BaseUrl = "https://api.deezer.com"; - public DeezerMetadataService(IHttpClientFactory httpClientFactory, IOptions settings) + public DeezerMetadataService( + IHttpClientFactory httpClientFactory, + IOptions settings, + GenreEnrichmentService genreEnrichment) { _httpClient = httpClientFactory.CreateClient(); _settings = settings.Value; + _genreEnrichment = genreEnrichment; } public async Task> SearchSongsAsync(string query, int limit = 20) @@ -203,6 +209,12 @@ public class DeezerMetadataService : IMusicMetadataService } } + // Enrich with MusicBrainz genres if missing + if (string.IsNullOrEmpty(song.Genre)) + { + await _genreEnrichment.EnrichSongGenreAsync(song); + } + return song; } diff --git a/allstarr/Services/Lyrics/LyricsStartupValidator.cs b/allstarr/Services/Lyrics/LyricsStartupValidator.cs index b45d764..057c77f 100644 --- a/allstarr/Services/Lyrics/LyricsStartupValidator.cs +++ b/allstarr/Services/Lyrics/LyricsStartupValidator.cs @@ -167,16 +167,14 @@ public class LyricsStartupValidator : BaseStartupValidator { try { - if (string.IsNullOrEmpty(_spotifySettings.ClientId)) + if (!_spotifySettings.Enabled) { - WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow); - WriteDetail("Set SpotifyApi__ClientId to enable"); + WriteStatus("Spotify API", "DISABLED", ConsoleColor.Gray); return true; } WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green); - WriteDetail($"Client ID: {_spotifySettings.ClientId.Substring(0, Math.Min(8, _spotifySettings.ClientId.Length))}..."); - WriteDetail("Note: Spotify API is used for track matching, not lyrics"); + WriteDetail("Note: Spotify API is used for track matching and lyrics"); return true; } catch (Exception ex) diff --git a/allstarr/Services/Qobuz/QobuzMetadataService.cs b/allstarr/Services/Qobuz/QobuzMetadataService.cs index c718fe7..6cd39b3 100644 --- a/allstarr/Services/Qobuz/QobuzMetadataService.cs +++ b/allstarr/Services/Qobuz/QobuzMetadataService.cs @@ -3,6 +3,7 @@ using allstarr.Models.Settings; using allstarr.Models.Download; using allstarr.Models.Search; using allstarr.Models.Subsonic; +using allstarr.Services.Common; using System.Text.Json; using Microsoft.Extensions.Options; @@ -18,6 +19,7 @@ public class QobuzMetadataService : IMusicMetadataService private readonly SubsonicSettings _settings; private readonly QobuzBundleService _bundleService; private readonly ILogger _logger; + private readonly GenreEnrichmentService _genreEnrichment; private readonly string? _userAuthToken; private readonly string? _userId; @@ -28,12 +30,14 @@ public class QobuzMetadataService : IMusicMetadataService IOptions settings, IOptions qobuzSettings, QobuzBundleService bundleService, - ILogger logger) + ILogger logger, + GenreEnrichmentService genreEnrichment) { _httpClient = httpClientFactory.CreateClient(); _settings = settings.Value; _bundleService = bundleService; _logger = logger; + _genreEnrichment = genreEnrichment; var qobuzConfig = qobuzSettings.Value; _userAuthToken = qobuzConfig.UserAuthToken; @@ -177,7 +181,15 @@ public class QobuzMetadataService : IMusicMetadataService if (track.TryGetProperty("error", out _)) return null; - return ParseQobuzTrackFull(track); + var song = ParseQobuzTrackFull(track); + + // Enrich with MusicBrainz genres if missing + if (song != null && string.IsNullOrEmpty(song.Genre)) + { + await _genreEnrichment.EnrichSongGenreAsync(song); + } + + return song; } catch (Exception ex) { diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs index 6dae3f8..cd69966 100644 --- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs @@ -56,6 +56,7 @@ public class SquidWTFMetadataService : IMusicMetadataService private readonly ILogger _logger; private readonly RedisCacheService _cache; private readonly RoundRobinFallbackHelper _fallbackHelper; + private readonly GenreEnrichmentService _genreEnrichment; public SquidWTFMetadataService( IHttpClientFactory httpClientFactory, @@ -63,13 +64,15 @@ public class SquidWTFMetadataService : IMusicMetadataService IOptions squidwtfSettings, ILogger logger, RedisCacheService cache, - List apiUrls) + List apiUrls, + GenreEnrichmentService genreEnrichment) { _httpClient = httpClientFactory.CreateClient(); _settings = settings.Value; _logger = logger; _cache = cache; _fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF"); + _genreEnrichment = genreEnrichment; // Set up default headers _httpClient.DefaultRequestHeaders.Add("User-Agent", @@ -286,6 +289,12 @@ public class SquidWTFMetadataService : IMusicMetadataService var song = ParseTidalTrackFull(track); + // Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres) + if (string.IsNullOrEmpty(song.Genre)) + { + await _genreEnrichment.EnrichSongGenreAsync(song); + } + // NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService) // This avoids redundant conversions and ensures it's done in parallel with the download diff --git a/allstarr/appsettings.json b/allstarr/appsettings.json index f5e6fa6..d3934c8 100644 --- a/allstarr/appsettings.json +++ b/allstarr/appsettings.json @@ -61,8 +61,6 @@ }, "SpotifyApi": { "Enabled": false, - "ClientId": "", - "ClientSecret": "", "SessionCookie": "", "CacheDurationMinutes": 60, "RateLimitDelayMs": 100, diff --git a/docker-compose.yml b/docker-compose.yml index f4ed98c..6b28456 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,8 +107,6 @@ services: # ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) ===== - SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false} - - SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-} - - SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-} - SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-} - SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-} - SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}