using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace Lumi.Companion.SongOverlay.Spotify; internal sealed record SpotifyEnrichment(string Link, string ReleaseYear, byte[]? CoverBytes, string CoverMime); internal sealed class SpotifyWebApiEnricher : IDisposable { private const string Scope = "user-read-currently-playing"; private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) }; private readonly SongOverlaySecretProtector _secrets; private readonly Action _save; private readonly Action _log; private readonly SongOverlaySettings _settings; private string _accessToken = ""; private DateTimeOffset _accessTokenExpiresAt = DateTimeOffset.MinValue; public SpotifyWebApiEnricher(SongOverlaySettings settings, SongOverlaySecretProtector secrets, Action save, Action log) { _settings = settings; _secrets = secrets; _save = save; _log = log; _http.DefaultRequestHeaders.UserAgent.ParseAdd("Lumi-Companion-NowPlaying/0.1.0"); } public bool IsConfigured => !string.IsNullOrWhiteSpace(_settings.SpotifyClientId) && !string.IsNullOrWhiteSpace(_settings.ProtectedSpotifyRefreshToken); public async Task AuthorizeAsync(CancellationToken cancellationToken) { var clientId = _settings.SpotifyClientId.Trim(); if (string.IsNullOrWhiteSpace(clientId)) throw new InvalidOperationException("Enter your Spotify application Client ID first."); var verifier = Base64Url(RandomNumberGenerator.GetBytes(64)); var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); var state = Base64Url(RandomNumberGenerator.GetBytes(24)); var port = FindFreePort(); var redirectUri = $"http://127.0.0.1:{port}/callback/"; using var listener = new HttpListener(); listener.Prefixes.Add(redirectUri); listener.Start(); var authorizationUrl = "https://accounts.spotify.com/authorize?" + BuildQuery(new Dictionary { ["client_id"] = clientId, ["response_type"] = "code", ["redirect_uri"] = redirectUri, ["scope"] = Scope, ["code_challenge_method"] = "S256", ["code_challenge"] = challenge, ["state"] = state }); Process.Start(new ProcessStartInfo(authorizationUrl) { UseShellExecute = true }); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromMinutes(3)); var contextTask = listener.GetContextAsync(); var completed = await Task.WhenAny(contextTask, Task.Delay(Timeout.InfiniteTimeSpan, timeout.Token)).ConfigureAwait(false); if (completed != contextTask) throw new TimeoutException("Spotify authorization timed out."); var context = await contextTask.ConfigureAwait(false); var query = context.Request.QueryString; var responseText = "Spotify is connected to Lumi Companion. You can close this page."; try { if (!string.Equals(query["state"], state, StringComparison.Ordinal)) throw new InvalidOperationException("Spotify returned an invalid authorization state."); if (!string.IsNullOrWhiteSpace(query["error"])) throw new InvalidOperationException("Spotify authorization was declined: " + query["error"]); var code = query["code"] ?? throw new InvalidOperationException("Spotify did not return an authorization code."); var token = await ExchangeAsync(new Dictionary { ["client_id"] = clientId, ["grant_type"] = "authorization_code", ["code"] = code, ["redirect_uri"] = redirectUri, ["code_verifier"] = verifier }, cancellationToken).ConfigureAwait(false); ApplyToken(token); if (string.IsNullOrWhiteSpace(token.RefreshToken)) throw new InvalidOperationException("Spotify did not provide a refresh token."); _settings.ProtectedSpotifyRefreshToken = _secrets.Protect(token.RefreshToken); _save(); } catch { responseText = "Spotify could not be connected to Lumi Companion. Return to the app for details."; throw; } finally { var bytes = Encoding.UTF8.GetBytes($"Lumi Companion

Lumi Companion

{WebUtility.HtmlEncode(responseText)}

"); context.Response.ContentType = "text/html; charset=utf-8"; context.Response.ContentLength64 = bytes.Length; await context.Response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); context.Response.Close(); } } public void Disconnect() { _accessToken = ""; _accessTokenExpiresAt = DateTimeOffset.MinValue; _settings.ProtectedSpotifyRefreshToken = ""; _save(); } public async Task EnrichAsync(MediaTrack track, CancellationToken cancellationToken) { if (!IsConfigured) return null; try { var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(token)) return null; using var response = await SendCurrentlyPlayingAsync(token, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NoContent) return null; if (response.StatusCode == HttpStatusCode.Unauthorized) { _accessToken = ""; token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(token)) return null; using var retry = await SendCurrentlyPlayingAsync(token, cancellationToken).ConfigureAwait(false); if (retry.StatusCode == HttpStatusCode.NoContent || !retry.IsSuccessStatusCode) return null; return await ParseEnrichmentAsync(retry, track, cancellationToken).ConfigureAwait(false); } if (!response.IsSuccessStatusCode) return null; return await ParseEnrichmentAsync(response, track, cancellationToken).ConfigureAwait(false); } catch (Exception error) { _log("Spotify metadata enrichment failed", error); return null; } } private async Task SendCurrentlyPlayingAsync(string token, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me/player/currently-playing?additional_types=track"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); return await _http.SendAsync(request, cancellationToken).ConfigureAwait(false); } private async Task ParseEnrichmentAsync(HttpResponseMessage response, MediaTrack track, CancellationToken cancellationToken) { using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); if (!document.RootElement.TryGetProperty("item", out var item) || item.ValueKind != JsonValueKind.Object) return null; var title = String(item, "name"); var artists = item.TryGetProperty("artists", out var artistArray) && artistArray.ValueKind == JsonValueKind.Array ? string.Join(", ", artistArray.EnumerateArray().Select(value => String(value, "name")).Where(value => value.Length > 0)) : ""; if (!LooseMatch(track.Title, title) || (!string.IsNullOrWhiteSpace(track.Artist) && !LooseMatch(track.Artist, artists))) return null; var link = item.TryGetProperty("external_urls", out var urls) ? String(urls, "spotify") : ""; var releaseYear = ""; string imageUrl = ""; if (item.TryGetProperty("album", out var album)) { var releaseDate = String(album, "release_date"); if (releaseDate.Length >= 4) releaseYear = releaseDate[..4]; if (album.TryGetProperty("images", out var images) && images.ValueKind == JsonValueKind.Array) imageUrl = images.EnumerateArray().Select(value => String(value, "url")).FirstOrDefault(value => value.Length > 0) ?? ""; } byte[]? cover = null; var mime = "image/jpeg"; if (!string.IsNullOrWhiteSpace(imageUrl)) { using var imageResponse = await _http.GetAsync(imageUrl, cancellationToken).ConfigureAwait(false); if (imageResponse.IsSuccessStatusCode) { cover = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); if (cover.Length > 3 * 1024 * 1024) cover = null; mime = imageResponse.Content.Headers.ContentType?.MediaType ?? mime; } } return new SpotifyEnrichment(link, releaseYear, cover, mime); } private async Task GetAccessTokenAsync(CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(_accessToken) && _accessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1)) return _accessToken; var refreshToken = _secrets.Unprotect(_settings.ProtectedSpotifyRefreshToken); if (string.IsNullOrWhiteSpace(refreshToken)) return ""; try { var token = await ExchangeAsync(new Dictionary { ["client_id"] = _settings.SpotifyClientId.Trim(), ["grant_type"] = "refresh_token", ["refresh_token"] = refreshToken }, cancellationToken).ConfigureAwait(false); ApplyToken(token); if (!string.IsNullOrWhiteSpace(token.RefreshToken)) { _settings.ProtectedSpotifyRefreshToken = _secrets.Protect(token.RefreshToken); _save(); } return _accessToken; } catch (SpotifyInvalidGrantException) { Disconnect(); return ""; } } private async Task ExchangeAsync(Dictionary values, CancellationToken cancellationToken) { using var response = await _http.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(values), cancellationToken).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { if (body.Contains("invalid_grant", StringComparison.OrdinalIgnoreCase)) throw new SpotifyInvalidGrantException(); throw new InvalidOperationException($"Spotify token exchange failed with HTTP {(int)response.StatusCode}."); } using var document = JsonDocument.Parse(body); return new TokenResponse( String(document.RootElement, "access_token"), String(document.RootElement, "refresh_token"), document.RootElement.TryGetProperty("expires_in", out var expires) ? expires.GetInt32() : 3600); } private void ApplyToken(TokenResponse token) { _accessToken = token.AccessToken; _accessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, token.ExpiresIn)); } private static string String(JsonElement element, string property) => element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : ""; private static bool LooseMatch(string left, string right) { static string Normalize(string value) => new(value.ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); var a = Normalize(left); var b = Normalize(right); return a.Length > 0 && b.Length > 0 && (a == b || a.Contains(b, StringComparison.Ordinal) || b.Contains(a, StringComparison.Ordinal)); } private static int FindFreePort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } private static string BuildQuery(IEnumerable> values) => string.Join("&", values.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")); private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); public void Dispose() => _http.Dispose(); private sealed record TokenResponse(string AccessToken, string RefreshToken, int ExpiresIn); private sealed class SpotifyInvalidGrantException : Exception { } }