diff --git a/allstarr/Services/Spotify/SpotifyApiClient.cs b/allstarr/Services/Spotify/SpotifyApiClient.cs index 774f2c8..245af2d 100644 --- a/allstarr/Services/Spotify/SpotifyApiClient.cs +++ b/allstarr/Services/Spotify/SpotifyApiClient.cs @@ -2,9 +2,11 @@ using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using allstarr.Models.Settings; using allstarr.Models.Spotify; using Microsoft.Extensions.Options; +using OtpNet; namespace allstarr.Services.Spotify; @@ -17,6 +19,8 @@ namespace allstarr.Services.Spotify; /// /// The session cookie (sp_dc) is required because Spotify's official API doesn't expose /// algorithmically generated "Made For You" playlists. +/// +/// Uses TOTP-based authentication similar to the Jellyfin Spotify Import plugin. /// public class SpotifyApiClient : IDisposable { @@ -24,17 +28,26 @@ public class SpotifyApiClient : IDisposable private readonly SpotifyApiSettings _settings; private readonly HttpClient _httpClient; private readonly HttpClient _webApiClient; + private readonly CookieContainer _cookieContainer; // Spotify API endpoints private const string OfficialApiBase = "https://api.spotify.com/v1"; private const string WebApiBase = "https://api-partner.spotify.com/pathfinder/v1"; - private const string TokenEndpoint = "https://open.spotify.com/get_access_token?reason=transport&productType=web_player"; + private const string SpotifyBaseUrl = "https://open.spotify.com"; + private const string TokenEndpoint = "https://open.spotify.com/api/token"; + + // URL for pre-scraped TOTP secrets (same as Jellyfin plugin uses) + private const string TotpSecretsUrl = "https://raw.githubusercontent.com/Viperinius/spotify-totp-secrets/refs/heads/main/secrets.json"; // Web API access token (obtained via session cookie) private string? _webAccessToken; private DateTime _webTokenExpiry = DateTime.MinValue; private readonly SemaphoreSlim _tokenLock = new(1, 1); + // Cached TOTP secrets + private TotpSecret? _cachedTotpSecret; + private DateTime _totpSecretFetchedAt = DateTime.MinValue; + public SpotifyApiClient( ILogger logger, IOptions settings) @@ -50,17 +63,18 @@ public class SpotifyApiClient : IDisposable }; // Client for web API (requires session cookie) + _cookieContainer = new CookieContainer(); var handler = new HttpClientHandler { UseCookies = true, - CookieContainer = new CookieContainer() + CookieContainer = _cookieContainer }; if (!string.IsNullOrEmpty(_settings.SessionCookie)) { - handler.CookieContainer.Add( - new Uri("https://open.spotify.com"), - new Cookie("sp_dc", _settings.SessionCookie)); + _cookieContainer.SetCookies( + new Uri(SpotifyBaseUrl), + $"sp_dc={_settings.SessionCookie}"); } _webApiClient = new HttpClient(handler) @@ -69,15 +83,15 @@ public class SpotifyApiClient : IDisposable }; // Common headers for web API - _webApiClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + _webApiClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"); _webApiClient.DefaultRequestHeaders.Add("Accept", "application/json"); - _webApiClient.DefaultRequestHeaders.Add("Accept-Language", "en"); + _webApiClient.DefaultRequestHeaders.Add("Accept-Language", "en-US"); _webApiClient.DefaultRequestHeaders.Add("app-platform", "WebPlayer"); - _webApiClient.DefaultRequestHeaders.Add("spotify-app-version", "1.2.31.0"); + _webApiClient.DefaultRequestHeaders.Add("spotify-app-version", "1.2.46.25.g7f189073"); } /// - /// Gets an access token using the session cookie. + /// Gets an access token using the session cookie and TOTP authentication. /// This token can be used for both the official API and web API. /// public async Task GetWebAccessTokenAsync(CancellationToken cancellationToken = default) @@ -97,11 +111,33 @@ public class SpotifyApiClient : IDisposable return _webAccessToken; } - _logger.LogDebug("Fetching new Spotify web access token"); + _logger.LogDebug("Fetching new Spotify web access token using TOTP authentication"); - // The cookie is already set in the CookieContainer during client construction - // No need to manually add Cookie header - HttpClient will handle it - var response = await _webApiClient.GetAsync(TokenEndpoint, cancellationToken); + // Fetch TOTP secrets if needed + var totpSecret = await GetTotpSecretAsync(cancellationToken); + if (totpSecret == null) + { + _logger.LogError("Failed to get TOTP secrets"); + return null; + } + + // Generate TOTP + var totpResult = await GenerateTotpAsync(totpSecret, cancellationToken); + if (totpResult == null) + { + _logger.LogError("Failed to generate TOTP"); + return null; + } + + var (otp, serverTime) = totpResult.Value; + var clientTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Build token URL with TOTP parameters + var tokenUrl = $"{TokenEndpoint}?reason=init&productType=web-player&totp={otp}&totpServer={otp}&totpVer={totpSecret.Version}&sTime={serverTime}&cTime={clientTime}"; + + _logger.LogDebug("Requesting token from: {Url}", tokenUrl.Replace(otp, "***")); + + var response = await _webApiClient.GetAsync(tokenUrl, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -111,32 +147,36 @@ public class SpotifyApiClient : IDisposable } var json = await response.Content.ReadAsStringAsync(cancellationToken); - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; + var tokenResponse = JsonSerializer.Deserialize(json); - if (root.TryGetProperty("accessToken", out var tokenElement)) + if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) { - _webAccessToken = tokenElement.GetString(); - - // Token typically expires in 1 hour, but we'll refresh early - if (root.TryGetProperty("accessTokenExpirationTimestampMs", out var expiryElement)) - { - var expiryMs = expiryElement.GetInt64(); - _webTokenExpiry = DateTimeOffset.FromUnixTimeMilliseconds(expiryMs).UtcDateTime; - // Refresh 5 minutes early - _webTokenExpiry = _webTokenExpiry.AddMinutes(-5); - } - else - { - _webTokenExpiry = DateTime.UtcNow.AddMinutes(55); - } - - _logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}", _webTokenExpiry); - return _webAccessToken; + _logger.LogError("No access token in Spotify response: {Json}", json); + return null; } - _logger.LogError("No access token in Spotify response"); - return null; + if (tokenResponse.IsAnonymous) + { + _logger.LogWarning("Spotify returned anonymous token - session cookie may be invalid"); + } + + _webAccessToken = tokenResponse.AccessToken; + + // Token typically expires in 1 hour, but we'll refresh early + if (tokenResponse.ExpirationTimestampMs > 0) + { + _webTokenExpiry = DateTimeOffset.FromUnixTimeMilliseconds(tokenResponse.ExpirationTimestampMs).UtcDateTime; + // Refresh 5 minutes early + _webTokenExpiry = _webTokenExpiry.AddMinutes(-5); + } + else + { + _webTokenExpiry = DateTime.UtcNow.AddMinutes(55); + } + + _logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}, anonymous: {IsAnonymous}", + _webTokenExpiry, tokenResponse.IsAnonymous); + return _webAccessToken; } catch (Exception ex) { @@ -149,6 +189,99 @@ public class SpotifyApiClient : IDisposable } } + /// + /// Fetches TOTP secrets from the pre-scraped secrets repository. + /// + private async Task GetTotpSecretAsync(CancellationToken cancellationToken) + { + // Return cached secret if fresh (cache for 1 hour) + if (_cachedTotpSecret != null && DateTime.UtcNow - _totpSecretFetchedAt < TimeSpan.FromHours(1)) + { + return _cachedTotpSecret; + } + + try + { + _logger.LogDebug("Fetching TOTP secrets from {Url}", TotpSecretsUrl); + + var response = await _webApiClient.GetAsync(TotpSecretsUrl, cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Failed to fetch TOTP secrets: {StatusCode}", response.StatusCode); + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var secrets = JsonSerializer.Deserialize(json); + + if (secrets == null || secrets.Length == 0) + { + _logger.LogError("No TOTP secrets found in response"); + return null; + } + + // Use the newest version + _cachedTotpSecret = secrets.OrderByDescending(s => s.Version).First(); + _totpSecretFetchedAt = DateTime.UtcNow; + + _logger.LogDebug("Got TOTP secret version {Version}", _cachedTotpSecret.Version); + return _cachedTotpSecret; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching TOTP secrets"); + return null; + } + } + + /// + /// Generates a TOTP code using the secret and server time. + /// Based on the Jellyfin plugin implementation. + /// + private async Task<(string Otp, long ServerTime)?> GenerateTotpAsync(TotpSecret secret, CancellationToken cancellationToken) + { + try + { + // Get server time from Spotify via HEAD request + var headRequest = new HttpRequestMessage(HttpMethod.Head, SpotifyBaseUrl); + var response = await _webApiClient.SendAsync(headRequest, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Failed to get Spotify server time: {StatusCode}", response.StatusCode); + return null; + } + + var serverTime = response.Headers.Date?.ToUnixTimeSeconds(); + if (serverTime == null) + { + _logger.LogError("No Date header in Spotify response"); + return null; + } + + // Compute secret from cipher bytes + // The secret bytes need to be transformed: XOR each byte with ((index % 33) + 9) + var cipherBytes = secret.Secret.ToArray(); + var transformedBytes = cipherBytes.Select((b, i) => (byte)(b ^ ((i % 33) + 9))).ToArray(); + + // Convert to UTF-8 string representation then back to bytes for TOTP + var transformedString = string.Join("", transformedBytes.Select(b => b.ToString())); + var utf8Bytes = Encoding.UTF8.GetBytes(transformedString); + + // Generate TOTP + var totp = new Totp(utf8Bytes, step: 30, totpSize: 6); + var otp = totp.ComputeTotp(DateTime.UnixEpoch.AddSeconds(serverTime.Value)); + + _logger.LogDebug("Generated TOTP for server time {ServerTime}", serverTime.Value); + return (otp, serverTime.Value); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating TOTP"); + return null; + } + } + /// /// Fetches a playlist with all its tracks from Spotify. /// Uses the web API to access editorial playlists that aren't available via the official API. @@ -432,15 +565,15 @@ public class SpotifyApiClient : IDisposable foreach (var item in items.EnumerateArray()) { - var name = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; + var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; // Check if name matches (case-insensitive) - if (name.Contains(searchName, StringComparison.OrdinalIgnoreCase)) + if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase)) { playlists.Add(new SpotifyPlaylist { - SpotifyId = item.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "", - Name = name, + SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "", + Name = itemName, Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null, TotalTracks = item.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("total", out var total) @@ -535,4 +668,29 @@ public class SpotifyApiClient : IDisposable _webApiClient.Dispose(); _tokenLock.Dispose(); } + + // Internal classes for JSON deserialization + private class SpotifyTokenResponse + { + [JsonPropertyName("accessToken")] + public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("accessTokenExpirationTimestampMs")] + public long ExpirationTimestampMs { get; set; } + + [JsonPropertyName("isAnonymous")] + public bool IsAnonymous { get; set; } + + [JsonPropertyName("clientId")] + public string ClientId { get; set; } = string.Empty; + } + + private class TotpSecret + { + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("secret")] + public List Secret { get; set; } = new(); + } } diff --git a/allstarr/allstarr.csproj b/allstarr/allstarr.csproj index 6fc8345..a9e9b3e 100644 --- a/allstarr/allstarr.csproj +++ b/allstarr/allstarr.csproj @@ -13,6 +13,7 @@ +