Spotify OAuth & Presence

This commit is contained in:
2025-11-02 15:32:20 +08:00
parent c74ab20236
commit 6817ab6b56
7 changed files with 502 additions and 1 deletions

View 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()
}
};
}
}

View File

@@ -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.");
}
}
}

View File

@@ -141,4 +141,4 @@ public class MicrosoftOidcService(
Provider = ProviderName Provider = ProviderName
}; };
} }
} }

View File

@@ -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}")
}; };

View 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();
}
}

View File

@@ -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);

View File

@@ -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>();