✨ 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()
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user