Lumi/companion/plugins/Lumi.Companion.SongOverlay/Spotify/SpotifyWebApiEnricher.cs

257 lines
13 KiB
C#

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<string, string, Exception?> _log;
private readonly SongOverlaySettings _settings;
private string _accessToken = "";
private DateTimeOffset _accessTokenExpiresAt = DateTimeOffset.MinValue;
public SpotifyWebApiEnricher(SongOverlaySettings settings, SongOverlaySecretProtector secrets, Action save, Action<string, string, Exception?> 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<string, string>
{
["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<string, string>
{
["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($"<!doctype html><meta charset=\"utf-8\"><title>Lumi Companion</title><style>body{{font:18px system-ui;max-width:720px;margin:15vh auto;padding:24px;background:#111827;color:#f9fafb}}</style><h1>Lumi Companion</h1><p>{WebUtility.HtmlEncode(responseText)}</p>");
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<SpotifyEnrichment?> 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", "Spotify metadata enrichment failed", error);
return null;
}
}
private async Task<HttpResponseMessage> 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<SpotifyEnrichment?> 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<string> 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<string, string>
{
["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<TokenResponse> ExchangeAsync(Dictionary<string, string> 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<KeyValuePair<string, string>> 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 { }
}