modules/Statistics/StatisticsEngine.psm1
|
# Statistics Engine Module # Provides listening data collection, analysis, and visualization for Spotify CLI using namespace System.Management.Automation # Data models for statistics class PlaybackEvent { [string] $TrackId [string] $TrackName [string] $ArtistName [string] $AlbumName [string[]] $Genres [DateTime] $Timestamp [int] $DurationMs [int] $PositionMs [string] $EventType # "play", "skip", "complete" [string] $DeviceType [bool] $IsExplicit PlaybackEvent([hashtable]$data) { $this.TrackId = $data.TrackId $this.TrackName = $data.TrackName $this.ArtistName = $data.ArtistName $this.AlbumName = $data.AlbumName $this.Genres = $data.Genres $this.Timestamp = $data.Timestamp $this.DurationMs = $data.DurationMs $this.PositionMs = $data.PositionMs $this.EventType = $data.EventType $this.DeviceType = $data.DeviceType $this.IsExplicit = $data.IsExplicit } } class ListeningStats { [hashtable] $TopTracks = @{} [hashtable] $TopArtists = @{} [hashtable] $TopAlbums = @{} [hashtable] $GenreDistribution = @{} [hashtable] $DailyPatterns = @{} [hashtable] $WeeklyPatterns = @{} [int] $TotalPlaytime = 0 [int] $CurrentStreak = 0 [DateTime] $PeriodStart [DateTime] $PeriodEnd [int] $TotalTracks = 0 [int] $UniqueArtists = 0 [int] $UniqueAlbums = 0 ListeningStats([DateTime]$start, [DateTime]$end) { $this.PeriodStart = $start $this.PeriodEnd = $end } } # Data collector for tracking playback events class DataCollector { [string] $DataDirectory [string] $DatabaseFile [int] $MaxRetentionDays = 365 [bool] $IsEnabled = $true DataCollector([string]$dataDir) { $this.DataDirectory = $dataDir $this.DatabaseFile = Join-Path $dataDir "playback_history.json" $this.EnsureDataDirectory() } [void] EnsureDataDirectory() { if (-not (Test-Path $this.DataDirectory)) { New-Item -ItemType Directory -Path $this.DataDirectory -Force | Out-Null } return } [void] RecordPlayback([hashtable]$trackData, [DateTime]$timestamp) { if (-not $this.IsEnabled) { return } try { $event = [PlaybackEvent]::new(@{ TrackId = $trackData.id TrackName = $trackData.name ArtistName = ($trackData.artists | ForEach-Object { $_.name }) -join ", " AlbumName = $trackData.album.name Genres = $trackData.genres ?? @() Timestamp = $timestamp DurationMs = $trackData.duration_ms PositionMs = $trackData.progress_ms ?? 0 EventType = "play" DeviceType = $trackData.device_type ?? "unknown" IsExplicit = $trackData.explicit ?? $false }) $this.SaveEvent($event) } catch { Write-Warning "Failed to record playback event: $($_.Exception.Message)" } } [void] RecordSkip([string]$trackId, [int]$positionMs) { if (-not $this.IsEnabled) { return } try { # Load recent events to get track details $recentEvents = $this.GetRecentEvents(1) if ($recentEvents.Count -gt 0 -and $recentEvents[0].TrackId -eq $trackId) { $lastEvent = $recentEvents[0] $event = [PlaybackEvent]::new(@{ TrackId = $lastEvent.TrackId TrackName = $lastEvent.TrackName ArtistName = $lastEvent.ArtistName AlbumName = $lastEvent.AlbumName Genres = $lastEvent.Genres Timestamp = [DateTime]::UtcNow DurationMs = $lastEvent.DurationMs PositionMs = $positionMs EventType = "skip" DeviceType = $lastEvent.DeviceType IsExplicit = $lastEvent.IsExplicit }) $this.SaveEvent($event) } } catch { Write-Warning "Failed to record skip event: $($_.Exception.Message)" } } [void] SaveEvent([PlaybackEvent]$event) { try { # Use file locking to prevent concurrent access issues $lockFile = "$($this.DatabaseFile).lock" $maxWaitTime = 5000 # 5 seconds $waitTime = 0 $waitIncrement = 100 # Wait for lock to be released while ((Test-Path $lockFile) -and $waitTime -lt $maxWaitTime) { Start-Sleep -Milliseconds $waitIncrement $waitTime += $waitIncrement } # Create lock file New-Item -Path $lockFile -ItemType File -Force | Out-Null try { $events = $this.LoadEvents() $events += $event # Keep only events within retention period $cutoffDate = [DateTime]::UtcNow.AddDays(-$this.MaxRetentionDays) $events = $events | Where-Object { $_.Timestamp -gt $cutoffDate } # Optimize: Only save if we have events to save if ($events.Count -gt 0) { $json = $events | ConvertTo-Json -Depth 10 -Compress Set-Content -Path $this.DatabaseFile -Value $json -Encoding UTF8 } } finally { # Always remove lock file if (Test-Path $lockFile) { Remove-Item -Path $lockFile -Force -ErrorAction SilentlyContinue } } } catch { Write-Warning "Failed to save playback event: $($_.Exception.Message)" } } [PlaybackEvent[]] LoadEvents() { try { if (-not (Test-Path $this.DatabaseFile)) { return @() } # Check for lock file and wait briefly $lockFile = "$($this.DatabaseFile).lock" $maxWaitTime = 2000 # 2 seconds for read operations $waitTime = 0 $waitIncrement = 50 while ((Test-Path $lockFile) -and $waitTime -lt $maxWaitTime) { Start-Sleep -Milliseconds $waitIncrement $waitTime += $waitIncrement } $json = Get-Content -Path $this.DatabaseFile -Raw -Encoding UTF8 if ([string]::IsNullOrWhiteSpace($json)) { return @() } $data = $json | ConvertFrom-Json $events = @() foreach ($item in $data) { try { $eventData = @{} $item.PSObject.Properties | ForEach-Object { $eventData[$_.Name] = $_.Value } # Convert timestamp string back to DateTime if needed if ($eventData.Timestamp -is [string]) { $eventData.Timestamp = [DateTime]::Parse($eventData.Timestamp) } $events += [PlaybackEvent]::new($eventData) } catch { Write-Warning "Failed to parse event data: $($_.Exception.Message)" continue } } return $events } catch { Write-Warning "Failed to load playback events: $($_.Exception.Message)" return @() } } [PlaybackEvent[]] GetPlaybackHistory([DateTime]$startDate, [DateTime]$endDate) { $allEvents = $this.LoadEvents() return $allEvents | Where-Object { $_.Timestamp -ge $startDate -and $_.Timestamp -le $endDate } } [PlaybackEvent[]] GetRecentEvents([int]$count) { $allEvents = $this.LoadEvents() return $allEvents | Sort-Object Timestamp -Descending | Select-Object -First $count } [void] ClearHistory() { try { if (Test-Path $this.DatabaseFile) { Remove-Item -Path $this.DatabaseFile -Force } } catch { Write-Warning "Failed to clear history: $($_.Exception.Message)" } return } [void] RecordPlaybackAsync([hashtable]$trackData, [DateTime]$timestamp) { if (-not $this.IsEnabled) { return } # Use background job for non-blocking data collection $scriptBlock = { param($DataCollector, $TrackData, $Timestamp) try { $DataCollector.RecordPlayback($TrackData, $Timestamp) } catch { # Silently handle errors in background to avoid disrupting user experience } } Start-Job -ScriptBlock $scriptBlock -ArgumentList $this, $trackData, $timestamp | Out-Null # Clean up completed jobs to prevent memory leaks Get-Job | Where-Object { $_.State -eq 'Completed' } | Remove-Job } [hashtable] GetStorageStats() { try { $events = $this.LoadEvents() $fileSize = 0 if (Test-Path $this.DatabaseFile) { $fileInfo = Get-Item $this.DatabaseFile $fileSize = $fileInfo.Length } return @{ TotalEvents = $events.Count FileSizeMB = [Math]::Round($fileSize / 1MB, 2) OldestEvent = ($events | Sort-Object Timestamp | Select-Object -First 1)?.Timestamp NewestEvent = ($events | Sort-Object Timestamp -Descending | Select-Object -First 1)?.Timestamp RetentionDays = $this.MaxRetentionDays DatabaseFile = $this.DatabaseFile IsEnabled = $this.IsEnabled } } catch { return @{ TotalEvents = 0 FileSizeMB = 0 Error = $_.Exception.Message IsEnabled = $this.IsEnabled } } } } # Analytics processor for calculating statistics class AnalyticsProcessor { [DataCollector] $DataCollector AnalyticsProcessor([DataCollector]$collector) { $this.DataCollector = $collector } [hashtable] GetTopTracks([string]$period, [int]$limit) { $dateRange = $this.GetDateRange($period) $events = $this.DataCollector.GetPlaybackHistory($dateRange.Start, $dateRange.End) # Group by track and count plays $trackCounts = @{} foreach ($event in $events) { if ($event.EventType -eq "play") { $key = "$($event.TrackName) - $($event.ArtistName)" if (-not $trackCounts.ContainsKey($key)) { $trackCounts[$key] = @{ Name = $event.TrackName Artist = $event.ArtistName Album = $event.AlbumName PlayCount = 0 TotalPlaytime = 0 } } $trackCounts[$key].PlayCount++ $trackCounts[$key].TotalPlaytime += $event.DurationMs } } # Sort by play count and take top N $topTracks = $trackCounts.Values | Sort-Object PlayCount -Descending | Select-Object -First $limit return @{ Period = $period Tracks = $topTracks TotalTracks = $trackCounts.Count } } [hashtable] GetTopArtists([string]$period, [int]$limit) { $dateRange = $this.GetDateRange($period) $events = $this.DataCollector.GetPlaybackHistory($dateRange.Start, $dateRange.End) # Group by artist and count plays $artistCounts = @{} foreach ($event in $events) { if ($event.EventType -eq "play") { $artist = $event.ArtistName if (-not $artistCounts.ContainsKey($artist)) { $artistCounts[$artist] = @{ Name = $artist PlayCount = 0 TotalPlaytime = 0 UniqueAlbums = [System.Collections.Generic.HashSet[string]]::new() UniqueTracks = [System.Collections.Generic.HashSet[string]]::new() } } $artistCounts[$artist].PlayCount++ $artistCounts[$artist].TotalPlaytime += $event.DurationMs $artistCounts[$artist].UniqueAlbums.Add($event.AlbumName) | Out-Null $artistCounts[$artist].UniqueTracks.Add($event.TrackName) | Out-Null } } # Convert HashSets to counts and sort $topArtists = $artistCounts.Values | ForEach-Object { @{ Name = $_.Name PlayCount = $_.PlayCount TotalPlaytime = $_.TotalPlaytime UniqueAlbums = $_.UniqueAlbums.Count UniqueTracks = $_.UniqueTracks.Count } } | Sort-Object PlayCount -Descending | Select-Object -First $limit return @{ Period = $period Artists = $topArtists TotalArtists = $artistCounts.Count } } [hashtable] GetGenreDistribution([string]$period) { $dateRange = $this.GetDateRange($period) $events = $this.DataCollector.GetPlaybackHistory($dateRange.Start, $dateRange.End) # Count genre occurrences and track playtime $genreCounts = @{} $genrePlaytime = @{} foreach ($event in $events) { if ($event.EventType -eq "play" -and $event.Genres) { foreach ($genre in $event.Genres) { if (-not $genreCounts.ContainsKey($genre)) { $genreCounts[$genre] = 0 $genrePlaytime[$genre] = 0 } $genreCounts[$genre]++ $genrePlaytime[$genre] += $event.DurationMs } } } # Calculate percentages and rankings $totalGenreCount = ($genreCounts.Values | Measure-Object -Sum).Sum $totalPlaytime = ($genrePlaytime.Values | Measure-Object -Sum).Sum $distribution = @{} foreach ($genre in $genreCounts.Keys) { $countPercentage = if ($totalGenreCount -gt 0) { [Math]::Round(($genreCounts[$genre] / $totalGenreCount) * 100, 1) } else { 0 } $playtimePercentage = if ($totalPlaytime -gt 0) { [Math]::Round(($genrePlaytime[$genre] / $totalPlaytime) * 100, 1) } else { 0 } $distribution[$genre] = @{ Count = $genreCounts[$genre] CountPercentage = $countPercentage PlaytimeMs = $genrePlaytime[$genre] PlaytimePercentage = $playtimePercentage PlaytimeHours = [Math]::Round($genrePlaytime[$genre] / (1000 * 60 * 60), 1) } } # Find dominant genre $dominantGenre = $null $maxPercentage = 0 foreach ($genre in $distribution.Keys) { if ($distribution[$genre].CountPercentage -gt $maxPercentage) { $maxPercentage = $distribution[$genre].CountPercentage $dominantGenre = $genre } } return @{ Period = $period Distribution = $distribution TotalGenres = $genreCounts.Count TotalPlays = $totalGenreCount TotalPlaytimeMs = $totalPlaytime DominantGenre = $dominantGenre DominantGenrePercentage = $maxPercentage } } [hashtable] GetListeningPatterns([string]$period) { $dateRange = $this.GetDateRange($period) $events = $this.DataCollector.GetPlaybackHistory($dateRange.Start, $dateRange.End) # Analyze daily patterns (by hour) $hourlyPattern = @{} for ($i = 0; $i -lt 24; $i++) { $hourlyPattern[$i] = 0 } # Analyze weekly patterns (by day) $weeklyPattern = @{} for ($i = 0; $i -lt 7; $i++) { $weeklyPattern[$i] = 0 } # Track daily listening sessions $dailySessions = @{} $peakHours = @{} foreach ($event in $events) { if ($event.EventType -eq "play") { $hour = $event.Timestamp.Hour $dayOfWeek = [int]$event.Timestamp.DayOfWeek $dateKey = $event.Timestamp.Date.ToString("yyyy-MM-dd") $hourlyPattern[$hour]++ $weeklyPattern[$dayOfWeek]++ # Track daily sessions if (-not $dailySessions.ContainsKey($dateKey)) { $dailySessions[$dateKey] = @{ TrackCount = 0 TotalPlaytime = 0 FirstPlay = $event.Timestamp LastPlay = $event.Timestamp UniqueArtists = [System.Collections.Generic.HashSet[string]]::new() } } $dailySessions[$dateKey].TrackCount++ $dailySessions[$dateKey].TotalPlaytime += $event.DurationMs $dailySessions[$dateKey].UniqueArtists.Add($event.ArtistName) | Out-Null if ($event.Timestamp -lt $dailySessions[$dateKey].FirstPlay) { $dailySessions[$dateKey].FirstPlay = $event.Timestamp } if ($event.Timestamp -gt $dailySessions[$dateKey].LastPlay) { $dailySessions[$dateKey].LastPlay = $event.Timestamp } } } # Calculate peak listening hours $sortedHours = $hourlyPattern.GetEnumerator() | Sort-Object Value -Descending $peakHour = if ($sortedHours.Count -gt 0) { $sortedHours[0].Key } else { -1 } # Calculate average session length $avgSessionLength = 0 if ($dailySessions.Count -gt 0) { $totalSessionTime = 0 foreach ($session in $dailySessions.Values) { $sessionDuration = ($session.LastPlay - $session.FirstPlay).TotalMinutes $totalSessionTime += $sessionDuration } $avgSessionLength = [Math]::Round($totalSessionTime / $dailySessions.Count, 1) } return @{ Period = $period HourlyPattern = $hourlyPattern WeeklyPattern = $weeklyPattern DailySessions = $dailySessions PeakListeningHour = $peakHour AverageSessionLength = $avgSessionLength TotalEvents = $events.Count ActiveDays = $dailySessions.Count } } [hashtable] GetDateRange([string]$period) { $now = [DateTime]::UtcNow switch ($period.ToLower()) { "day" { return @{ Start = $now.AddDays(-1); End = $now } } "week" { return @{ Start = $now.AddDays(-7); End = $now } } "month" { return @{ Start = $now.AddDays(-30); End = $now } } "year" { return @{ Start = $now.AddDays(-365); End = $now } } default { return @{ Start = $now.AddDays(-30); End = $now } } } } [hashtable] CalculateListeningStreaks() { # Get all events sorted by date $allEvents = $this.DataCollector.LoadEvents() | Where-Object { $_.EventType -eq "play" } $eventsByDate = $allEvents | Group-Object { $_.Timestamp.Date.ToString("yyyy-MM-dd") } | Sort-Object Name if ($eventsByDate.Count -eq 0) { return @{ CurrentStreak = 0 LongestStreak = 0 StreakStartDate = $null StreakEndDate = $null TotalActiveDays = 0 } } $currentStreak = 0 $longestStreak = 0 $currentStreakStart = $null $longestStreakStart = $null $longestStreakEnd = $null $lastDate = $null foreach ($dateGroup in $eventsByDate) { $currentDate = [DateTime]::Parse($dateGroup.Name) if ($lastDate -eq $null) { # First date $currentStreak = 1 $currentStreakStart = $currentDate } elseif (($currentDate - $lastDate).Days -eq 1) { # Consecutive day $currentStreak++ } else { # Streak broken if ($currentStreak -gt $longestStreak) { $longestStreak = $currentStreak $longestStreakStart = $currentStreakStart $longestStreakEnd = $lastDate } $currentStreak = 1 $currentStreakStart = $currentDate } $lastDate = $currentDate } # Check if current streak is the longest if ($currentStreak -gt $longestStreak) { $longestStreak = $currentStreak $longestStreakStart = $currentStreakStart $longestStreakEnd = $lastDate } # Check if current streak is still active (today or yesterday) $today = [DateTime]::Today $isCurrentStreakActive = $lastDate -ne $null -and ($today - $lastDate).Days -le 1 return @{ CurrentStreak = if ($isCurrentStreakActive) { $currentStreak } else { 0 } LongestStreak = $longestStreak StreakStartDate = $longestStreakStart StreakEndDate = $longestStreakEnd TotalActiveDays = $eventsByDate.Count IsStreakActive = $isCurrentStreakActive } } [ListeningStats] GenerateComprehensiveStats([string]$period) { $dateRange = $this.GetDateRange($period) $stats = [ListeningStats]::new($dateRange.Start, $dateRange.End) # Get all data $topTracks = $this.GetTopTracks($period, 10) $topArtists = $this.GetTopArtists($period, 10) $genreDistribution = $this.GetGenreDistribution($period) $patterns = $this.GetListeningPatterns($period) $streaks = $this.CalculateListeningStreaks() # Populate stats object $stats.TopTracks = $topTracks $stats.TopArtists = $topArtists $stats.GenreDistribution = $genreDistribution $stats.DailyPatterns = $patterns.HourlyPattern $stats.WeeklyPatterns = $patterns.WeeklyPattern $stats.CurrentStreak = $streaks.CurrentStreak # Calculate totals $events = $this.DataCollector.GetPlaybackHistory($dateRange.Start, $dateRange.End) $playEvents = $events | Where-Object { $_.EventType -eq "play" } $stats.TotalPlaytime = ($playEvents | Measure-Object -Property DurationMs -Sum).Sum $stats.TotalTracks = $playEvents.Count $stats.UniqueArtists = ($playEvents | Select-Object -Property ArtistName -Unique).Count $stats.UniqueAlbums = ($playEvents | Select-Object -Property AlbumName -Unique).Count return $stats } } # ASCII visualization generator class VisualizationGenerator { [string] GenerateBarChart([hashtable]$data, [string]$title, [int]$width = 50) { $output = @() $output += $title $output += "=" * $title.Length $output += "" if ($data.Count -eq 0) { $output += "No data available" return $output -join "`n" } # Find max value for scaling $maxValue = ($data.Values | Measure-Object -Maximum).Maximum if ($maxValue -eq 0) { $output += "No data to display" return $output -join "`n" } # Generate bars foreach ($key in ($data.Keys | Sort-Object { $data[$_] } -Descending)) { $value = $data[$key] $barLength = [Math]::Round(($value / $maxValue) * $width) $bar = "█" * $barLength $output += "$key : $bar $value" } return $output -join "`n" } [string] GeneratePieChart([hashtable]$data, [string]$title) { $output = @() $output += $title $output += "=" * $title.Length $output += "" if ($data.Count -eq 0) { $output += "No data available" return $output -join "`n" } # Calculate total and percentages $total = ($data.Values | Measure-Object -Sum).Sum if ($total -eq 0) { $output += "No data to display" return $output -join "`n" } # ASCII pie chart using different characters $pieChars = @("█", "▓", "▒", "░", "▄", "▀", "▌", "▐", "■", "□", "●", "○", "◆", "◇", "▲", "△") $charIndex = 0 # Create legend and visual representation $sortedData = $data.GetEnumerator() | Sort-Object Value -Descending foreach ($item in $sortedData) { $percentage = [Math]::Round(($item.Value / $total) * 100, 1) $char = $pieChars[$charIndex % $pieChars.Length] $barLength = [Math]::Round($percentage / 2) # Scale down for display $bar = $char * $barLength $output += "$($item.Key.PadRight(20)) $bar $($percentage)% ($($item.Value))" $charIndex++ } $output += "" $output += "Total: $total items" return $output -join "`n" } [string] GenerateTimelineVisualization([hashtable]$dailySessions, [string]$title) { $output = @() $output += $title $output += "=" * $title.Length $output += "" if ($dailySessions.Count -eq 0) { $output += "No timeline data available" return $output -join "`n" } # Sort dates and create timeline $sortedDates = $dailySessions.Keys | Sort-Object $maxTracks = ($dailySessions.Values | ForEach-Object { $_.TrackCount } | Measure-Object -Maximum).Maximum if ($maxTracks -eq 0) { $output += "No activity data to display" return $output -join "`n" } # Create timeline visualization foreach ($dateKey in $sortedDates) { $session = $dailySessions[$dateKey] $date = [DateTime]::Parse($dateKey) $intensity = [Math]::Round(($session.TrackCount / $maxTracks) * 10) # Create intensity bar $intensityBar = switch ($intensity) { { $_ -eq 0 } { "░" } { $_ -le 2 } { "▒" } { $_ -le 5 } { "▓" } { $_ -le 8 } { "█" } default { "█" } } $intensityDisplay = $intensityBar * [Math]::Max(1, $intensity) $dateDisplay = $date.ToString("MM/dd") $trackCount = $session.TrackCount $playtimeHours = [Math]::Round($session.TotalPlaytime / (1000 * 60 * 60), 1) $output += "$dateDisplay : $intensityDisplay $trackCount tracks ($playtimeHours h)" } $output += "" $output += "Legend: ░ Light ▒ Moderate ▓ Heavy █ Intense" return $output -join "`n" } [string] GenerateHourlyPattern([hashtable]$hourlyData) { $output = @() $output += "Listening Activity by Hour" $output += "==========================" $output += "" $maxValue = ($hourlyData.Values | Measure-Object -Maximum).Maximum if ($maxValue -eq 0) { $output += "No hourly data available" return $output -join "`n" } # Create a more detailed hourly visualization for ($hour = 0; $hour -lt 24; $hour++) { $value = $hourlyData[$hour] $barLength = [Math]::Round(($value / $maxValue) * 30) # Use different characters for different intensities $char = switch ($value) { { $_ -eq 0 } { "░" } { $_ -le ($maxValue * 0.25) } { "▒" } { $_ -le ($maxValue * 0.5) } { "▓" } default { "█" } } $bar = $char * $barLength $hourStr = "{0:D2}:00" -f $hour $percentage = if ($maxValue -gt 0) { [Math]::Round(($value / $maxValue) * 100, 1) } else { 0 } $output += "$hourStr : $bar $value ($percentage%)" } return $output -join "`n" } [string] GenerateWeeklyPattern([hashtable]$weeklyData) { $output = @() $output += "Listening Activity by Day" $output += "=========================" $output += "" $dayNames = @("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday") $maxValue = ($weeklyData.Values | Measure-Object -Maximum).Maximum if ($maxValue -eq 0) { $output += "No weekly data available" return $output -join "`n" } for ($day = 0; $day -lt 7; $day++) { $value = $weeklyData[$day] $barLength = [Math]::Round(($value / $maxValue) * 30) # Use different characters for different intensities $char = switch ($value) { { $_ -eq 0 } { "░" } { $_ -le ($maxValue * 0.25) } { "▒" } { $_ -le ($maxValue * 0.5) } { "▓" } default { "█" } } $bar = $char * $barLength $dayName = $dayNames[$day].PadRight(9) $percentage = if ($maxValue -gt 0) { [Math]::Round(($value / $maxValue) * 100, 1) } else { 0 } $output += "$dayName : $bar $value ($percentage%)" } return $output -join "`n" } [string] GenerateTopItemsChart([array]$items, [string]$title, [string]$itemType) { $output = @() $output += $title $output += "=" * $title.Length $output += "" if ($items.Count -eq 0) { $output += "No $itemType data available" return $output -join "`n" } $maxValue = ($items | ForEach-Object { $_.PlayCount } | Measure-Object -Maximum).Maximum if ($maxValue -eq 0) { $output += "No play count data to display" return $output -join "`n" } for ($i = 0; $i -lt [Math]::Min(10, $items.Count); $i++) { $item = $items[$i] $rank = $i + 1 $barLength = [Math]::Round(($item.PlayCount / $maxValue) * 40) $bar = "█" * $barLength $name = if ($itemType -eq "tracks") { "$($item.Name) - $($item.Artist)" } else { $item.Name } # Truncate long names if ($name.Length -gt 35) { $name = $name.Substring(0, 32) + "..." } $output += "$rank. $($name.PadRight(35)) $bar $($item.PlayCount)" } return $output -join "`n" } [string] GenerateStreakVisualization([hashtable]$streakData) { $output = @() $output += "Listening Streaks" $output += "================" $output += "" if ($streakData.TotalActiveDays -eq 0) { $output += "No listening activity recorded" return $output -join "`n" } $currentStreak = $streakData.CurrentStreak $longestStreak = $streakData.LongestStreak $isActive = $streakData.IsStreakActive # Current streak visualization $streakBar = "█" * [Math]::Min($currentStreak, 50) $streakStatus = if ($isActive) { "🔥 ACTIVE" } else { "💔 BROKEN" } $output += "Current Streak: $currentStreak days $streakStatus" $output += "[$streakBar]" $output += "" # Longest streak $longestBar = "▓" * [Math]::Min($longestStreak, 50) $output += "Longest Streak: $longestStreak days" $output += "[$longestBar]" if ($streakData.StreakStartDate -and $streakData.StreakEndDate) { $startDate = $streakData.StreakStartDate.ToString("MM/dd/yyyy") $endDate = $streakData.StreakEndDate.ToString("MM/dd/yyyy") $output += "Period: $startDate - $endDate" } $output += "" $output += "Total Active Days: $($streakData.TotalActiveDays)" return $output -join "`n" } } # Main statistics engine class StatisticsEngine { [DataCollector] $DataCollector [AnalyticsProcessor] $AnalyticsProcessor [VisualizationGenerator] $VisualizationGenerator [hashtable] $Configuration StatisticsEngine([hashtable]$config) { $this.Configuration = $config # Initialize components $dataDir = $config.ContainsKey('DataDirectory') ? $config.DataDirectory : (Join-Path $env:APPDATA "SpotifyCLI\Statistics") $this.DataCollector = [DataCollector]::new($dataDir) $this.AnalyticsProcessor = [AnalyticsProcessor]::new($this.DataCollector) $this.VisualizationGenerator = [VisualizationGenerator]::new() # Configure data collector if ($config.ContainsKey('TrackingEnabled')) { $this.DataCollector.IsEnabled = $config.TrackingEnabled } if ($config.ContainsKey('RetentionDays')) { $this.DataCollector.MaxRetentionDays = $config.RetentionDays } } [void] RecordPlayback([hashtable]$trackData) { $this.DataCollector.RecordPlayback($trackData, [DateTime]::UtcNow) return } [void] RecordSkip([string]$trackId, [int]$positionMs) { $this.DataCollector.RecordSkip($trackId, $positionMs) return } [ListeningStats] GetStats([string]$period) { return $this.AnalyticsProcessor.GenerateComprehensiveStats($period) } [string] GenerateReport([string]$period) { $stats = $this.GetStats($period) $streaks = $this.AnalyticsProcessor.CalculateListeningStreaks() $output = @() $output += "Spotify Listening Statistics - $period" $output += "=" * 40 $output += "" # Summary $totalHours = [Math]::Round($stats.TotalPlaytime / (1000 * 60 * 60), 1) $output += "Summary:" $output += " Total listening time: $totalHours hours" $output += " Total tracks played: $($stats.TotalTracks)" $output += " Unique artists: $($stats.UniqueArtists)" $output += " Unique albums: $($stats.UniqueAlbums)" $output += " Current streak: $($stats.CurrentStreak) days" $output += "" # Streak visualization $output += $this.VisualizationGenerator.GenerateStreakVisualization($streaks) $output += "" # Top tracks if ($stats.TopTracks.Tracks.Count -gt 0) { $output += $this.VisualizationGenerator.GenerateTopItemsChart($stats.TopTracks.Tracks, "Top Tracks", "tracks") $output += "" } # Top artists if ($stats.TopArtists.Artists.Count -gt 0) { $output += $this.VisualizationGenerator.GenerateTopItemsChart($stats.TopArtists.Artists, "Top Artists", "artists") $output += "" } # Genre distribution if ($stats.GenreDistribution.Distribution.Count -gt 0) { $genreData = @{} foreach ($genre in $stats.GenreDistribution.Distribution.Keys) { $genreData[$genre] = $stats.GenreDistribution.Distribution[$genre].Count } $output += $this.VisualizationGenerator.GeneratePieChart($genreData, "Genre Distribution") $output += "" } # Listening patterns $output += $this.VisualizationGenerator.GenerateHourlyPattern($stats.DailyPatterns) $output += "" $output += $this.VisualizationGenerator.GenerateWeeklyPattern($stats.WeeklyPatterns) return $output -join "`n" } [hashtable] ExportStats([string]$format, [string]$period, [hashtable]$filters = @{}) { return $this.AnalyticsProcessor.ExportData($format, $period, $filters) } [hashtable] ExportRawData([DateTime]$startDate, [DateTime]$endDate, [string]$format) { return $this.AnalyticsProcessor.ExportRawData($startDate, $endDate, $format) } [hashtable] BackupData([string]$backupPath = $null) { if (-not $backupPath) { $backupPath = Join-Path (Split-Path $this.DataCollector.DataDirectory -Parent) "Backups" } return $this.AnalyticsProcessor.BackupData($backupPath) } [hashtable] RestoreData([string]$backupFile) { return $this.AnalyticsProcessor.RestoreData($backupFile) } [hashtable] ExportData([string]$format, [string]$period, [hashtable]$filters = @{}) { $stats = $this.GetStats($period) $timestamp = [DateTime]::Now.ToString("yyyyMMdd_HHmmss") switch ($format.ToLower()) { "json" { # Create comprehensive JSON export $exportData = @{ ExportInfo = @{ Timestamp = [DateTime]::UtcNow Period = $period Filters = $filters Version = "1.0" } Summary = @{ TotalPlaytime = $stats.TotalPlaytime TotalTracks = $stats.TotalTracks UniqueArtists = $stats.UniqueArtists UniqueAlbums = $stats.UniqueAlbums CurrentStreak = $stats.CurrentStreak } TopTracks = $stats.TopTracks TopArtists = $stats.TopArtists GenreDistribution = $stats.GenreDistribution ListeningPatterns = @{ HourlyPattern = $stats.DailyPatterns WeeklyPattern = $stats.WeeklyPatterns } } return @{ Format = "json" Data = $exportData | ConvertTo-Json -Depth 10 FileName = "spotify_stats_${period}_${timestamp}.json" Success = $true } } "csv" { # Create detailed CSV export with multiple sheets worth of data $csvData = @() # Export tracks foreach ($track in $stats.TopTracks.Tracks) { $csvData += [PSCustomObject]@{ Type = "Track" Name = $track.Name Artist = $track.Artist Album = $track.Album PlayCount = $track.PlayCount TotalPlaytimeMs = $track.TotalPlaytime TotalPlaytimeHours = [Math]::Round($track.TotalPlaytime / (1000 * 60 * 60), 2) Period = $period ExportDate = [DateTime]::UtcNow.ToString("yyyy-MM-dd HH:mm:ss") } } # Export artists foreach ($artist in $stats.TopArtists.Artists) { $csvData += [PSCustomObject]@{ Type = "Artist" Name = $artist.Name Artist = $artist.Name Album = "" PlayCount = $artist.PlayCount TotalPlaytimeMs = $artist.TotalPlaytime TotalPlaytimeHours = [Math]::Round($artist.TotalPlaytime / (1000 * 60 * 60), 2) Period = $period ExportDate = [DateTime]::UtcNow.ToString("yyyy-MM-dd HH:mm:ss") } } # Export genre data foreach ($genre in $stats.GenreDistribution.Distribution.Keys) { $genreData = $stats.GenreDistribution.Distribution[$genre] $csvData += [PSCustomObject]@{ Type = "Genre" Name = $genre Artist = "" Album = "" PlayCount = $genreData.Count TotalPlaytimeMs = $genreData.PlaytimeMs TotalPlaytimeHours = $genreData.PlaytimeHours Period = $period ExportDate = [DateTime]::UtcNow.ToString("yyyy-MM-dd HH:mm:ss") } } $csv = $csvData | ConvertTo-Csv -NoTypeInformation return @{ Format = "csv" Data = $csv -join "`n" FileName = "spotify_stats_${period}_${timestamp}.csv" Success = $true } } default { return @{ Success = $false Error = "Unsupported export format: $format. Supported formats: json, csv" } } } } [hashtable] ExportRawData([DateTime]$startDate, [DateTime]$endDate, [string]$format) { try { $events = $this.DataCollector.GetPlaybackHistory($startDate, $endDate) $timestamp = [DateTime]::Now.ToString("yyyyMMdd_HHmmss") switch ($format.ToLower()) { "json" { $exportData = @{ ExportInfo = @{ Timestamp = [DateTime]::UtcNow StartDate = $startDate EndDate = $endDate TotalEvents = $events.Count Version = "1.0" } Events = $events } return @{ Format = "json" Data = $exportData | ConvertTo-Json -Depth 10 FileName = "spotify_raw_data_${timestamp}.json" Success = $true } } "csv" { $csvData = @() foreach ($event in $events) { $csvData += [PSCustomObject]@{ TrackId = $event.TrackId TrackName = $event.TrackName ArtistName = $event.ArtistName AlbumName = $event.AlbumName Genres = ($event.Genres -join "; ") Timestamp = $event.Timestamp.ToString("yyyy-MM-dd HH:mm:ss") DurationMs = $event.DurationMs PositionMs = $event.PositionMs EventType = $event.EventType DeviceType = $event.DeviceType IsExplicit = $event.IsExplicit } } $csv = $csvData | ConvertTo-Csv -NoTypeInformation return @{ Format = "csv" Data = $csv -join "`n" FileName = "spotify_raw_data_${timestamp}.csv" Success = $true } } default { return @{ Success = $false Error = "Unsupported export format: $format. Supported formats: json, csv" } } } } catch { return @{ Success = $false Error = "Failed to export raw data: $($_.Exception.Message)" } } } [hashtable] BackupData([string]$backupPath) { try { $timestamp = [DateTime]::Now.ToString("yyyyMMdd_HHmmss") $backupFileName = "spotify_backup_${timestamp}.json" $fullBackupPath = Join-Path $backupPath $backupFileName # Ensure backup directory exists if (-not (Test-Path $backupPath)) { New-Item -ItemType Directory -Path $backupPath -Force | Out-Null } # Get all data $allEvents = $this.DataCollector.LoadEvents() $storageStats = $this.DataCollector.GetStorageStats() $backupData = @{ BackupInfo = @{ Timestamp = [DateTime]::UtcNow Version = "1.0" OriginalFile = $this.DataCollector.DatabaseFile EventCount = $allEvents.Count } StorageStats = $storageStats Events = $allEvents } $json = $backupData | ConvertTo-Json -Depth 10 Set-Content -Path $fullBackupPath -Value $json -Encoding UTF8 return @{ Success = $true BackupFile = $fullBackupPath EventCount = $allEvents.Count BackupSizeMB = [Math]::Round((Get-Item $fullBackupPath).Length / 1MB, 2) } } catch { return @{ Success = $false Error = "Failed to create backup: $($_.Exception.Message)" } } } [hashtable] RestoreData([string]$backupFile) { try { if (-not (Test-Path $backupFile)) { return @{ Success = $false Error = "Backup file not found: $backupFile" } } # Load backup data $json = Get-Content -Path $backupFile -Raw -Encoding UTF8 $backupData = $json | ConvertFrom-Json if (-not $backupData.Events) { return @{ Success = $false Error = "Invalid backup file format: no events data found" } } # Create backup of current data before restore $currentBackup = $this.BackupData((Split-Path $this.DataCollector.DatabaseFile -Parent)) # Restore events $restoredEvents = @() foreach ($eventData in $backupData.Events) { try { $eventHash = @{} $eventData.PSObject.Properties | ForEach-Object { $eventHash[$_.Name] = $_.Value } # Convert timestamp string back to DateTime if needed if ($eventHash.Timestamp -is [string]) { $eventHash.Timestamp = [DateTime]::Parse($eventHash.Timestamp) } $restoredEvents += [PlaybackEvent]::new($eventHash) } catch { Write-Warning "Failed to restore event: $($_.Exception.Message)" continue } } # Save restored data $json = $restoredEvents | ConvertTo-Json -Depth 10 Set-Content -Path $this.DataCollector.DatabaseFile -Value $json -Encoding UTF8 return @{ Success = $true RestoredEvents = $restoredEvents.Count BackupCreated = $currentBackup.BackupFile RestoreDate = [DateTime]::UtcNow } } catch { return @{ Success = $false Error = "Failed to restore data: $($_.Exception.Message)" } } } [void] ClearData() { $this.DataCollector.ClearHistory() return } [hashtable] GetStorageInfo() { return $this.DataCollector.GetStorageStats() } } # Export classes and functions # Note: PowerShell classes are automatically available when the module is imported # We don't need to explicitly export them, but we can create helper functions function New-StatisticsEngine { param([hashtable]$Configuration = @{}) return [StatisticsEngine]::new($Configuration) } function New-DataCollector { param([string]$DataDirectory) return [DataCollector]::new($DataDirectory) } function New-AnalyticsProcessor { param([DataCollector]$DataCollector) return [AnalyticsProcessor]::new($DataCollector) } function New-VisualizationGenerator { return [VisualizationGenerator]::new() } Export-ModuleMember -Function @( 'New-StatisticsEngine', 'New-DataCollector', 'New-AnalyticsProcessor', 'New-VisualizationGenerator' ) |