modules/Lyrics/LyricsEngine.psm1
|
# Lyrics Engine Module # Provides lyrics fetching, caching, and display functionality for Spotify CLI using namespace System.Management.Automation # Base interface for lyrics providers class ILyricsProvider { [hashtable] FetchLyrics([string]$artist, [string]$track) { throw [System.NotImplementedException]::new() } [bool] IsAvailable() { throw [System.NotImplementedException]::new() } [string] GetProviderName() { throw [System.NotImplementedException]::new() } } # Abstract base class for lyrics providers class LyricsProviderBase : ILyricsProvider { [string] $ProviderName [hashtable] $Configuration = @{} [int] $TimeoutMs = 10000 LyricsProviderBase([string]$name, [hashtable]$config) { $this.ProviderName = $name $this.Configuration = $config if ($config.ContainsKey('TimeoutMs')) { $this.TimeoutMs = $config.TimeoutMs } } [hashtable] FetchLyrics([string]$artist, [string]$track) { if ([string]::IsNullOrWhiteSpace($artist) -or [string]::IsNullOrWhiteSpace($track)) { throw [System.ArgumentException]::new("Artist and track name are required") } return $this.FetchLyricsInternal($artist, $track) } [hashtable] FetchLyricsInternal([string]$artist, [string]$track) { # Override in derived classes throw [System.NotImplementedException]::new() } [bool] IsAvailable() { # Override in derived classes for availability check return $true } [string] GetProviderName() { return $this.ProviderName } [string] SanitizeSearchTerm([string]$term) { # Remove special characters and normalize for search $sanitized = $term -replace '[^\w\s-]', '' $sanitized = $sanitized -replace '\s+', ' ' return $sanitized.Trim() } } # Genius API lyrics provider class GeniusLyricsProvider : LyricsProviderBase { [string] $ApiKey [string] $BaseUrl = "https://api.genius.com" GeniusLyricsProvider([hashtable]$config) : base("Genius", $config) { if ($config.ContainsKey('ApiKey')) { $this.ApiKey = $config.ApiKey } } [hashtable] FetchLyricsInternal([string]$artist, [string]$track) { if ([string]::IsNullOrWhiteSpace($this.ApiKey)) { throw [System.InvalidOperationException]::new("Genius API key not configured") } try { # Search for the song $searchQuery = "$($this.SanitizeSearchTerm($artist)) $($this.SanitizeSearchTerm($track))" $searchUrl = "$($this.BaseUrl)/search?q=$([System.Uri]::EscapeDataString($searchQuery))" $headers = @{ 'Authorization' = "Bearer $($this.ApiKey)" 'User-Agent' = 'SpotifyCLI/1.0' } $searchResponse = Invoke-RestMethod -Uri $searchUrl -Headers $headers -TimeoutSec ($this.TimeoutMs / 1000) if (-not $searchResponse.response.hits -or $searchResponse.response.hits.Count -eq 0) { return @{ Success = $false Error = "No lyrics found" Provider = $this.ProviderName } } # Get the first hit (most relevant) $hit = $searchResponse.response.hits[0] $songUrl = $hit.result.url # Note: Genius doesn't provide lyrics directly via API # This would require web scraping which is against their ToS # Return metadata for now return @{ Success = $true TrackId = "$artist-$track" FullText = "Lyrics available at: $songUrl" SyncedLines = @() Source = $this.ProviderName HasSyncedLyrics = $false Url = $songUrl CachedAt = [DateTime]::UtcNow } } catch { return @{ Success = $false Error = $_.Exception.Message Provider = $this.ProviderName } } } [bool] IsAvailable() { return -not [string]::IsNullOrWhiteSpace($this.ApiKey) } } # LRCLIB.net API lyrics provider (Free, no API key required, supports synced lyrics!) class LrclibProvider : LyricsProviderBase { [string] $BaseUrl = "https://lrclib.net/api" LrclibProvider([hashtable]$config) : base("LRCLIB", $config) { } [hashtable] FetchLyricsInternal([string]$artist, [string]$track) { try { # Search for track - LRCLIB uses search endpoint $artistEncoded = [System.Uri]::EscapeDataString($this.SanitizeSearchTerm($artist)) $trackEncoded = [System.Uri]::EscapeDataString($this.SanitizeSearchTerm($track)) $url = "$($this.BaseUrl)/search?track_name=$trackEncoded&artist_name=$artistEncoded" $response = Invoke-RestMethod -Uri $url -TimeoutSec ($this.TimeoutMs / 1000) -ErrorAction Stop if (-not $response -or $response.Count -eq 0) { return @{ Success = $false Error = "No lyrics found" Provider = $this.ProviderName } } # Take first match $result = $response[0] # Check if instrumental if ($result.instrumental) { return @{ Success = $false Error = "Track is instrumental (no lyrics)" Provider = $this.ProviderName } } # Get plain lyrics $plainLyrics = if ($result.plainLyrics) { $result.plainLyrics.Trim() } else { "" } $syncedLyrics = if ($result.syncedLyrics) { $result.syncedLyrics.Trim() } else { "" } if ([string]::IsNullOrWhiteSpace($plainLyrics) -and [string]::IsNullOrWhiteSpace($syncedLyrics)) { return @{ Success = $false Error = "No lyrics available for this track" Provider = $this.ProviderName } } # Parse synced lyrics if available (LRC format) $syncedLines = @() $hasSynced = $false if (-not [string]::IsNullOrWhiteSpace($syncedLyrics)) { $syncedLines = $this.ParseLrcFormat($syncedLyrics) $hasSynced = $syncedLines.Count -gt 0 } return @{ Success = $true TrackId = "$artist-$track" FullText = $plainLyrics SyncedLines = $syncedLines Source = $this.ProviderName HasSyncedLyrics = $hasSynced CachedAt = [DateTime]::UtcNow } } catch { $errorMessage = $_.Exception.Message # Handle common errors if ($_.Exception.Response.StatusCode -eq 404) { $errorMessage = "Lyrics not found in database" } return @{ Success = $false Error = $errorMessage Provider = $this.ProviderName } } } [array] ParseLrcFormat([string]$lrcText) { # Parse LRC format: [mm:ss.xx] Lyrics text $lines = @() $lrcLines = $lrcText -split "`n" foreach ($line in $lrcLines) { if ($line -match '^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$') { $minutes = [int]$matches[1] $seconds = [int]$matches[2] $centiseconds = [int]$matches[3] $text = $matches[4].Trim() $timestampMs = ($minutes * 60 * 1000) + ($seconds * 1000) + ($centiseconds * 10) $lines += @{ Timestamp = $timestampMs Text = $text } } } return $lines } [bool] IsAvailable() { # Always available - no API key required return $true } } # Musixmatch API lyrics provider class MusixmatchLyricsProvider : LyricsProviderBase { [string] $ApiKey [string] $BaseUrl = "https://api.musixmatch.com/ws/1.1" MusixmatchLyricsProvider([hashtable]$config) : base("Musixmatch", $config) { if ($config.ContainsKey('ApiKey')) { $this.ApiKey = $config.ApiKey } } [hashtable] FetchLyricsInternal([string]$artist, [string]$track) { if ([string]::IsNullOrWhiteSpace($this.ApiKey)) { throw [System.InvalidOperationException]::new("Musixmatch API key not configured") } try { # Search for the track $searchQuery = @{ 'apikey' = $this.ApiKey 'q_artist' = $this.SanitizeSearchTerm($artist) 'q_track' = $this.SanitizeSearchTerm($track) 'page_size' = 1 'page' = 1 's_track_rating' = 'desc' } $searchParams = ($searchQuery.GetEnumerator() | ForEach-Object { "$($_.Key)=$([System.Uri]::EscapeDataString($_.Value))" }) -join '&' $searchUrl = "$($this.BaseUrl)/track.search?$searchParams" $searchResponse = Invoke-RestMethod -Uri $searchUrl -TimeoutSec ($this.TimeoutMs / 1000) if ($searchResponse.message.header.status_code -ne 200 -or -not $searchResponse.message.body.track_list -or $searchResponse.message.body.track_list.Count -eq 0) { return @{ Success = $false Error = "No track found" Provider = $this.ProviderName } } $track_id = $searchResponse.message.body.track_list[0].track.track_id # Get lyrics for the track $lyricsParams = @{ 'apikey' = $this.ApiKey 'track_id' = $track_id } $lyricsParamsStr = ($lyricsParams.GetEnumerator() | ForEach-Object { "$($_.Key)=$([System.Uri]::EscapeDataString($_.Value))" }) -join '&' $lyricsUrl = "$($this.BaseUrl)/track.lyrics.get?$lyricsParamsStr" $lyricsResponse = Invoke-RestMethod -Uri $lyricsUrl -TimeoutSec ($this.TimeoutMs / 1000) if ($lyricsResponse.message.header.status_code -ne 200 -or -not $lyricsResponse.message.body.lyrics) { return @{ Success = $false Error = "No lyrics available" Provider = $this.ProviderName } } $lyricsBody = $lyricsResponse.message.body.lyrics.lyrics_body # Try to get synced lyrics $syncedLyricsUrl = "$($this.BaseUrl)/track.subtitle.get?$lyricsParamsStr" $syncedLines = @() $hasSyncedLyrics = $false try { $syncedResponse = Invoke-RestMethod -Uri $syncedLyricsUrl -TimeoutSec ($this.TimeoutMs / 1000) if ($syncedResponse.message.header.status_code -eq 200 -and $syncedResponse.message.body.subtitle) { $subtitleBody = $syncedResponse.message.body.subtitle.subtitle_body if (-not [string]::IsNullOrWhiteSpace($subtitleBody)) { $syncedLines = $this.ParseSubtitleFormat($subtitleBody) $hasSyncedLyrics = $syncedLines.Count -gt 0 } } } catch { # Synced lyrics not available, continue with regular lyrics } return @{ Success = $true TrackId = "$artist-$track" FullText = $lyricsBody SyncedLines = $syncedLines Source = $this.ProviderName HasSyncedLyrics = $hasSyncedLyrics CachedAt = [DateTime]::UtcNow } } catch { return @{ Success = $false Error = $_.Exception.Message Provider = $this.ProviderName } } } [array] ParseSubtitleFormat([string]$subtitleText) { $lines = @() $subtitleLines = $subtitleText -split "`n" foreach ($line in $subtitleLines) { if ($line -match '^\[(\d{2}):(\d{2})\.(\d{2})\]\s*(.*)$') { $minutes = [int]$matches[1] $seconds = [int]$matches[2] $centiseconds = [int]$matches[3] $text = $matches[4].Trim() $timestampMs = ($minutes * 60 * 1000) + ($seconds * 1000) + ($centiseconds * 10) if (-not [string]::IsNullOrWhiteSpace($text)) { $lines += @{ Timestamp = $timestampMs Text = $text } } } } return $lines } [bool] IsAvailable() { return -not [string]::IsNullOrWhiteSpace($this.ApiKey) } } # Mock lyrics provider for testing class MockLyricsProvider : LyricsProviderBase { MockLyricsProvider([hashtable]$config) : base("Mock", $config) { } [hashtable] FetchLyricsInternal([string]$artist, [string]$track) { # Return mock lyrics for testing $mockLyrics = @" [00:00.00] Mock lyrics for testing [00:05.00] Artist: $artist [00:10.00] Track: $track [00:15.00] This is a sample lyric line [00:20.00] Another line of lyrics [00:25.00] End of mock lyrics "@ $syncedLines = @( @{ Timestamp = 0; Text = "Mock lyrics for testing" }, @{ Timestamp = 5000; Text = "Artist: $artist" }, @{ Timestamp = 10000; Text = "Track: $track" }, @{ Timestamp = 15000; Text = "This is a sample lyric line" }, @{ Timestamp = 20000; Text = "Another line of lyrics" }, @{ Timestamp = 25000; Text = "End of mock lyrics" } ) return @{ Success = $true TrackId = "$artist-$track" FullText = $mockLyrics SyncedLines = $syncedLines Source = $this.ProviderName HasSyncedLyrics = $true CachedAt = [DateTime]::UtcNow } } [bool] IsAvailable() { return $true } } # Lyrics cache manager class LyricsCache { [string] $CacheDirectory [int] $MaxAgeHours = 720 # 30 days [int] $MaxCacheSizeMB = 50 [hashtable] $PerformanceStats = @{ Hits = 0 Misses = 0 Stores = 0 Cleanups = 0 LastCleanup = $null } LyricsCache([string]$cacheDir) { $this.CacheDirectory = $cacheDir $this.EnsureCacheDirectory() } [void] EnsureCacheDirectory() { if (-not (Test-Path $this.CacheDirectory)) { New-Item -ItemType Directory -Path $this.CacheDirectory -Force | Out-Null } } [string] GetCacheKey([string]$trackId) { # Create a safe filename from track ID $safeKey = $trackId -replace '[^\w\-]', '_' return "$safeKey.json" } [string] GetCachePath([string]$trackId) { $cacheKey = $this.GetCacheKey($trackId) return Join-Path $this.CacheDirectory $cacheKey } [void] StoreLyrics([string]$trackId, [hashtable]$lyricsData) { try { $cachePath = $this.GetCachePath($trackId) $lyricsData.CachedAt = [DateTime]::UtcNow $json = $lyricsData | ConvertTo-Json -Depth 10 Set-Content -Path $cachePath -Value $json -Encoding UTF8 $this.PerformanceStats.Stores++ # Check if cache size exceeds limit and cleanup if needed $this.CheckCacheSizeAndCleanup() } catch { Write-Warning "Failed to cache lyrics for $trackId : $($_.Exception.Message)" } } [hashtable] GetCachedLyrics([string]$trackId) { try { $cachePath = $this.GetCachePath($trackId) if (-not (Test-Path $cachePath)) { $this.PerformanceStats.Misses++ return $null } $json = Get-Content -Path $cachePath -Raw -Encoding UTF8 $lyricsData = $json | ConvertFrom-Json # Check if cache is still valid if ($this.IsCacheExpired($lyricsData.CachedAt)) { Remove-Item -Path $cachePath -Force -ErrorAction SilentlyContinue $this.PerformanceStats.Misses++ return $null } # Convert back to hashtable $result = @{} $lyricsData.PSObject.Properties | ForEach-Object { $result[$_.Name] = $_.Value } $this.PerformanceStats.Hits++ return $result } catch { Write-Warning "Failed to read cached lyrics for $trackId : $($_.Exception.Message)" $this.PerformanceStats.Misses++ return $null } } [bool] HasCachedLyrics([string]$trackId) { $cached = $this.GetCachedLyrics($trackId) return $null -ne $cached } [bool] IsCacheExpired([DateTime]$cachedAt) { $age = [DateTime]::UtcNow - $cachedAt return $age.TotalHours -gt $this.MaxAgeHours } [void] CleanupOldEntries([int]$maxAgeHours) { try { $cutoffTime = [DateTime]::UtcNow.AddHours(-$maxAgeHours) $cacheFiles = Get-ChildItem -Path $this.CacheDirectory -Filter "*.json" -ErrorAction SilentlyContinue $cleanedCount = 0 foreach ($file in $cacheFiles) { if ($file.LastWriteTime -lt $cutoffTime) { Remove-Item -Path $file.FullName -Force -ErrorAction SilentlyContinue $cleanedCount++ } } $this.PerformanceStats.Cleanups++ $this.PerformanceStats.LastCleanup = [DateTime]::UtcNow if ($cleanedCount -gt 0) { Write-Verbose "Cleaned up $cleanedCount old cache entries" } } catch { Write-Warning "Failed to cleanup old cache entries: $($_.Exception.Message)" } } [void] ClearCache() { try { $cacheFiles = Get-ChildItem -Path $this.CacheDirectory -Filter "*.json" -ErrorAction SilentlyContinue foreach ($file in $cacheFiles) { Remove-Item -Path $file.FullName -Force -ErrorAction SilentlyContinue } } catch { Write-Warning "Failed to clear cache: $($_.Exception.Message)" } } [void] CheckCacheSizeAndCleanup() { try { $cacheFiles = Get-ChildItem -Path $this.CacheDirectory -Filter "*.json" -ErrorAction SilentlyContinue $totalSize = ($cacheFiles | Measure-Object -Property Length -Sum).Sum $totalSizeMB = $totalSize / 1MB if ($totalSizeMB -gt $this.MaxCacheSizeMB) { # Remove oldest files first $sortedFiles = $cacheFiles | Sort-Object LastWriteTime $targetSize = $this.MaxCacheSizeMB * 0.8 # Clean to 80% of max size $currentSize = $totalSizeMB foreach ($file in $sortedFiles) { if ($currentSize -le $targetSize) { break } $fileSizeMB = $file.Length / 1MB Remove-Item -Path $file.FullName -Force -ErrorAction SilentlyContinue $currentSize -= $fileSizeMB } Write-Verbose "Cache size exceeded limit. Cleaned up to $([Math]::Round($currentSize, 2))MB" } } catch { Write-Warning "Failed to check cache size: $($_.Exception.Message)" } } [hashtable] GetCacheStats() { try { $cacheFiles = Get-ChildItem -Path $this.CacheDirectory -Filter "*.json" -ErrorAction SilentlyContinue $totalSize = ($cacheFiles | Measure-Object -Property Length -Sum).Sum $totalSizeMB = [Math]::Round($totalSize / 1MB, 2) $hitRate = 0 $totalRequests = $this.PerformanceStats.Hits + $this.PerformanceStats.Misses if ($totalRequests -gt 0) { $hitRate = [Math]::Round(($this.PerformanceStats.Hits / $totalRequests) * 100, 2) } return @{ TotalFiles = $cacheFiles.Count TotalSizeMB = $totalSizeMB MaxSizeMB = $this.MaxCacheSizeMB MaxAgeHours = $this.MaxAgeHours CacheDirectory = $this.CacheDirectory Performance = @{ Hits = $this.PerformanceStats.Hits Misses = $this.PerformanceStats.Misses Stores = $this.PerformanceStats.Stores Cleanups = $this.PerformanceStats.Cleanups HitRate = "$hitRate%" LastCleanup = $this.PerformanceStats.LastCleanup } } } catch { return @{ TotalFiles = 0 TotalSizeMB = 0 MaxSizeMB = $this.MaxCacheSizeMB MaxAgeHours = $this.MaxAgeHours CacheDirectory = $this.CacheDirectory Error = $_.Exception.Message Performance = $this.PerformanceStats } } } } # Unified lyrics interface for external consumers class LyricsManager { [LyricsEngine] $Engine [hashtable] $Stats LyricsManager([hashtable]$config) { $this.Engine = [LyricsEngine]::new($config) $this.Stats = @{ CacheHits = 0 CacheMisses = 0 ProviderRequests = @{} LastRequest = $null } } [hashtable] GetLyrics([string]$artist, [string]$track) { $this.Stats.LastRequest = [DateTime]::UtcNow $result = $this.Engine.GetLyrics($artist, $track) if ($result.ContainsKey('FromCache') -and $result.FromCache) { $this.Stats.CacheHits++ } else { $this.Stats.CacheMisses++ if ($result.ContainsKey('Source')) { $provider = $result.Source if (-not $this.Stats.ProviderRequests.ContainsKey($provider)) { $this.Stats.ProviderRequests[$provider] = 0 } $this.Stats.ProviderRequests[$provider]++ } } return $result } [array] GetAvailableProviders() { $providers = @() foreach ($provider in $this.Engine.Providers) { $providers += @{ Name = $provider.GetProviderName() Available = $provider.IsAvailable() } } return $providers } [hashtable] GetStats() { $engineStats = $this.Engine.GetCacheStats() return @{ Cache = $engineStats Performance = $this.Stats Providers = $this.GetAvailableProviders() } } [void] ClearCache() { $this.Engine.ClearCache() } [void] CleanupCache([int]$maxAgeHours) { $this.Engine.CleanupCache($maxAgeHours) } } # Main lyrics engine class LyricsEngine { [System.Collections.Generic.List[ILyricsProvider]] $Providers [LyricsCache] $Cache [hashtable] $Configuration LyricsEngine([hashtable]$config) { $this.Configuration = $config $this.Providers = [System.Collections.Generic.List[ILyricsProvider]]::new() # Initialize cache $cacheDir = if ($config.ContainsKey('CacheDirectory')) { $config.CacheDirectory } else { Join-Path $env:TEMP "SpotifyLyrics" } $this.Cache = [LyricsCache]::new($cacheDir) # Initialize providers $this.InitializeProviders() } [void] InitializeProviders() { # Add LRCLIB provider first (primary provider - free, no API key, supports synced lyrics!) $lrclibProvider = [LrclibProvider]::new(@{}) $this.Providers.Add($lrclibProvider) # Add Genius provider if configured (fallback #1) if ($this.Configuration.ContainsKey('GeniusApiKey')) { $geniusProvider = [GeniusLyricsProvider]::new(@{ ApiKey = $this.Configuration.GeniusApiKey }) $this.Providers.Add($geniusProvider) } # Add Musixmatch provider if configured (fallback #2) if ($this.Configuration.ContainsKey('MusixmatchApiKey')) { $musixmatchProvider = [MusixmatchLyricsProvider]::new(@{ ApiKey = $this.Configuration.MusixmatchApiKey }) $this.Providers.Add($musixmatchProvider) } # Add mock provider for testing (lowest priority) $mockProvider = [MockLyricsProvider]::new(@{}) $this.Providers.Add($mockProvider) } [hashtable] GetLyrics([string]$artist, [string]$track) { $trackId = "$artist-$track" # Try cache first $cached = $this.Cache.GetCachedLyrics($trackId) if ($cached) { $cached.FromCache = $true return $cached } # Try each provider foreach ($provider in $this.Providers) { if (-not $provider.IsAvailable()) { continue } try { $result = $provider.FetchLyrics($artist, $track) if ($result.Success) { # Cache the result $this.Cache.StoreLyrics($trackId, $result) $result.FromCache = $false return $result } } catch { Write-Warning "Provider $($provider.GetProviderName()) failed: $($_.Exception.Message)" continue } } # No lyrics found return @{ Success = $false Error = "No lyrics found from any provider" TrackId = $trackId } } [hashtable] GetCacheStats() { return $this.Cache.GetCacheStats() } [void] ClearCache() { $this.Cache.ClearCache() } [void] CleanupCache([int]$maxAgeHours) { $this.Engine.CleanupCache($maxAgeHours) } [void] PerformMaintenance() { # Perform routine maintenance tasks $this.Engine.CleanupCache($this.Engine.Cache.MaxAgeHours) $this.Engine.Cache.CheckCacheSizeAndCleanup() } } # Scrollable lyrics display interface class LyricsDisplay { [hashtable] $LyricsData [int] $CurrentLine = 0 [int] $DisplayHeight = 20 [int] $DisplayWidth = 80 [bool] $ShowTimestamps = $true [bool] $HighlightCurrent = $true [string] $SearchTerm = "" [array] $SearchResults = @() [int] $CurrentSearchIndex = -1 LyricsDisplay([hashtable]$lyricsData) { $this.LyricsData = $lyricsData $this.InitializeDisplay() } [void] InitializeDisplay() { # Use default dimensions - console access from classes can be problematic # These can be overridden by calling code if needed $this.DisplayWidth = 80 $this.DisplayHeight = 20 } [string] RenderDisplay([int]$currentPositionMs) { $output = [System.Text.StringBuilder]::new() # Header $title = "Lyrics: $($this.LyricsData.TrackId)" $header = $title.PadRight($this.DisplayWidth) $output.AppendLine("┌$('─' * $this.DisplayWidth)┐") | Out-Null $output.AppendLine("│$header│") | Out-Null $output.AppendLine("├$('─' * $this.DisplayWidth)┤") | Out-Null # Get lines to display $lines = $this.GetDisplayLines($currentPositionMs) # Display lines for ($i = 0; $i -lt $this.DisplayHeight; $i++) { if ($i -lt $lines.Count) { $line = $lines[$i] $output.AppendLine("│$($line.PadRight($this.DisplayWidth))│") | Out-Null } else { $output.AppendLine("│$(' ' * $this.DisplayWidth)│") | Out-Null } } # Footer with controls $output.AppendLine("├$('─' * $this.DisplayWidth)┤") | Out-Null $controls = "↑↓: Scroll | /: Search | Esc: Exit" if ($this.SearchTerm) { $controls = "Search: '$($this.SearchTerm)' | n: Next | N: Prev | Esc: Clear" } $output.AppendLine("│$($controls.PadRight($this.DisplayWidth))│") | Out-Null $output.AppendLine("└$('─' * $this.DisplayWidth)┘") | Out-Null return $output.ToString() } [array] GetDisplayLines([int]$currentPositionMs) { $lines = @() if ($this.LyricsData.HasSyncedLyrics -and $this.LyricsData.SyncedLines) { # Use synced lyrics $syncedLines = $this.LyricsData.SyncedLines $currentLineIndex = $this.FindCurrentLine($currentPositionMs, $syncedLines) # Calculate display range $startIndex = [Math]::Max(0, $this.CurrentLine) $endIndex = [Math]::Min($syncedLines.Count - 1, $startIndex + $this.DisplayHeight - 1) for ($i = $startIndex; $i -le $endIndex; $i++) { $line = $syncedLines[$i] $timestamp = $this.FormatTimestamp($line.Timestamp) $text = $line.Text # Highlight current line $prefix = " " if ($this.HighlightCurrent -and $i -eq $currentLineIndex) { $prefix = "► " $text = "[$text]" # Simple highlighting } # Add timestamp if enabled if ($this.ShowTimestamps) { $displayLine = "$prefix$timestamp $text" } else { $displayLine = "$prefix$text" } # Highlight search results if ($this.SearchTerm -and $text -match [regex]::Escape($this.SearchTerm)) { $displayLine = $displayLine -replace [regex]::Escape($this.SearchTerm), "**$($this.SearchTerm)**" } $lines += $displayLine } } else { # Use plain text lyrics $textLines = $this.LyricsData.FullText -split "`n" $startIndex = [Math]::Max(0, $this.CurrentLine) $endIndex = [Math]::Min($textLines.Count - 1, $startIndex + $this.DisplayHeight - 1) for ($i = $startIndex; $i -le $endIndex; $i++) { $text = $textLines[$i].Trim() $displayLine = " $text" # Highlight search results if ($this.SearchTerm -and $text -match [regex]::Escape($this.SearchTerm)) { $displayLine = $displayLine -replace [regex]::Escape($this.SearchTerm), "**$($this.SearchTerm)**" } $lines += $displayLine } } return $lines } [int] FindCurrentLine([int]$currentPositionMs, [array]$syncedLines) { for ($i = $syncedLines.Count - 1; $i -ge 0; $i--) { if ($syncedLines[$i].Timestamp -le $currentPositionMs) { return $i } } return 0 } [string] FormatTimestamp([int]$timestampMs) { $totalSeconds = [int][Math]::Floor($timestampMs / 1000) $minutes = [int][Math]::Floor($totalSeconds / 60) $seconds = [int]($totalSeconds % 60) return "{0:D2}:{1:D2}" -f $minutes, $seconds } [void] ScrollUp([int]$lines = 1) { $this.CurrentLine = [Math]::Max(0, $this.CurrentLine - $lines) } [void] ScrollDown([int]$lines = 1) { $maxLines = $this.GetMaxScrollPosition() $this.CurrentLine = [Math]::Min($maxLines, $this.CurrentLine + $lines) } [int] GetMaxScrollPosition() { if ($this.LyricsData.HasSyncedLyrics -and $this.LyricsData.SyncedLines) { return [Math]::Max(0, $this.LyricsData.SyncedLines.Count - $this.DisplayHeight) } else { $textLines = $this.LyricsData.FullText -split "`n" return [Math]::Max(0, $textLines.Count - $this.DisplayHeight) } } [void] Search([string]$term) { $this.SearchTerm = $term $this.SearchResults = @() $this.CurrentSearchIndex = -1 if ([string]::IsNullOrWhiteSpace($term)) { return } if ($this.LyricsData.HasSyncedLyrics -and $this.LyricsData.SyncedLines) { for ($i = 0; $i -lt $this.LyricsData.SyncedLines.Count; $i++) { if ($this.LyricsData.SyncedLines[$i].Text -match [regex]::Escape($term)) { $this.SearchResults += $i } } } else { $textLines = $this.LyricsData.FullText -split "`n" for ($i = 0; $i -lt $textLines.Count; $i++) { if ($textLines[$i] -match [regex]::Escape($term)) { $this.SearchResults += $i } } } } [void] NextSearchResult() { if ($this.SearchResults.Count -eq 0) { return } $this.CurrentSearchIndex = ($this.CurrentSearchIndex + 1) % $this.SearchResults.Count $this.CurrentLine = $this.SearchResults[$this.CurrentSearchIndex] } [void] PreviousSearchResult() { if ($this.SearchResults.Count -eq 0) { return } $this.CurrentSearchIndex-- if ($this.CurrentSearchIndex -lt 0) { $this.CurrentSearchIndex = $this.SearchResults.Count - 1 } $this.CurrentLine = $this.SearchResults[$this.CurrentSearchIndex] } [void] ClearSearch() { $this.SearchTerm = "" $this.SearchResults = @() $this.CurrentSearchIndex = -1 } [void] ToggleTimestamps() { $this.ShowTimestamps = -not $this.ShowTimestamps } [void] ToggleHighlighting() { $this.HighlightCurrent = -not $this.HighlightCurrent } } # Interactive lyrics viewer class InteractiveLyricsViewer { [LyricsDisplay] $Display [bool] $IsRunning = $false [int] $CurrentPositionMs = 0 [string] $InputBuffer = "" [bool] $SearchMode = $false [DateTime] $LastUpdate = [DateTime]::MinValue [ScriptBlock] $GetPlaybackPosition = $null [int] $ApiSyncIntervalMs = 5000 # Sync with API every 5 seconds [DateTime] $LastApiSync = [DateTime]::MinValue InteractiveLyricsViewer([hashtable]$lyricsData) { $this.Display = [LyricsDisplay]::new($lyricsData) } [void] SetPlaybackPositionGetter([ScriptBlock]$getter) { $this.GetPlaybackPosition = $getter } [void] Start([int]$initialPositionMs = 0) { $this.CurrentPositionMs = $initialPositionMs $this.IsRunning = $true try { # Hide cursor [Console]::CursorVisible = $false $this.MainLoop() } finally { # Restore cursor [Console]::CursorVisible = $true [Console]::Clear() } } [void] MainLoop() { $this.LastUpdate = [DateTime]::UtcNow $this.LastApiSync = [DateTime]::UtcNow while ($this.IsRunning) { try { # Update playback position $this.UpdatePlaybackPosition() # Clear screen and render [Console]::Clear() $output = $this.Display.RenderDisplay($this.CurrentPositionMs) Write-Host $output if ($this.SearchMode) { Write-Host "Search: $($this.InputBuffer)_" -NoNewline } # Handle input if ([Console]::KeyAvailable) { $key = [Console]::ReadKey($true) $this.HandleKeyPress($key) } Start-Sleep -Milliseconds 100 } catch { # Handle any console access errors gracefully Write-Warning "Display error: $($_.Exception.Message)" Start-Sleep -Milliseconds 500 } } } [void] UpdatePlaybackPosition() { $now = [DateTime]::UtcNow $timeSinceLastUpdate = ($now - $this.LastUpdate).TotalMilliseconds # Update position based on elapsed time (smooth playback simulation) $this.CurrentPositionMs += [int]$timeSinceLastUpdate $this.LastUpdate = $now # Sync with API periodically to correct drift $timeSinceApiSync = ($now - $this.LastApiSync).TotalMilliseconds if ($timeSinceApiSync -ge $this.ApiSyncIntervalMs -and $this.GetPlaybackPosition -ne $null) { try { $apiPosition = & $this.GetPlaybackPosition if ($apiPosition -ne $null) { $this.CurrentPositionMs = $apiPosition $this.LastApiSync = $now } } catch { # Silently ignore API errors, continue with time-based tracking } } } [void] HandleKeyPress([System.ConsoleKeyInfo]$key) { if ($this.SearchMode) { $this.HandleSearchInput($key) return } switch ($key.Key) { 'UpArrow' { $this.Display.ScrollUp() } 'DownArrow' { $this.Display.ScrollDown() } 'PageUp' { $this.Display.ScrollUp(5) } 'PageDown' { $this.Display.ScrollDown(5) } 'Home' { $this.Display.CurrentLine = 0 } 'End' { $this.Display.CurrentLine = $this.Display.GetMaxScrollPosition() } 'T' { $this.Display.ToggleTimestamps() } 'H' { $this.Display.ToggleHighlighting() } 'Escape' { $this.IsRunning = $false } 'Q' { $this.IsRunning = $false } default { if ($key.KeyChar -eq '/') { $this.StartSearch() } elseif ($key.KeyChar -eq 'n') { $this.Display.NextSearchResult() } elseif ($key.KeyChar -eq 'N') { $this.Display.PreviousSearchResult() } } } } [void] HandleSearchInput([System.ConsoleKeyInfo]$key) { switch ($key.Key) { 'Enter' { $this.Display.Search($this.InputBuffer) $this.SearchMode = $false if ($this.Display.SearchResults.Count -gt 0) { $this.Display.NextSearchResult() } } 'Escape' { $this.SearchMode = $false $this.InputBuffer = "" $this.Display.ClearSearch() } 'Backspace' { if ($this.InputBuffer.Length -gt 0) { $this.InputBuffer = $this.InputBuffer.Substring(0, $this.InputBuffer.Length - 1) } } default { if ($key.KeyChar -match '[a-zA-Z0-9\s]') { $this.InputBuffer += $key.KeyChar } } } } [void] StartSearch() { $this.SearchMode = $true $this.InputBuffer = "" } [void] UpdatePosition([int]$positionMs) { $this.CurrentPositionMs = $positionMs } } # Public functions for external use function New-LyricsManager { param( [hashtable]$Configuration = @{} ) return [LyricsManager]::new($Configuration) } function Test-LyricsProviders { param( [hashtable]$Configuration = @{} ) $manager = [LyricsManager]::new($Configuration) return $manager.GetAvailableProviders() } function Show-Lyrics { param( [Parameter(Mandatory)] [hashtable]$LyricsData, [int]$InitialPositionMs = 0, [ScriptBlock]$GetPlaybackPosition = $null ) if (-not $LyricsData.Success) { Write-Error "Cannot display lyrics: $($LyricsData.Error)" return } $viewer = [InteractiveLyricsViewer]::new($LyricsData) if ($GetPlaybackPosition -ne $null) { $viewer.SetPlaybackPositionGetter($GetPlaybackPosition) } $viewer.Start($InitialPositionMs) } function Format-LyricsDisplay { param( [Parameter(Mandatory)] [hashtable]$LyricsData, [int]$CurrentPositionMs = 0, [int]$DisplayHeight = 10, [bool]$ShowTimestamps = $true ) if (-not $LyricsData.Success) { return "No lyrics available: $($LyricsData.Error)" } $display = [LyricsDisplay]::new($LyricsData) $display.DisplayHeight = $DisplayHeight $display.ShowTimestamps = $ShowTimestamps return $display.RenderDisplay($CurrentPositionMs) } # Export classes and functions Export-ModuleMember -Function @('New-LyricsManager', 'Test-LyricsProviders', 'Show-Lyrics', 'Format-LyricsDisplay') -Variable @() |