From 6817ab6b5688e1ead5131566c04cf22fe0529202 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 2 Nov 2025 15:32:20 +0800 Subject: [PATCH] :sparkles: Spotify OAuth & Presence --- .../Presences/SpotifyPresenceService.cs | 209 ++++++++++++++ .../Presences/SpotifyPresenceUpdateJob.cs | 21 ++ .../Auth/OpenId/MicrosoftOidcService.cs | 2 +- .../Auth/OpenId/OidcController.cs | 1 + .../Auth/OpenId/SpotifyOidcService.cs | 255 ++++++++++++++++++ .../Startup/ScheduledJobsConfiguration.cs | 12 + .../Startup/ServiceCollectionExtensions.cs | 3 + 7 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 DysonNetwork.Pass/Account/Presences/SpotifyPresenceService.cs create mode 100644 DysonNetwork.Pass/Account/Presences/SpotifyPresenceUpdateJob.cs create mode 100644 DysonNetwork.Pass/Auth/OpenId/SpotifyOidcService.cs diff --git a/DysonNetwork.Pass/Account/Presences/SpotifyPresenceService.cs b/DysonNetwork.Pass/Account/Presences/SpotifyPresenceService.cs new file mode 100644 index 0000000..8a75901 --- /dev/null +++ b/DysonNetwork.Pass/Account/Presences/SpotifyPresenceService.cs @@ -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 logger +) +{ + /// + /// Updates presence activities for users who have Spotify connections and are currently playing music + /// + 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); + } + } + + /// + /// Updates the Spotify presence activity for a specific user + /// + 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); + } + } + + /// + /// Removes the Spotify presence activity for a user + /// + 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 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(); + 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 + { + ["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() + } + }; + } +} diff --git a/DysonNetwork.Pass/Account/Presences/SpotifyPresenceUpdateJob.cs b/DysonNetwork.Pass/Account/Presences/SpotifyPresenceUpdateJob.cs new file mode 100644 index 0000000..87517e1 --- /dev/null +++ b/DysonNetwork.Pass/Account/Presences/SpotifyPresenceUpdateJob.cs @@ -0,0 +1,21 @@ +using Quartz; + +namespace DysonNetwork.Pass.Account.Presences; + +public class SpotifyPresenceUpdateJob(SpotifyPresenceService spotifyPresenceService, ILogger 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."); + } + } +} diff --git a/DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs index fd28ea9..1e25337 100644 --- a/DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs +++ b/DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs @@ -141,4 +141,4 @@ public class MicrosoftOidcService( Provider = ProviderName }; } -} +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/OidcController.cs b/DysonNetwork.Pass/Auth/OpenId/OidcController.cs index 653d2fb..6ec1843 100644 --- a/DysonNetwork.Pass/Auth/OpenId/OidcController.cs +++ b/DysonNetwork.Pass/Auth/OpenId/OidcController.cs @@ -123,6 +123,7 @@ public class OidcController( "microsoft" => serviceProvider.GetRequiredService(), "discord" => serviceProvider.GetRequiredService(), "github" => serviceProvider.GetRequiredService(), + "spotify" => serviceProvider.GetRequiredService(), "afdian" => serviceProvider.GetRequiredService(), _ => throw new ArgumentException($"Unsupported provider: {provider}") }; diff --git a/DysonNetwork.Pass/Auth/OpenId/SpotifyOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/SpotifyOidcService.cs new file mode 100644 index 0000000..3777fce --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/SpotifyOidcService.cs @@ -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 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 + { + { "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 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 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 ExchangeCodeForTokensAsync(string code, + string? codeVerifier = null) + { + var config = GetProviderConfig(); + var client = HttpClientFactory.CreateClient(); + + var content = new FormUrlEncodedContent(new Dictionary + { + { "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(); + } + + /// + /// Refreshes an access token using the refresh token + /// + public async Task RefreshTokenAsync(string refreshToken) + { + var config = GetProviderConfig(); + var client = HttpClientFactory.CreateClient(); + + var content = new FormUrlEncodedContent(new Dictionary + { + { "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(); + } + + /// + /// Gets a valid access token, refreshing if necessary + /// + public async Task 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 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 + }; + } + + /// + /// Gets the user's currently playing track + /// + public async Task 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(); + } +} diff --git a/DysonNetwork.Pass/Startup/ScheduledJobsConfiguration.cs b/DysonNetwork.Pass/Startup/ScheduledJobsConfiguration.cs index 8e75c9b..3ed3625 100644 --- a/DysonNetwork.Pass/Startup/ScheduledJobsConfiguration.cs +++ b/DysonNetwork.Pass/Startup/ScheduledJobsConfiguration.cs @@ -1,3 +1,5 @@ +using DysonNetwork.Pass.Account; +using DysonNetwork.Pass.Account.Presences; using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Handlers; using DysonNetwork.Pass.Wallet; @@ -81,6 +83,16 @@ public static class ScheduledJobsConfiguration .ForJob(socialCreditValidationJob) .WithIdentity("SocialCreditValidationTrigger") .WithCronSchedule("0 0 0 * * ?")); + + var spotifyPresenceUpdateJob = new JobKey("SpotifyPresenceUpdate"); + q.AddJob(opts => opts.WithIdentity(spotifyPresenceUpdateJob)); + q.AddTrigger(opts => opts + .ForJob(spotifyPresenceUpdateJob) + .WithIdentity("SpotifyPresenceUpdateTrigger") + .WithSimpleSchedule(o => o + .WithIntervalInMinutes(2) + .RepeatForever()) + ); }); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); diff --git a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs index dbe7050..be7ecfe 100644 --- a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs @@ -57,12 +57,14 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddControllers().AddJsonOptions(options => { @@ -142,6 +144,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped();