From 375e1894f3ddf309476194f368e9a8b967b0a5b7 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 3 Feb 2026 14:06:40 -0500 Subject: [PATCH] Add Spotify direct API integration for lyrics, ISRC matching, and playlist ordering Features: - SpotifyApiClient: Direct Spotify API client using sp_dc session cookie - SpotifyLyricsService: Fetch synced lyrics from Spotify's color-lyrics API - SpotifyPlaylistFetcher: Get playlists with correct track ordering and ISRC codes - SpotifyTrackMatchingService: ISRC-based exact track matching for external providers Improvements: - Lyrics endpoint now prioritizes: 1) Jellyfin embedded, 2) Spotify synced, 3) LRCLIB - Fixed playback progress reporting - removed incorrect body wrapping for Jellyfin API - Added SpotifyApiSettings configuration model Security: - Session cookie and client ID properly masked in startup logs - All credentials read from environment variables only --- .env.example | 37 ++ allstarr/Controllers/JellyfinController.cs | 600 +++++++++++++----- .../Models/Settings/SpotifyApiSettings.cs | 66 ++ .../Models/Spotify/SpotifyPlaylistTrack.cs | 231 +++++++ allstarr/Program.cs | 62 +- .../Services/Jellyfin/JellyfinProxyService.cs | 30 +- .../Services/Lyrics/SpotifyLyricsService.cs | 474 ++++++++++++++ allstarr/Services/Spotify/SpotifyApiClient.cs | 538 ++++++++++++++++ .../Spotify/SpotifyPlaylistFetcher.cs | 317 +++++++++ .../Spotify/SpotifyTrackMatchingService.cs | 249 +++++++- allstarr/appsettings.json | 9 + 11 files changed, 2410 insertions(+), 203 deletions(-) create mode 100644 allstarr/Models/Settings/SpotifyApiSettings.cs create mode 100644 allstarr/Models/Spotify/SpotifyPlaylistTrack.cs create mode 100644 allstarr/Services/Lyrics/SpotifyLyricsService.cs create mode 100644 allstarr/Services/Spotify/SpotifyApiClient.cs create mode 100644 allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs diff --git a/.env.example b/.env.example index 4730c3c..8f9b826 100644 --- a/.env.example +++ b/.env.example @@ -149,3 +149,40 @@ SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS= # Format: [["PlaylistName","JellyfinPlaylistId","first|last"],...] # Note: This format may not work in .env files due to Docker Compose limitations # SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","4383a46d8bcac3be2ef9385053ea18df","first"],["Release Radar","ba50e26c867ec9d57ab2f7bf24cfd6b0","last"]] + +# ===== SPOTIFY DIRECT API (RECOMMENDED - ENABLES TRACK ORDERING & LYRICS) ===== +# This is the preferred method for Spotify playlist integration. +# Provides: Correct track ordering, ISRC-based exact matching, synchronized lyrics +# Does NOT require the Jellyfin Spotify Import plugin (can work standalone) + +# 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. +# +# To get your sp_dc cookie: +# 1. Open https://open.spotify.com in your browser and log in +# 2. Open DevTools (F12) → Application → Cookies → https://open.spotify.com +# 3. Find the cookie named "sp_dc" and copy its value +# 4. Note: This cookie expires periodically (typically every few months) +SPOTIFY_API_SESSION_COOKIE= + +# Cache duration for playlist data in minutes (default: 60) +# Release Radar updates weekly, Discover Weekly updates Mondays +SPOTIFY_API_CACHE_DURATION_MINUTES=60 + +# Rate limit delay between API requests in milliseconds (default: 100) +SPOTIFY_API_RATE_LIMIT_DELAY_MS=100 + +# Prefer ISRC matching over fuzzy title/artist matching (default: true) +# ISRC provides exact track identification across different streaming services +SPOTIFY_API_PREFER_ISRC_MATCHING=true diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 2cd5a2e..082183a 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -2,14 +2,17 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using System.Text.Json; using allstarr.Models.Domain; +using allstarr.Models.Lyrics; using allstarr.Models.Settings; using allstarr.Models.Subsonic; +using allstarr.Models.Spotify; using allstarr.Services; using allstarr.Services.Common; using allstarr.Services.Local; using allstarr.Services.Jellyfin; using allstarr.Services.Subsonic; using allstarr.Services.Lyrics; +using allstarr.Services.Spotify; using allstarr.Filters; namespace allstarr.Controllers; @@ -24,6 +27,7 @@ public class JellyfinController : ControllerBase { private readonly JellyfinSettings _settings; private readonly SpotifyImportSettings _spotifySettings; + private readonly SpotifyApiSettings _spotifyApiSettings; private readonly IMusicMetadataService _metadataService; private readonly ILocalLibraryService _localLibraryService; private readonly IDownloadService _downloadService; @@ -32,12 +36,15 @@ public class JellyfinController : ControllerBase private readonly JellyfinProxyService _proxyService; private readonly JellyfinSessionManager _sessionManager; private readonly PlaylistSyncService? _playlistSyncService; + private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher; + private readonly SpotifyLyricsService? _spotifyLyricsService; private readonly RedisCacheService _cache; private readonly ILogger _logger; public JellyfinController( IOptions settings, IOptions spotifySettings, + IOptions spotifyApiSettings, IMusicMetadataService metadataService, ILocalLibraryService localLibraryService, IDownloadService downloadService, @@ -47,10 +54,13 @@ public class JellyfinController : ControllerBase JellyfinSessionManager sessionManager, RedisCacheService cache, ILogger logger, - PlaylistSyncService? playlistSyncService = null) + PlaylistSyncService? playlistSyncService = null, + SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null, + SpotifyLyricsService? spotifyLyricsService = null) { _settings = settings.Value; _spotifySettings = spotifySettings.Value; + _spotifyApiSettings = spotifyApiSettings.Value; _metadataService = metadataService; _localLibraryService = localLibraryService; _downloadService = downloadService; @@ -59,6 +69,8 @@ public class JellyfinController : ControllerBase _proxyService = proxyService; _sessionManager = sessionManager; _playlistSyncService = playlistSyncService; + _spotifyPlaylistFetcher = spotifyPlaylistFetcher; + _spotifyLyricsService = spotifyLyricsService; _cache = cache; _logger = logger; @@ -988,6 +1000,7 @@ public class JellyfinController : ControllerBase /// /// Gets lyrics for an item. + /// Priority: 1. Jellyfin embedded lyrics, 2. Spotify synced lyrics, 3. LRCLIB /// [HttpGet("Audio/{itemId}/Lyrics")] [HttpGet("Items/{itemId}/Lyrics")] @@ -1010,19 +1023,21 @@ public class JellyfinController : ControllerBase if (jellyfinLyrics != null && statusCode == 200) { - _logger.LogInformation("✓ Found embedded lyrics in Jellyfin for track {ItemId}", itemId); + _logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId); return new JsonResult(JsonSerializer.Deserialize(jellyfinLyrics.RootElement.GetRawText())); } - _logger.LogInformation("No embedded lyrics found in Jellyfin, falling back to LRCLIB search"); + _logger.LogInformation("No embedded lyrics found in Jellyfin, trying Spotify/LRCLIB"); } - // For external tracks or when Jellyfin doesn't have lyrics, search LRCLIB + // Get song metadata for lyrics search Song? song = null; + string? spotifyTrackId = null; if (isExternal) { song = await _metadataService.GetSongAsync(provider!, externalId!); + // For Deezer tracks, we'll search Spotify by metadata } else { @@ -1038,6 +1053,15 @@ public class JellyfinController : ControllerBase Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "", Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0 }; + + // Check for Spotify ID in provider IDs + if (item.RootElement.TryGetProperty("ProviderIds", out var providerIds)) + { + if (providerIds.TryGetProperty("Spotify", out var spotifyId)) + { + spotifyTrackId = spotifyId.GetString(); + } + } } } @@ -1046,21 +1070,54 @@ public class JellyfinController : ControllerBase return NotFound(new { error = "Song not found" }); } - // Try to get lyrics from LRCLIB - _logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}", - song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist, - song.Title); - var lyricsService = HttpContext.RequestServices.GetService(); - if (lyricsService == null) + LyricsInfo? lyrics = null; + + // Try Spotify lyrics first (better synced lyrics quality) + if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled) { - return NotFound(new { error = "Lyrics service not available" }); + _logger.LogInformation("Trying Spotify lyrics for: {Artist} - {Title}", song.Artist, song.Title); + + SpotifyLyricsResult? spotifyLyrics = null; + + // If we have a Spotify track ID, use it directly + if (!string.IsNullOrEmpty(spotifyTrackId)) + { + spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId); + } + else + { + // Search by metadata + spotifyLyrics = await _spotifyLyricsService.SearchAndGetLyricsAsync( + song.Title, + song.Artists.Count > 0 ? song.Artists[0] : song.Artist ?? "", + song.Album, + song.Duration.HasValue ? song.Duration.Value * 1000 : null); + } + + if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0) + { + _logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})", + song.Artist, song.Title, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType); + lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics); + } + } + + // Fall back to LRCLIB if no Spotify lyrics + if (lyrics == null) + { + _logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}", + song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist, + song.Title); + var lrclibService = HttpContext.RequestServices.GetService(); + if (lrclibService != null) + { + lyrics = await lrclibService.GetLyricsAsync( + song.Title, + song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist ?? "" }, + song.Album ?? "", + song.Duration ?? 0); + } } - - var lyrics = await lyricsService.GetLyricsAsync( - song.Title, - song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist ?? "" }, - song.Album ?? "", - song.Duration ?? 0); if (lyrics == null) { @@ -2725,165 +2782,24 @@ public class JellyfinController : ControllerBase /// /// Gets tracks for a Spotify playlist by matching missing tracks against external providers /// and merging with existing local tracks from Jellyfin. + /// + /// Supports two modes: + /// 1. Direct Spotify API (new): Uses SpotifyPlaylistFetcher for ordered tracks with ISRC matching + /// 2. Jellyfin Plugin (legacy): Uses MissingTrack data from Jellyfin Spotify Import plugin /// private async Task GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId) { try { - var cacheKey = $"spotify:matched:{spotifyPlaylistName}"; - var cachedTracks = await _cache.GetAsync>(cacheKey); - - if (cachedTracks != null) + // Try ordered cache first (from direct Spotify API mode) + if (_spotifyApiSettings.Enabled && _spotifyPlaylistFetcher != null) { - _logger.LogDebug("Returning {Count} cached matched tracks for {Playlist}", - cachedTracks.Count, spotifyPlaylistName); - return _responseBuilder.CreateItemsResponse(cachedTracks); - } - - // Get existing Jellyfin playlist items (tracks the plugin already found) - var (existingTracksResponse, _) = await _proxyService.GetJsonAsync( - $"Playlists/{playlistId}/Items", - null, - Request.Headers); - - var existingTracks = new List(); - var existingSpotifyIds = new HashSet(); - - if (existingTracksResponse != null && - existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) - { - foreach (var item in items.EnumerateArray()) - { - var song = _modelMapper.ParseSong(item); - existingTracks.Add(song); - - // Track Spotify IDs to avoid duplicates - if (item.TryGetProperty("ProviderIds", out var providerIds) && - providerIds.TryGetProperty("Spotify", out var spotifyId)) - { - existingSpotifyIds.Add(spotifyId.GetString() ?? ""); - } - } - _logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count); - } - - var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}"; - var missingTracks = await _cache.GetAsync>(missingTracksKey); - - // Fallback to file cache if Redis is empty - if (missingTracks == null || missingTracks.Count == 0) - { - missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName); - - // If we loaded from file, restore to Redis with no expiration - if (missingTracks != null && missingTracks.Count > 0) - { - await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365)); - _logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)", - missingTracks.Count, spotifyPlaylistName); - } + var orderedResult = await GetSpotifyPlaylistTracksOrderedAsync(spotifyPlaylistName, playlistId); + if (orderedResult != null) return orderedResult; } - if (missingTracks == null || missingTracks.Count == 0) - { - _logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks", - spotifyPlaylistName, existingTracks.Count); - return _responseBuilder.CreateItemsResponse(existingTracks); - } - - _logger.LogInformation("Matching {Count} missing tracks for {Playlist}", - missingTracks.Count, spotifyPlaylistName); - - // Match missing tracks sequentially with rate limiting (excluding ones we already have locally) - var matchedBySpotifyId = new Dictionary(); - var tracksToMatch = missingTracks - .Where(track => !existingSpotifyIds.Contains(track.SpotifyId)) - .ToList(); - - foreach (var track in tracksToMatch) - { - try - { - // Search with just title and artist for better matching - var query = $"{track.Title} {track.PrimaryArtist}"; - var results = await _metadataService.SearchSongsAsync(query, limit: 5); - - if (results.Count > 0) - { - // Fuzzy match to find best result - // Check that ALL artists match (not just some) - var bestMatch = results - .Select(song => new - { - Song = song, - TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title), - // Calculate artist score by checking ALL artists match - ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors) - }) - .Select(x => new - { - x.Song, - x.TitleScore, - x.ArtistScore, - TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) // Weight title more - }) - .OrderByDescending(x => x.TotalScore) - .FirstOrDefault(); - - // Only add if match is good enough (>60% combined score) - if (bestMatch != null && bestMatch.TotalScore >= 60) - { - _logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})", - track.Title, track.PrimaryArtist, - bestMatch.Song.Title, bestMatch.Song.Artist, - bestMatch.TotalScore); - matchedBySpotifyId[track.SpotifyId] = bestMatch.Song; - } - else - { - _logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})", - track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0); - } - } - - // Rate limiting: small delay between searches to avoid overwhelming the service - await Task.Delay(100); // 100ms delay = max 10 searches/second - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}", - track.Title, track.PrimaryArtist); - } - } - - // Build final track list based on playlist configuration - // Local tracks position is configurable per-playlist - var playlistConfig = _spotifySettings.GetPlaylistById(playlistId); - var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First; - - var finalTracks = new List(); - if (localTracksPosition == LocalTracksPosition.First) - { - // Local tracks first, external tracks at the end - finalTracks.AddRange(existingTracks); - finalTracks.AddRange(matchedBySpotifyId.Values); - } - else - { - // External tracks first, local tracks at the end - finalTracks.AddRange(matchedBySpotifyId.Values); - finalTracks.AddRange(existingTracks); - } - - await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1)); - - _logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})", - finalTracks.Count, - existingTracks.Count, - matchedBySpotifyId.Count, - localTracksPosition); - - return _responseBuilder.CreateItemsResponse(finalTracks); + // Fall back to legacy unordered mode + return await GetSpotifyPlaylistTracksLegacyAsync(spotifyPlaylistName, playlistId); } catch (Exception ex) { @@ -2891,6 +2807,286 @@ public class JellyfinController : ControllerBase return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks"); } } + + /// + /// New mode: Gets playlist tracks with correct ordering using direct Spotify API data. + /// + private async Task GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId) + { + // Check for ordered matched tracks from SpotifyTrackMatchingService + var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}"; + var orderedTracks = await _cache.GetAsync>(orderedCacheKey); + + if (orderedTracks == null || orderedTracks.Count == 0) + { + _logger.LogDebug("No ordered matched tracks in cache for {Playlist}, checking if we can fetch", + spotifyPlaylistName); + return null; // Fall back to legacy mode + } + + _logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}", + orderedTracks.Count, spotifyPlaylistName); + + // Get existing Jellyfin playlist items (tracks the Spotify Import plugin already found) + var (existingTracksResponse, _) = await _proxyService.GetJsonAsync( + $"Playlists/{playlistId}/Items", + null, + Request.Headers); + + var existingTracks = new List(); + var existingSpotifyIds = new HashSet(); + var existingPositions = new Dictionary(); // SpotifyId -> position from Jellyfin + + if (existingTracksResponse != null && + existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) + { + var position = 0; + foreach (var item in items.EnumerateArray()) + { + var song = _modelMapper.ParseSong(item); + existingTracks.Add(song); + + // Track Spotify IDs and their positions + if (item.TryGetProperty("ProviderIds", out var providerIds) && + providerIds.TryGetProperty("Spotify", out var spotifyId)) + { + var id = spotifyId.GetString() ?? ""; + existingSpotifyIds.Add(id); + existingPositions[id] = position; + } + position++; + } + _logger.LogInformation("Found {Count} existing local tracks in Jellyfin playlist", existingTracks.Count); + } + + // Get the full playlist from Spotify to know the correct order + var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName); + if (spotifyTracks.Count == 0) + { + _logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName); + return null; // Fall back to legacy + } + + // Build the final track list in correct Spotify order + var finalTracks = new List(); + var localUsed = new HashSet(); // Track which local tracks we've placed + + foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) + { + // Check if this track exists locally + if (existingSpotifyIds.Contains(spotifyTrack.SpotifyId)) + { + // Use the local version + if (existingPositions.TryGetValue(spotifyTrack.SpotifyId, out var localIndex) && + localIndex < existingTracks.Count) + { + finalTracks.Add(existingTracks[localIndex]); + localUsed.Add(localIndex); + continue; + } + } + + // Check if we have a matched external track + var matched = orderedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId); + if (matched != null) + { + finalTracks.Add(matched.MatchedSong); + } + // If no match, the track is simply omitted (not available from any source) + } + + // Cache the result + var cacheKey = $"spotify:matched:{spotifyPlaylistName}"; + await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1)); + await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks); + + _logger.LogInformation( + "Final ordered playlist: {Total} tracks ({Local} local + {External} external) for {Playlist}", + finalTracks.Count, + localUsed.Count, + finalTracks.Count - localUsed.Count, + spotifyPlaylistName); + + return _responseBuilder.CreateItemsResponse(finalTracks); + } + + /// + /// Legacy mode: Gets playlist tracks without ordering (from Jellyfin Spotify Import plugin). + /// + private async Task GetSpotifyPlaylistTracksLegacyAsync(string spotifyPlaylistName, string playlistId) + { + var cacheKey = $"spotify:matched:{spotifyPlaylistName}"; + var cachedTracks = await _cache.GetAsync>(cacheKey); + + if (cachedTracks != null && cachedTracks.Count > 0) + { + _logger.LogDebug("Returning {Count} cached matched tracks from Redis for {Playlist}", + cachedTracks.Count, spotifyPlaylistName); + return _responseBuilder.CreateItemsResponse(cachedTracks); + } + + // Try file cache if Redis is empty + if (cachedTracks == null || cachedTracks.Count == 0) + { + cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName); + if (cachedTracks != null && cachedTracks.Count > 0) + { + // Restore to Redis with 1 hour TTL + await _cache.SetAsync(cacheKey, cachedTracks, TimeSpan.FromHours(1)); + _logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}", + cachedTracks.Count, spotifyPlaylistName); + return _responseBuilder.CreateItemsResponse(cachedTracks); + } + } + + // Get existing Jellyfin playlist items (tracks the plugin already found) + var (existingTracksResponse, _) = await _proxyService.GetJsonAsync( + $"Playlists/{playlistId}/Items", + null, + Request.Headers); + + var existingTracks = new List(); + var existingSpotifyIds = new HashSet(); + + if (existingTracksResponse != null && + existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + var song = _modelMapper.ParseSong(item); + existingTracks.Add(song); + + // Track Spotify IDs to avoid duplicates + if (item.TryGetProperty("ProviderIds", out var providerIds) && + providerIds.TryGetProperty("Spotify", out var spotifyId)) + { + existingSpotifyIds.Add(spotifyId.GetString() ?? ""); + } + } + _logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count); + } + + var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}"; + var missingTracks = await _cache.GetAsync>(missingTracksKey); + + // Fallback to file cache if Redis is empty + if (missingTracks == null || missingTracks.Count == 0) + { + missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName); + + // If we loaded from file, restore to Redis with no expiration + if (missingTracks != null && missingTracks.Count > 0) + { + await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365)); + _logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)", + missingTracks.Count, spotifyPlaylistName); + } + } + + if (missingTracks == null || missingTracks.Count == 0) + { + _logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks", + spotifyPlaylistName, existingTracks.Count); + return _responseBuilder.CreateItemsResponse(existingTracks); + } + + _logger.LogInformation("Matching {Count} missing tracks for {Playlist}", + missingTracks.Count, spotifyPlaylistName); + + // Match missing tracks sequentially with rate limiting (excluding ones we already have locally) + var matchedBySpotifyId = new Dictionary(); + var tracksToMatch = missingTracks + .Where(track => !existingSpotifyIds.Contains(track.SpotifyId)) + .ToList(); + + foreach (var track in tracksToMatch) + { + try + { + // Search with just title and artist for better matching + var query = $"{track.Title} {track.PrimaryArtist}"; + var results = await _metadataService.SearchSongsAsync(query, limit: 5); + + if (results.Count > 0) + { + // Fuzzy match to find best result + // Check that ALL artists match (not just some) + var bestMatch = results + .Select(song => new + { + Song = song, + TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title), + // Calculate artist score by checking ALL artists match + ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors) + }) + .Select(x => new + { + x.Song, + x.TitleScore, + x.ArtistScore, + TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) // Weight title more + }) + .OrderByDescending(x => x.TotalScore) + .FirstOrDefault(); + + // Only add if match is good enough (>60% combined score) + if (bestMatch != null && bestMatch.TotalScore >= 60) + { + _logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})", + track.Title, track.PrimaryArtist, + bestMatch.Song.Title, bestMatch.Song.Artist, + bestMatch.TotalScore); + matchedBySpotifyId[track.SpotifyId] = bestMatch.Song; + } + else + { + _logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})", + track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0); + } + } + + // Rate limiting: small delay between searches to avoid overwhelming the service + await Task.Delay(100); // 100ms delay = max 10 searches/second + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}", + track.Title, track.PrimaryArtist); + } + } + + // Build final track list based on playlist configuration + // Local tracks position is configurable per-playlist + var playlistConfig = _spotifySettings.GetPlaylistById(playlistId); + var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First; + + var finalTracks = new List(); + if (localTracksPosition == LocalTracksPosition.First) + { + // Local tracks first, external tracks at the end + finalTracks.AddRange(existingTracks); + finalTracks.AddRange(matchedBySpotifyId.Values); + } + else + { + // External tracks first, local tracks at the end + finalTracks.AddRange(matchedBySpotifyId.Values); + finalTracks.AddRange(existingTracks); + } + + await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1)); + + // Also save to file cache for persistence across restarts + await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks); + + _logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})", + finalTracks.Count, + existingTracks.Count, + matchedBySpotifyId.Count, + localTracksPosition); + + return _responseBuilder.CreateItemsResponse(finalTracks); + } /// /// Copies an external track to the kept folder when favorited. @@ -2994,6 +3190,74 @@ public class JellyfinController : ControllerBase } } + /// + /// Loads matched/combined tracks from file cache as fallback when Redis is empty. + /// + private async Task?> LoadMatchedTracksFromFile(string playlistName) + { + try + { + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); + var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_matched.json"); + + if (!System.IO.File.Exists(filePath)) + { + _logger.LogDebug("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath); + return null; + } + + var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath); + + // Check if cache is too old (more than 24 hours) + if (fileAge.TotalHours > 24) + { + _logger.LogInformation("Matched tracks file cache for {Playlist} is too old ({Age:F1}h), will rebuild", + playlistName, fileAge.TotalHours); + return null; + } + + _logger.LogDebug("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours); + + var json = await System.IO.File.ReadAllTextAsync(filePath); + var tracks = JsonSerializer.Deserialize>(json); + + _logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist} (age: {Age:F1}h)", + tracks?.Count ?? 0, playlistName, fileAge.TotalHours); + + return tracks; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load matched tracks from file for {Playlist}", playlistName); + return null; + } + } + + /// + /// Saves matched/combined tracks to file cache for persistence across restarts. + /// + private async Task SaveMatchedTracksToFile(string playlistName, List tracks) + { + try + { + var cacheDir = "/app/cache/spotify"; + Directory.CreateDirectory(cacheDir); + + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); + var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json"); + + var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true }); + await System.IO.File.WriteAllTextAsync(filePath, json); + + _logger.LogInformation("Saved {Count} matched tracks to file cache for {Playlist}", + tracks.Count, playlistName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName); + } + } + /// /// Manual trigger endpoint to force fetch Spotify missing tracks. /// GET /spotify/sync?api_key=YOUR_KEY diff --git a/allstarr/Models/Settings/SpotifyApiSettings.cs b/allstarr/Models/Settings/SpotifyApiSettings.cs new file mode 100644 index 0000000..6e99d66 --- /dev/null +++ b/allstarr/Models/Settings/SpotifyApiSettings.cs @@ -0,0 +1,66 @@ +namespace allstarr.Models.Settings; + +/// +/// Configuration for direct Spotify API access. +/// This enables fetching playlist data directly from Spotify rather than relying on the Jellyfin plugin. +/// +/// Benefits over Jellyfin plugin approach: +/// - Track ordering is preserved (critical for playlists like Release Radar) +/// - ISRC codes available for exact matching +/// - Real-time data without waiting for plugin sync +/// - Full track metadata (duration, release date, etc.) +/// +public class SpotifyApiSettings +{ + /// + /// Enable direct Spotify API integration. + /// When enabled, playlists will be fetched directly from Spotify instead of the Jellyfin plugin. + /// + 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. + /// These playlists are not available via the official API. + /// + /// To get this cookie: + /// 1. Log into open.spotify.com in your browser + /// 2. Open DevTools (F12) > Application > Cookies > https://open.spotify.com + /// 3. Copy the value of the "sp_dc" cookie + /// + /// Note: This cookie expires periodically and will need to be refreshed. + /// + public string SessionCookie { get; set; } = string.Empty; + + /// + /// Cache duration in minutes for playlist data. + /// Playlists like Release Radar only update weekly, so caching is beneficial. + /// Default: 60 minutes + /// + public int CacheDurationMinutes { get; set; } = 60; + + /// + /// Rate limit delay between Spotify API requests in milliseconds. + /// Default: 100ms (Spotify allows ~100 requests per minute) + /// + public int RateLimitDelayMs { get; set; } = 100; + + /// + /// Whether to prefer ISRC matching over fuzzy title/artist matching when ISRC is available. + /// ISRC provides exact track identification across services. + /// Default: true + /// + public bool PreferIsrcMatching { get; set; } = true; +} diff --git a/allstarr/Models/Spotify/SpotifyPlaylistTrack.cs b/allstarr/Models/Spotify/SpotifyPlaylistTrack.cs new file mode 100644 index 0000000..ad82c33 --- /dev/null +++ b/allstarr/Models/Spotify/SpotifyPlaylistTrack.cs @@ -0,0 +1,231 @@ +using allstarr.Models.Domain; + +namespace allstarr.Models.Spotify; + +/// +/// Represents a track from a Spotify playlist with full metadata including position. +/// This model preserves track ordering which is critical for playlists like Release Radar. +/// +public class SpotifyPlaylistTrack +{ + /// + /// Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL") + /// + public string SpotifyId { get; set; } = string.Empty; + + /// + /// Track's position in the playlist (0-based index). + /// This is critical for maintaining correct playlist order. + /// + public int Position { get; set; } + + /// + /// Track title + /// + public string Title { get; set; } = string.Empty; + + /// + /// Album name + /// + public string Album { get; set; } = string.Empty; + + /// + /// Album Spotify ID + /// + public string AlbumId { get; set; } = string.Empty; + + /// + /// List of artist names + /// + public List Artists { get; set; } = new(); + + /// + /// List of artist Spotify IDs + /// + public List ArtistIds { get; set; } = new(); + + /// + /// ISRC (International Standard Recording Code) for exact track identification. + /// This enables precise matching across different streaming services. + /// + public string? Isrc { get; set; } + + /// + /// Track duration in milliseconds + /// + public int DurationMs { get; set; } + + /// + /// Whether the track contains explicit content + /// + public bool Explicit { get; set; } + + /// + /// Track's popularity score (0-100) + /// + public int Popularity { get; set; } + + /// + /// Preview URL for 30-second audio clip (may be null) + /// + public string? PreviewUrl { get; set; } + + /// + /// Album artwork URL (largest available) + /// + public string? AlbumArtUrl { get; set; } + + /// + /// Release date of the album (format varies: YYYY, YYYY-MM, or YYYY-MM-DD) + /// + public string? ReleaseDate { get; set; } + + /// + /// When this track was added to the playlist + /// + public DateTime? AddedAt { get; set; } + + /// + /// Disc number within the album + /// + public int DiscNumber { get; set; } = 1; + + /// + /// Track number within the disc + /// + public int TrackNumber { get; set; } = 1; + + /// + /// Primary (first) artist name + /// + public string PrimaryArtist => Artists.FirstOrDefault() ?? string.Empty; + + /// + /// All artists as a comma-separated string + /// + public string AllArtists => string.Join(", ", Artists); + + /// + /// Track duration as TimeSpan + /// + public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMs); + + /// + /// Converts to the legacy MissingTrack format for compatibility with existing matching logic. + /// + public MissingTrack ToMissingTrack() => new() + { + SpotifyId = SpotifyId, + Title = Title, + Album = Album, + Artists = Artists + }; +} + +/// +/// Represents a Spotify playlist with its tracks in order. +/// +public class SpotifyPlaylist +{ + /// + /// Spotify playlist ID + /// + public string SpotifyId { get; set; } = string.Empty; + + /// + /// Playlist name + /// + public string Name { get; set; } = string.Empty; + + /// + /// Playlist description + /// + public string? Description { get; set; } + + /// + /// Playlist owner's display name + /// + public string? OwnerName { get; set; } + + /// + /// Playlist owner's Spotify ID + /// + public string? OwnerId { get; set; } + + /// + /// Total number of tracks in the playlist + /// + public int TotalTracks { get; set; } + + /// + /// Playlist cover image URL + /// + public string? ImageUrl { get; set; } + + /// + /// Whether this is a collaborative playlist + /// + public bool Collaborative { get; set; } + + /// + /// Whether this playlist is public + /// + public bool Public { get; set; } + + /// + /// Tracks in the playlist, ordered by position + /// + public List Tracks { get; set; } = new(); + + /// + /// When this data was fetched from Spotify + /// + public DateTime FetchedAt { get; set; } = DateTime.UtcNow; + + /// + /// Snapshot ID for change detection (Spotify's playlist version identifier) + /// + public string? SnapshotId { get; set; } +} + +/// +/// Represents a Spotify track that has been matched to an external provider track. +/// Preserves position for correct playlist ordering. +/// +public class MatchedTrack +{ + /// + /// Position in the original Spotify playlist (0-based) + /// + public int Position { get; set; } + + /// + /// Original Spotify track ID + /// + public string SpotifyId { get; set; } = string.Empty; + + /// + /// Original Spotify track title (for debugging/logging) + /// + public string SpotifyTitle { get; set; } = string.Empty; + + /// + /// Original Spotify artist (for debugging/logging) + /// + public string SpotifyArtist { get; set; } = string.Empty; + + /// + /// ISRC used for matching (if available) + /// + public string? Isrc { get; set; } + + /// + /// How the match was made: "isrc" or "fuzzy" + /// + public string MatchType { get; set; } = string.Empty; + + /// + /// The matched song from the external provider + /// + public Song MatchedSong { get; set; } = null!; +} diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 49acc36..2346f91 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -463,7 +463,67 @@ builder.Services.AddHostedService(); // Register cache cleanup service (only runs when StorageMode is Cache) builder.Services.AddHostedService(); -// Register Spotify missing tracks fetcher (only runs when SpotifyImport is enabled) +// Register Spotify API client, lyrics service, and settings for direct API access +// Configure from environment variables with SPOTIFY_API_ prefix +builder.Services.Configure(options => +{ + builder.Configuration.GetSection("SpotifyApi").Bind(options); + + // Override from environment variables + var enabled = builder.Configuration.GetValue("SpotifyApi:Enabled"); + if (!string.IsNullOrEmpty(enabled)) + { + 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)) + { + options.SessionCookie = sessionCookie; + } + + var cacheDuration = builder.Configuration.GetValue("SpotifyApi:CacheDurationMinutes"); + if (cacheDuration.HasValue) + { + options.CacheDurationMinutes = cacheDuration.Value; + } + + var preferIsrc = builder.Configuration.GetValue("SpotifyApi:PreferIsrcMatching"); + if (!string.IsNullOrEmpty(preferIsrc)) + { + options.PreferIsrcMatching = preferIsrc.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + // 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($" CacheDurationMinutes: {options.CacheDurationMinutes}"); + Console.WriteLine($" PreferIsrcMatching: {options.PreferIsrcMatching}"); +}); +builder.Services.AddSingleton(); + +// Register Spotify lyrics service (uses Spotify's color-lyrics API) +builder.Services.AddSingleton(); + +// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled) +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +// Register Spotify missing tracks fetcher (legacy - only runs when SpotifyImport is enabled and SpotifyApi is disabled) builder.Services.AddHostedService(); // Register Spotify track matching service (pre-matches tracks with rate limiting) diff --git a/allstarr/Services/Jellyfin/JellyfinProxyService.cs b/allstarr/Services/Jellyfin/JellyfinProxyService.cs index 1ec9749..8547d95 100644 --- a/allstarr/Services/Jellyfin/JellyfinProxyService.cs +++ b/allstarr/Services/Jellyfin/JellyfinProxyService.cs @@ -284,33 +284,11 @@ public class JellyfinProxyService } } - // Handle special case for playback endpoints - Jellyfin expects wrapped body + // Handle special case for playback endpoints + // NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo + // DIRECTLY as the body, NOT wrapped in a field. Do NOT wrap the body. var bodyToSend = body; - if (!string.IsNullOrWhiteSpace(body)) - { - // Check if this is a playback progress endpoint - if (endpoint.Contains("Sessions/Playing/Progress", StringComparison.OrdinalIgnoreCase)) - { - // Wrap the body in playbackProgressInfo field - bodyToSend = $"{{\"playbackProgressInfo\":{body}}}"; - _logger.LogDebug("Wrapped body for playback progress endpoint"); - } - else if (endpoint.Contains("Sessions/Playing/Stopped", StringComparison.OrdinalIgnoreCase)) - { - // Wrap the body in playbackStopInfo field - bodyToSend = $"{{\"playbackStopInfo\":{body}}}"; - _logger.LogDebug("Wrapped body for playback stopped endpoint"); - } - else if (endpoint.Contains("Sessions/Playing", StringComparison.OrdinalIgnoreCase) && - !endpoint.Contains("Progress", StringComparison.OrdinalIgnoreCase) && - !endpoint.Contains("Stopped", StringComparison.OrdinalIgnoreCase)) - { - // Wrap the body in playbackStartInfo field for /Sessions/Playing - bodyToSend = $"{{\"playbackStartInfo\":{body}}}"; - _logger.LogDebug("Wrapped body for playback start endpoint"); - } - } - else + if (string.IsNullOrWhiteSpace(body)) { bodyToSend = "{}"; _logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url); diff --git a/allstarr/Services/Lyrics/SpotifyLyricsService.cs b/allstarr/Services/Lyrics/SpotifyLyricsService.cs new file mode 100644 index 0000000..c356e0d --- /dev/null +++ b/allstarr/Services/Lyrics/SpotifyLyricsService.cs @@ -0,0 +1,474 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using allstarr.Models.Lyrics; +using allstarr.Models.Settings; +using allstarr.Services.Common; +using allstarr.Services.Spotify; +using Microsoft.Extensions.Options; + +namespace allstarr.Services.Lyrics; + +/// +/// Service for fetching synchronized lyrics from Spotify's internal color-lyrics API. +/// +/// Spotify's lyrics API provides: +/// - Line-by-line synchronized lyrics with precise timestamps +/// - Word-level timing for karaoke-style display (syllable sync) +/// - Background color suggestions based on album art +/// - Support for multiple languages and translations +/// +/// This requires the sp_dc session cookie for authentication. +/// +public class SpotifyLyricsService +{ + private readonly ILogger _logger; + private readonly SpotifyApiSettings _settings; + private readonly SpotifyApiClient _spotifyClient; + private readonly RedisCacheService _cache; + private readonly HttpClient _httpClient; + + private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track"; + + public SpotifyLyricsService( + ILogger logger, + IOptions settings, + SpotifyApiClient spotifyClient, + RedisCacheService cache, + IHttpClientFactory httpClientFactory) + { + _logger = logger; + _settings = settings.Value; + _spotifyClient = spotifyClient; + _cache = cache; + + _httpClient = httpClientFactory.CreateClient(); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0"); + _httpClient.DefaultRequestHeaders.Add("App-Platform", "WebPlayer"); + } + + /// + /// Gets synchronized lyrics for a Spotify track by its ID. + /// + /// Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL") + /// Lyrics info with synced lyrics in LRC format, or null if not available + public async Task GetLyricsByTrackIdAsync(string spotifyTrackId) + { + if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie)) + { + _logger.LogDebug("Spotify API not enabled or no session cookie configured"); + return null; + } + + // Normalize track ID (remove URI prefix if present) + spotifyTrackId = ExtractTrackId(spotifyTrackId); + + // Check cache + var cacheKey = $"spotify:lyrics:{spotifyTrackId}"; + var cached = await _cache.GetAsync(cacheKey); + if (cached != null) + { + _logger.LogDebug("Returning cached Spotify lyrics for track {TrackId}", spotifyTrackId); + return cached; + } + + try + { + // Get access token + var token = await _spotifyClient.GetWebAccessTokenAsync(); + if (string.IsNullOrEmpty(token)) + { + _logger.LogWarning("Could not get Spotify access token for lyrics"); + return null; + } + + // Request lyrics from Spotify's color-lyrics API + var url = $"{LyricsApiBase}/{spotifyTrackId}?format=json&vocalRemoval=false&market=from_token"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Add("Accept", "application/json"); + + var response = await _httpClient.SendAsync(request); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + _logger.LogDebug("No lyrics found on Spotify for track {TrackId}", spotifyTrackId); + return null; + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Spotify lyrics API returned {StatusCode} for track {TrackId}", + response.StatusCode, spotifyTrackId); + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + var result = ParseLyricsResponse(json, spotifyTrackId); + + if (result != null) + { + // Cache for 30 days (lyrics don't change) + await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30)); + _logger.LogInformation("Cached Spotify lyrics for track {TrackId} ({LineCount} lines)", + spotifyTrackId, result.Lines.Count); + } + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Spotify lyrics for track {TrackId}", spotifyTrackId); + return null; + } + } + + /// + /// Searches for a track on Spotify and returns its lyrics. + /// Useful when you have track metadata but not a Spotify ID. + /// + public async Task SearchAndGetLyricsAsync( + string trackName, + string artistName, + string? albumName = null, + int? durationMs = null) + { + if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie)) + { + return null; + } + + try + { + var token = await _spotifyClient.GetWebAccessTokenAsync(); + if (string.IsNullOrEmpty(token)) + { + return null; + } + + // Search for the track + var query = $"track:{trackName} artist:{artistName}"; + if (!string.IsNullOrEmpty(albumName)) + { + query += $" album:{albumName}"; + } + + var searchUrl = $"https://api.spotify.com/v1/search?q={Uri.EscapeDataString(query)}&type=track&limit=5"; + + var request = new HttpRequestMessage(HttpMethod.Get, searchUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (!root.TryGetProperty("tracks", out var tracks) || + !tracks.TryGetProperty("items", out var items) || + items.GetArrayLength() == 0) + { + return null; + } + + // Find best match considering duration if provided + string? bestMatchId = null; + var bestScore = 0; + + foreach (var item in items.EnumerateArray()) + { + var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + if (string.IsNullOrEmpty(id)) continue; + + var score = 100; // Base score + + // Check duration match + if (durationMs.HasValue && item.TryGetProperty("duration_ms", out var durProp)) + { + var trackDuration = durProp.GetInt32(); + var durationDiff = Math.Abs(trackDuration - durationMs.Value); + if (durationDiff < 2000) score += 50; // Within 2 seconds + else if (durationDiff < 5000) score += 25; // Within 5 seconds + } + + if (score > bestScore) + { + bestScore = score; + bestMatchId = id; + } + } + + if (!string.IsNullOrEmpty(bestMatchId)) + { + return await GetLyricsByTrackIdAsync(bestMatchId); + } + + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching Spotify for lyrics: {Track} - {Artist}", trackName, artistName); + return null; + } + } + + /// + /// Converts Spotify lyrics to LRCLIB-compatible LyricsInfo format. + /// + public LyricsInfo? ToLyricsInfo(SpotifyLyricsResult spotifyLyrics) + { + if (spotifyLyrics.Lines.Count == 0) + { + return null; + } + + // Build synced lyrics in LRC format + var lrcLines = new List(); + foreach (var line in spotifyLyrics.Lines) + { + var timestamp = TimeSpan.FromMilliseconds(line.StartTimeMs); + var mm = (int)timestamp.TotalMinutes; + var ss = timestamp.Seconds; + var ms = timestamp.Milliseconds / 10; // LRC uses centiseconds + + lrcLines.Add($"[{mm:D2}:{ss:D2}.{ms:D2}]{line.Words}"); + } + + return new LyricsInfo + { + TrackName = spotifyLyrics.TrackName ?? "", + ArtistName = spotifyLyrics.ArtistName ?? "", + AlbumName = spotifyLyrics.AlbumName ?? "", + Duration = (int)(spotifyLyrics.DurationMs / 1000), + Instrumental = spotifyLyrics.Lines.Count == 0, + SyncedLyrics = string.Join("\n", lrcLines), + PlainLyrics = string.Join("\n", spotifyLyrics.Lines.Select(l => l.Words)) + }; + } + + private SpotifyLyricsResult? ParseLyricsResponse(string json, string trackId) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var result = new SpotifyLyricsResult + { + SpotifyTrackId = trackId + }; + + // Parse lyrics lines + if (root.TryGetProperty("lyrics", out var lyrics)) + { + // Check sync type + if (lyrics.TryGetProperty("syncType", out var syncType)) + { + result.SyncType = syncType.GetString() ?? "LINE_SYNCED"; + } + + // Parse lines + if (lyrics.TryGetProperty("lines", out var lines)) + { + foreach (var line in lines.EnumerateArray()) + { + var lyricsLine = new SpotifyLyricsLine + { + StartTimeMs = line.TryGetProperty("startTimeMs", out var start) + ? long.Parse(start.GetString() ?? "0") : 0, + Words = line.TryGetProperty("words", out var words) + ? words.GetString() ?? "" : "", + EndTimeMs = line.TryGetProperty("endTimeMs", out var end) + ? long.Parse(end.GetString() ?? "0") : 0 + }; + + // Parse syllables if available (for word-level sync) + if (line.TryGetProperty("syllables", out var syllables)) + { + foreach (var syllable in syllables.EnumerateArray()) + { + lyricsLine.Syllables.Add(new SpotifyLyricsSyllable + { + StartTimeMs = syllable.TryGetProperty("startTimeMs", out var sStart) + ? long.Parse(sStart.GetString() ?? "0") : 0, + Text = syllable.TryGetProperty("charsIndex", out var text) + ? text.GetString() ?? "" : "" + }); + } + } + + result.Lines.Add(lyricsLine); + } + } + + // Parse color information + if (lyrics.TryGetProperty("colors", out var colors)) + { + result.Colors = new SpotifyLyricsColors + { + Background = colors.TryGetProperty("background", out var bg) + ? ParseColorValue(bg) : null, + Text = colors.TryGetProperty("text", out var txt) + ? ParseColorValue(txt) : null, + HighlightText = colors.TryGetProperty("highlightText", out var ht) + ? ParseColorValue(ht) : null + }; + } + + // Language + if (lyrics.TryGetProperty("language", out var lang)) + { + result.Language = lang.GetString(); + } + + // Provider info + if (lyrics.TryGetProperty("provider", out var provider)) + { + result.Provider = provider.GetString(); + } + + // Display info + if (lyrics.TryGetProperty("providerDisplayName", out var providerDisplay)) + { + result.ProviderDisplayName = providerDisplay.GetString(); + } + } + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error parsing Spotify lyrics response"); + return null; + } + } + + private static int? ParseColorValue(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Number) + { + return element.GetInt32(); + } + if (element.ValueKind == JsonValueKind.String) + { + var str = element.GetString(); + if (!string.IsNullOrEmpty(str) && int.TryParse(str, out var val)) + { + return val; + } + } + return null; + } + + private static string ExtractTrackId(string input) + { + if (string.IsNullOrEmpty(input)) return input; + + // Handle spotify:track:xxxxx format + if (input.StartsWith("spotify:track:")) + { + return input.Substring("spotify:track:".Length); + } + + // Handle https://open.spotify.com/track/xxxxx format + if (input.Contains("open.spotify.com/track/")) + { + var start = input.IndexOf("/track/") + "/track/".Length; + var end = input.IndexOf('?', start); + return end > 0 ? input.Substring(start, end - start) : input.Substring(start); + } + + return input; + } +} + +/// +/// Result from Spotify's color-lyrics API. +/// +public class SpotifyLyricsResult +{ + public string SpotifyTrackId { get; set; } = string.Empty; + public string? TrackName { get; set; } + public string? ArtistName { get; set; } + public string? AlbumName { get; set; } + public long DurationMs { get; set; } + + /// + /// Sync type: "LINE_SYNCED", "SYLLABLE_SYNCED", or "UNSYNCED" + /// + public string SyncType { get; set; } = "LINE_SYNCED"; + + /// + /// Language code (e.g., "en", "es", "ja") + /// + public string? Language { get; set; } + + /// + /// Lyrics provider (e.g., "MusixMatch", "Spotify") + /// + public string? Provider { get; set; } + + public string? ProviderDisplayName { get; set; } + + /// + /// Lyrics lines in order + /// + public List Lines { get; set; } = new(); + + /// + /// Color suggestions based on album art + /// + public SpotifyLyricsColors? Colors { get; set; } +} + +public class SpotifyLyricsLine +{ + /// + /// Start time in milliseconds + /// + public long StartTimeMs { get; set; } + + /// + /// End time in milliseconds + /// + public long EndTimeMs { get; set; } + + /// + /// The lyrics text for this line + /// + public string Words { get; set; } = string.Empty; + + /// + /// Syllable-level timing for karaoke display (if available) + /// + public List Syllables { get; set; } = new(); +} + +public class SpotifyLyricsSyllable +{ + public long StartTimeMs { get; set; } + public string Text { get; set; } = string.Empty; +} + +public class SpotifyLyricsColors +{ + /// + /// Suggested background color (ARGB integer) + /// + public int? Background { get; set; } + + /// + /// Suggested text color (ARGB integer) + /// + public int? Text { get; set; } + + /// + /// Suggested highlight/active text color (ARGB integer) + /// + public int? HighlightText { get; set; } +} diff --git a/allstarr/Services/Spotify/SpotifyApiClient.cs b/allstarr/Services/Spotify/SpotifyApiClient.cs new file mode 100644 index 0000000..c657cbc --- /dev/null +++ b/allstarr/Services/Spotify/SpotifyApiClient.cs @@ -0,0 +1,538 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using allstarr.Models.Settings; +using allstarr.Models.Spotify; +using Microsoft.Extensions.Options; + +namespace allstarr.Services.Spotify; + +/// +/// Client for accessing Spotify's APIs directly. +/// +/// Supports two modes: +/// 1. Official API - For public playlists and standard operations +/// 2. Web API (with session cookie) - For editorial/personalized playlists like Release Radar, Discover Weekly +/// +/// The session cookie (sp_dc) is required because Spotify's official API doesn't expose +/// algorithmically generated "Made For You" playlists. +/// +public class SpotifyApiClient : IDisposable +{ + private readonly ILogger _logger; + private readonly SpotifyApiSettings _settings; + private readonly HttpClient _httpClient; + private readonly HttpClient _webApiClient; + + // Spotify API endpoints + private const string OfficialApiBase = "https://api.spotify.com/v1"; + private const string WebApiBase = "https://api-partner.spotify.com/pathfinder/v1"; + private const string TokenEndpoint = "https://open.spotify.com/get_access_token"; + + // Web API access token (obtained via session cookie) + private string? _webAccessToken; + private DateTime _webTokenExpiry = DateTime.MinValue; + private readonly SemaphoreSlim _tokenLock = new(1, 1); + + public SpotifyApiClient( + ILogger logger, + IOptions settings) + { + _logger = logger; + _settings = settings.Value; + + // Client for official API + _httpClient = new HttpClient + { + BaseAddress = new Uri(OfficialApiBase), + Timeout = TimeSpan.FromSeconds(30) + }; + + // Client for web API (requires session cookie) + var handler = new HttpClientHandler + { + UseCookies = true, + CookieContainer = new CookieContainer() + }; + + if (!string.IsNullOrEmpty(_settings.SessionCookie)) + { + handler.CookieContainer.Add( + new Uri("https://open.spotify.com"), + new Cookie("sp_dc", _settings.SessionCookie)); + } + + _webApiClient = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(30) + }; + + // Common headers for web API + _webApiClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + _webApiClient.DefaultRequestHeaders.Add("Accept", "application/json"); + _webApiClient.DefaultRequestHeaders.Add("Accept-Language", "en"); + _webApiClient.DefaultRequestHeaders.Add("app-platform", "WebPlayer"); + _webApiClient.DefaultRequestHeaders.Add("spotify-app-version", "1.2.31.0"); + } + + /// + /// Gets an access token using the session cookie. + /// This token can be used for both the official API and web API. + /// + public async Task GetWebAccessTokenAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(_settings.SessionCookie)) + { + _logger.LogWarning("No Spotify session cookie configured"); + return null; + } + + await _tokenLock.WaitAsync(cancellationToken); + try + { + // Return cached token if still valid + if (!string.IsNullOrEmpty(_webAccessToken) && DateTime.UtcNow < _webTokenExpiry) + { + return _webAccessToken; + } + + _logger.LogDebug("Fetching new Spotify web access token"); + + var request = new HttpRequestMessage(HttpMethod.Get, TokenEndpoint); + request.Headers.Add("Cookie", $"sp_dc={_settings.SessionCookie}"); + + var response = await _webApiClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Failed to get Spotify access token: {StatusCode}", response.StatusCode); + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("accessToken", out var tokenElement)) + { + _webAccessToken = tokenElement.GetString(); + + // Token typically expires in 1 hour, but we'll refresh early + if (root.TryGetProperty("accessTokenExpirationTimestampMs", out var expiryElement)) + { + var expiryMs = expiryElement.GetInt64(); + _webTokenExpiry = DateTimeOffset.FromUnixTimeMilliseconds(expiryMs).UtcDateTime; + // Refresh 5 minutes early + _webTokenExpiry = _webTokenExpiry.AddMinutes(-5); + } + else + { + _webTokenExpiry = DateTime.UtcNow.AddMinutes(55); + } + + _logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}", _webTokenExpiry); + return _webAccessToken; + } + + _logger.LogError("No access token in Spotify response"); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting Spotify web access token"); + return null; + } + finally + { + _tokenLock.Release(); + } + } + + /// + /// Fetches a playlist with all its tracks from Spotify. + /// Uses the web API to access editorial playlists that aren't available via the official API. + /// + /// Spotify playlist ID or URI + /// Cancellation token + /// Playlist with tracks in correct order, or null if not found + public async Task GetPlaylistAsync(string playlistId, CancellationToken cancellationToken = default) + { + // Extract ID from URI if needed (spotify:playlist:xxxxx or https://open.spotify.com/playlist/xxxxx) + playlistId = ExtractPlaylistId(playlistId); + + var token = await GetWebAccessTokenAsync(cancellationToken); + if (string.IsNullOrEmpty(token)) + { + _logger.LogError("Cannot fetch playlist without access token"); + return null; + } + + try + { + // Use the official API with the web token for playlist data + var playlist = await FetchPlaylistMetadataAsync(playlistId, token, cancellationToken); + if (playlist == null) return null; + + // Fetch all tracks with pagination + var tracks = await FetchAllPlaylistTracksAsync(playlistId, token, cancellationToken); + playlist.Tracks = tracks; + playlist.TotalTracks = tracks.Count; + + _logger.LogInformation("Fetched playlist '{Name}' with {Count} tracks", playlist.Name, tracks.Count); + return playlist; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching playlist {PlaylistId}", playlistId); + return null; + } + } + + private async Task FetchPlaylistMetadataAsync( + string playlistId, + string token, + CancellationToken cancellationToken) + { + var url = $"{OfficialApiBase}/playlists/{playlistId}?fields=id,name,description,owner(display_name,id),images,collaborative,public,snapshot_id,tracks.total"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Failed to fetch playlist metadata: {StatusCode}", response.StatusCode); + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var playlist = new SpotifyPlaylist + { + SpotifyId = root.GetProperty("id").GetString() ?? playlistId, + Name = root.GetProperty("name").GetString() ?? "Unknown Playlist", + Description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null, + SnapshotId = root.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null, + Collaborative = root.TryGetProperty("collaborative", out var collab) && collab.GetBoolean(), + Public = root.TryGetProperty("public", out var pub) && pub.ValueKind != JsonValueKind.Null && pub.GetBoolean(), + FetchedAt = DateTime.UtcNow + }; + + if (root.TryGetProperty("owner", out var owner)) + { + playlist.OwnerName = owner.TryGetProperty("display_name", out var dn) ? dn.GetString() : null; + playlist.OwnerId = owner.TryGetProperty("id", out var oid) ? oid.GetString() : null; + } + + if (root.TryGetProperty("images", out var images) && images.GetArrayLength() > 0) + { + playlist.ImageUrl = images[0].GetProperty("url").GetString(); + } + + if (root.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("total", out var total)) + { + playlist.TotalTracks = total.GetInt32(); + } + + return playlist; + } + + private async Task> FetchAllPlaylistTracksAsync( + string playlistId, + string token, + CancellationToken cancellationToken) + { + var allTracks = new List(); + var offset = 0; + const int limit = 100; // Spotify's max + + while (true) + { + var tracks = await FetchPlaylistTracksPageAsync(playlistId, token, offset, limit, cancellationToken); + if (tracks == null || tracks.Count == 0) break; + + allTracks.AddRange(tracks); + + if (tracks.Count < limit) break; + + offset += limit; + + // Rate limiting + if (_settings.RateLimitDelayMs > 0) + { + await Task.Delay(_settings.RateLimitDelayMs, cancellationToken); + } + } + + return allTracks; + } + + private async Task?> FetchPlaylistTracksPageAsync( + string playlistId, + string token, + int offset, + int limit, + CancellationToken cancellationToken) + { + // Request fields needed for matching and ordering + var fields = "items(added_at,track(id,name,album(id,name,images,release_date),artists(id,name),duration_ms,explicit,popularity,preview_url,disc_number,track_number,external_ids))"; + var url = $"{OfficialApiBase}/playlists/{playlistId}/tracks?offset={offset}&limit={limit}&fields={fields}"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Failed to fetch playlist tracks: {StatusCode}", response.StatusCode); + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (!root.TryGetProperty("items", out var items)) + { + return new List(); + } + + var tracks = new List(); + var position = offset; + + foreach (var item in items.EnumerateArray()) + { + // Skip null tracks (can happen with deleted/unavailable tracks) + if (!item.TryGetProperty("track", out var trackElement) || + trackElement.ValueKind == JsonValueKind.Null) + { + position++; + continue; + } + + var track = ParseTrack(trackElement, position); + + // Parse added_at timestamp + if (item.TryGetProperty("added_at", out var addedAt) && + addedAt.ValueKind != JsonValueKind.Null) + { + var addedAtStr = addedAt.GetString(); + if (DateTime.TryParse(addedAtStr, out var addedAtDate)) + { + track.AddedAt = addedAtDate; + } + } + + tracks.Add(track); + position++; + } + + return tracks; + } + + private SpotifyPlaylistTrack ParseTrack(JsonElement track, int position) + { + var result = new SpotifyPlaylistTrack + { + Position = position, + SpotifyId = track.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "", + Title = track.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "", + DurationMs = track.TryGetProperty("duration_ms", out var dur) ? dur.GetInt32() : 0, + Explicit = track.TryGetProperty("explicit", out var exp) && exp.GetBoolean(), + Popularity = track.TryGetProperty("popularity", out var pop) ? pop.GetInt32() : 0, + PreviewUrl = track.TryGetProperty("preview_url", out var prev) && prev.ValueKind != JsonValueKind.Null + ? prev.GetString() : null, + DiscNumber = track.TryGetProperty("disc_number", out var disc) ? disc.GetInt32() : 1, + TrackNumber = track.TryGetProperty("track_number", out var tn) ? tn.GetInt32() : 1 + }; + + // Parse album + if (track.TryGetProperty("album", out var album)) + { + result.Album = album.TryGetProperty("name", out var albumName) + ? albumName.GetString() ?? "" : ""; + result.AlbumId = album.TryGetProperty("id", out var albumId) + ? albumId.GetString() ?? "" : ""; + result.ReleaseDate = album.TryGetProperty("release_date", out var rd) + ? rd.GetString() : null; + + if (album.TryGetProperty("images", out var images) && images.GetArrayLength() > 0) + { + result.AlbumArtUrl = images[0].GetProperty("url").GetString(); + } + } + + // Parse artists + if (track.TryGetProperty("artists", out var artists)) + { + foreach (var artist in artists.EnumerateArray()) + { + if (artist.TryGetProperty("name", out var artistName)) + { + result.Artists.Add(artistName.GetString() ?? ""); + } + if (artist.TryGetProperty("id", out var artistId)) + { + result.ArtistIds.Add(artistId.GetString() ?? ""); + } + } + } + + // Parse ISRC from external_ids + if (track.TryGetProperty("external_ids", out var externalIds) && + externalIds.TryGetProperty("isrc", out var isrc)) + { + result.Isrc = isrc.GetString(); + } + + return result; + } + + /// + /// Searches for a user's playlists by name. + /// Useful for finding playlists like "Release Radar" or "Discover Weekly" by their names. + /// + public async Task> SearchUserPlaylistsAsync( + string searchName, + CancellationToken cancellationToken = default) + { + var token = await GetWebAccessTokenAsync(cancellationToken); + if (string.IsNullOrEmpty(token)) + { + return new List(); + } + + try + { + var playlists = new List(); + var offset = 0; + const int limit = 50; + + while (true) + { + var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) break; + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) + break; + + foreach (var item in items.EnumerateArray()) + { + var name = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; + + // Check if name matches (case-insensitive) + if (name.Contains(searchName, StringComparison.OrdinalIgnoreCase)) + { + playlists.Add(new SpotifyPlaylist + { + SpotifyId = item.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "", + Name = name, + Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null, + TotalTracks = item.TryGetProperty("tracks", out var tracks) && + tracks.TryGetProperty("total", out var total) + ? total.GetInt32() : 0, + SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null + }); + } + } + + if (items.GetArrayLength() < limit) break; + offset += limit; + + if (_settings.RateLimitDelayMs > 0) + { + await Task.Delay(_settings.RateLimitDelayMs, cancellationToken); + } + } + + return playlists; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName); + return new List(); + } + } + + /// + /// Gets the current user's profile to verify authentication is working. + /// + public async Task<(bool Success, string? UserId, string? DisplayName)> GetCurrentUserAsync( + CancellationToken cancellationToken = default) + { + var token = await GetWebAccessTokenAsync(cancellationToken); + if (string.IsNullOrEmpty(token)) + { + return (false, null, null); + } + + try + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{OfficialApiBase}/me"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return (false, null, null); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var userId = root.TryGetProperty("id", out var id) ? id.GetString() : null; + var displayName = root.TryGetProperty("display_name", out var dn) ? dn.GetString() : null; + + return (true, userId, displayName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting current Spotify user"); + return (false, null, null); + } + } + + private static string ExtractPlaylistId(string input) + { + if (string.IsNullOrEmpty(input)) return input; + + // Handle spotify:playlist:xxxxx format + if (input.StartsWith("spotify:playlist:")) + { + return input.Substring("spotify:playlist:".Length); + } + + // Handle https://open.spotify.com/playlist/xxxxx format + if (input.Contains("open.spotify.com/playlist/")) + { + var start = input.IndexOf("/playlist/") + "/playlist/".Length; + var end = input.IndexOf('?', start); + return end > 0 ? input.Substring(start, end - start) : input.Substring(start); + } + + return input; + } + + public void Dispose() + { + _httpClient.Dispose(); + _webApiClient.Dispose(); + _tokenLock.Dispose(); + } +} diff --git a/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs b/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs new file mode 100644 index 0000000..1c64594 --- /dev/null +++ b/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs @@ -0,0 +1,317 @@ +using allstarr.Models.Settings; +using allstarr.Models.Spotify; +using allstarr.Services.Common; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace allstarr.Services.Spotify; + +/// +/// Background service that fetches playlist tracks directly from Spotify's API. +/// +/// This replaces the Jellyfin Spotify Import plugin dependency with key advantages: +/// - Track ordering is preserved (critical for playlists like Release Radar) +/// - ISRC codes available for exact matching +/// - Real-time data without waiting for plugin sync schedules +/// - Full track metadata (duration, release date, etc.) +/// +public class SpotifyPlaylistFetcher : BackgroundService +{ + private readonly ILogger _logger; + private readonly SpotifyApiSettings _spotifyApiSettings; + private readonly SpotifyImportSettings _spotifyImportSettings; + private readonly SpotifyApiClient _spotifyClient; + private readonly RedisCacheService _cache; + + private const string CacheDirectory = "/app/cache/spotify"; + private const string CacheKeyPrefix = "spotify:playlist:"; + + // Track Spotify playlist IDs after discovery + private readonly Dictionary _playlistNameToSpotifyId = new(); + + public SpotifyPlaylistFetcher( + ILogger logger, + IOptions spotifyApiSettings, + IOptions spotifyImportSettings, + SpotifyApiClient spotifyClient, + RedisCacheService cache) + { + _logger = logger; + _spotifyApiSettings = spotifyApiSettings.Value; + _spotifyImportSettings = spotifyImportSettings.Value; + _spotifyClient = spotifyClient; + _cache = cache; + } + + /// + /// Gets the Spotify playlist tracks in order, using cache if available. + /// + /// Playlist name (e.g., "Release Radar", "Discover Weekly") + /// List of tracks in playlist order, or empty list if not found + public async Task> GetPlaylistTracksAsync(string playlistName) + { + var cacheKey = $"{CacheKeyPrefix}{playlistName}"; + + // Try Redis cache first + var cached = await _cache.GetAsync(cacheKey); + if (cached != null && cached.Tracks.Count > 0) + { + var age = DateTime.UtcNow - cached.FetchedAt; + if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes) + { + _logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)", + playlistName, cached.Tracks.Count, age.TotalMinutes); + return cached.Tracks; + } + } + + // Try file cache + var filePath = GetCacheFilePath(playlistName); + if (File.Exists(filePath)) + { + try + { + var json = await File.ReadAllTextAsync(filePath); + var filePlaylist = JsonSerializer.Deserialize(json); + if (filePlaylist != null && filePlaylist.Tracks.Count > 0) + { + var age = DateTime.UtcNow - filePlaylist.FetchedAt; + if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes) + { + _logger.LogDebug("Using file-cached playlist '{Name}' ({Count} tracks)", + playlistName, filePlaylist.Tracks.Count); + return filePlaylist.Tracks; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read file cache for '{Name}'", playlistName); + } + } + + // Need to fetch fresh - try to use cached Spotify playlist ID + if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId)) + { + _logger.LogDebug("Spotify playlist ID not cached for '{Name}', searching...", playlistName); + var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName); + + var exactMatch = playlists.FirstOrDefault(p => + p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); + + if (exactMatch == null) + { + _logger.LogWarning("Could not find Spotify playlist named '{Name}'", playlistName); + + // Return file cache even if expired, as a fallback + if (File.Exists(filePath)) + { + var json = await File.ReadAllTextAsync(filePath); + var fallback = JsonSerializer.Deserialize(json); + if (fallback != null) + { + _logger.LogWarning("Using expired file cache as fallback for '{Name}'", playlistName); + return fallback.Tracks; + } + } + + return new List(); + } + + spotifyId = exactMatch.SpotifyId; + _playlistNameToSpotifyId[playlistName] = spotifyId; + _logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId); + } + + // Fetch the full playlist + var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId); + if (playlist == null || playlist.Tracks.Count == 0) + { + _logger.LogWarning("Failed to fetch playlist '{Name}' from Spotify", playlistName); + return cached?.Tracks ?? new List(); + } + + // Update cache + await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2)); + await SaveToFileCacheAsync(playlistName, playlist); + + _logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order", + playlistName, playlist.Tracks.Count); + + return playlist.Tracks; + } + + /// + /// Gets missing tracks for a playlist (tracks not found in Jellyfin library). + /// This provides compatibility with the existing SpotifyMissingTracksFetcher interface. + /// + /// Playlist name + /// Set of Spotify IDs that exist in Jellyfin library + /// List of missing tracks with position preserved + public async Task> GetMissingTracksAsync( + string playlistName, + HashSet jellyfinTrackIds) + { + var allTracks = await GetPlaylistTracksAsync(playlistName); + + // Filter to only tracks not in Jellyfin, preserving order + return allTracks + .Where(t => !jellyfinTrackIds.Contains(t.SpotifyId)) + .ToList(); + } + + /// + /// Manual trigger to refresh a specific playlist. + /// + public async Task RefreshPlaylistAsync(string playlistName) + { + _logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName); + + // Clear cache to force refresh + var cacheKey = $"{CacheKeyPrefix}{playlistName}"; + await _cache.DeleteAsync(cacheKey); + + // Re-fetch + await GetPlaylistTracksAsync(playlistName); + } + + /// + /// Manual trigger to refresh all configured playlists. + /// + public async Task TriggerFetchAsync() + { + _logger.LogInformation("Manual fetch triggered for all playlists"); + + foreach (var config in _spotifyImportSettings.Playlists) + { + await RefreshPlaylistAsync(config.Name); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("========================================"); + _logger.LogInformation("SpotifyPlaylistFetcher: Starting up..."); + + // Ensure cache directory exists + Directory.CreateDirectory(CacheDirectory); + + if (!_spotifyApiSettings.Enabled) + { + _logger.LogInformation("Spotify API integration is DISABLED"); + _logger.LogInformation("========================================"); + return; + } + + if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie)) + { + _logger.LogWarning("Spotify session cookie not configured - cannot access editorial playlists"); + _logger.LogInformation("========================================"); + return; + } + + // Verify authentication + var (success, userId, displayName) = await _spotifyClient.GetCurrentUserAsync(stoppingToken); + if (!success) + { + _logger.LogError("Failed to authenticate with Spotify - check session cookie"); + _logger.LogInformation("========================================"); + return; + } + + _logger.LogInformation("Spotify API ENABLED"); + _logger.LogInformation("Authenticated as: {DisplayName} ({UserId})", displayName, userId); + _logger.LogInformation("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes); + _logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled"); + _logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count); + + foreach (var playlist in _spotifyImportSettings.Playlists) + { + _logger.LogInformation(" - {Name}", playlist.Name); + } + + _logger.LogInformation("========================================"); + + // Initial fetch of all playlists + await FetchAllPlaylistsAsync(stoppingToken); + + // Periodic refresh loop + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken); + + try + { + await FetchAllPlaylistsAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during periodic playlist refresh"); + } + } + } + + private async Task FetchAllPlaylistsAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("=== FETCHING SPOTIFY PLAYLISTS ==="); + + foreach (var config in _spotifyImportSettings.Playlists) + { + if (cancellationToken.IsCancellationRequested) break; + + try + { + var tracks = await GetPlaylistTracksAsync(config.Name); + _logger.LogInformation(" {Name}: {Count} tracks", config.Name, tracks.Count); + + // Log sample of track order for debugging + if (tracks.Count > 0) + { + _logger.LogDebug(" First track: #{Position} {Title} - {Artist}", + tracks[0].Position, tracks[0].Title, tracks[0].PrimaryArtist); + + if (tracks.Count > 1) + { + var last = tracks[^1]; + _logger.LogDebug(" Last track: #{Position} {Title} - {Artist}", + last.Position, last.Title, last.PrimaryArtist); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name); + } + + // Rate limiting between playlists + await Task.Delay(_spotifyApiSettings.RateLimitDelayMs, cancellationToken); + } + + _logger.LogInformation("=== FINISHED FETCHING SPOTIFY PLAYLISTS ==="); + } + + private string GetCacheFilePath(string playlistName) + { + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); + return Path.Combine(CacheDirectory, $"{safeName}_spotify.json"); + } + + private async Task SaveToFileCacheAsync(string playlistName, SpotifyPlaylist playlist) + { + try + { + var filePath = GetCacheFilePath(playlistName); + var json = JsonSerializer.Serialize(playlist, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + await File.WriteAllTextAsync(filePath, json); + _logger.LogDebug("Saved playlist '{Name}' to file cache", playlistName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save file cache for '{Name}'", playlistName); + } + } +} diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index d122867..c638d1f 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -8,12 +8,18 @@ using System.Text.Json; namespace allstarr.Services.Spotify; /// -/// Background service that pre-matches Spotify missing tracks with external providers. -/// Runs after SpotifyMissingTracksFetcher completes to avoid rate limiting during playlist loading. +/// Background service that pre-matches Spotify tracks with external providers. +/// +/// Supports two modes: +/// 1. Legacy mode: Uses MissingTrack from Jellyfin plugin (no ISRC, no ordering) +/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering) +/// +/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching. /// public class SpotifyTrackMatchingService : BackgroundService { - private readonly IOptions _spotifySettings; + private readonly SpotifyImportSettings _spotifySettings; + private readonly SpotifyApiSettings _spotifyApiSettings; private readonly RedisCacheService _cache; private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; @@ -21,11 +27,13 @@ public class SpotifyTrackMatchingService : BackgroundService public SpotifyTrackMatchingService( IOptions spotifySettings, + IOptions spotifyApiSettings, RedisCacheService cache, IServiceProvider serviceProvider, ILogger logger) { - _spotifySettings = spotifySettings; + _spotifySettings = spotifySettings.Value; + _spotifyApiSettings = spotifyApiSettings.Value; _cache = cache; _serviceProvider = serviceProvider; _logger = logger; @@ -35,11 +43,15 @@ public class SpotifyTrackMatchingService : BackgroundService { _logger.LogInformation("SpotifyTrackMatchingService: Starting up..."); - if (!_spotifySettings.Value.Enabled) + if (!_spotifySettings.Enabled) { _logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run"); return; } + + var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching + ? "ISRC-preferred" : "fuzzy"; + _logger.LogInformation("Matching mode: {Mode}", matchMode); // Wait a bit for the fetcher to run first await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); @@ -85,7 +97,7 @@ public class SpotifyTrackMatchingService : BackgroundService { _logger.LogInformation("=== STARTING TRACK MATCHING ==="); - var playlists = _spotifySettings.Value.Playlists; + var playlists = _spotifySettings.Playlists; if (playlists.Count == 0) { _logger.LogInformation("No playlists configured for matching"); @@ -94,6 +106,13 @@ public class SpotifyTrackMatchingService : BackgroundService using var scope = _serviceProvider.CreateScope(); var metadataService = scope.ServiceProvider.GetRequiredService(); + + // Check if we should use the new SpotifyPlaylistFetcher + SpotifyPlaylistFetcher? playlistFetcher = null; + if (_spotifyApiSettings.Enabled) + { + playlistFetcher = scope.ServiceProvider.GetService(); + } foreach (var playlist in playlists) { @@ -101,7 +120,18 @@ public class SpotifyTrackMatchingService : BackgroundService try { - await MatchPlaylistTracksAsync(playlist.Name, metadataService, cancellationToken); + if (playlistFetcher != null) + { + // Use new direct API mode with ISRC support + await MatchPlaylistTracksWithIsrcAsync( + playlist.Name, playlistFetcher, metadataService, cancellationToken); + } + else + { + // Fall back to legacy mode + await MatchPlaylistTracksLegacyAsync( + playlist.Name, metadataService, cancellationToken); + } } catch (Exception ex) { @@ -111,8 +141,211 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogInformation("=== FINISHED TRACK MATCHING ==="); } + + /// + /// New matching mode that uses ISRC when available for exact matches. + /// Preserves track position for correct playlist ordering. + /// + private async Task MatchPlaylistTracksWithIsrcAsync( + string playlistName, + SpotifyPlaylistFetcher playlistFetcher, + IMusicMetadataService metadataService, + CancellationToken cancellationToken) + { + var matchedTracksKey = $"spotify:matched:ordered:{playlistName}"; + + // Get playlist tracks with full metadata including ISRC and position + var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName); + if (spotifyTracks.Count == 0) + { + _logger.LogInformation("No tracks found for {Playlist}, skipping matching", playlistName); + return; + } + + // Check cache - use snapshot/timestamp to detect changes + var existingMatched = await _cache.GetAsync>(matchedTracksKey); + if (existingMatched != null && existingMatched.Count == spotifyTracks.Count) + { + _logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping", + playlistName, existingMatched.Count); + return; + } - private async Task MatchPlaylistTracksAsync( + _logger.LogInformation("Matching {Count} tracks for {Playlist} (ISRC: {IsrcEnabled})", + spotifyTracks.Count, playlistName, _spotifyApiSettings.PreferIsrcMatching); + + var matchedTracks = new List(); + var isrcMatches = 0; + var fuzzyMatches = 0; + var noMatch = 0; + + foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) + { + if (cancellationToken.IsCancellationRequested) break; + + try + { + Song? matchedSong = null; + var matchType = "none"; + + // Try ISRC match first if available and enabled + if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc)) + { + matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService); + if (matchedSong != null) + { + matchType = "isrc"; + isrcMatches++; + } + } + + // Fall back to fuzzy matching + if (matchedSong == null) + { + matchedSong = await TryMatchByFuzzyAsync( + spotifyTrack.Title, + spotifyTrack.Artists, + metadataService); + + if (matchedSong != null) + { + matchType = "fuzzy"; + fuzzyMatches++; + } + } + + if (matchedSong != null) + { + matchedTracks.Add(new MatchedTrack + { + Position = spotifyTrack.Position, + SpotifyId = spotifyTrack.SpotifyId, + SpotifyTitle = spotifyTrack.Title, + SpotifyArtist = spotifyTrack.PrimaryArtist, + Isrc = spotifyTrack.Isrc, + MatchType = matchType, + MatchedSong = matchedSong + }); + + _logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match: {MatchedTitle}", + spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist, + matchType, matchedSong.Title); + } + else + { + noMatch++; + _logger.LogDebug(" #{Position} {Title} - {Artist} → no match", + spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist); + } + + // Rate limiting + await Task.Delay(DelayBetweenSearchesMs, cancellationToken); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}", + spotifyTrack.Title, spotifyTrack.PrimaryArtist); + } + } + + if (matchedTracks.Count > 0) + { + // Cache matched tracks with position data + await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1)); + + // Also update legacy cache for backward compatibility + var legacyKey = $"spotify:matched:{playlistName}"; + var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList(); + await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1)); + + _logger.LogInformation( + "✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})", + matchedTracks.Count, spotifyTracks.Count, playlistName, isrcMatches, fuzzyMatches, noMatch); + } + else + { + _logger.LogInformation("No tracks matched for {Playlist}", playlistName); + } + } + + /// + /// Attempts to match a track by ISRC using provider search. + /// + private async Task TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService) + { + try + { + // Search by ISRC directly - most providers support this + var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1); + if (results.Count > 0 && results[0].Isrc == isrc) + { + return results[0]; + } + + // Some providers may not support isrc: prefix, try without + results = await metadataService.SearchSongsAsync(isrc, limit: 5); + var exactMatch = results.FirstOrDefault(r => + !string.IsNullOrEmpty(r.Isrc) && + r.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase)); + + return exactMatch; + } + catch + { + return null; + } + } + + /// + /// Attempts to match a track by title and artist using fuzzy matching. + /// + private async Task TryMatchByFuzzyAsync( + string title, + List artists, + IMusicMetadataService metadataService) + { + try + { + var primaryArtist = artists.FirstOrDefault() ?? ""; + var query = $"{title} {primaryArtist}"; + var results = await metadataService.SearchSongsAsync(query, limit: 5); + + if (results.Count == 0) return null; + + var bestMatch = results + .Select(song => new + { + Song = song, + TitleScore = FuzzyMatcher.CalculateSimilarity(title, song.Title), + ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors) + }) + .Select(x => new + { + x.Song, + x.TitleScore, + x.ArtistScore, + TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) + }) + .OrderByDescending(x => x.TotalScore) + .FirstOrDefault(); + + if (bestMatch != null && bestMatch.TotalScore >= 60) + { + return bestMatch.Song; + } + + return null; + } + catch + { + return null; + } + } + + /// + /// Legacy matching mode using MissingTrack from Jellyfin plugin. + /// + private async Task MatchPlaylistTracksLegacyAsync( string playlistName, IMusicMetadataService metadataService, CancellationToken cancellationToken) diff --git a/allstarr/appsettings.json b/allstarr/appsettings.json index de60894..83185e6 100644 --- a/allstarr/appsettings.json +++ b/allstarr/appsettings.json @@ -60,5 +60,14 @@ "Enabled": true } ] + }, + "SpotifyApi": { + "Enabled": false, + "ClientId": "", + "ClientSecret": "", + "SessionCookie": "", + "CacheDurationMinutes": 60, + "RateLimitDelayMs": 100, + "PreferIsrcMatching": true } }