257 lines
13 KiB
C#
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 { }
|
|
}
|