✨ Spotify OAuth & Presence
This commit is contained in:
		
							
								
								
									
										209
									
								
								DysonNetwork.Pass/Account/Presences/SpotifyPresenceService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								DysonNetwork.Pass/Account/Presences/SpotifyPresenceService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,209 @@
 | 
				
			|||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Models;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Account.Presences;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class SpotifyPresenceService(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    Auth.OpenId.SpotifyOidcService spotifyService,
 | 
				
			||||||
 | 
					    AccountEventService accountEventService,
 | 
				
			||||||
 | 
					    ILogger<SpotifyPresenceService> logger
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Updates presence activities for users who have Spotify connections and are currently playing music
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    public async Task UpdateAllSpotifyPresencesAsync()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var userConnections = await db.AccountConnections
 | 
				
			||||||
 | 
					            .Where(c => c.Provider == "spotify" && c.AccessToken != null && c.RefreshToken != null)
 | 
				
			||||||
 | 
					            .Include(c => c.Account)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach (var connection in userConnections)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await UpdateSpotifyPresenceAsync(connection.Account);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Updates the Spotify presence activity for a specific user
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    public async Task UpdateSpotifyPresenceAsync(SnAccount account)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var connection = await db.AccountConnections
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(c => c.AccountId == account.Id && c.Provider == "spotify");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (connection?.RefreshToken == null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            // No Spotify connection, remove any existing Spotify presence
 | 
				
			||||||
 | 
					            await RemoveSpotifyPresenceAsync(account.Id);
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var currentlyPlayingJson = await spotifyService.GetCurrentlyPlayingAsync(
 | 
				
			||||||
 | 
					                connection.RefreshToken,
 | 
				
			||||||
 | 
					                connection.AccessToken
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (string.IsNullOrEmpty(currentlyPlayingJson) || currentlyPlayingJson == "{}")
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                // Nothing playing, remove the presence
 | 
				
			||||||
 | 
					                await RemoveSpotifyPresenceAsync(account.Id);
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var presenceActivity = await ParseAndCreatePresenceActivityAsync(account.Id, currentlyPlayingJson);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Update or create the presence activity
 | 
				
			||||||
 | 
					            await accountEventService.UpdateActivityByManualId(
 | 
				
			||||||
 | 
					                "spotify",
 | 
				
			||||||
 | 
					                account.Id,
 | 
				
			||||||
 | 
					                activity =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    activity.Type = PresenceType.Music;
 | 
				
			||||||
 | 
					                    activity.Title = presenceActivity.Title;
 | 
				
			||||||
 | 
					                    activity.Subtitle = presenceActivity.Subtitle;
 | 
				
			||||||
 | 
					                    activity.Caption = presenceActivity.Caption;
 | 
				
			||||||
 | 
					                    activity.LargeImage = presenceActivity.LargeImage;
 | 
				
			||||||
 | 
					                    activity.SmallImage = presenceActivity.SmallImage;
 | 
				
			||||||
 | 
					                    activity.TitleUrl = presenceActivity.TitleUrl;
 | 
				
			||||||
 | 
					                    activity.SubtitleUrl = presenceActivity.SubtitleUrl;
 | 
				
			||||||
 | 
					                    activity.Meta = presenceActivity.Meta;
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                10 // 10 minute lease
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            // On error, remove the presence to avoid stale data
 | 
				
			||||||
 | 
					            await RemoveSpotifyPresenceAsync(account.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // In a real implementation, you might want to log the error
 | 
				
			||||||
 | 
					            logger.LogError(ex, "Failed to update Spotify presence for user {UserId}", account.Id);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Removes the Spotify presence activity for a user
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    private async Task RemoveSpotifyPresenceAsync(Guid accountId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        await accountEventService.UpdateActivityByManualId(
 | 
				
			||||||
 | 
					            "spotify",
 | 
				
			||||||
 | 
					            accountId,
 | 
				
			||||||
 | 
					            activity =>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                // Mark it for immediate expiration
 | 
				
			||||||
 | 
					                activity.LeaseExpiresAt = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private async Task<SnPresenceActivity> ParseAndCreatePresenceActivityAsync(Guid accountId, string currentlyPlayingJson)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var document = JsonDocument.Parse(currentlyPlayingJson);
 | 
				
			||||||
 | 
					        var root = document.RootElement;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Extract track information
 | 
				
			||||||
 | 
					        var item = root.GetProperty("item");
 | 
				
			||||||
 | 
					        var trackName = item.GetProperty("name").GetString() ?? "";
 | 
				
			||||||
 | 
					        var isPlaying = root.GetProperty("is_playing").GetBoolean();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Only create presence if actually playing
 | 
				
			||||||
 | 
					        if (!isPlaying)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            throw new InvalidOperationException("Track is not currently playing");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Get artists
 | 
				
			||||||
 | 
					        var artists = item.GetProperty("artists");
 | 
				
			||||||
 | 
					        var artistNames = artists.EnumerateArray()
 | 
				
			||||||
 | 
					            .Select(a => a.GetProperty("name").GetString() ?? "")
 | 
				
			||||||
 | 
					            .Where(name => !string.IsNullOrEmpty(name))
 | 
				
			||||||
 | 
					            .ToArray();
 | 
				
			||||||
 | 
					        var artistsString = string.Join(", ", artistNames);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Get album
 | 
				
			||||||
 | 
					        var album = item.GetProperty("album");
 | 
				
			||||||
 | 
					        var albumName = album.GetProperty("name").GetString() ?? "";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Get album images (artwork)
 | 
				
			||||||
 | 
					        string? albumImageUrl = null;
 | 
				
			||||||
 | 
					        if (album.TryGetProperty("images", out var images) && images.ValueKind == JsonValueKind.Array)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var albumImages = images.EnumerateArray().ToList();
 | 
				
			||||||
 | 
					            // Take the largest image (usually last in the array)
 | 
				
			||||||
 | 
					            if (albumImages.Count > 0)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                albumImageUrl = albumImages[0].GetProperty("url").GetString();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Get external URLs
 | 
				
			||||||
 | 
					        var externalUrls = item.GetProperty("external_urls");
 | 
				
			||||||
 | 
					        var trackUrl = externalUrls.GetProperty("spotify").GetString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var artistsUrls = new List<string>();
 | 
				
			||||||
 | 
					        foreach (var artist in artists.EnumerateArray())
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            if (artist.TryGetProperty("external_urls", out var artistUrls))
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                var spotifyUrl = artistUrls.GetProperty("spotify").GetString();
 | 
				
			||||||
 | 
					                if (!string.IsNullOrEmpty(spotifyUrl))
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    artistsUrls.Add(spotifyUrl);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Get progress and duration for metadata
 | 
				
			||||||
 | 
					        var progressMs = root.GetProperty("progress_ms").GetInt32();
 | 
				
			||||||
 | 
					        var durationMs = item.GetProperty("duration_ms").GetInt32();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Calculate progress percentage
 | 
				
			||||||
 | 
					        var progressPercent = durationMs > 0 ? (double)progressMs / durationMs * 100 : 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Get context info (playlist, album, etc.)
 | 
				
			||||||
 | 
					        string? contextType = null;
 | 
				
			||||||
 | 
					        string? contextUrl = null;
 | 
				
			||||||
 | 
					        if (root.TryGetProperty("context", out var context))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            contextType = context.GetProperty("type").GetString();
 | 
				
			||||||
 | 
					            var contextExternalUrls = context.GetProperty("external_urls");
 | 
				
			||||||
 | 
					            contextUrl = contextExternalUrls.GetProperty("spotify").GetString();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return new SnPresenceActivity
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            AccountId = accountId,
 | 
				
			||||||
 | 
					            Type = PresenceType.Music,
 | 
				
			||||||
 | 
					            ManualId = "spotify",
 | 
				
			||||||
 | 
					            Title = trackName,
 | 
				
			||||||
 | 
					            Subtitle = artistsString,
 | 
				
			||||||
 | 
					            Caption = albumName,
 | 
				
			||||||
 | 
					            LargeImage = albumImageUrl,
 | 
				
			||||||
 | 
					            TitleUrl = trackUrl,
 | 
				
			||||||
 | 
					            SubtitleUrl = artistsUrls.FirstOrDefault(),
 | 
				
			||||||
 | 
					            Meta = new Dictionary<string, object>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                ["track_duration_ms"] = durationMs,
 | 
				
			||||||
 | 
					                ["progress_ms"] = progressMs,
 | 
				
			||||||
 | 
					                ["progress_percent"] = progressPercent,
 | 
				
			||||||
 | 
					                ["track_id"] = item.GetProperty("id").GetString() ?? "",
 | 
				
			||||||
 | 
					                ["album_id"] = album.GetProperty("id").GetString() ?? "",
 | 
				
			||||||
 | 
					                ["artist_ids"] = artists.EnumerateArray().Select(a => a.GetProperty("id").GetString() ?? "").ToArray(),
 | 
				
			||||||
 | 
					                ["context_type"] = contextType,
 | 
				
			||||||
 | 
					                ["context_url"] = contextUrl,
 | 
				
			||||||
 | 
					                ["is_explicit"] = item.GetProperty("explicit").GetBoolean(),
 | 
				
			||||||
 | 
					                ["popularity"] = item.GetProperty("popularity").GetInt32(),
 | 
				
			||||||
 | 
					                ["spotify_track_url"] = trackUrl,
 | 
				
			||||||
 | 
					                ["updated_at"] = SystemClock.Instance.GetCurrentInstant()
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					using Quartz;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Account.Presences;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class SpotifyPresenceUpdateJob(SpotifyPresenceService spotifyPresenceService, ILogger<SpotifyPresenceUpdateJob> logger) : IJob
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public async Task Execute(IJobExecutionContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        logger.LogInformation("Starting Spotify presence updates...");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await spotifyPresenceService.UpdateAllSpotifyPresencesAsync();
 | 
				
			||||||
 | 
					            logger.LogInformation("Spotify presence updates completed successfully.");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            logger.LogError(ex, "Error occurred during Spotify presence updates.");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -123,6 +123,7 @@ public class OidcController(
 | 
				
			|||||||
            "microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
 | 
					            "microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
 | 
				
			||||||
            "discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
 | 
					            "discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
 | 
				
			||||||
            "github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
 | 
					            "github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
 | 
				
			||||||
 | 
					            "spotify" => serviceProvider.GetRequiredService<SpotifyOidcService>(),
 | 
				
			||||||
            "afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
 | 
					            "afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
 | 
				
			||||||
            _ => throw new ArgumentException($"Unsupported provider: {provider}")
 | 
					            _ => throw new ArgumentException($"Unsupported provider: {provider}")
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										255
									
								
								DysonNetwork.Pass/Auth/OpenId/SpotifyOidcService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								DysonNetwork.Pass/Auth/OpenId/SpotifyOidcService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,255 @@
 | 
				
			|||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Auth.OpenId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class SpotifyOidcService(
 | 
				
			||||||
 | 
					    IConfiguration configuration,
 | 
				
			||||||
 | 
					    IHttpClientFactory httpClientFactory,
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    AuthService auth,
 | 
				
			||||||
 | 
					    ICacheService cache
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					    : OidcService(configuration, httpClientFactory, db, auth, cache)
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public override string ProviderName => "Spotify";
 | 
				
			||||||
 | 
					    protected override string DiscoveryEndpoint => ""; // Spotify doesn't have a standard OIDC discovery endpoint
 | 
				
			||||||
 | 
					    protected override string ConfigSectionName => "Spotify";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override Task<string> GetAuthorizationUrlAsync(string state, string nonce)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return Task.FromResult(GetAuthorizationUrl(state, nonce));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override string GetAuthorizationUrl(string state, string nonce)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var config = GetProviderConfig();
 | 
				
			||||||
 | 
					        var queryParams = new Dictionary<string, string>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            { "client_id", config.ClientId },
 | 
				
			||||||
 | 
					            { "redirect_uri", config.RedirectUri },
 | 
				
			||||||
 | 
					            { "response_type", "code" },
 | 
				
			||||||
 | 
					            { "scope", "user-read-private user-read-current-playing user-read-email" },
 | 
				
			||||||
 | 
					            { "state", state },
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
 | 
				
			||||||
 | 
					        return $"https://accounts.spotify.com/authorize?{queryString}";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return Task.FromResult(new OidcDiscoveryDocument
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            AuthorizationEndpoint = "https://accounts.spotify.com/authorize",
 | 
				
			||||||
 | 
					            TokenEndpoint = "https://accounts.spotify.com/api/token",
 | 
				
			||||||
 | 
					            UserinfoEndpoint = "https://api.spotify.com/v1/me",
 | 
				
			||||||
 | 
					            JwksUri = null
 | 
				
			||||||
 | 
					        })!;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
 | 
				
			||||||
 | 
					        if (tokenResponse?.AccessToken == null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            throw new InvalidOperationException("Failed to obtain access token from Spotify");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        userInfo.AccessToken = tokenResponse.AccessToken;
 | 
				
			||||||
 | 
					        userInfo.RefreshToken = tokenResponse.RefreshToken;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return userInfo;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
 | 
				
			||||||
 | 
					        string? codeVerifier = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var config = GetProviderConfig();
 | 
				
			||||||
 | 
					        var client = HttpClientFactory.CreateClient();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var content = new FormUrlEncodedContent(new Dictionary<string, string>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            { "client_id", config.ClientId },
 | 
				
			||||||
 | 
					            { "client_secret", config.ClientSecret },
 | 
				
			||||||
 | 
					            { "grant_type", "authorization_code" },
 | 
				
			||||||
 | 
					            { "code", code },
 | 
				
			||||||
 | 
					            { "redirect_uri", config.RedirectUri },
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var response = await client.PostAsync("https://accounts.spotify.com/api/token", content);
 | 
				
			||||||
 | 
					        response.EnsureSuccessStatusCode();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Refreshes an access token using the refresh token
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    public async Task<OidcTokenResponse?> RefreshTokenAsync(string refreshToken)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var config = GetProviderConfig();
 | 
				
			||||||
 | 
					        var client = HttpClientFactory.CreateClient();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var content = new FormUrlEncodedContent(new Dictionary<string, string>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            { "client_id", config.ClientId },
 | 
				
			||||||
 | 
					            { "client_secret", config.ClientSecret },
 | 
				
			||||||
 | 
					            { "grant_type", "refresh_token" },
 | 
				
			||||||
 | 
					            { "refresh_token", refreshToken },
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var response = await client.PostAsync("https://accounts.spotify.com/api/token", content);
 | 
				
			||||||
 | 
					        if (!response.IsSuccessStatusCode)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return null; // Refresh failed
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Gets a valid access token, refreshing if necessary
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    public async Task<string?> GetValidAccessTokenAsync(string refreshToken, string? currentAccessToken = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        // If we don't have a current token, we need to refresh
 | 
				
			||||||
 | 
					        if (string.IsNullOrEmpty(currentAccessToken))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var refreshedTokens = await RefreshTokenAsync(refreshToken);
 | 
				
			||||||
 | 
					            return refreshedTokens?.AccessToken;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Test if the current token is still valid by making a lightweight API call
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var client = HttpClientFactory.CreateClient();
 | 
				
			||||||
 | 
					            var request = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me");
 | 
				
			||||||
 | 
					            request.Headers.Add("Authorization", $"Bearer {currentAccessToken}");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var response = await client.SendAsync(request);
 | 
				
			||||||
 | 
					            if (response.IsSuccessStatusCode)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                // Token is still valid
 | 
				
			||||||
 | 
					                return currentAccessToken;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // If unauthorized, try to refresh
 | 
				
			||||||
 | 
					            if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                var refreshedTokens = await RefreshTokenAsync(refreshToken);
 | 
				
			||||||
 | 
					                return refreshedTokens?.AccessToken;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            // On any error, try to refresh
 | 
				
			||||||
 | 
					            var refreshedTokens = await RefreshTokenAsync(refreshToken);
 | 
				
			||||||
 | 
					            return refreshedTokens?.AccessToken;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Current token is invalid and refresh failed
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var client = HttpClientFactory.CreateClient();
 | 
				
			||||||
 | 
					        var request = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me");
 | 
				
			||||||
 | 
					        request.Headers.Add("Authorization", $"Bearer {accessToken}");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var response = await client.SendAsync(request);
 | 
				
			||||||
 | 
					        response.EnsureSuccessStatusCode();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var json = await response.Content.ReadAsStringAsync();
 | 
				
			||||||
 | 
					        var spotifyUser = JsonDocument.Parse(json).RootElement;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var userId = spotifyUser.GetProperty("id").GetString() ?? "";
 | 
				
			||||||
 | 
					        var email = spotifyUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Get display name - prefer display_name, then fallback to id
 | 
				
			||||||
 | 
					        var displayName = spotifyUser.TryGetProperty("display_name", out var displayNameElement)
 | 
				
			||||||
 | 
					            ? displayNameElement.GetString() ?? ""
 | 
				
			||||||
 | 
					            : userId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Get profile picture - take the first image URL if available
 | 
				
			||||||
 | 
					        string? profilePictureUrl = null;
 | 
				
			||||||
 | 
					        if (spotifyUser.TryGetProperty("images", out var imagesElement) && imagesElement.ValueKind == JsonValueKind.Array)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var images = imagesElement.EnumerateArray().ToList();
 | 
				
			||||||
 | 
					            if (images.Count > 0)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                profilePictureUrl = images[0].TryGetProperty("url", out var urlElement)
 | 
				
			||||||
 | 
					                    ? urlElement.GetString()
 | 
				
			||||||
 | 
					                    : null;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return new OidcUserInfo
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            UserId = userId,
 | 
				
			||||||
 | 
					            Email = email,
 | 
				
			||||||
 | 
					            DisplayName = displayName,
 | 
				
			||||||
 | 
					            PreferredUsername = userId, // Spotify doesn't have a separate username field like some platforms
 | 
				
			||||||
 | 
					            ProfilePictureUrl = profilePictureUrl,
 | 
				
			||||||
 | 
					            Provider = ProviderName
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Gets the user's currently playing track
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    public async Task<string?> GetCurrentlyPlayingAsync(string refreshToken, string? currentAccessToken = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var validToken = await GetValidAccessTokenAsync(refreshToken, currentAccessToken);
 | 
				
			||||||
 | 
					        if (string.IsNullOrEmpty(validToken))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return null; // Couldn't get a valid token
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var client = HttpClientFactory.CreateClient();
 | 
				
			||||||
 | 
					        var request = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me/player/currently-playing");
 | 
				
			||||||
 | 
					        request.Headers.Add("Authorization", $"Bearer {validToken}");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var response = await client.SendAsync(request);
 | 
				
			||||||
 | 
					        if (!response.IsSuccessStatusCode)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                // 204 No Content means nothing is currently playing
 | 
				
			||||||
 | 
					                return "{}";
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Try one more time with a fresh token if it failed with unauthorized
 | 
				
			||||||
 | 
					            if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                var freshToken = await RefreshTokenAsync(refreshToken);
 | 
				
			||||||
 | 
					                if (freshToken?.AccessToken != null)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", freshToken.AccessToken);
 | 
				
			||||||
 | 
					                    response = await client.SendAsync(request);
 | 
				
			||||||
 | 
					                    if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        return "{}";
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    if (!response.IsSuccessStatusCode)
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        return null;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                else
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    return null;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            else
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                return null;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return await response.Content.ReadAsStringAsync();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,3 +1,5 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Pass.Account;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Account.Presences;
 | 
				
			||||||
using DysonNetwork.Pass.Credit;
 | 
					using DysonNetwork.Pass.Credit;
 | 
				
			||||||
using DysonNetwork.Pass.Handlers;
 | 
					using DysonNetwork.Pass.Handlers;
 | 
				
			||||||
using DysonNetwork.Pass.Wallet;
 | 
					using DysonNetwork.Pass.Wallet;
 | 
				
			||||||
@@ -81,6 +83,16 @@ public static class ScheduledJobsConfiguration
 | 
				
			|||||||
                .ForJob(socialCreditValidationJob)
 | 
					                .ForJob(socialCreditValidationJob)
 | 
				
			||||||
                .WithIdentity("SocialCreditValidationTrigger")
 | 
					                .WithIdentity("SocialCreditValidationTrigger")
 | 
				
			||||||
                .WithCronSchedule("0 0 0 * * ?"));
 | 
					                .WithCronSchedule("0 0 0 * * ?"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var spotifyPresenceUpdateJob = new JobKey("SpotifyPresenceUpdate");
 | 
				
			||||||
 | 
					            q.AddJob<SpotifyPresenceUpdateJob>(opts => opts.WithIdentity(spotifyPresenceUpdateJob));
 | 
				
			||||||
 | 
					            q.AddTrigger(opts => opts
 | 
				
			||||||
 | 
					                .ForJob(spotifyPresenceUpdateJob)
 | 
				
			||||||
 | 
					                .WithIdentity("SpotifyPresenceUpdateTrigger")
 | 
				
			||||||
 | 
					                .WithSimpleSchedule(o => o
 | 
				
			||||||
 | 
					                    .WithIntervalInMinutes(2)
 | 
				
			||||||
 | 
					                    .RepeatForever())
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
        services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
 | 
					        services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -57,12 +57,14 @@ public static class ServiceCollectionExtensions
 | 
				
			|||||||
        services.AddScoped<OidcService, MicrosoftOidcService>();
 | 
					        services.AddScoped<OidcService, MicrosoftOidcService>();
 | 
				
			||||||
        services.AddScoped<OidcService, DiscordOidcService>();
 | 
					        services.AddScoped<OidcService, DiscordOidcService>();
 | 
				
			||||||
        services.AddScoped<OidcService, AfdianOidcService>();
 | 
					        services.AddScoped<OidcService, AfdianOidcService>();
 | 
				
			||||||
 | 
					        services.AddScoped<OidcService, SpotifyOidcService>();
 | 
				
			||||||
        services.AddScoped<GoogleOidcService>();
 | 
					        services.AddScoped<GoogleOidcService>();
 | 
				
			||||||
        services.AddScoped<AppleOidcService>();
 | 
					        services.AddScoped<AppleOidcService>();
 | 
				
			||||||
        services.AddScoped<GitHubOidcService>();
 | 
					        services.AddScoped<GitHubOidcService>();
 | 
				
			||||||
        services.AddScoped<MicrosoftOidcService>();
 | 
					        services.AddScoped<MicrosoftOidcService>();
 | 
				
			||||||
        services.AddScoped<DiscordOidcService>();
 | 
					        services.AddScoped<DiscordOidcService>();
 | 
				
			||||||
        services.AddScoped<AfdianOidcService>();
 | 
					        services.AddScoped<AfdianOidcService>();
 | 
				
			||||||
 | 
					        services.AddScoped<SpotifyOidcService>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        services.AddControllers().AddJsonOptions(options =>
 | 
					        services.AddControllers().AddJsonOptions(options =>
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -142,6 +144,7 @@ public static class ServiceCollectionExtensions
 | 
				
			|||||||
        services.AddScoped<ActionLogService>();
 | 
					        services.AddScoped<ActionLogService>();
 | 
				
			||||||
        services.AddScoped<RelationshipService>();
 | 
					        services.AddScoped<RelationshipService>();
 | 
				
			||||||
        services.AddScoped<MagicSpellService>();
 | 
					        services.AddScoped<MagicSpellService>();
 | 
				
			||||||
 | 
					        services.AddScoped<DysonNetwork.Pass.Account.Presences.SpotifyPresenceService>();
 | 
				
			||||||
        services.AddScoped<AuthService>();
 | 
					        services.AddScoped<AuthService>();
 | 
				
			||||||
        services.AddScoped<TokenAuthService>();
 | 
					        services.AddScoped<TokenAuthService>();
 | 
				
			||||||
        services.AddScoped<AccountUsernameService>();
 | 
					        services.AddScoped<AccountUsernameService>();
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user