modules/Core/ApiClientManager.psm1
|
# API Client Manager Module # Provides enhanced Spotify API client with rate limiting, caching, and error handling using namespace System.Management.Automation # using module .\ErrorHandling.psm1 # Load required assemblies try { Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue } catch { # Ignore if already loaded or unavailable } # Rate limiter class with enhanced exponential backoff class RateLimiter { [int] $MaxRequestsPerMinute [System.Collections.Generic.Queue[DateTime]] $RequestTimes [System.Threading.Mutex] $Mutex [int] $BackoffMultiplier = 2 [int] $MaxBackoffSeconds = 300 [int] $BaseBackoffMs = 1000 [hashtable] $BackoffHistory = @{} RateLimiter([int]$maxRequestsPerMinute) { $this.MaxRequestsPerMinute = $maxRequestsPerMinute $this.RequestTimes = [System.Collections.Generic.Queue[DateTime]]::new() $this.Mutex = [System.Threading.Mutex]::new() } [bool] CanMakeRequest() { $this.Mutex.WaitOne() | Out-Null try { $now = [DateTime]::UtcNow $oneMinuteAgo = $now.AddMinutes(-1) # Remove old requests while ($this.RequestTimes.Count -gt 0 -and $this.RequestTimes.Peek() -lt $oneMinuteAgo) { $this.RequestTimes.Dequeue() | Out-Null } return $this.RequestTimes.Count -lt $this.MaxRequestsPerMinute } finally { $this.Mutex.ReleaseMutex() } } [void] RecordRequest() { $this.Mutex.WaitOne() | Out-Null try { $this.RequestTimes.Enqueue([DateTime]::UtcNow) } finally { $this.Mutex.ReleaseMutex() } } [int] GetWaitTimeMs() { $this.Mutex.WaitOne() | Out-Null try { if ($this.RequestTimes.Count -eq 0) { return 0 } $oldestRequest = $this.RequestTimes.Peek() $waitUntil = $oldestRequest.AddMinutes(1) $waitTime = $waitUntil - [DateTime]::UtcNow return [Math]::Max(0, [int]$waitTime.TotalMilliseconds) } finally { $this.Mutex.ReleaseMutex() } } [int] GetExponentialBackoffDelay([string]$endpoint, [int]$attempt) { $key = "backoff_$endpoint" # Calculate exponential backoff with jitter $baseDelay = $this.BaseBackoffMs * [Math]::Pow($this.BackoffMultiplier, $attempt - 1) $maxDelay = $this.MaxBackoffSeconds * 1000 $delay = [Math]::Min($baseDelay, $maxDelay) # Add jitter (±25% randomization) $jitter = $delay * 0.25 $randomJitter = (Get-Random -Minimum (-$jitter) -Maximum $jitter) $finalDelay = [Math]::Max(0, $delay + $randomJitter) # Store backoff history $this.BackoffHistory[$key] = @{ LastAttempt = [DateTime]::UtcNow Attempt = $attempt Delay = $finalDelay } return [int]$finalDelay } [void] ResetBackoff([string]$endpoint) { $key = "backoff_$endpoint" if ($this.BackoffHistory.ContainsKey($key)) { $this.BackoffHistory.Remove($key) } } [hashtable] GetStats() { $this.Mutex.WaitOne() | Out-Null try { $now = [DateTime]::UtcNow $oneMinuteAgo = $now.AddMinutes(-1) # Count recent requests $recentRequests = 0 foreach ($requestTime in $this.RequestTimes) { if ($requestTime -gt $oneMinuteAgo) { $recentRequests++ } } return @{ MaxRequestsPerMinute = $this.MaxRequestsPerMinute RecentRequests = $recentRequests RemainingRequests = $this.MaxRequestsPerMinute - $recentRequests WaitTimeMs = $this.GetWaitTimeMs() } } finally { $this.Mutex.ReleaseMutex() } } } # Cache entry class for enhanced metadata class CacheEntry { [object] $Data [DateTime] $CreatedAt [DateTime] $LastAccessedAt [DateTime] $ExpiresAt [int] $AccessCount [string] $CacheType [hashtable] $Metadata [int] $Priority CacheEntry([object]$data, [int]$cacheDurationMs, [string]$cacheType = "api", [int]$priority = 0) { $this.Data = $data $this.CreatedAt = [DateTime]::UtcNow $this.LastAccessedAt = [DateTime]::UtcNow $this.ExpiresAt = [DateTime]::UtcNow.AddMilliseconds($cacheDurationMs) $this.AccessCount = 1 $this.CacheType = $cacheType $this.Metadata = @{} $this.Priority = $priority } [void] UpdateAccess() { $this.LastAccessedAt = [DateTime]::UtcNow $this.AccessCount++ } [bool] IsExpired() { return [DateTime]::UtcNow -gt $this.ExpiresAt } [void] ExtendExpiry([int]$additionalMs) { $this.ExpiresAt = $this.ExpiresAt.AddMilliseconds($additionalMs) } } # Enhanced cache manager class with persistent storage class CacheManager { [hashtable] $Cache = @{} [int] $DefaultCacheDurationMs [int] $MaxCacheSize [System.Threading.ReaderWriterLockSlim] $Lock [string] $PersistentCacheDir [bool] $PersistentCacheEnabled [hashtable] $CacheTypeSettings = @{} [System.Timers.Timer] $CleanupTimer CacheManager([int]$defaultCacheDurationMs, [int]$maxCacheSize, [string]$persistentCacheDir = "", [bool]$persistentCacheEnabled = $false) { $this.DefaultCacheDurationMs = $defaultCacheDurationMs $this.MaxCacheSize = $maxCacheSize $this.Lock = [System.Threading.ReaderWriterLockSlim]::new() $this.PersistentCacheDir = $persistentCacheDir $this.PersistentCacheEnabled = $persistentCacheEnabled # Initialize cache type settings $this.InitializeCacheTypeSettings() # Create persistent cache directory if needed if ($this.PersistentCacheEnabled -and -not [string]::IsNullOrWhiteSpace($this.PersistentCacheDir)) { if (-not (Test-Path $this.PersistentCacheDir)) { New-Item -ItemType Directory -Path $this.PersistentCacheDir -Force | Out-Null } } # Start cleanup timer (runs every 5 minutes) $this.CleanupTimer = [System.Timers.Timer]::new(300000) $this.CleanupTimer.AutoReset = $true $this.CleanupTimer.add_Elapsed({ $this.PerformCleanup() }) $this.CleanupTimer.Start() # Load persistent cache if enabled if ($this.PersistentCacheEnabled) { $this.LoadPersistentCache() } } [void] InitializeCacheTypeSettings() { $this.CacheTypeSettings = @{ "api" = @{ CacheDurationMs = $this.DefaultCacheDurationMs PersistToDisk = $false Priority = 1 } "lyrics" = @{ CacheDurationMs = 2592000000 # 30 days PersistToDisk = $true Priority = 3 } "statistics" = @{ CacheDurationMs = 86400000 # 24 hours PersistToDisk = $true Priority = 2 } "track_info" = @{ CacheDurationMs = 3600000 # 1 hour PersistToDisk = $false Priority = 1 } } } [string] GenerateCacheKey([string]$method, [string]$path, [hashtable]$query, [string]$cacheType = "api") { $keyParts = @($cacheType, $method, $path) if ($query -and $query.Count -gt 0) { $sortedQuery = $query.GetEnumerator() | Sort-Object Key | ForEach-Object { "$($_.Key)=$($_.Value)" } $keyParts += ($sortedQuery -join "&") } return $keyParts -join "|" } [string] GenerateSimpleCacheKey([string]$identifier, [string]$cacheType) { return "$cacheType|$identifier" } [bool] TryGetCachedResponse([string]$cacheKey, [ref]$cachedResponse) { $this.Lock.EnterReadLock() try { if (-not $this.Cache.ContainsKey($cacheKey)) { # Try loading from persistent cache if ($this.PersistentCacheEnabled) { $this.Lock.ExitReadLock() $loaded = $this.LoadFromPersistentCache($cacheKey) $this.Lock.EnterReadLock() if ($loaded -and $this.Cache.ContainsKey($cacheKey)) { # Successfully loaded from disk, continue with normal flow } else { return $false } } else { return $false } } $entry = $this.Cache[$cacheKey] if ($entry.IsExpired()) { # Cache expired, remove it $this.Lock.ExitReadLock() $this.Lock.EnterWriteLock() try { $this.Cache.Remove($cacheKey) if ($this.PersistentCacheEnabled -and $entry.CacheType -in @("lyrics", "statistics")) { $this.RemoveFromPersistentCache($cacheKey) } } finally { $this.Lock.ExitWriteLock() $this.Lock.EnterReadLock() } return $false } # Update access information $entry.UpdateAccess() $cachedResponse.Value = $entry.Data return $true } finally { $this.Lock.ExitReadLock() } } [void] CacheResponse([string]$cacheKey, $response, [string]$cacheType = "api", [int]$customDurationMs = 0) { $this.Lock.EnterWriteLock() try { # Check cache size and evict entries if necessary if ($this.Cache.Count -ge $this.MaxCacheSize) { $this.EvictLRUEntries(10) # Remove 10 least recently used entries } # Determine cache duration $cacheDurationMs = if ($customDurationMs -gt 0) { $customDurationMs } elseif ($this.CacheTypeSettings.ContainsKey($cacheType)) { $this.CacheTypeSettings[$cacheType].CacheDurationMs } else { $this.DefaultCacheDurationMs } # Determine priority $priority = if ($this.CacheTypeSettings.ContainsKey($cacheType)) { $this.CacheTypeSettings[$cacheType].Priority } else { 1 } # Create cache entry $entry = [CacheEntry]::new($response, $cacheDurationMs, $cacheType, $priority) $this.Cache[$cacheKey] = $entry # Save to persistent cache if applicable if ($this.PersistentCacheEnabled -and $this.CacheTypeSettings.ContainsKey($cacheType) -and $this.CacheTypeSettings[$cacheType].PersistToDisk) { $this.SaveToPersistentCache($cacheKey, $entry) } } finally { $this.Lock.ExitWriteLock() } } [void] EvictLRUEntries([int]$count) { # Sort by last accessed time and priority, remove least recently used entries with lowest priority $sortedEntries = $this.Cache.GetEnumerator() | Sort-Object { $_.Value.Priority }, { $_.Value.LastAccessedAt } | Select-Object -First $count foreach ($entry in $sortedEntries) { $cacheEntry = $entry.Value $this.Cache.Remove($entry.Key) # Remove from persistent cache if applicable if ($this.PersistentCacheEnabled -and $cacheEntry.CacheType -in @("lyrics", "statistics")) { $this.RemoveFromPersistentCache($entry.Key) } } } [void] SaveToPersistentCache([string]$cacheKey, [CacheEntry]$entry) { if (-not $this.PersistentCacheEnabled -or [string]::IsNullOrWhiteSpace($this.PersistentCacheDir)) { return } try { $fileName = ($cacheKey -replace '[^\w\-]', '_') + ".json" $filePath = Join-Path $this.PersistentCacheDir $fileName $persistentData = @{ Data = $entry.Data CreatedAt = $entry.CreatedAt.ToString("o") ExpiresAt = $entry.ExpiresAt.ToString("o") CacheType = $entry.CacheType Metadata = $entry.Metadata Priority = $entry.Priority } $json = $persistentData | ConvertTo-Json -Depth 10 -Compress [System.IO.File]::WriteAllText($filePath, $json, [System.Text.Encoding]::UTF8) } catch { Write-Warning "Failed to save cache entry to disk: $($_.Exception.Message)" } } [bool] LoadFromPersistentCache([string]$cacheKey) { if (-not $this.PersistentCacheEnabled -or [string]::IsNullOrWhiteSpace($this.PersistentCacheDir)) { return $false } try { $fileName = ($cacheKey -replace '[^\w\-]', '_') + ".json" $filePath = Join-Path $this.PersistentCacheDir $fileName if (-not (Test-Path $filePath)) { return $false } $json = [System.IO.File]::ReadAllText($filePath, [System.Text.Encoding]::UTF8) $persistentData = $json | ConvertFrom-Json # Check if expired $expiresAt = [DateTime]::Parse($persistentData.ExpiresAt) if ([DateTime]::UtcNow -gt $expiresAt) { # Remove expired file Remove-Item $filePath -Force -ErrorAction SilentlyContinue return $false } # Recreate cache entry $createdAt = [DateTime]::Parse($persistentData.CreatedAt) $remainingMs = ($expiresAt - [DateTime]::UtcNow).TotalMilliseconds $entry = [CacheEntry]::new($persistentData.Data, [int]$remainingMs, $persistentData.CacheType, $persistentData.Priority) $entry.CreatedAt = $createdAt $entry.ExpiresAt = $expiresAt $entry.Metadata = $persistentData.Metadata # Add to memory cache $this.Lock.EnterWriteLock() try { $this.Cache[$cacheKey] = $entry } finally { $this.Lock.ExitWriteLock() } return $true } catch { Write-Warning "Failed to load cache entry from disk: $($_.Exception.Message)" return $false } } [void] LoadPersistentCache() { if (-not $this.PersistentCacheEnabled -or [string]::IsNullOrWhiteSpace($this.PersistentCacheDir) -or -not (Test-Path $this.PersistentCacheDir)) { return } try { $cacheFiles = Get-ChildItem -Path $this.PersistentCacheDir -Filter "*.json" -ErrorAction SilentlyContinue $loadedCount = 0 foreach ($file in $cacheFiles) { try { $cacheKey = $file.BaseName if ($this.LoadFromPersistentCache($cacheKey)) { $loadedCount++ } } catch { # Remove corrupted cache file Remove-Item $file.FullName -Force -ErrorAction SilentlyContinue } } Write-Verbose "Loaded $loadedCount cache entries from persistent storage" } catch { Write-Warning "Failed to load persistent cache: $($_.Exception.Message)" } } [void] RemoveFromPersistentCache([string]$cacheKey) { if (-not $this.PersistentCacheEnabled -or [string]::IsNullOrWhiteSpace($this.PersistentCacheDir)) { return } try { $fileName = ($cacheKey -replace '[^\w\-]', '_') + ".json" $filePath = Join-Path $this.PersistentCacheDir $fileName if (Test-Path $filePath) { Remove-Item $filePath -Force } } catch { Write-Warning "Failed to remove cache entry from disk: $($_.Exception.Message)" } } [void] PerformCleanup() { $this.Lock.EnterWriteLock() try { $expiredKeys = @() # Find expired entries foreach ($kvp in $this.Cache.GetEnumerator()) { if ($kvp.Value.IsExpired()) { $expiredKeys += $kvp.Key } } # Remove expired entries foreach ($key in $expiredKeys) { $entry = $this.Cache[$key] $this.Cache.Remove($key) # Remove from persistent cache if applicable if ($this.PersistentCacheEnabled -and $entry.CacheType -in @("lyrics", "statistics")) { $this.RemoveFromPersistentCache($key) } } Write-Verbose "Cache cleanup removed $($expiredKeys.Count) expired entries" } finally { $this.Lock.ExitWriteLock() } } [void] ClearCache([string]$cacheType = "") { $this.Lock.EnterWriteLock() try { if ([string]::IsNullOrWhiteSpace($cacheType)) { # Clear all cache foreach ($kvp in $this.Cache.GetEnumerator()) { $entry = $kvp.Value if ($this.PersistentCacheEnabled -and $entry.CacheType -in @("lyrics", "statistics")) { $this.RemoveFromPersistentCache($kvp.Key) } } $this.Cache.Clear() } else { # Clear specific cache type $keysToRemove = @() foreach ($kvp in $this.Cache.GetEnumerator()) { if ($kvp.Value.CacheType -eq $cacheType) { $keysToRemove += $kvp.Key } } foreach ($key in $keysToRemove) { $entry = $this.Cache[$key] $this.Cache.Remove($key) if ($this.PersistentCacheEnabled -and $entry.CacheType -in @("lyrics", "statistics")) { $this.RemoveFromPersistentCache($key) } } } } finally { $this.Lock.ExitWriteLock() } } [void] InvalidateCache([string]$pattern) { $this.Lock.EnterWriteLock() try { $keysToRemove = @() foreach ($key in $this.Cache.Keys) { if ($key -like $pattern) { $keysToRemove += $key } } foreach ($key in $keysToRemove) { $entry = $this.Cache[$key] $this.Cache.Remove($key) if ($this.PersistentCacheEnabled -and $entry.CacheType -in @("lyrics", "statistics")) { $this.RemoveFromPersistentCache($key) } } Write-Verbose "Invalidated $($keysToRemove.Count) cache entries matching pattern: $pattern" } finally { $this.Lock.ExitWriteLock() } } [void] InvalidateCacheByTrack([string]$trackId) { $patterns = @( "*track*$trackId*", "*lyrics*$trackId*", "*statistics*$trackId*" ) foreach ($pattern in $patterns) { $this.InvalidateCache($pattern) } } [hashtable] GetCacheStats() { $this.Lock.EnterReadLock() try { $now = [DateTime]::UtcNow $totalEntries = $this.Cache.Count $expiredEntries = 0 $statsByType = @{} $totalAccessCount = 0 $oldestEntry = $null $newestEntry = $null foreach ($kvp in $this.Cache.GetEnumerator()) { $entry = $kvp.Value if ($entry.IsExpired()) { $expiredEntries++ } # Stats by type if (-not $statsByType.ContainsKey($entry.CacheType)) { $statsByType[$entry.CacheType] = @{ Count = 0 TotalAccessCount = 0 ExpiredCount = 0 } } $statsByType[$entry.CacheType].Count++ $statsByType[$entry.CacheType].TotalAccessCount += $entry.AccessCount if ($entry.IsExpired()) { $statsByType[$entry.CacheType].ExpiredCount++ } $totalAccessCount += $entry.AccessCount # Track oldest and newest entries if ($oldestEntry -eq $null -or $entry.CreatedAt -lt $oldestEntry.CreatedAt) { $oldestEntry = $entry } if ($newestEntry -eq $null -or $entry.CreatedAt -gt $newestEntry.CreatedAt) { $newestEntry = $entry } } return @{ TotalEntries = $totalEntries ExpiredEntries = $expiredEntries ValidEntries = $totalEntries - $expiredEntries MaxCacheSize = $this.MaxCacheSize CacheDurationMs = $this.DefaultCacheDurationMs PersistentCacheEnabled = $this.PersistentCacheEnabled PersistentCacheDir = $this.PersistentCacheDir StatsByType = $statsByType TotalAccessCount = $totalAccessCount AverageAccessCount = if ($totalEntries -gt 0) { [Math]::Round($totalAccessCount / $totalEntries, 2) } else { 0 } OldestEntryAge = if ($oldestEntry) { ($now - $oldestEntry.CreatedAt).TotalMinutes } else { 0 } NewestEntryAge = if ($newestEntry) { ($now - $newestEntry.CreatedAt).TotalMinutes } else { 0 } } } finally { $this.Lock.ExitReadLock() } } [void] Dispose() { if ($this.CleanupTimer) { $this.CleanupTimer.Stop() $this.CleanupTimer.Dispose() } if ($this.Lock) { $this.Lock.Dispose() } } } # Request queue item class class QueuedRequest { [string] $Id [string] $Method [string] $Path [hashtable] $Query [object] $Body [DateTime] $QueuedAt [int] $Priority [System.Management.Automation.PowerShell] $Callback QueuedRequest([string]$method, [string]$path, [hashtable]$query, $body, [int]$priority = 0) { $this.Id = [System.Guid]::NewGuid().ToString() $this.Method = $method $this.Path = $path $this.Query = $query $this.Body = $body $this.QueuedAt = [DateTime]::UtcNow $this.Priority = $priority } } # Connection pool manager class ConnectionPoolManager { [int] $MaxConnections [int] $ConnectionTimeoutMs [System.Collections.Concurrent.ConcurrentQueue[System.Net.Http.HttpClient]] $AvailableConnections [System.Collections.Generic.HashSet[System.Net.Http.HttpClient]] $ActiveConnections [System.Threading.SemaphoreSlim] $ConnectionSemaphore [object] $Lock = [object]::new() ConnectionPoolManager([int]$maxConnections, [int]$connectionTimeoutMs) { $this.MaxConnections = $maxConnections $this.ConnectionTimeoutMs = $connectionTimeoutMs $this.AvailableConnections = [System.Collections.Concurrent.ConcurrentQueue[System.Net.Http.HttpClient]]::new() $this.ActiveConnections = [System.Collections.Generic.HashSet[System.Net.Http.HttpClient]]::new() $this.ConnectionSemaphore = [System.Threading.SemaphoreSlim]::new($maxConnections, $maxConnections) # Pre-create connections for ($i = 0; $i -lt $maxConnections; $i++) { $client = $this.CreateHttpClient() $this.AvailableConnections.Enqueue($client) } } [System.Net.Http.HttpClient] CreateHttpClient() { $client = [System.Net.Http.HttpClient]::new() $client.Timeout = [TimeSpan]::FromMilliseconds($this.ConnectionTimeoutMs) $client.DefaultRequestHeaders.Add("User-Agent", "SpotifyCLI-Enhanced/1.0") return $client } [System.Net.Http.HttpClient] AcquireConnection() { $this.ConnectionSemaphore.Wait() $client = $null if (-not $this.AvailableConnections.TryDequeue([ref]$client)) { # Create new connection if pool is empty (shouldn't happen with semaphore) $client = $this.CreateHttpClient() } lock ($this.Lock) { $this.ActiveConnections.Add($client) | Out-Null } return $client } [void] ReleaseConnection([System.Net.Http.HttpClient]$client) { if ($client -eq $null) { return } lock ($this.Lock) { if ($this.ActiveConnections.Contains($client)) { $this.ActiveConnections.Remove($client) | Out-Null $this.AvailableConnections.Enqueue($client) $this.ConnectionSemaphore.Release() } } } [hashtable] GetPoolStats() { lock ($this.Lock) { return @{ MaxConnections = $this.MaxConnections ActiveConnections = $this.ActiveConnections.Count AvailableConnections = $this.AvailableConnections.Count ConnectionTimeoutMs = $this.ConnectionTimeoutMs } } return @{} # Explicit return for all code paths (never reached) } [void] Dispose() { # Dispose all connections $client = $null while ($this.AvailableConnections.TryDequeue([ref]$client)) { $client.Dispose() } lock ($this.Lock) { foreach ($client in $this.ActiveConnections) { $client.Dispose() } $this.ActiveConnections.Clear() } $this.ConnectionSemaphore.Dispose() } } # Enhanced API client class class EnhancedSpotifyApiClient { [string] $ClientId [string] $ClientSecret [string] $AccessToken [string] $RefreshToken [DateTime] $TokenExpiry [string] $ApiBase = "https://api.spotify.com/v1" [int] $TimeoutMs [RateLimiter] $RateLimiter [CacheManager] $CacheManager # [ErrorHandler] $ErrorHandler # Removed to break circular dependency [ConnectionPoolManager] $ConnectionPool [bool] $CacheEnabled [bool] $QueueingEnabled [System.Collections.Concurrent.ConcurrentQueue[QueuedRequest]] $RequestQueue [System.Threading.CancellationTokenSource] $QueueCancellationToken [System.Threading.Tasks.Task] $QueueProcessorTask [hashtable] $RequestStats = @{ TotalRequests = 0 CacheHits = 0 CacheMisses = 0 RateLimitHits = 0 Errors = 0 QueuedRequests = 0 ProcessedRequests = 0 } EnhancedSpotifyApiClient([hashtable]$config) { $this.ClientId = $config.ClientId $this.ClientSecret = $config.ClientSecret $this.TimeoutMs = if ($config.ContainsKey('TimeoutMs')) { $config.TimeoutMs } else { 10000 } $this.CacheEnabled = if ($config.ContainsKey('CacheEnabled')) { $config.CacheEnabled } else { $true } $this.QueueingEnabled = if ($config.ContainsKey('QueueingEnabled')) { $config.QueueingEnabled } else { $false } # Initialize rate limiter $maxRequestsPerMinute = if ($config.ContainsKey('MaxRequestsPerMinute')) { $config.MaxRequestsPerMinute } else { 60 } $this.RateLimiter = [RateLimiter]::new($maxRequestsPerMinute) # Initialize cache manager $cacheDurationMs = if ($config.ContainsKey('CacheDurationMs')) { $config.CacheDurationMs } else { 60000 } $maxCacheSize = if ($config.ContainsKey('MaxCacheSize')) { $config.MaxCacheSize } else { 1000 } $persistentCacheDir = if ($config.ContainsKey('PersistentCacheDir')) { $config.PersistentCacheDir } else { Join-Path $env:APPDATA "SpotifyCLI\Cache" } $persistentCacheEnabled = if ($config.ContainsKey('PersistentCacheEnabled')) { $config.PersistentCacheEnabled } else { $true } $this.CacheManager = [CacheManager]::new($cacheDurationMs, $maxCacheSize, $persistentCacheDir, $persistentCacheEnabled) # Initialize connection pool $maxConnections = if ($config.ContainsKey('MaxConnections')) { $config.MaxConnections } else { 5 } $this.ConnectionPool = [ConnectionPoolManager]::new($maxConnections, $this.TimeoutMs) # Note: ErrorHandler removed to break circular dependency with ErrorHandling.psm1 # Initialize request queue if enabled if ($this.QueueingEnabled) { $this.RequestQueue = [System.Collections.Concurrent.ConcurrentQueue[QueuedRequest]]::new() $this.QueueCancellationToken = [System.Threading.CancellationTokenSource]::new() $this.StartQueueProcessor() } # Load existing tokens $this.LoadTokens() } [void] LoadTokens() { # This would integrate with the existing token storage system # For now, we'll use a placeholder implementation try { $tokenFile = Join-Path $env:APPDATA "SpotifyCLI\tokens.json" if (Test-Path $tokenFile) { $json = Get-Content -Path $tokenFile -Raw -Encoding UTF8 $tokens = $json | ConvertFrom-Json $this.AccessToken = $tokens.access_token $this.RefreshToken = $tokens.refresh_token if ($tokens.obtained_at -and $tokens.expires_in) { $obtainedAt = [DateTimeOffset]::FromUnixTimeSeconds($tokens.obtained_at).DateTime $this.TokenExpiry = $obtainedAt.AddSeconds($tokens.expires_in) } } } catch { Write-Warning "Failed to load tokens: $($_.Exception.Message)" } } [bool] IsTokenValid() { return -not [string]::IsNullOrWhiteSpace($this.AccessToken) -and $this.TokenExpiry -gt [DateTime]::UtcNow.AddMinutes(5) } [void] RefreshTokenIfNeeded() { if ($this.IsTokenValid()) { return } if ([string]::IsNullOrWhiteSpace($this.RefreshToken)) { throw "AUTHENTICATION_ERROR: No refresh token available. Re-authentication required." } try { $body = @{ grant_type = "refresh_token" refresh_token = $this.RefreshToken client_id = $this.ClientId client_secret = $this.ClientSecret } $response = Invoke-RestMethod -Method Post -Uri "https://accounts.spotify.com/api/token" -Body $body -TimeoutSec ($this.TimeoutMs / 1000) $this.AccessToken = $response.access_token if ($response.refresh_token) { $this.RefreshToken = $response.refresh_token } $this.TokenExpiry = [DateTime]::UtcNow.AddSeconds($response.expires_in) # Save updated tokens (would integrate with existing token storage) Write-Verbose "Token refreshed successfully" } catch { throw "AUTHENTICATION_ERROR: Token refresh failed: $($_.Exception.Message)" } } [void] StartQueueProcessor() { $this.QueueProcessorTask = [System.Threading.Tasks.Task]::Run({ while (-not $this.QueueCancellationToken.Token.IsCancellationRequested) { try { $request = $null if ($this.RequestQueue.TryDequeue([ref]$request)) { $this.ProcessQueuedRequest($request) $this.RequestStats.ProcessedRequests++ } else { Start-Sleep -Milliseconds 100 } } catch { Write-Warning "Queue processor error: $($_.Exception.Message)" Start-Sleep -Milliseconds 1000 } } }) } [void] ProcessQueuedRequest([QueuedRequest]$request) { try { $response = $this.MakeRequestInternal($request.Method, $request.Path, $request.Query, $request.Body) if ($request.Callback) { $request.Callback.AddScript({ param($response) return $response }).Invoke($response) } } catch { if ($request.Callback) { $request.Callback.AddScript({ param($error) throw $error }).Invoke($_) } } } [object] MakeRequest([string]$method, [string]$path, [hashtable]$query, $body) { if ($this.QueueingEnabled -and $method -ne "GET") { # Queue non-GET requests for background processing $request = [QueuedRequest]::new($method, $path, $query, $body) $this.RequestQueue.Enqueue($request) $this.RequestStats.QueuedRequests++ return $null # Async operation } return $this.MakeRequestInternal($method, $path, $query, $body) } [object] MakeRequestInternal([string]$method, [string]$path, [hashtable]$query, $body) { $this.RequestStats.TotalRequests++ $attempt = 1 $maxAttempts = 3 while ($attempt -le $maxAttempts) { try { # Check cache first (only for GET requests) if ($method -eq "GET" -and $this.CacheEnabled) { $cacheType = $this.DetermineCacheType($path) $cacheKey = $this.CacheManager.GenerateCacheKey($method, $path, $query, $cacheType) $cachedResponse = $null if ($this.CacheManager.TryGetCachedResponse($cacheKey, [ref]$cachedResponse)) { $this.RequestStats.CacheHits++ return $cachedResponse } $this.RequestStats.CacheMisses++ } # Check rate limit if (-not $this.RateLimiter.CanMakeRequest()) { $waitTime = $this.RateLimiter.GetWaitTimeMs() $this.RequestStats.RateLimitHits++ if ($waitTime -gt 0) { Start-Sleep -Milliseconds $waitTime } } # Ensure token is valid $this.RefreshTokenIfNeeded() # Build URI $uri = "$($this.ApiBase)$path" if ($query -and $query.Count -gt 0) { $queryString = ($query.GetEnumerator() | ForEach-Object { "$($_.Key)=$([System.Uri]::EscapeDataString($_.Value))" }) -join "&" $uri += "?$queryString" } # Prepare headers $headers = @{ 'Authorization' = "Bearer $($this.AccessToken)" 'Content-Type' = 'application/json' } # Record the request $this.RateLimiter.RecordRequest() # Make the request using connection pool $httpClient = $this.ConnectionPool.AcquireConnection() try { # Make the request $response = if ($body) { Invoke-RestMethod -Method $method -Uri $uri -Headers $headers -Body ($body | ConvertTo-Json -Depth 10) -TimeoutSec ($this.TimeoutMs / 1000) } else { Invoke-RestMethod -Method $method -Uri $uri -Headers $headers -TimeoutSec ($this.TimeoutMs / 1000) } # Cache the response (only for GET requests) if ($method -eq "GET" -and $this.CacheEnabled) { $cacheType = $this.DetermineCacheType($path) $cacheKey = $this.CacheManager.GenerateCacheKey($method, $path, $query, $cacheType) $this.CacheManager.CacheResponse($cacheKey, $response, $cacheType) } # Reset backoff on success $this.RateLimiter.ResetBackoff($path) return $response } finally { $this.ConnectionPool.ReleaseConnection($httpClient) } } catch [System.Net.WebException] { $this.RequestStats.Errors++ $statusCode = 0 $responseBody = "" if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode try { $stream = $_.Exception.Response.GetResponseStream() $reader = [System.IO.StreamReader]::new($stream) $responseBody = $reader.ReadToEnd() } catch { $responseBody = "Unable to read response body" } } # Handle retryable errors with exponential backoff if ($statusCode -in @(429, 500, 502, 503, 504) -and $attempt -lt $maxAttempts) { $backoffDelay = $this.RateLimiter.GetExponentialBackoffDelay($path, $attempt) Write-Verbose "Request failed with status $statusCode. Retrying in $backoffDelay ms (attempt $($attempt + 1)/$maxAttempts)" Start-Sleep -Milliseconds $backoffDelay $attempt++ continue } # Create appropriate exception based on status code switch ($statusCode) { 401 { throw "AUTHENTICATION_ERROR: Authentication failed" } 429 { $retryAfter = 60 # Default retry after 60 seconds if ($_.Exception.Response.Headers["Retry-After"]) { $retryAfter = [int]$_.Exception.Response.Headers["Retry-After"] } throw "RATE_LIMIT_ERROR: Rate limit exceeded. Retry after $retryAfter seconds" } default { throw "API_CLIENT_ERROR: API request failed (Status: $statusCode): $($_.Exception.Message)" } } } catch { $this.RequestStats.Errors++ # Handle retryable network errors if ($attempt -lt $maxAttempts) { $backoffDelay = $this.RateLimiter.GetExponentialBackoffDelay($path, $attempt) Write-Verbose "Request failed with network error. Retrying in $backoffDelay ms (attempt $($attempt + 1)/$maxAttempts)" Start-Sleep -Milliseconds $backoffDelay $attempt++ continue } throw "API_CLIENT_ERROR: Request failed: $($_.Exception.Message)" } } # This should never be reached due to throw statements in the loop throw "API_CLIENT_ERROR: Maximum retry attempts exceeded" return $null # Explicit return for all code paths (never reached) } [object] Get([string]$path, [hashtable]$query = @{}) { return $this.MakeRequest("GET", $path, $query, $null) } [object] Post([string]$path, $body, [hashtable]$query = @{}) { return $this.MakeRequest("POST", $path, $query, $body) } [object] Put([string]$path, $body, [hashtable]$query = @{}) { return $this.MakeRequest("PUT", $path, $query, $body) } [object] Delete([string]$path, [hashtable]$query = @{}) { return $this.MakeRequest("DELETE", $path, $query, $null) } [hashtable] GetStats() { $rateLimiterStats = $this.RateLimiter.GetStats() $cacheStats = $this.CacheManager.GetCacheStats() $connectionPoolStats = $this.ConnectionPool.GetPoolStats() $stats = @{ Requests = $this.RequestStats.Clone() RateLimit = $rateLimiterStats Cache = $cacheStats ConnectionPool = $connectionPoolStats TokenExpiry = $this.TokenExpiry IsTokenValid = $this.IsTokenValid() } if ($this.QueueingEnabled) { $stats.Queue = @{ QueueSize = $this.RequestQueue.Count QueueingEnabled = $this.QueueingEnabled ProcessorRunning = $this.QueueProcessorTask -and -not $this.QueueProcessorTask.IsCompleted } } return $stats } [void] ClearCache() { $this.CacheManager.ClearCache() } [void] ResetStats() { $this.RequestStats = @{ TotalRequests = 0 CacheHits = 0 CacheMisses = 0 RateLimitHits = 0 Errors = 0 QueuedRequests = 0 ProcessedRequests = 0 } } [void] StopQueueProcessor() { if ($this.QueueCancellationToken) { $this.QueueCancellationToken.Cancel() } if ($this.QueueProcessorTask) { try { $this.QueueProcessorTask.Wait(5000) # Wait up to 5 seconds } catch { Write-Warning "Queue processor did not stop gracefully" } } } [string] DetermineCacheType([string]$path) { if ($path -like "*track*") { return "track_info" } elseif ($path -like "*lyrics*" -or $path -like "*genius*" -or $path -like "*musixmatch*") { return "lyrics" } elseif ($path -like "*stats*" -or $path -like "*analytics*" -or $path -like "*top*" -or $path -like "*recently-played*") { return "statistics" } else { return "api" } } [void] CacheLyrics([string]$trackId, [string]$artist, [string]$title, $lyricsData) { $cacheKey = $this.CacheManager.GenerateSimpleCacheKey("$trackId|$artist|$title", "lyrics") $this.CacheManager.CacheResponse($cacheKey, $lyricsData, "lyrics") } [object] GetCachedLyrics([string]$trackId, [string]$artist, [string]$title) { $cacheKey = $this.CacheManager.GenerateSimpleCacheKey("$trackId|$artist|$title", "lyrics") $cachedResponse = $null if ($this.CacheManager.TryGetCachedResponse($cacheKey, [ref]$cachedResponse)) { return $cachedResponse } return $null } [void] CacheStatistics([string]$userId, [string]$period, $statisticsData) { $cacheKey = $this.CacheManager.GenerateSimpleCacheKey("$userId|$period", "statistics") $this.CacheManager.CacheResponse($cacheKey, $statisticsData, "statistics") } [object] GetCachedStatistics([string]$userId, [string]$period) { $cacheKey = $this.CacheManager.GenerateSimpleCacheKey("$userId|$period", "statistics") $cachedResponse = $null if ($this.CacheManager.TryGetCachedResponse($cacheKey, [ref]$cachedResponse)) { return $cachedResponse } return $null } [void] InvalidateTrackCache([string]$trackId) { $this.CacheManager.InvalidateCacheByTrack($trackId) } [void] Dispose() { $this.StopQueueProcessor() if ($this.ConnectionPool) { $this.ConnectionPool.Dispose() } if ($this.CacheManager) { $this.CacheManager.Dispose() } if ($this.QueueCancellationToken) { $this.QueueCancellationToken.Dispose() } } } # API Client Manager factory class ApiClientManager { static [EnhancedSpotifyApiClient] CreateClient([hashtable]$config) { # Set default values $defaultConfig = @{ ClientId = $env:SPOTIFY_CLIENT_ID ClientSecret = $env:SPOTIFY_CLIENT_SECRET TimeoutMs = 10000 MaxRequestsPerMinute = 60 CacheEnabled = $true CacheDurationMs = 60000 MaxCacheSize = 1000 MaxConnections = 5 QueueingEnabled = $false PersistentCacheEnabled = $true PersistentCacheDir = (Join-Path $env:APPDATA "SpotifyCLI\Cache") } # Merge with provided config foreach ($key in $config.Keys) { $defaultConfig[$key] = $config[$key] } # Validate required settings if ([string]::IsNullOrWhiteSpace($defaultConfig.ClientId)) { throw "CONFIGURATION_ERROR: Spotify Client ID not configured" } if ([string]::IsNullOrWhiteSpace($defaultConfig.ClientSecret)) { throw "CONFIGURATION_ERROR: Spotify Client Secret not configured" } return [EnhancedSpotifyApiClient]::new($defaultConfig) } static [EnhancedSpotifyApiClient] CreateDefaultClient() { return [ApiClientManager]::CreateClient(@{}) } } # Helper functions for backward compatibility function New-EnhancedSpotifyApiClient { param( [hashtable]$Configuration = @{} ) return [ApiClientManager]::CreateClient($Configuration) } function Get-SpotifyApiClientStats { param( [Parameter(Mandatory)] [EnhancedSpotifyApiClient]$Client ) return $Client.GetStats() } function Clear-SpotifyApiClientCache { param( [Parameter(Mandatory)] [EnhancedSpotifyApiClient]$Client ) $Client.ClearCache() } function Reset-SpotifyApiClientStats { param( [Parameter(Mandatory)] [EnhancedSpotifyApiClient]$Client ) $Client.ResetStats() } function Set-SpotifyApiClientCacheType { param( [Parameter(Mandatory)] [EnhancedSpotifyApiClient]$Client, [Parameter(Mandatory)] [string]$CacheType, [int]$CacheDurationMs, [bool]$PersistToDisk, [int]$Priority = 1 ) $settings = @{ Priority = $Priority } if ($CacheDurationMs -gt 0) { $settings.CacheDurationMs = $CacheDurationMs } if ($PSBoundParameters.ContainsKey('PersistToDisk')) { $settings.PersistToDisk = $PersistToDisk } $Client.CacheManager.CacheTypeSettings[$CacheType] = $settings } function Get-SpotifyApiClientCacheStats { param( [Parameter(Mandatory)] [EnhancedSpotifyApiClient]$Client, [string]$CacheType = "" ) $stats = $Client.CacheManager.GetCacheStats() if (-not [string]::IsNullOrWhiteSpace($CacheType) -and $stats.StatsByType.ContainsKey($CacheType)) { return $stats.StatsByType[$CacheType] } return $stats } function Clear-SpotifyApiClientCacheByType { param( [Parameter(Mandatory)] [EnhancedSpotifyApiClient]$Client, [string]$CacheType = "" ) $Client.CacheManager.ClearCache($CacheType) } function Invoke-SpotifyApiClientCacheCleanup { param( [Parameter(Mandatory)] [EnhancedSpotifyApiClient]$Client ) $Client.CacheManager.PerformCleanup() } # Export functions and classes Export-ModuleMember -Function @( 'New-EnhancedSpotifyApiClient', 'Get-SpotifyApiClientStats', 'Clear-SpotifyApiClientCache', 'Reset-SpotifyApiClientStats', 'Set-SpotifyApiClientCacheType', 'Get-SpotifyApiClientCacheStats', 'Clear-SpotifyApiClientCacheByType', 'Invoke-SpotifyApiClientCacheCleanup' ) -Variable @() |