From ac6b7b30086d1fa4dbef32e9d6ccfeda96a0c70a Mon Sep 17 00:00:00 2001 From: IDGBAN <106408231+IDGBAN@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:57:19 -0400 Subject: [PATCH 1/4] Add New Features - Ability to process podcasts and audiobooks - More playback context - Improve detail view - Add fast reload cache - Fix skip-detection bug --- Sortify/Models/DetailStats.cs | 73 +++++ Sortify/Models/FilterOptions.cs | 11 +- Sortify/Models/PlayRecord.cs | 111 ++++++- Sortify/Models/Stats.cs | 153 ++++++++- Sortify/Services/AnalysisEngine.cs | 432 +++++++++++++++++++++++--- Sortify/Services/AppSettings.cs | 50 +++ Sortify/Services/ChartBuilder.cs | 219 ++++++++++++- Sortify/Services/DetailEngine.cs | 121 ++++++++ Sortify/Services/ExportService.cs | 84 ++++- Sortify/Services/FilterEngine.cs | 39 ++- Sortify/Services/HistoryParser.cs | 245 ++++++++++++--- Sortify/Services/RecordCache.cs | 205 ++++++++++++ Sortify/Services/TimeFormat.cs | 3 + Sortify/ViewModels/FilterViewModel.cs | 7 +- Sortify/ViewModels/MainViewModel.cs | 271 ++++++++++++++-- Sortify/Views/DetailWindow.xaml | 118 +++++++ Sortify/Views/DetailWindow.xaml.cs | 71 +++++ Sortify/Views/FilterPanel.xaml | 9 +- Sortify/Views/MainWindow.xaml | 293 ++++++++++++++++- Sortify/Views/MainWindow.xaml.cs | 110 ++++++- 20 files changed, 2446 insertions(+), 179 deletions(-) create mode 100644 Sortify/Models/DetailStats.cs create mode 100644 Sortify/Services/AppSettings.cs create mode 100644 Sortify/Services/DetailEngine.cs create mode 100644 Sortify/Services/RecordCache.cs create mode 100644 Sortify/Views/DetailWindow.xaml create mode 100644 Sortify/Views/DetailWindow.xaml.cs diff --git a/Sortify/Models/DetailStats.cs b/Sortify/Models/DetailStats.cs new file mode 100644 index 0000000..cff42f4 --- /dev/null +++ b/Sortify/Models/DetailStats.cs @@ -0,0 +1,73 @@ +namespace Sortify.Models; + +/// What a was built for. +public enum DetailScope +{ + Artist, + Track, + Album, +} + +/// +/// A focused breakdown of one artist, track or album, computed on demand when the user +/// drills into a grid row. Everything here respects the filters that were active at the time. +/// +public sealed class DetailResult +{ + public required DetailScope Scope { get; init; } + + /// Primary name: the artist, track or album that was opened. + public required string Title { get; init; } + + /// Secondary line: the artist, for a track or album. Empty for an artist. + public string Subtitle { get; init; } = string.Empty; + + public long TotalMsPlayed { get; init; } + public int PlayCount { get; init; } + public DateTime? FirstPlayed { get; init; } + public DateTime? LastPlayed { get; init; } + + /// Distinct calendar days this had at least one play. + public int ActiveDays { get; init; } + + /// Tracks that make up this selection, most listened first. + public IReadOnlyList Tracks { get; init; } = Array.Empty(); + + /// Albums that make up this selection, most listened first. + public IReadOnlyList Albums { get; init; } = Array.Empty(); + + /// Listening time per calendar month, oldest first. + public IReadOnlyList ByMonth { get; init; } = Array.Empty(); + + /// ms played indexed by hour of day 0-23. + public long[] ByHour { get; init; } = new long[24]; + + /// + /// A Spotify URI seen on one of these plays ("spotify:track:..."), when the export + /// carried one. Empty for older exports and for anything Spotify didn't tag. + /// + public string Uri { get; init; } = string.Empty; + + public TimeSpan TotalTime => TimeSpan.FromMilliseconds(TotalMsPlayed); + + /// + /// Browser URL for this selection. Prefers the exact track when the export carried a + /// URI, and otherwise falls back to a Spotify search, which always resolves to something. + /// + public string WebUrl + { + get + { + // "spotify:track:abc" -> "https://open.spotify.com/track/abc" + if (Uri.StartsWith("spotify:", StringComparison.OrdinalIgnoreCase)) + { + var parts = Uri.Split(':'); + if (parts.Length >= 3 && parts[1].Length > 0 && parts[2].Length > 0) + return $"https://open.spotify.com/{parts[1]}/{parts[2]}"; + } + + var query = Subtitle.Length > 0 ? $"{Title} {Subtitle}" : Title; + return "https://open.spotify.com/search/" + System.Uri.EscapeDataString(query); + } + } +} diff --git a/Sortify/Models/FilterOptions.cs b/Sortify/Models/FilterOptions.cs index a51fe1a..9626899 100644 --- a/Sortify/Models/FilterOptions.cs +++ b/Sortify/Models/FilterOptions.cs @@ -11,10 +11,17 @@ public sealed class FilterOptions /// Minimum ms_played for a record to be counted. public int MinMsPlayed { get; set; } = DefaultMinMs; - /// Inclusive start of the date range (UTC). Null = no lower bound. + /// + /// When false (the default) only music plays feed the track/artist/album statistics, + /// so podcasts and audiobooks can't distort them. The Podcasts tab reads its own + /// aggregates and is unaffected by this. + /// + public bool IncludePodcasts { get; set; } + + /// Inclusive start of the date range (local time, matching record timestamps). Null = no lower bound. public DateTime? StartDate { get; set; } - /// Inclusive end of the date range (UTC). Null = no upper bound. + /// Inclusive end of the date range (local time, matching record timestamps). Null = no upper bound. public DateTime? EndDate { get; set; } /// Case-insensitive substring matched against track, artist or album name. Empty = no search filter. diff --git a/Sortify/Models/PlayRecord.cs b/Sortify/Models/PlayRecord.cs index 822a28b..1278803 100644 --- a/Sortify/Models/PlayRecord.cs +++ b/Sortify/Models/PlayRecord.cs @@ -2,6 +2,14 @@ namespace Sortify.Models; +/// What kind of content a play refers to. Spotify mixes all three into one file. +public enum ContentKind +{ + Music, + Podcast, + Audiobook, +} + /// /// A single normalized listening event parsed from Spotify extended streaming history. /// @@ -18,16 +26,61 @@ public sealed class PlayRecord /// Spotify "reason_end" (e.g. "trackdone", "fwdbtn", "endplay"). public string? ReasonEnd { get; init; } - /// Spotify "skipped" flag, when present. + /// + /// True when the play was skipped. Spotify's own "skipped" flag is null across large + /// stretches of real exports, so falls back to reason_end. + /// public bool Skipped { get; init; } + + // ---- Content kind ------------------------------------------------------------------- + + /// Music, podcast or audiobook. Music-only stats filter on this. + public ContentKind Kind { get; init; } = ContentKind.Music; + + /// Podcast show name, or audiobook title. Empty for music. + public string ShowName { get; init; } = string.Empty; + + /// Podcast episode name, or audiobook chapter title. Empty for music. + public string EpisodeName { get; init; } = string.Empty; + + // ---- Playback context --------------------------------------------------------------- + + /// Device/app the play happened on ("windows", "android", "web_player", ...). + public string Platform { get; init; } = string.Empty; + + /// Two-letter country the play was streamed from ("conn_country"). + public string Country { get; init; } = string.Empty; + + /// True when the play started in shuffle mode. + public bool Shuffle { get; init; } + + /// True when the play happened offline. + public bool Offline { get; init; } + + /// True when the play happened in a private session. + public bool Incognito { get; init; } + + /// Spotify URI for the track/episode, used to build "open in Spotify" links. + public string Uri { get; init; } = string.Empty; + + /// + /// True when this row came from an export that carries playback context at all. The + /// legacy account-data format has none, so shuffle/offline percentages must not count + /// those rows in their denominator. + /// + public bool HasPlaybackFlags { get; init; } } /// -/// Raw shape of an entry in a Spotify extended streaming history JSON file. +/// Raw shape of an entry in a Spotify streaming history JSON file. Covers both the +/// extended history export ("ts", "ms_played", "master_metadata_*") and the older +/// account-data export ("endTime", "msPlayed", "artistName", "trackName"). /// Only the fields Sortify needs are mapped. /// public sealed class SpotifyHistoryEntry { + // ---- Extended streaming history -------------------------------------------------------- + [JsonPropertyName("ts")] public string? Ts { get; set; } @@ -48,4 +101,58 @@ public sealed class SpotifyHistoryEntry [JsonPropertyName("skipped")] public bool? Skipped { get; set; } + + [JsonPropertyName("spotify_track_uri")] + public string? TrackUri { get; set; } + + // ---- Podcasts and audiobooks ----------------------------------------------------------- + + [JsonPropertyName("episode_name")] + public string? EpisodeName { get; set; } + + [JsonPropertyName("episode_show_name")] + public string? EpisodeShowName { get; set; } + + [JsonPropertyName("spotify_episode_uri")] + public string? EpisodeUri { get; set; } + + [JsonPropertyName("audiobook_title")] + public string? AudiobookTitle { get; set; } + + [JsonPropertyName("audiobook_chapter_title")] + public string? AudiobookChapterTitle { get; set; } + + [JsonPropertyName("audiobook_uri")] + public string? AudiobookUri { get; set; } + + // ---- Playback context -------------------------------------------------------------------- + + [JsonPropertyName("platform")] + public string? Platform { get; set; } + + [JsonPropertyName("conn_country")] + public string? ConnCountry { get; set; } + + [JsonPropertyName("shuffle")] + public bool? Shuffle { get; set; } + + [JsonPropertyName("offline")] + public bool? Offline { get; set; } + + [JsonPropertyName("incognito_mode")] + public bool? IncognitoMode { get; set; } + + // ---- Legacy account-data history ("StreamingHistory0.json") ---------------------------- + + [JsonPropertyName("endTime")] + public string? LegacyEndTime { get; set; } + + [JsonPropertyName("msPlayed")] + public long? LegacyMsPlayed { get; set; } + + [JsonPropertyName("artistName")] + public string? LegacyArtistName { get; set; } + + [JsonPropertyName("trackName")] + public string? LegacyTrackName { get; set; } } diff --git a/Sortify/Models/Stats.cs b/Sortify/Models/Stats.cs index 95cac90..0af60a6 100644 --- a/Sortify/Models/Stats.cs +++ b/Sortify/Models/Stats.cs @@ -6,8 +6,10 @@ public sealed class ArtistStat public required string Artist { get; init; } public long TotalMsPlayed { get; set; } public int PlayCount { get; set; } - public DateTime FirstPlayed { get; set; } = DateTime.MaxValue; - public DateTime LastPlayed { get; set; } = DateTime.MinValue; + + /// Null when no counted play of this artist carried a timestamp. + public DateTime? FirstPlayed { get; set; } + public DateTime? LastPlayed { get; set; } /// The track that was playing at the artist's first recorded listen. public string FirstTrack { get; set; } = "Unknown Track"; @@ -23,8 +25,10 @@ public sealed class TrackStat public required string Artist { get; init; } public long TotalMsPlayed { get; set; } public int PlayCount { get; set; } - public DateTime FirstPlayed { get; set; } = DateTime.MaxValue; - public DateTime LastPlayed { get; set; } = DateTime.MinValue; + + /// Null when no counted play of this track carried a timestamp. + public DateTime? FirstPlayed { get; set; } + public DateTime? LastPlayed { get; set; } public TimeSpan TotalTime => TimeSpan.FromMilliseconds(TotalMsPlayed); public double TotalHours => TotalMsPlayed / 3_600_000d; @@ -37,13 +41,83 @@ public sealed class AlbumStat public required string Artist { get; init; } public long TotalMsPlayed { get; set; } public int PlayCount { get; set; } - public DateTime FirstPlayed { get; set; } = DateTime.MaxValue; - public DateTime LastPlayed { get; set; } = DateTime.MinValue; + + /// Null when no counted play of this album carried a timestamp. + public DateTime? FirstPlayed { get; set; } + public DateTime? LastPlayed { get; set; } + + public TimeSpan TotalTime => TimeSpan.FromMilliseconds(TotalMsPlayed); + public double TotalHours => TotalMsPlayed / 3_600_000d; +} + +/// Aggregated listening statistics for a single calendar year. +public sealed class YearStat +{ + public required int Year { get; init; } + public long TotalMsPlayed { get; set; } + public int PlayCount { get; set; } + public int UniqueArtists { get; set; } + public int UniqueTracks { get; set; } + public string TopArtist { get; set; } = "-"; + public string TopTrack { get; set; } = "-"; + + public TimeSpan TotalTime => TimeSpan.FromMilliseconds(TotalMsPlayed); + public double TotalHours => TotalMsPlayed / 3_600_000d; +} + +/// Aggregated listening statistics for one podcast show or audiobook. +public sealed class ShowStat +{ + public required string Show { get; init; } + public long TotalMsPlayed { get; set; } + public int PlayCount { get; set; } + + /// Distinct episodes (or chapters) of this show that were played. + public int EpisodeCount { get; set; } + + public DateTime? FirstPlayed { get; set; } + public DateTime? LastPlayed { get; set; } + + /// Audiobooks are reported alongside podcasts but labelled separately. + public ContentKind Kind { get; init; } = ContentKind.Podcast; + + public TimeSpan TotalTime => TimeSpan.FromMilliseconds(TotalMsPlayed); + public double TotalHours => TotalMsPlayed / 3_600_000d; +} + +/// Aggregated listening statistics for one podcast episode or audiobook chapter. +public sealed class EpisodeStat +{ + public required string Episode { get; init; } + public required string Show { get; init; } + public long TotalMsPlayed { get; set; } + public int PlayCount { get; set; } + + public DateTime? FirstPlayed { get; set; } + public DateTime? LastPlayed { get; set; } + + public TimeSpan TotalTime => TimeSpan.FromMilliseconds(TotalMsPlayed); + public double TotalHours => TotalMsPlayed / 3_600_000d; +} + +/// Listening time and play count for one playback-context bucket (device, country...). +public sealed class ContextStat +{ + public required string Name { get; init; } + public long TotalMsPlayed { get; set; } + public int PlayCount { get; set; } public TimeSpan TotalTime => TimeSpan.FromMilliseconds(TotalMsPlayed); public double TotalHours => TotalMsPlayed / 3_600_000d; } +/// How often plays ended with a given Spotify "reason_end" value. +public sealed class ReasonEndStat +{ + public required string Reason { get; init; } + public int Count { get; set; } +} + /// /// Skip counts for a single track. Computed with the minimum-duration filter relaxed, /// because skipped plays are usually shorter than the cutoff and would otherwise vanish. @@ -66,6 +140,51 @@ public sealed class AnalysisResult public IReadOnlyList Tracks { get; init; } = Array.Empty(); public IReadOnlyList Albums { get; init; } = Array.Empty(); + // The same stats pre-sorted by play count, so switching a chart between "by time" + // and "by plays" (or paging it while scrolling) never re-sorts on the UI thread. + public IReadOnlyList ArtistsByPlayCount { get; init; } = Array.Empty(); + public IReadOnlyList TracksByPlayCount { get; init; } = Array.Empty(); + public IReadOnlyList AlbumsByPlayCount { get; init; } = Array.Empty(); + + /// Per-calendar-year rollups, oldest year first. + public IReadOnlyList Years { get; init; } = Array.Empty(); + + /// + /// Counts of Spotify "reason_end" values, most common first. Like the skip statistics, + /// computed with the minimum-duration cutoff relaxed so skip-style endings show up. + /// + public IReadOnlyList ReasonEnds { get; init; } = Array.Empty(); + + // ---- Podcasts and audiobooks ---------------------------------------------------------- + // Always aggregated, independent of FilterOptions.IncludePodcasts (which only controls + // whether this content also counts toward the music track/artist/album statistics). + + /// Podcast shows and audiobooks, most listened first. + public IReadOnlyList Shows { get; init; } = Array.Empty(); + + /// Individual episodes and chapters, most listened first. + public IReadOnlyList Episodes { get; init; } = Array.Empty(); + + public long PodcastMsPlayed { get; init; } + public int PodcastPlays { get; init; } + + // ---- Playback context ------------------------------------------------------------------- + + /// Listening time grouped into device families (Desktop, Mobile, Web...). + public IReadOnlyList Platforms { get; init; } = Array.Empty(); + + /// Listening time grouped by the country the play streamed from. + public IReadOnlyList Countries { get; init; } = Array.Empty(); + + /// Plays that started in shuffle mode, out of . + public int ShufflePlays { get; init; } + + /// Plays carrying a shuffle flag at all (older exports omit it). + public int ShuffleEligiblePlays { get; init; } + + public int OfflinePlays { get; init; } + public int IncognitoPlays { get; init; } + public int TotalPlays { get; init; } public long TotalMsPlayed { get; init; } public int UniqueArtists => Artists.Count; @@ -86,6 +205,9 @@ public sealed class AnalysisResult /// ms played bucketed by [day of week 0=Sunday..6, hour of day 0-23] for the heatmap. public long[,] PlaytimeByDowHour { get; init; } = new long[7, 24]; + /// Count of artists first heard in each calendar month (local time). + public IReadOnlyList NewArtistsByMonth { get; init; } = Array.Empty(); + // ---- Skip statistics (computed with the min-duration filter relaxed) ---------------- /// Tracks ordered by how often they were skipped. @@ -107,10 +229,29 @@ public sealed class AnalysisResult public DateTime? LongestStreakStart { get; init; } public DateTime? LongestStreakEnd { get; init; } + /// Run of consecutive listening days ending on the most recent listening day. + public int CurrentStreakDays { get; init; } + /// The single calendar day with the most listening time. public DateTime? BiggestDay { get; init; } public long BiggestDayMs { get; init; } + // ---- Sessions -------------------------------------------------------------------------- + // A session is a run of plays where each play starts within AnalysisEngine.SessionGap + // of the previous play's end. + + /// Number of distinct listening sessions. + public int SessionCount { get; init; } + + /// Average listening time per session, in ms. + public long AvgSessionMs { get; init; } + + /// Listening time of the single longest session, in ms. + public long LongestSessionMs { get; init; } + + /// The day the longest session started. + public DateTime? LongestSessionDate { get; init; } + public TimeSpan TotalTime => TimeSpan.FromMilliseconds(TotalMsPlayed); public static AnalysisResult Empty { get; } = new(); diff --git a/Sortify/Services/AnalysisEngine.cs b/Sortify/Services/AnalysisEngine.cs index 2ea0814..ebf2be0 100644 --- a/Sortify/Services/AnalysisEngine.cs +++ b/Sortify/Services/AnalysisEngine.cs @@ -1,13 +1,18 @@ +using System.Runtime.InteropServices; using Sortify.Models; namespace Sortify.Services; /// -/// Aggregates filtered play records into per-artist, per-track and per-album statistics -/// plus time-based breakdowns, skip statistics and streaks used by the charts and insights. +/// Aggregates filtered play records into per-artist, per-track, per-album and per-year +/// statistics plus time-based breakdowns, skip statistics, sessions and streaks used by +/// the charts and insights. /// public static class AnalysisEngine { + /// Plays separated by more than this gap belong to different listening sessions. + public static readonly TimeSpan SessionGap = TimeSpan.FromMinutes(30); + /// Runs aggregation on a background thread so the UI stays responsive. public static Task AnalyzeAsync( IReadOnlyList records, @@ -25,40 +30,107 @@ public static AnalysisResult Analyze( var artists = new Dictionary(StringComparer.Ordinal); var tracks = new Dictionary(StringComparer.Ordinal); var albums = new Dictionary(StringComparer.Ordinal); - var skips = new Dictionary(StringComparer.Ordinal); + var skips = new Dictionary(StringComparer.Ordinal); + var reasons = new Dictionary(StringComparer.OrdinalIgnoreCase); + var years = new Dictionary(); var byDay = new Dictionary(); var byHour = new long[24]; var byDow = new long[7]; var byDowHour = new long[7, 24]; + var timedPlays = new List<(DateTime End, int Ms)>(); + + var shows = new Dictionary(StringComparer.Ordinal); + var showEpisodeKeys = new Dictionary>(StringComparer.Ordinal); + var episodes = new Dictionary(StringComparer.Ordinal); + var platforms = new Dictionary(StringComparer.Ordinal); + var countries = new Dictionary(StringComparer.OrdinalIgnoreCase); int totalPlays = 0; long totalMs = 0; int skipEligiblePlays = 0; int totalSkips = 0; - DateTime firstListen = DateTime.MaxValue; - DateTime lastListen = DateTime.MinValue; + long podcastMs = 0; + int podcastPlays = 0; + int shufflePlays = 0; + int shuffleEligible = 0; + int offlinePlays = 0; + int incognitoPlays = 0; + DateTime? firstListen = null; + DateTime? lastListen = null; - // Enumerate with the min-duration cutoff relaxed so skip statistics see the short - // plays that skipping produces; the cutoff is re-applied manually for everything else. + // Enumerate with the min-duration cutoff relaxed so skip and reason-end statistics + // see the short plays that skipping produces; the cutoff is re-applied manually + // for everything else. int counter = 0; foreach (var r in FilterEngine.Apply(records, filter, ignoreMinDuration: true)) { if ((++counter & 0x3FFF) == 0) cancellationToken.ThrowIfCancellationRequested(); - var trackKey = r.TrackName + "\u0000" + r.ArtistName; + bool isMusic = r.Kind == ContentKind.Music; + + // ---- Podcast / audiobook aggregates --------------------------------------------- + // Always collected so the Podcasts tab works regardless of IncludePodcasts, which + // only governs whether this content also feeds the music statistics below. + if (!isMusic && r.MsPlayed >= filter.MinMsPlayed) + { + podcastPlays++; + podcastMs += r.MsPlayed; + AccumulateShow(shows, showEpisodeKeys, episodes, r); + } + + // ---- Playback context ------------------------------------------------------------ + // Describes all listening, so it is collected before the music-only gate. + if (r.MsPlayed >= filter.MinMsPlayed) + { + if (r.Platform.Length > 0) + Bump(platforms, PlatformFamily(r.Platform), r.MsPlayed); + if (r.Country.Length > 0) + Bump(countries, r.Country, r.MsPlayed); + + // Older exports omit these flags entirely; only count rows that carry one so + // the percentages aren't diluted by records that never could have set it. + if (r.HasPlaybackFlags) + { + shuffleEligible++; + if (r.Shuffle) shufflePlays++; + if (r.Offline) offlinePlays++; + if (r.Incognito) incognitoPlays++; + } + } + + // Podcasts and audiobooks stay out of the music statistics unless asked for. + if (!isMusic && !filter.IncludePodcasts) + continue; + + // When podcasts are folded in, the episode stands in for the track and the show + // for both the artist and the album, so every ranking stays populated. + string trackName = isMusic ? r.TrackName : r.EpisodeName; + string artistName = isMusic ? r.ArtistName : r.ShowName; + string albumName = isMusic ? r.AlbumName : r.ShowName; + + var trackKey = trackName + "\n" + artistName; + // ---- Relaxed-duration statistics: skips and end reasons ------------------------- skipEligiblePlays++; if (r.Skipped) totalSkips++; - if (!skips.TryGetValue(trackKey, out var skip)) + + // Counted as a plain value tuple: allocating a SkippedTrackStat per unique track + // wastes one object for every track that was never skipped, which is most of them. + ref var counts = ref CollectionsMarshal.GetValueRefOrAddDefault(skips, trackKey, out _); + counts.Plays++; + if (r.Skipped) counts.Skips++; + + var reasonKey = string.IsNullOrWhiteSpace(r.ReasonEnd) ? "unknown" : r.ReasonEnd; + if (!reasons.TryGetValue(reasonKey, out var reason)) { - skip = new SkippedTrackStat { Track = r.TrackName, Artist = r.ArtistName }; - skips[trackKey] = skip; + reason = new ReasonEndStat { Reason = reasonKey }; + reasons[reasonKey] = reason; } - skip.PlayCount++; - if (r.Skipped) skip.SkipCount++; + reason.Count++; + // ---- Everything below only counts plays that pass the duration cutoff ---------- if (r.MsPlayed < filter.MinMsPlayed) continue; @@ -66,28 +138,28 @@ public static AnalysisResult Analyze( totalMs += r.MsPlayed; // Artist aggregate. - if (!artists.TryGetValue(r.ArtistName, out var artist)) + if (!artists.TryGetValue(artistName, out var artist)) { - artist = new ArtistStat { Artist = r.ArtistName }; - artists[r.ArtistName] = artist; + artist = new ArtistStat { Artist = artistName }; + artists[artistName] = artist; } artist.TotalMsPlayed += r.MsPlayed; artist.PlayCount++; - // Track aggregate keyed by "track\u0000artist" to avoid collisions across artists. + // Track aggregate keyed by "track\nartist" to avoid collisions across artists. if (!tracks.TryGetValue(trackKey, out var track)) { - track = new TrackStat { Track = r.TrackName, Artist = r.ArtistName }; + track = new TrackStat { Track = trackName, Artist = artistName }; tracks[trackKey] = track; } track.TotalMsPlayed += r.MsPlayed; track.PlayCount++; // Album aggregate keyed the same way. - var albumKey = r.AlbumName + "\u0000" + r.ArtistName; + var albumKey = albumName + "\n" + artistName; if (!albums.TryGetValue(albumKey, out var album)) { - album = new AlbumStat { Album = r.AlbumName, Artist = r.ArtistName }; + album = new AlbumStat { Album = albumName, Artist = artistName }; albums[albumKey] = album; } album.TotalMsPlayed += r.MsPlayed; @@ -95,43 +167,76 @@ public static AnalysisResult Analyze( if (r.Timestamp != DateTime.MinValue) { - if (r.Timestamp < artist.FirstPlayed) + if (artist.FirstPlayed is null || r.Timestamp < artist.FirstPlayed) { artist.FirstPlayed = r.Timestamp; - artist.FirstTrack = r.TrackName; + artist.FirstTrack = trackName; } - if (r.Timestamp > artist.LastPlayed) artist.LastPlayed = r.Timestamp; + if (artist.LastPlayed is null || r.Timestamp > artist.LastPlayed) artist.LastPlayed = r.Timestamp; - if (r.Timestamp < track.FirstPlayed) track.FirstPlayed = r.Timestamp; - if (r.Timestamp > track.LastPlayed) track.LastPlayed = r.Timestamp; + if (track.FirstPlayed is null || r.Timestamp < track.FirstPlayed) track.FirstPlayed = r.Timestamp; + if (track.LastPlayed is null || r.Timestamp > track.LastPlayed) track.LastPlayed = r.Timestamp; - if (r.Timestamp < album.FirstPlayed) album.FirstPlayed = r.Timestamp; - if (r.Timestamp > album.LastPlayed) album.LastPlayed = r.Timestamp; + if (album.FirstPlayed is null || r.Timestamp < album.FirstPlayed) album.FirstPlayed = r.Timestamp; + if (album.LastPlayed is null || r.Timestamp > album.LastPlayed) album.LastPlayed = r.Timestamp; - if (r.Timestamp < firstListen) firstListen = r.Timestamp; - if (r.Timestamp > lastListen) lastListen = r.Timestamp; + if (firstListen is null || r.Timestamp < firstListen) firstListen = r.Timestamp; + if (lastListen is null || r.Timestamp > lastListen) lastListen = r.Timestamp; var day = r.Timestamp.Date; byDay[day] = byDay.TryGetValue(day, out var d) ? d + r.MsPlayed : r.MsPlayed; byHour[r.Timestamp.Hour] += r.MsPlayed; byDow[(int)r.Timestamp.DayOfWeek] += r.MsPlayed; byDowHour[(int)r.Timestamp.DayOfWeek, r.Timestamp.Hour] += r.MsPlayed; + + timedPlays.Add((r.Timestamp, r.MsPlayed)); + + // Per-year rollup. + int year = r.Timestamp.Year; + if (!years.TryGetValue(year, out var acc)) + { + acc = new YearAccumulator(); + years[year] = acc; + } + acc.Ms += r.MsPlayed; + acc.Plays++; + acc.ArtistMs[artistName] = acc.ArtistMs.TryGetValue(artistName, out var am) ? am + r.MsPlayed : r.MsPlayed; + acc.TrackMs[trackKey] = acc.TrackMs.TryGetValue(trackKey, out var tm) ? tm + r.MsPlayed : r.MsPlayed; } } - var artistList = artists.Values - .OrderByDescending(a => a.TotalMsPlayed) - .ToList(); - var trackList = tracks.Values - .OrderByDescending(t => t.TotalMsPlayed) - .ToList(); - var albumList = albums.Values - .OrderByDescending(a => a.TotalMsPlayed) - .ToList(); - var skippedList = skips.Values - .Where(s => s.SkipCount > 0) - .OrderByDescending(s => s.SkipCount) - .ToList(); + cancellationToken.ThrowIfCancellationRequested(); + + var artistList = artists.Values.OrderByDescending(a => a.TotalMsPlayed).ToList(); + var trackList = tracks.Values.OrderByDescending(t => t.TotalMsPlayed).ToList(); + var albumList = albums.Values.OrderByDescending(a => a.TotalMsPlayed).ToList(); + var artistsByCount = artists.Values.OrderByDescending(a => a.PlayCount).ToList(); + var tracksByCount = tracks.Values.OrderByDescending(t => t.PlayCount).ToList(); + var albumsByCount = albums.Values.OrderByDescending(a => a.PlayCount).ToList(); + // Only tracks that were actually skipped become objects; the rest never leave the + // counting dictionary. + var skippedList = new List(); + foreach (var (key, counts) in skips) + { + if (counts.Skips == 0) + continue; + int sep = key.IndexOf('\n'); + skippedList.Add(new SkippedTrackStat + { + Track = key[..sep], + Artist = key[(sep + 1)..], + SkipCount = counts.Skips, + PlayCount = counts.Plays, + }); + } + skippedList.Sort(static (a, b) => b.SkipCount.CompareTo(a.SkipCount)); + + var reasonList = reasons.Values.OrderByDescending(x => x.Count).ToList(); + + var showList = shows.Values.OrderByDescending(s => s.TotalMsPlayed).ToList(); + var episodeList = episodes.Values.OrderByDescending(e => e.TotalMsPlayed).ToList(); + var platformList = platforms.Values.OrderByDescending(p => p.TotalMsPlayed).ToList(); + var countryList = countries.Values.OrderByDescending(c => c.TotalMsPlayed).ToList(); var dayPoints = byDay .OrderBy(kv => kv.Key) @@ -139,6 +244,7 @@ public static AnalysisResult Analyze( .ToList(); var (streakDays, streakStart, streakEnd) = LongestStreak(dayPoints); + int currentStreak = CurrentStreak(dayPoints); DateTime? biggestDay = null; long biggestDayMs = 0; @@ -151,28 +257,191 @@ public static AnalysisResult Analyze( } } + var (sessionCount, avgSessionMs, longestSessionMs, longestSessionDate) = ComputeSessions(timedPlays); + + var newArtistsByMonth = artistList + .Where(a => a.FirstPlayed is not null) + .GroupBy(a => new DateTime(a.FirstPlayed!.Value.Year, a.FirstPlayed.Value.Month, 1)) + .OrderBy(g => g.Key) + .Select(g => new DateTimePoint(g.Key, g.Count())) + .ToList(); + + var yearList = years + .OrderBy(kv => kv.Key) + .Select(kv => BuildYearStat(kv.Key, kv.Value)) + .ToList(); + return new AnalysisResult { Artists = artistList, Tracks = trackList, Albums = albumList, + ArtistsByPlayCount = artistsByCount, + TracksByPlayCount = tracksByCount, + AlbumsByPlayCount = albumsByCount, + Years = yearList, + ReasonEnds = reasonList, SkippedTracks = skippedList, + Shows = showList, + Episodes = episodeList, + PodcastMsPlayed = podcastMs, + PodcastPlays = podcastPlays, + Platforms = platformList, + Countries = countryList, + ShufflePlays = shufflePlays, + ShuffleEligiblePlays = shuffleEligible, + OfflinePlays = offlinePlays, + IncognitoPlays = incognitoPlays, TotalPlays = totalPlays, TotalMsPlayed = totalMs, SkipEligiblePlays = skipEligiblePlays, TotalSkips = totalSkips, - FirstListen = firstListen == DateTime.MaxValue ? null : firstListen, - LastListen = lastListen == DateTime.MinValue ? null : lastListen, + FirstListen = firstListen, + LastListen = lastListen, PlaytimeByDay = dayPoints, PlaytimeByHour = byHour, PlaytimeByDayOfWeek = byDow, PlaytimeByDowHour = byDowHour, + NewArtistsByMonth = newArtistsByMonth, ActiveDays = byDay.Count, LongestStreakDays = streakDays, LongestStreakStart = streakStart, LongestStreakEnd = streakEnd, + CurrentStreakDays = currentStreak, BiggestDay = biggestDay, BiggestDayMs = biggestDayMs, + SessionCount = sessionCount, + AvgSessionMs = avgSessionMs, + LongestSessionMs = longestSessionMs, + LongestSessionDate = longestSessionDate, + }; + } + + /// Adds one podcast/audiobook play to the show and episode aggregates. + private static void AccumulateShow( + Dictionary shows, + Dictionary> showEpisodeKeys, + Dictionary episodes, + PlayRecord r) + { + if (!shows.TryGetValue(r.ShowName, out var show)) + { + show = new ShowStat { Show = r.ShowName, Kind = r.Kind }; + shows[r.ShowName] = show; + showEpisodeKeys[r.ShowName] = new HashSet(StringComparer.Ordinal); + } + show.TotalMsPlayed += r.MsPlayed; + show.PlayCount++; + if (showEpisodeKeys[r.ShowName].Add(r.EpisodeName)) + show.EpisodeCount++; + + var episodeKey = r.EpisodeName + "\n" + r.ShowName; + if (!episodes.TryGetValue(episodeKey, out var episode)) + { + episode = new EpisodeStat { Episode = r.EpisodeName, Show = r.ShowName }; + episodes[episodeKey] = episode; + } + episode.TotalMsPlayed += r.MsPlayed; + episode.PlayCount++; + + if (r.Timestamp == DateTime.MinValue) + return; + + if (show.FirstPlayed is null || r.Timestamp < show.FirstPlayed) show.FirstPlayed = r.Timestamp; + if (show.LastPlayed is null || r.Timestamp > show.LastPlayed) show.LastPlayed = r.Timestamp; + if (episode.FirstPlayed is null || r.Timestamp < episode.FirstPlayed) episode.FirstPlayed = r.Timestamp; + if (episode.LastPlayed is null || r.Timestamp > episode.LastPlayed) episode.LastPlayed = r.Timestamp; + } + + private static void Bump(Dictionary map, string name, int ms) + { + if (!map.TryGetValue(name, out var stat)) + { + stat = new ContextStat { Name = name }; + map[name] = stat; + } + stat.TotalMsPlayed += ms; + stat.PlayCount++; + } + + /// + /// Collapses Spotify's very granular platform strings (which embed OS builds, device + /// models and SDK versions) into a handful of families worth charting. + /// + internal static string PlatformFamily(string platform) + { + if (platform.Contains("web_player", StringComparison.OrdinalIgnoreCase) || + platform.Contains("webplayer", StringComparison.OrdinalIgnoreCase)) + return "Web player"; + if (platform.Contains("android", StringComparison.OrdinalIgnoreCase) || + platform.Contains("ios", StringComparison.OrdinalIgnoreCase) || + platform.Contains("iphone", StringComparison.OrdinalIgnoreCase) || + platform.Contains("ipad", StringComparison.OrdinalIgnoreCase)) + return "Mobile"; + if (platform.Contains("cast", StringComparison.OrdinalIgnoreCase) || + platform.Contains("sonos", StringComparison.OrdinalIgnoreCase) || + platform.Contains("speaker", StringComparison.OrdinalIgnoreCase) || + platform.Contains("partner", StringComparison.OrdinalIgnoreCase)) + return "Speaker / cast"; + if (platform.Contains("tv", StringComparison.OrdinalIgnoreCase) || + platform.Contains("xbox", StringComparison.OrdinalIgnoreCase) || + platform.Contains("playstation", StringComparison.OrdinalIgnoreCase)) + return "TV / console"; + if (platform.Contains("car", StringComparison.OrdinalIgnoreCase) || + platform.Contains("automotive", StringComparison.OrdinalIgnoreCase)) + return "Car"; + // Spotify writes macOS as "OS X 10.15.7 [x86_64]" - with a space - so match both forms. + if (platform.Contains("windows", StringComparison.OrdinalIgnoreCase) || + platform.Contains("osx", StringComparison.OrdinalIgnoreCase) || + platform.Contains("os x", StringComparison.OrdinalIgnoreCase) || + platform.Contains("mac", StringComparison.OrdinalIgnoreCase) || + platform.Contains("linux", StringComparison.OrdinalIgnoreCase)) + return "Desktop"; + return "Other"; + } + + private sealed class YearAccumulator + { + public long Ms; + public int Plays; + public readonly Dictionary ArtistMs = new(StringComparer.Ordinal); + public readonly Dictionary TrackMs = new(StringComparer.Ordinal); + } + + private static YearStat BuildYearStat(int year, YearAccumulator acc) + { + string topArtist = "-"; + long best = -1; + foreach (var (name, ms) in acc.ArtistMs) + { + if (ms > best) + { + best = ms; + topArtist = name; + } + } + + string topTrack = "-"; + best = -1; + foreach (var (key, ms) in acc.TrackMs) + { + if (ms > best) + { + best = ms; + // Track keys are "track\nartist"; show just the track name. + topTrack = key[..key.IndexOf('\n')]; + } + } + + return new YearStat + { + Year = year, + TotalMsPlayed = acc.Ms, + PlayCount = acc.Plays, + UniqueArtists = acc.ArtistMs.Count, + UniqueTracks = acc.TrackMs.Count, + TopArtist = topArtist, + TopTrack = topTrack, }; } @@ -208,4 +477,79 @@ private static (int days, DateTime? start, DateTime? end) LongestStreak(IReadOnl return (best, bestStart, bestEnd); } + + /// Length of the run of consecutive listening days ending on the last listening day. + private static int CurrentStreak(IReadOnlyList dayPoints) + { + if (dayPoints.Count == 0) + return 0; + + int streak = 1; + for (int i = dayPoints.Count - 1; i > 0; i--) + { + if (dayPoints[i].Date == dayPoints[i - 1].Date.AddDays(1)) + streak++; + else + break; + } + return streak; + } + + /// + /// Groups timestamped plays into sessions. A play whose start (end time minus duration) + /// falls within of the previous play's end continues the session. + /// + private static (int count, long avgMs, long longestMs, DateTime? longestDate) ComputeSessions( + List<(DateTime End, int Ms)> plays) + { + if (plays.Count == 0) + return (0, 0, 0, null); + + plays.Sort(static (a, b) => a.End.CompareTo(b.End)); + + int count = 1; + long totalMs = plays[0].Ms; + long currentMs = plays[0].Ms; + long longestMs = 0; + DateTime sessionStart = StartOf(plays[0]); + DateTime longestStart = sessionStart; + DateTime prevEnd = plays[0].End; + + for (int i = 1; i < plays.Count; i++) + { + var start = StartOf(plays[i]); + if (start - prevEnd > SessionGap) + { + if (currentMs > longestMs) + { + longestMs = currentMs; + longestStart = sessionStart; + } + count++; + currentMs = 0; + sessionStart = start; + } + + currentMs += plays[i].Ms; + totalMs += plays[i].Ms; + if (plays[i].End > prevEnd) + prevEnd = plays[i].End; + } + + if (currentMs > longestMs) + { + longestMs = currentMs; + longestStart = sessionStart; + } + + return (count, totalMs / count, longestMs, longestStart.Date); + } + + private static DateTime StartOf((DateTime End, int Ms) play) + { + // Timestamps mark when a play ended; subtract the duration to get its start, + // clamped so contrived data near DateTime.MinValue can't underflow. + long ticks = play.End.Ticks - play.Ms * TimeSpan.TicksPerMillisecond; + return new DateTime(Math.Max(0L, ticks)); + } } diff --git a/Sortify/Services/AppSettings.cs b/Sortify/Services/AppSettings.cs new file mode 100644 index 0000000..be9da89 --- /dev/null +++ b/Sortify/Services/AppSettings.cs @@ -0,0 +1,50 @@ +using System.IO; +using System.Text.Json; + +namespace Sortify.Services; + +/// +/// Small user-preferences blob persisted next to the record cache. Everything here is +/// convenience only, so any read or write failure degrades to defaults rather than +/// surfacing an error. +/// +public sealed class AppSettings +{ + /// Folder passed to Open Folder last time, reopened at startup when it still exists. + public string? LastFolder { get; set; } + + /// Whether to reload the last folder automatically on launch. + public bool ReopenLastFolder { get; set; } = true; + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private static string SettingsFile => Path.Combine(RecordCache.CacheDirectory, "settings.json"); + + public static AppSettings Load() + { + try + { + if (!File.Exists(SettingsFile)) + return new AppSettings(); + return JsonSerializer.Deserialize(File.ReadAllText(SettingsFile), JsonOptions) + ?? new AppSettings(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + return new AppSettings(); + } + } + + public void Save() + { + try + { + Directory.CreateDirectory(RecordCache.CacheDirectory); + File.WriteAllText(SettingsFile, JsonSerializer.Serialize(this, JsonOptions)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Preferences are best-effort. + } + } +} diff --git a/Sortify/Services/ChartBuilder.cs b/Sortify/Services/ChartBuilder.cs index abfe7fc..da244dd 100644 --- a/Sortify/Services/ChartBuilder.cs +++ b/Sortify/Services/ChartBuilder.cs @@ -36,10 +36,31 @@ public static class ChartBuilder new(176, 205, 80), }; + /// Friendly display names for Spotify "reason_end" codes. + private static readonly Dictionary ReasonLabels = new(StringComparer.OrdinalIgnoreCase) + { + ["trackdone"] = "Track finished", + ["fwdbtn"] = "Skipped (next)", + ["backbtn"] = "Back button", + ["endplay"] = "Playback stopped", + ["logout"] = "Logged out", + ["trackerror"] = "Track error", + ["remote"] = "Remote control", + ["appload"] = "App reloaded", + ["popup"] = "Popup", + ["uriopen"] = "Opened elsewhere", + ["clickrow"] = "Picked another track", + ["playbtn"] = "Play button", + ["clickside"] = "Sidebar click", + ["unexpected-exit"] = "App exited", + ["unexpected-exit-while-paused"] = "Exited while paused", + ["unknown"] = "Unknown", + }; + private static SolidColorPaint Label() => new(Text); private static string ShortLabel(string s, int max = 22) - => s.Length <= max ? s : s[..(max - 1)] + "\u2026"; + => s.Length <= max ? s : s[..(max - 1)] + "…"; // ---- Horizontal bar charts (RowSeries) ------------------------------------------------- @@ -53,7 +74,7 @@ public static (ISeries[] series, Axis[] x, Axis[] y) TopTracksByTime(AnalysisRes public static (ISeries[] series, Axis[] x, Axis[] y) TopTracksByCount(AnalysisResult r, int take) { - var items = r.Tracks.OrderByDescending(t => t.PlayCount).Take(take).Reverse().ToList(); + var items = r.TracksByPlayCount.Take(take).Reverse().ToList(); var values = items.Select(t => (double)t.PlayCount).ToArray(); var labels = items.Select(t => ShortLabel(t.Track)).ToArray(); return Rows(values, labels, "Plays", Accent2); @@ -69,7 +90,7 @@ public static (ISeries[] series, Axis[] x, Axis[] y) TopArtistsByTime(AnalysisRe public static (ISeries[] series, Axis[] x, Axis[] y) TopArtistsByCount(AnalysisResult r, int take) { - var items = r.Artists.OrderByDescending(a => a.PlayCount).Take(take).Reverse().ToList(); + var items = r.ArtistsByPlayCount.Take(take).Reverse().ToList(); var values = items.Select(a => (double)a.PlayCount).ToArray(); var labels = items.Select(a => ShortLabel(a.Artist)).ToArray(); return Rows(values, labels, "Plays", Accent2); @@ -85,7 +106,7 @@ public static (ISeries[] series, Axis[] x, Axis[] y) TopAlbumsByTime(AnalysisRes public static (ISeries[] series, Axis[] x, Axis[] y) TopAlbumsByCount(AnalysisResult r, int take) { - var items = r.Albums.OrderByDescending(a => a.PlayCount).Take(take).Reverse().ToList(); + var items = r.AlbumsByPlayCount.Take(take).Reverse().ToList(); var values = items.Select(a => (double)a.PlayCount).ToArray(); var labels = items.Select(a => ShortLabel(a.Album)).ToArray(); return Rows(values, labels, "Plays", Accent2); @@ -117,7 +138,7 @@ private static (ISeries[], Axis[], Axis[]) Rows(double[] values, string[] labels }; var y = new[] { - // One label per bar, smaller text so the 15 category names don't overlap. + // One label per bar, smaller text so the category names don't overlap. new Axis { Labels = labels, @@ -168,6 +189,14 @@ public static (ISeries[] series, Axis[] x, Axis[] y) ByDayOfWeek(AnalysisResult return Columns(values, names, "Hours", "Day of week", Accent2); } + /// Total listening time per calendar year. + public static (ISeries[] series, Axis[] x, Axis[] y) HoursPerYear(AnalysisResult r) + { + var values = r.Years.Select(y => Math.Round(y.TotalHours, 1)).ToArray(); + var labels = r.Years.Select(y => y.Year.ToString()).ToArray(); + return Columns(values, labels, "Hours", "Year", Accent); + } + private static (ISeries[], Axis[], Axis[]) Columns(double[] values, string[] labels, string unit, string xName, SKColor color) { var series = new ISeries[] @@ -232,23 +261,45 @@ public static (ISeries[] series, Axis[] x, Axis[] y) OverTime( GeometrySize = 0, }, }; - var x = new[] + var x = new[] { DateAxis(unitTicks) }; + var y = new[] { new Axis { Name = "Hours", NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }; + return (series, x, y); + } + + /// Count of artists heard for the first time, per calendar month. + public static (ISeries[] series, Axis[] x, Axis[] y) NewArtistsByMonth(AnalysisResult r) + { + var points = r.NewArtistsByMonth + .Select(p => new LcDateTimePoint(p.Date, p.Value)) + .ToArray(); + + var series = new ISeries[] { - new Axis + new LineSeries { - LabelsPaint = Label(), - Labeler = value => - { - try { return new DateTime((long)value).ToString("yyyy-MM"); } - catch { return string.Empty; } - }, - UnitWidth = unitTicks, + Values = points, + Name = "New artists", + Fill = new SolidColorPaint(Accent2.WithAlpha(40)), + Stroke = new SolidColorPaint(Accent2) { StrokeThickness = 2 }, + GeometrySize = 0, }, }; - var y = new[] { new Axis { Name = "Hours", NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }; + var x = new[] { DateAxis(TimeSpan.FromDays(30).Ticks) }; + var y = new[] { new Axis { Name = "New artists", NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }; return (series, x, y); } + private static Axis DateAxis(long unitTicks) => new() + { + LabelsPaint = Label(), + Labeler = value => + { + try { return new DateTime((long)value).ToString("yyyy-MM"); } + catch { return string.Empty; } + }, + UnitWidth = unitTicks, + }; + // ---- Heatmap --------------------------------------------------------------------------- /// Hour-of-day (x) by day-of-week (y) listening heatmap. @@ -339,4 +390,142 @@ public static ISeries[] ArtistShare(AnalysisResult r) return series.ToArray(); } + + /// Donut of why plays ended (reason_end), top reasons plus "Other". + public static ISeries[] ReasonEndShare(AnalysisResult r) + { + const int maxSlices = 8; + long total = r.ReasonEnds.Sum(x => (long)x.Count); + if (total <= 0) + return Array.Empty(); + + var series = new List(); + int i = 0; + foreach (var reason in r.ReasonEnds.Take(maxSlices)) + { + string label = ReasonLabels.TryGetValue(reason.Reason, out var friendly) + ? friendly + : reason.Reason; + AddCountSlice(series, label, reason.Count, total, Palette[i % Palette.Length]); + i++; + } + + int otherCount = r.ReasonEnds.Skip(maxSlices).Sum(x => x.Count); + if (otherCount > 0) + AddCountSlice(series, "Other", otherCount, total, new SKColor(110, 110, 110)); + + return series.ToArray(); + } + + /// Donut of listening time by device family. + public static ISeries[] PlatformShare(AnalysisResult r) => ContextShare(r.Platforms, maxSlices: 8); + + /// Donut of listening time by the country each play streamed from. + public static ISeries[] CountryShare(AnalysisResult r) => ContextShare(r.Countries, maxSlices: 10); + + private static ISeries[] ContextShare(IReadOnlyList stats, int maxSlices) + { + long total = stats.Sum(s => s.TotalMsPlayed); + if (total <= 0) + return Array.Empty(); + + var series = new List(); + int i = 0; + foreach (var stat in stats.Take(maxSlices)) + { + AddHoursSlice(series, stat.Name, stat.TotalMsPlayed, total, Palette[i % Palette.Length]); + i++; + } + + long otherMs = stats.Skip(maxSlices).Sum(s => s.TotalMsPlayed); + if (otherMs > 0) + AddHoursSlice(series, "Other", otherMs, total, new SKColor(110, 110, 110)); + + return series.ToArray(); + } + + private static void AddHoursSlice(List series, string label, long ms, long total, SKColor color) + { + double hours = Math.Round(ms / 3_600_000d, 2); + double pct = ms * 100.0 / total; + series.Add(new PieSeries + { + Values = new double[] { hours }, + Name = $"{ShortLabel(label, 22)} ({pct:0.#}%)", + Fill = new SolidColorPaint(color), + ToolTipLabelFormatter = _ => $"{hours:0.#} h ({pct:0.#}%)", + InnerRadius = 60, + }); + } + + // ---- Drill-down detail -------------------------------------------------------------------- + + /// Monthly listening time for a single artist, track or album. + public static (ISeries[] series, Axis[] x, Axis[] y) DetailByMonth(DetailResult d) + { + var points = d.ByMonth + .Select(p => new LcDateTimePoint(p.Date, Math.Round(p.Value, 2))) + .ToArray(); + + var series = new ISeries[] + { + new ColumnSeries + { + Values = points, + Name = "Hours", + Fill = new SolidColorPaint(Accent), + }, + }; + var x = new[] + { + new Axis + { + Labeler = value => new DateTime((long)value).ToString("yyyy-MM"), + UnitWidth = TimeSpan.FromDays(30).Ticks, + LabelsPaint = Label(), + TextSize = 11, + }, + }; + var y = new[] { new Axis { Name = "Hours", NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }; + return (series, x, y); + } + + /// Hour-of-day profile for a single artist, track or album. + public static (ISeries[] series, Axis[] x, Axis[] y) DetailByHour(DetailResult d) + { + var values = d.ByHour.Select(ms => Math.Round(ms / 3_600_000d, 2)).ToArray(); + var labels = Enumerable.Range(0, 24).Select(h => h.ToString("00")).ToArray(); + return Columns(values, labels, "Hours", "Hour of day", Accent2); + } + + // ---- Podcasts ---------------------------------------------------------------------------- + + public static (ISeries[] series, Axis[] x, Axis[] y) TopShows(AnalysisResult r, int take = 20) + { + var items = r.Shows.Take(take).Reverse().ToList(); + var values = items.Select(s => Math.Round(s.TotalHours, 2)).ToArray(); + var labels = items.Select(s => ShortLabel(s.Show)).ToArray(); + return Rows(values, labels, "Hours", new SKColor(155, 93, 229)); + } + + public static (ISeries[] series, Axis[] x, Axis[] y) TopEpisodes(AnalysisResult r, int take = 20) + { + var items = r.Episodes.Take(take).Reverse().ToList(); + var values = items.Select(e => Math.Round(e.TotalHours, 2)).ToArray(); + var labels = items.Select(e => ShortLabel(e.Episode)).ToArray(); + return Rows(values, labels, "Hours", new SKColor(76, 201, 240)); + } + + private static void AddCountSlice(List series, string label, int count, long total, SKColor color) + { + double pct = count * 100.0 / total; + series.Add(new PieSeries + { + Values = new double[] { count }, + Name = $"{label} ({pct:0.#}%)", + Fill = new SolidColorPaint(color), + ToolTipLabelFormatter = _ => $"{count:N0} plays ({pct:0.#}%)", + InnerRadius = 60, + }); + } } diff --git a/Sortify/Services/DetailEngine.cs b/Sortify/Services/DetailEngine.cs new file mode 100644 index 0000000..9197c08 --- /dev/null +++ b/Sortify/Services/DetailEngine.cs @@ -0,0 +1,121 @@ +using Sortify.Models; + +namespace Sortify.Services; + +/// +/// Builds the focused breakdown shown when the user drills into a grid row. Runs a single +/// filtered pass over the raw records, so it is cheap enough to compute on double-click +/// rather than precomputing one of these per artist up front. +/// +public static class DetailEngine +{ + public static Task BuildAsync( + IReadOnlyList records, + FilterOptions filter, + DetailScope scope, + string title, + string subtitle, + CancellationToken cancellationToken = default) + => Task.Run(() => Build(records, filter, scope, title, subtitle, cancellationToken), cancellationToken); + + public static DetailResult Build( + IReadOnlyList records, + FilterOptions filter, + DetailScope scope, + string title, + string subtitle, + CancellationToken cancellationToken = default) + { + var tracks = new Dictionary(StringComparer.Ordinal); + var albums = new Dictionary(StringComparer.Ordinal); + var byMonth = new Dictionary(); + var days = new HashSet(); + var byHour = new long[24]; + + long totalMs = 0; + int playCount = 0; + DateTime? first = null; + DateTime? last = null; + string uri = string.Empty; + + int counter = 0; + foreach (var r in FilterEngine.Apply(records, filter)) + { + if ((++counter & 0x3FFF) == 0) + cancellationToken.ThrowIfCancellationRequested(); + + if (!IsMatch(r, scope, title, subtitle)) + continue; + + totalMs += r.MsPlayed; + playCount++; + + // Any URI from a matching play works; the first non-empty one wins. + if (uri.Length == 0 && r.Uri.Length > 0) + uri = r.Uri; + + var trackKey = r.TrackName + "\n" + r.ArtistName; + if (!tracks.TryGetValue(trackKey, out var track)) + { + track = new TrackStat { Track = r.TrackName, Artist = r.ArtistName }; + tracks[trackKey] = track; + } + track.TotalMsPlayed += r.MsPlayed; + track.PlayCount++; + + var albumKey = r.AlbumName + "\n" + r.ArtistName; + if (!albums.TryGetValue(albumKey, out var album)) + { + album = new AlbumStat { Album = r.AlbumName, Artist = r.ArtistName }; + albums[albumKey] = album; + } + album.TotalMsPlayed += r.MsPlayed; + album.PlayCount++; + + if (r.Timestamp == DateTime.MinValue) + continue; + + if (first is null || r.Timestamp < first) first = r.Timestamp; + if (last is null || r.Timestamp > last) last = r.Timestamp; + if (track.FirstPlayed is null || r.Timestamp < track.FirstPlayed) track.FirstPlayed = r.Timestamp; + if (track.LastPlayed is null || r.Timestamp > track.LastPlayed) track.LastPlayed = r.Timestamp; + if (album.FirstPlayed is null || r.Timestamp < album.FirstPlayed) album.FirstPlayed = r.Timestamp; + if (album.LastPlayed is null || r.Timestamp > album.LastPlayed) album.LastPlayed = r.Timestamp; + + days.Add(r.Timestamp.Date); + byHour[r.Timestamp.Hour] += r.MsPlayed; + + var month = new DateTime(r.Timestamp.Year, r.Timestamp.Month, 1); + byMonth[month] = byMonth.TryGetValue(month, out var m) ? m + r.MsPlayed : r.MsPlayed; + } + + return new DetailResult + { + Scope = scope, + Title = title, + Subtitle = subtitle, + TotalMsPlayed = totalMs, + PlayCount = playCount, + FirstPlayed = first, + LastPlayed = last, + ActiveDays = days.Count, + Tracks = tracks.Values.OrderByDescending(t => t.TotalMsPlayed).ToList(), + Albums = albums.Values.OrderByDescending(a => a.TotalMsPlayed).ToList(), + ByMonth = byMonth.OrderBy(kv => kv.Key) + .Select(kv => new DateTimePoint(kv.Key, kv.Value / 3_600_000d)) + .ToList(), + ByHour = byHour, + Uri = uri, + }; + } + + private static bool IsMatch(PlayRecord r, DetailScope scope, string title, string subtitle) => scope switch + { + DetailScope.Artist => string.Equals(r.ArtistName, title, StringComparison.Ordinal), + DetailScope.Track => string.Equals(r.TrackName, title, StringComparison.Ordinal) + && string.Equals(r.ArtistName, subtitle, StringComparison.Ordinal), + DetailScope.Album => string.Equals(r.AlbumName, title, StringComparison.Ordinal) + && string.Equals(r.ArtistName, subtitle, StringComparison.Ordinal), + _ => false, + }; +} diff --git a/Sortify/Services/ExportService.cs b/Sortify/Services/ExportService.cs index 3cc9f79..b0cfad1 100644 --- a/Sortify/Services/ExportService.cs +++ b/Sortify/Services/ExportService.cs @@ -11,38 +11,70 @@ public static async Task SaveTxtAsync(string path, AnalysisResult result) { var sb = new StringBuilder(); + // ---- Summary ------------------------------------------------------------------- + sb.AppendLine("Sortify Results"); + sb.AppendLine($"Generated: {TimeFormat.Timestamp(DateTime.Now)}"); + sb.AppendLine(); + sb.AppendLine($"Total listening time: {TimeFormat.Friendly(result.TotalTime)} ({TimeFormat.HhMmSs(result.TotalTime)})"); + sb.AppendLine($"Total plays: {result.TotalPlays:N0}"); + sb.AppendLine($"Unique tracks: {result.UniqueTracks:N0}"); + sb.AppendLine($"Unique artists: {result.UniqueArtists:N0}"); + sb.AppendLine($"Unique albums: {result.UniqueAlbums:N0}"); + if (result.FirstListen is { } first && result.LastListen is { } last) + sb.AppendLine($"Date range: {TimeFormat.Timestamp(first)} to {TimeFormat.Timestamp(last)}"); + if (result.ActiveDays > 0) + sb.AppendLine($"Active days: {result.ActiveDays:N0}"); + if (result.LongestStreakDays > 0 && result.LongestStreakStart is { } ss && result.LongestStreakEnd is { } se) + sb.AppendLine($"Longest streak: {result.LongestStreakDays} days ({ss:yyyy-MM-dd} to {se:yyyy-MM-dd})"); + if (result.BiggestDay is { } bd) + sb.AppendLine($"Biggest day: {bd:yyyy-MM-dd} ({TimeFormat.Friendly(TimeSpan.FromMilliseconds(result.BiggestDayMs))})"); + if (result.SessionCount > 0) + sb.AppendLine($"Listening sessions: {result.SessionCount:N0} (avg {TimeFormat.Friendly(TimeSpan.FromMilliseconds(result.AvgSessionMs))})"); + sb.AppendLine(); + + // ---- Rankings ------------------------------------------------------------------ + // Tracks/Artists/Albums arrive pre-sorted by listening time; the play-count and + // first-listen orderings come from the result's pre-sorted views where available. sb.AppendLine("Tracks Ranked by Time Listened:"); int rank = 1; - foreach (var t in result.Tracks.OrderByDescending(t => t.TotalMsPlayed)) + foreach (var t in result.Tracks) sb.AppendLine($"{rank++}. {TimeFormat.HhMmSs(t.TotalTime)} - {t.Track} - {t.Artist}"); sb.AppendLine(); sb.AppendLine("Tracks Ranked by Counts Listened:"); rank = 1; - foreach (var t in result.Tracks.OrderByDescending(t => t.PlayCount)) + foreach (var t in result.TracksByPlayCount) sb.AppendLine($"{rank++}. {t.PlayCount} times - {t.Track} - {t.Artist}"); sb.AppendLine(); sb.AppendLine("Tracks Ranked by First Time Listened:"); rank = 1; foreach (var t in result.Tracks - .Where(t => t.FirstPlayed != DateTime.MaxValue) + .Where(t => t.FirstPlayed is not null) .OrderBy(t => t.FirstPlayed)) sb.AppendLine($"{rank++}. {TimeFormat.Timestamp(t.FirstPlayed)} - {t.Track} - {t.Artist}"); sb.AppendLine(); sb.AppendLine("Artists Ranked by Time Listened:"); rank = 1; - foreach (var a in result.Artists.OrderByDescending(a => a.TotalMsPlayed)) + foreach (var a in result.Artists) sb.AppendLine($"{rank++}. {TimeFormat.HhMmSs(a.TotalTime)} - {a.Artist}"); sb.AppendLine(); sb.AppendLine("Albums Ranked by Time Listened:"); rank = 1; - foreach (var a in result.Albums.OrderByDescending(a => a.TotalMsPlayed)) + foreach (var a in result.Albums) sb.AppendLine($"{rank++}. {TimeFormat.HhMmSs(a.TotalTime)} - {a.Album} - {a.Artist}"); sb.AppendLine(); + if (result.Years.Count > 0) + { + sb.AppendLine("Listening by Year:"); + foreach (var y in result.Years) + sb.AppendLine($"{y.Year}: {TimeFormat.HhMmSs(y.TotalTime)} across {y.PlayCount:N0} plays - top artist: {y.TopArtist} - top track: {y.TopTrack}"); + sb.AppendLine(); + } + await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8).ConfigureAwait(false); } @@ -51,7 +83,7 @@ public static async Task SaveTracksCsvAsync(string path, AnalysisResult result) var sb = new StringBuilder(); sb.AppendLine("Rank,Track,Artist,TotalTime,Hours,PlayCount,FirstPlayed,LastPlayed"); int rank = 1; - foreach (var t in result.Tracks.OrderByDescending(t => t.TotalMsPlayed)) + foreach (var t in result.Tracks) { sb.Append(rank++).Append(',') .Append(Csv(t.Track)).Append(',') @@ -59,8 +91,8 @@ public static async Task SaveTracksCsvAsync(string path, AnalysisResult result) .Append(TimeFormat.HhMmSs(t.TotalTime)).Append(',') .Append(t.TotalHours.ToString("0.00")).Append(',') .Append(t.PlayCount).Append(',') - .Append(t.FirstPlayed == DateTime.MaxValue ? "" : TimeFormat.Timestamp(t.FirstPlayed)).Append(',') - .Append(t.LastPlayed == DateTime.MinValue ? "" : TimeFormat.Timestamp(t.LastPlayed)) + .Append(TimeFormat.Timestamp(t.FirstPlayed)).Append(',') + .Append(TimeFormat.Timestamp(t.LastPlayed)) .AppendLine(); } await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8).ConfigureAwait(false); @@ -71,16 +103,16 @@ public static async Task SaveArtistsCsvAsync(string path, AnalysisResult result) var sb = new StringBuilder(); sb.AppendLine("Rank,Artist,TotalTime,Hours,PlayCount,FirstPlayed,FirstTrack,LastPlayed"); int rank = 1; - foreach (var a in result.Artists.OrderByDescending(a => a.TotalMsPlayed)) + foreach (var a in result.Artists) { sb.Append(rank++).Append(',') .Append(Csv(a.Artist)).Append(',') .Append(TimeFormat.HhMmSs(a.TotalTime)).Append(',') .Append(a.TotalHours.ToString("0.00")).Append(',') .Append(a.PlayCount).Append(',') - .Append(a.FirstPlayed == DateTime.MaxValue ? "" : TimeFormat.Timestamp(a.FirstPlayed)).Append(',') + .Append(TimeFormat.Timestamp(a.FirstPlayed)).Append(',') .Append(Csv(a.FirstTrack)).Append(',') - .Append(a.LastPlayed == DateTime.MinValue ? "" : TimeFormat.Timestamp(a.LastPlayed)) + .Append(TimeFormat.Timestamp(a.LastPlayed)) .AppendLine(); } await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8).ConfigureAwait(false); @@ -91,7 +123,7 @@ public static async Task SaveAlbumsCsvAsync(string path, AnalysisResult result) var sb = new StringBuilder(); sb.AppendLine("Rank,Album,Artist,TotalTime,Hours,PlayCount,FirstPlayed,LastPlayed"); int rank = 1; - foreach (var a in result.Albums.OrderByDescending(a => a.TotalMsPlayed)) + foreach (var a in result.Albums) { sb.Append(rank++).Append(',') .Append(Csv(a.Album)).Append(',') @@ -99,16 +131,36 @@ public static async Task SaveAlbumsCsvAsync(string path, AnalysisResult result) .Append(TimeFormat.HhMmSs(a.TotalTime)).Append(',') .Append(a.TotalHours.ToString("0.00")).Append(',') .Append(a.PlayCount).Append(',') - .Append(a.FirstPlayed == DateTime.MaxValue ? "" : TimeFormat.Timestamp(a.FirstPlayed)).Append(',') - .Append(a.LastPlayed == DateTime.MinValue ? "" : TimeFormat.Timestamp(a.LastPlayed)) + .Append(TimeFormat.Timestamp(a.FirstPlayed)).Append(',') + .Append(TimeFormat.Timestamp(a.LastPlayed)) + .AppendLine(); + } + await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8).ConfigureAwait(false); + } + + public static async Task SaveYearsCsvAsync(string path, AnalysisResult result) + { + var sb = new StringBuilder(); + sb.AppendLine("Year,TotalTime,Hours,PlayCount,UniqueArtists,UniqueTracks,TopArtist,TopTrack"); + foreach (var y in result.Years) + { + sb.Append(y.Year).Append(',') + .Append(TimeFormat.HhMmSs(y.TotalTime)).Append(',') + .Append(y.TotalHours.ToString("0.00")).Append(',') + .Append(y.PlayCount).Append(',') + .Append(y.UniqueArtists).Append(',') + .Append(y.UniqueTracks).Append(',') + .Append(Csv(y.TopArtist)).Append(',') + .Append(Csv(y.TopTrack)) .AppendLine(); } await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8).ConfigureAwait(false); } - private static string Csv(string value) + /// Quotes a CSV field when it contains a delimiter, quote or line break. + internal static string Csv(string value) { - if (value.Contains(',') || value.Contains('"') || value.Contains('\n')) + if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) return '"' + value.Replace("\"", "\"\"") + '"'; return value; } diff --git a/Sortify/Services/FilterEngine.cs b/Sortify/Services/FilterEngine.cs index be12555..8acd391 100644 --- a/Sortify/Services/FilterEngine.cs +++ b/Sortify/Services/FilterEngine.cs @@ -12,8 +12,15 @@ public static class FilterEngine public static IEnumerable Apply( IEnumerable records, FilterOptions filter, bool ignoreMinDuration = false) { + // Hoist per-filter invariants out of the per-record loop; HasAllDaysSelected in + // particular walks the day array, which adds up over hundreds of thousands of rows. bool hasSearch = !string.IsNullOrWhiteSpace(filter.SearchTerm); string search = filter.SearchTerm.Trim(); + bool allDays = filter.HasAllDaysSelected; + bool fullHours = filter.HasFullHourRange; + bool hasExcludedArtists = filter.ExcludedArtists.Count > 0; + bool hasExcludedTracks = filter.ExcludedTracks.Count > 0; + bool checkTimeOfDay = !fullHours || !allDays; foreach (var r in records) { @@ -26,29 +33,43 @@ public static IEnumerable Apply( if (filter.EndDate is { } end && r.Timestamp > end) continue; - if (filter.ExcludedArtists.Contains(r.ArtistName)) + if (hasExcludedArtists && filter.ExcludedArtists.Contains(r.ArtistName)) continue; - if (filter.ExcludedTracks.Contains(r.TrackName)) + if (hasExcludedTracks && filter.ExcludedTracks.Contains(r.TrackName)) continue; // Time-of-day / day-of-week only apply when a real timestamp exists. - if (r.Timestamp != DateTime.MinValue) + if (checkTimeOfDay && r.Timestamp != DateTime.MinValue) { - if (!filter.HourMatches(r.Timestamp.Hour)) + if (!fullHours && !filter.HourMatches(r.Timestamp.Hour)) continue; - if (!filter.HasAllDaysSelected && !filter.IncludedDaysOfWeek[(int)r.Timestamp.DayOfWeek]) + if (!allDays && !filter.IncludedDaysOfWeek[(int)r.Timestamp.DayOfWeek]) continue; } - if (hasSearch && - r.TrackName.IndexOf(search, StringComparison.OrdinalIgnoreCase) < 0 && - r.ArtistName.IndexOf(search, StringComparison.OrdinalIgnoreCase) < 0 && - r.AlbumName.IndexOf(search, StringComparison.OrdinalIgnoreCase) < 0) + if (hasSearch && !Matches(r, search)) continue; yield return r; } } + + /// + /// Searches the fields that carry a name for this record's kind: track/artist/album for + /// music, show/episode (or audiobook/chapter) titles otherwise. + /// + private static bool Matches(PlayRecord r, string search) + { + if (r.Kind == ContentKind.Music) + { + return r.TrackName.Contains(search, StringComparison.OrdinalIgnoreCase) + || r.ArtistName.Contains(search, StringComparison.OrdinalIgnoreCase) + || r.AlbumName.Contains(search, StringComparison.OrdinalIgnoreCase); + } + + return r.ShowName.Contains(search, StringComparison.OrdinalIgnoreCase) + || r.EpisodeName.Contains(search, StringComparison.OrdinalIgnoreCase); + } } diff --git a/Sortify/Services/HistoryParser.cs b/Sortify/Services/HistoryParser.cs index 71f3085..0fc6307 100644 --- a/Sortify/Services/HistoryParser.cs +++ b/Sortify/Services/HistoryParser.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Globalization; using System.IO; using System.Text.Json; @@ -14,8 +15,9 @@ public sealed class ParseResult } /// -/// Reads selected Spotify extended streaming history JSON files into normalized PlayRecords. -/// Empty or invalid files are skipped gracefully (mirrors the original Python behavior). +/// Reads selected Spotify streaming history JSON files into normalized PlayRecords. +/// Handles both the extended history and the older account-data format. Empty or +/// invalid files are skipped gracefully (mirrors the original Python behavior). /// public sealed class HistoryParser { @@ -24,70 +26,173 @@ public sealed class HistoryParser PropertyNameCaseInsensitive = true, }; + /// + /// Finds Spotify history JSON files under (recursively). + /// Prefers files matching Spotify's export naming; falls back to any top-level + /// .json files so hand-renamed exports still work. + /// + public static IReadOnlyList FindHistoryFiles(string folder) + { + if (!Directory.Exists(folder)) + return Array.Empty(); + + var named = Directory + .EnumerateFiles(folder, "*.json", SearchOption.AllDirectories) + .Where(f => + { + var name = Path.GetFileName(f); + return name.StartsWith("Streaming_History", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("StreamingHistory", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("endsong", StringComparison.OrdinalIgnoreCase); + }) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (named.Count > 0) + return named; + + return Directory + .EnumerateFiles(folder, "*.json", SearchOption.TopDirectoryOnly) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + public async Task ParseAsync( IEnumerable filePaths, IProgress? progress = null, CancellationToken cancellationToken = default) { var result = new ParseResult(); + var paths = filePaths.ToList(); + if (paths.Count == 0) + return result; - foreach (var path in filePaths) - { - cancellationToken.ThrowIfCancellationRequested(); - progress?.Report($"Reading {Path.GetFileName(path)}..."); + int completed = 0; + var pool = new StringPool(); - try - { - var info = new FileInfo(path); - if (!info.Exists || info.Length == 0) - { - result.SkippedFiles.Add(path); - continue; - } - - await using var stream = File.OpenRead(path); - var entries = await JsonSerializer - .DeserializeAsync>(stream, JsonOptions, cancellationToken) - .ConfigureAwait(false); - - if (entries is null) - { - result.SkippedFiles.Add(path); - continue; - } - - foreach (var entry in entries) - { - var record = Normalize(entry); - if (record is not null) - result.Records.Add(record); - } - } - catch (JsonException ex) + // Parse files concurrently; each task returns its own outcome and the results are + // merged in selection order below, so the output is deterministic. + var tasks = paths + .Select(path => Task.Run(async () => { - result.Warnings.Add($"Skipping invalid JSON file: {Path.GetFileName(path)} ({ex.Message})"); - } - catch (IOException ex) - { - result.Warnings.Add($"Could not read {Path.GetFileName(path)} ({ex.Message})"); - } - catch (UnauthorizedAccessException ex) + var outcome = await ParseFileAsync(path, pool, cancellationToken).ConfigureAwait(false); + int n = Interlocked.Increment(ref completed); + progress?.Report($"Read {n} of {paths.Count} file(s)..."); + return outcome; + }, cancellationToken)) + .ToList(); + + foreach (var task in tasks) + { + var outcome = await task.ConfigureAwait(false); + if (outcome.Records is { } records) { - result.Warnings.Add($"Access denied to {Path.GetFileName(path)} ({ex.Message})"); + result.Records.EnsureCapacity(result.Records.Count + records.Count); + result.Records.AddRange(records); } + if (outcome.SkippedFile is { } skipped) + result.SkippedFiles.Add(skipped); + if (outcome.Warning is { } warning) + result.Warnings.Add(warning); } return result; } - private static PlayRecord? Normalize(SpotifyHistoryEntry entry) + private readonly record struct FileOutcome(List? Records, string? SkippedFile, string? Warning); + + private static async Task ParseFileAsync(string path, StringPool pool, CancellationToken cancellationToken) + { + try + { + var info = new FileInfo(path); + if (!info.Exists || info.Length == 0) + return new FileOutcome(null, path, null); + + await using var stream = new FileStream( + path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + var entries = await JsonSerializer + .DeserializeAsync>(stream, JsonOptions, cancellationToken) + .ConfigureAwait(false); + + if (entries is null) + return new FileOutcome(null, path, null); + + var records = new List(entries.Count); + foreach (var entry in entries) + { + var record = Normalize(entry, pool); + if (record is not null) + records.Add(record); + } + return new FileOutcome(records, null, null); + } + catch (JsonException ex) + { + return new FileOutcome(null, null, $"Skipping invalid JSON file: {Path.GetFileName(path)} ({ex.Message})"); + } + catch (NotSupportedException ex) + { + return new FileOutcome(null, null, $"Skipping unsupported JSON file: {Path.GetFileName(path)} ({ex.Message})"); + } + catch (IOException ex) + { + return new FileOutcome(null, null, $"Could not read {Path.GetFileName(path)} ({ex.Message})"); + } + catch (UnauthorizedAccessException ex) + { + return new FileOutcome(null, null, $"Access denied to {Path.GetFileName(path)} ({ex.Message})"); + } + } + + /// + /// reason_end values that mean the user moved on deliberately. Spotify's own "skipped" + /// flag is absent (null) across large stretches of real exports, so it alone badly + /// under-reports skips; these codes are the fallback signal. + /// + private static bool IsSkipReason(string? reasonEnd) + => string.Equals(reasonEnd, "fwdbtn", StringComparison.OrdinalIgnoreCase); + + private static PlayRecord? Normalize(SpotifyHistoryEntry entry, StringPool pool) { - // Skip podcast / non-music rows that lack track metadata. - if (string.IsNullOrWhiteSpace(entry.TrackName) && string.IsNullOrWhiteSpace(entry.ArtistName)) + string? trackName = FirstNonEmpty(entry.TrackName, entry.LegacyTrackName); + string? artistName = FirstNonEmpty(entry.ArtistName, entry.LegacyArtistName); + + // Rows carry exactly one kind of content; music first, then podcast, then audiobook. + ContentKind kind; + string show = string.Empty, episode = string.Empty, uri; + + if (trackName is not null || artistName is not null) + { + kind = ContentKind.Music; + uri = entry.TrackUri ?? string.Empty; + } + else if (FirstNonEmpty(entry.EpisodeName, entry.EpisodeShowName) is not null) + { + kind = ContentKind.Podcast; + show = entry.EpisodeShowName ?? "Unknown Show"; + episode = entry.EpisodeName ?? "Unknown Episode"; + uri = entry.EpisodeUri ?? string.Empty; + } + else if (FirstNonEmpty(entry.AudiobookTitle, entry.AudiobookChapterTitle) is not null) + { + kind = ContentKind.Audiobook; + show = entry.AudiobookTitle ?? "Unknown Audiobook"; + episode = entry.AudiobookChapterTitle ?? "Unknown Chapter"; + uri = entry.AudiobookUri ?? string.Empty; + } + else + { + // No usable metadata of any kind. return null; + } - // Spotify "ts" is UTC; convert to local time so hour-of-day and day-of-week - // charts and filters reflect the user's clock, not UTC. + long ms = entry.MsPlayed != 0 ? entry.MsPlayed : entry.LegacyMsPlayed ?? 0; + + // Spotify timestamps are UTC; convert to local time so hour-of-day and + // day-of-week charts and filters reflect the user's clock, not UTC. DateTime timestamp = DateTime.MinValue; if (!string.IsNullOrEmpty(entry.Ts) && DateTime.TryParse(entry.Ts, CultureInfo.InvariantCulture, @@ -95,16 +200,54 @@ public async Task ParseAsync( { timestamp = parsed.ToLocalTime(); } + else if (!string.IsNullOrEmpty(entry.LegacyEndTime) && + DateTime.TryParseExact(entry.LegacyEndTime, "yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var legacyParsed)) + { + timestamp = legacyParsed.ToLocalTime(); + } return new PlayRecord { - TrackName = string.IsNullOrWhiteSpace(entry.TrackName) ? "Unknown Track" : entry.TrackName!, - ArtistName = string.IsNullOrWhiteSpace(entry.ArtistName) ? "Unknown Artist" : entry.ArtistName!, + TrackName = trackName ?? "Unknown Track", + ArtistName = artistName ?? "Unknown Artist", AlbumName = string.IsNullOrWhiteSpace(entry.AlbumName) ? "Unknown Album" : entry.AlbumName!, - MsPlayed = entry.MsPlayed < 0 ? 0 : (int)Math.Min(entry.MsPlayed, int.MaxValue), + MsPlayed = ms < 0 ? 0 : (int)Math.Min(ms, int.MaxValue), Timestamp = timestamp, ReasonEnd = entry.ReasonEnd, - Skipped = entry.Skipped ?? false, + Skipped = entry.Skipped ?? IsSkipReason(entry.ReasonEnd), + Kind = kind, + ShowName = show, + EpisodeName = episode, + Uri = uri, + // Platform and country repeat across nearly every row, so pool them instead of + // holding one string instance per play. + Platform = pool.Get(entry.Platform), + Country = pool.Get(entry.ConnCountry), + Shuffle = entry.Shuffle ?? false, + Offline = entry.Offline ?? false, + Incognito = entry.IncognitoMode ?? false, + HasPlaybackFlags = entry.Shuffle is not null || entry.Offline is not null || entry.IncognitoMode is not null, }; } + + private static string? FirstNonEmpty(string? a, string? b) + => !string.IsNullOrWhiteSpace(a) ? a : !string.IsNullOrWhiteSpace(b) ? b : null; + + /// + /// Deduplicates the handful of distinct platform/country strings that repeat across + /// every record. Shared across the concurrent per-file parse tasks, so it must be + /// thread-safe. + /// + private sealed class StringPool + { + private readonly ConcurrentDictionary _pool = new(StringComparer.Ordinal); + + public string Get(string? value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + return _pool.GetOrAdd(value, static v => v); + } + } } diff --git a/Sortify/Services/RecordCache.cs b/Sortify/Services/RecordCache.cs new file mode 100644 index 0000000..a3cff7b --- /dev/null +++ b/Sortify/Services/RecordCache.cs @@ -0,0 +1,205 @@ +using System.IO; +using System.Text; +using Sortify.Models; + +namespace Sortify.Services; + +/// +/// Caches parsed s in a compact binary file so reopening the same +/// export skips JSON parsing entirely. The cache is keyed by the exact set of source files +/// and their sizes and write times, so editing, adding or removing a file invalidates it. +/// +public static class RecordCache +{ + /// Bumped whenever the record layout changes, so stale caches are ignored. + private const int FormatVersion = 1; + + private static readonly byte[] Magic = "SRTFY\0"u8.ToArray(); + + /// %LOCALAPPDATA%\Sortify — created on demand. + public static string CacheDirectory { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Sortify"); + + private static string CacheFile => Path.Combine(CacheDirectory, "records.cache"); + + /// + /// Fingerprints the source files. Two runs over an unchanged export produce the same + /// key; any change to the file set, a file's length or its write time produces a + /// different one. + /// + internal static string BuildKey(IEnumerable filePaths) + { + var parts = filePaths + .Select(p => + { + var info = new FileInfo(p); + long length = info.Exists ? info.Length : -1; + long ticks = info.Exists ? info.LastWriteTimeUtc.Ticks : -1; + return $"{info.FullName.ToLowerInvariant()}|{length}|{ticks}"; + }) + .OrderBy(s => s, StringComparer.Ordinal); + + var joined = string.Join("\n", parts); + var hash = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(joined)); + return Convert.ToHexString(hash); + } + + /// + /// Returns the cached records for these files, or null when there is no usable cache. + /// Any failure - missing, truncated, stale or unreadable - is treated as a miss, since + /// re-parsing is always a correct fallback. + /// + public static IReadOnlyList? TryLoad(IEnumerable filePaths) + { + try + { + if (!File.Exists(CacheFile)) + return null; + + using var stream = new FileStream(CacheFile, FileMode.Open, FileAccess.Read, FileShare.Read, + 64 * 1024, FileOptions.SequentialScan); + using var reader = new BinaryReader(stream, Encoding.UTF8); + + var magic = reader.ReadBytes(Magic.Length); + if (!magic.AsSpan().SequenceEqual(Magic)) + return null; + if (reader.ReadInt32() != FormatVersion) + return null; + if (!string.Equals(reader.ReadString(), BuildKey(filePaths), StringComparison.Ordinal)) + return null; + + int count = reader.ReadInt32(); + if (count < 0) + return null; + + // Platform and country repeat constantly, so they are written once into a table + // and referenced by index; this keeps the file small and restores the sharing. + var pool = new string[reader.ReadInt32()]; + for (int i = 0; i < pool.Length; i++) + pool[i] = reader.ReadString(); + + var records = new List(count); + for (int i = 0; i < count; i++) + { + records.Add(new PlayRecord + { + TrackName = reader.ReadString(), + ArtistName = reader.ReadString(), + AlbumName = reader.ReadString(), + MsPlayed = reader.ReadInt32(), + Timestamp = new DateTime(reader.ReadInt64()), + ReasonEnd = reader.ReadBoolean() ? reader.ReadString() : null, + Skipped = reader.ReadBoolean(), + Kind = (ContentKind)reader.ReadByte(), + ShowName = reader.ReadString(), + EpisodeName = reader.ReadString(), + Uri = reader.ReadString(), + Platform = pool[reader.ReadInt32()], + Country = pool[reader.ReadInt32()], + Shuffle = reader.ReadBoolean(), + Offline = reader.ReadBoolean(), + Incognito = reader.ReadBoolean(), + HasPlaybackFlags = reader.ReadBoolean(), + }); + } + return records; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or EndOfStreamException or ArgumentException + or IndexOutOfRangeException or OverflowException) + { + // A corrupt or partially written cache must never break loading. + return null; + } + } + + /// + /// Writes the cache for these files. Best-effort: a failure here costs nothing but the + /// speed-up next time, so it is swallowed. + /// + public static void TrySave(IEnumerable filePaths, IReadOnlyList records) + { + var temp = CacheFile + ".tmp"; + try + { + Directory.CreateDirectory(CacheDirectory); + + // Write to a temp file and move into place, so an interrupted save can't leave a + // half-written cache behind. + using (var stream = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None, + 64 * 1024, FileOptions.SequentialScan)) + using (var writer = new BinaryWriter(stream, Encoding.UTF8)) + { + writer.Write(Magic); + writer.Write(FormatVersion); + writer.Write(BuildKey(filePaths)); + writer.Write(records.Count); + + var indexOf = new Dictionary(StringComparer.Ordinal); + var pool = new List(); + foreach (var r in records) + { + Intern(r.Platform); + Intern(r.Country); + } + + writer.Write(pool.Count); + foreach (var value in pool) + writer.Write(value); + + foreach (var r in records) + { + writer.Write(r.TrackName); + writer.Write(r.ArtistName); + writer.Write(r.AlbumName); + writer.Write(r.MsPlayed); + writer.Write(r.Timestamp.Ticks); + writer.Write(r.ReasonEnd is not null); + if (r.ReasonEnd is not null) + writer.Write(r.ReasonEnd); + writer.Write(r.Skipped); + writer.Write((byte)r.Kind); + writer.Write(r.ShowName); + writer.Write(r.EpisodeName); + writer.Write(r.Uri); + writer.Write(indexOf[r.Platform]); + writer.Write(indexOf[r.Country]); + writer.Write(r.Shuffle); + writer.Write(r.Offline); + writer.Write(r.Incognito); + writer.Write(r.HasPlaybackFlags); + } + + void Intern(string value) + { + if (indexOf.ContainsKey(value)) + return; + indexOf[value] = pool.Count; + pool.Add(value); + } + } + + File.Move(temp, CacheFile, overwrite: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + TryDelete(temp); + } + } + + /// Removes the cache file, e.g. after a format change or on user request. + public static void Clear() => TryDelete(CacheFile); + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Nothing useful to do; the cache is disposable by design. + } + } +} diff --git a/Sortify/Services/TimeFormat.cs b/Sortify/Services/TimeFormat.cs index 380df93..aa30629 100644 --- a/Sortify/Services/TimeFormat.cs +++ b/Sortify/Services/TimeFormat.cs @@ -21,4 +21,7 @@ public static string Friendly(TimeSpan td) } public static string Timestamp(DateTime dt) => dt.ToString("yyyy-MM-dd HH:mm"); + + /// Null-tolerant variant used by exports; empty string when no timestamp exists. + public static string Timestamp(DateTime? dt) => dt is { } d ? Timestamp(d) : string.Empty; } diff --git a/Sortify/ViewModels/FilterViewModel.cs b/Sortify/ViewModels/FilterViewModel.cs index 126b6af..a0eee1d 100644 --- a/Sortify/ViewModels/FilterViewModel.cs +++ b/Sortify/ViewModels/FilterViewModel.cs @@ -16,6 +16,7 @@ public sealed partial class FilterViewModel : ObservableObject private bool _suppress; [ObservableProperty] private int _minSeconds = FilterOptions.DefaultMinMs / 1000; + [ObservableProperty] private bool _includePodcasts; [ObservableProperty] private DateTime? _startDate; [ObservableProperty] private DateTime? _endDate; [ObservableProperty] private string _searchTerm = string.Empty; @@ -47,6 +48,7 @@ public FilterViewModel() } partial void OnMinSecondsChanged(int value) => Raise(); + partial void OnIncludePodcastsChanged(bool value) => Raise(); partial void OnStartDateChanged(DateTime? value) => Raise(); partial void OnEndDateChanged(DateTime? value) => Raise(); partial void OnSearchTermChanged(string value) => Raise(); @@ -64,7 +66,9 @@ public FilterOptions ToOptions() { var opts = new FilterOptions { - MinMsPlayed = Math.Max(0, MinSeconds) * 1000, + // long math + clamp so an absurd seconds value can't overflow to a negative cutoff. + MinMsPlayed = (int)Math.Clamp(MinSeconds * 1000L, 0L, int.MaxValue), + IncludePodcasts = IncludePodcasts, StartDate = StartDate?.Date, EndDate = EndDate?.Date.AddDays(1).AddTicks(-1), SearchTerm = SearchTerm ?? string.Empty, @@ -124,6 +128,7 @@ private void Reset() { _suppress = true; MinSeconds = FilterOptions.DefaultMinMs / 1000; + IncludePodcasts = false; StartDate = null; EndDate = null; SearchTerm = string.Empty; diff --git a/Sortify/ViewModels/MainViewModel.cs b/Sortify/ViewModels/MainViewModel.cs index 9e8a9fc..11b3ad6 100644 --- a/Sortify/ViewModels/MainViewModel.cs +++ b/Sortify/ViewModels/MainViewModel.cs @@ -12,6 +12,7 @@ namespace Sortify.ViewModels; public sealed partial class MainViewModel : ObservableObject { private readonly HistoryParser _parser = new(); + private readonly AppSettings _settings = AppSettings.Load(); private readonly DispatcherTimer _debounce; private List _rawRecords = new(); private AnalysisResult _result = AnalysisResult.Empty; @@ -26,9 +27,12 @@ public sealed partial class MainViewModel : ObservableObject [ObservableProperty] private IReadOnlyList _tracks = Array.Empty(); [ObservableProperty] private IReadOnlyList _artists = Array.Empty(); [ObservableProperty] private IReadOnlyList _albums = Array.Empty(); + [ObservableProperty] private IReadOnlyList _years = Array.Empty(); [ObservableProperty] private IReadOnlyList _skippedTracks = Array.Empty(); + [ObservableProperty] private IReadOnlyList _shows = Array.Empty(); + [ObservableProperty] private IReadOnlyList _episodes = Array.Empty(); - [ObservableProperty] private string _statusText = "Click \"Run Analysis\" or drop your Spotify history JSON files here to begin."; + [ObservableProperty] private string _statusText = "Click \"Run Analysis\" or drop your Spotify history JSON files (or their folder) here to begin."; [ObservableProperty] private bool _isBusy; [ObservableProperty] private bool _hasData; @@ -41,11 +45,32 @@ public sealed partial class MainViewModel : ObservableObject // Insights ------------------------------------------------------------------------------ [ObservableProperty] private string _longestStreakText = "-"; + [ObservableProperty] private string _currentStreakText = "-"; [ObservableProperty] private string _biggestDayText = "-"; [ObservableProperty] private string _activeDaysText = "-"; [ObservableProperty] private string _avgPerDayText = "-"; [ObservableProperty] private string _skipRateText = "-"; [ObservableProperty] private string _peakHourText = "-"; + [ObservableProperty] private string _sessionsText = "-"; + [ObservableProperty] private string _avgSessionText = "-"; + [ObservableProperty] private string _longestSessionText = "-"; + [ObservableProperty] private string _weekSplitText = "-"; + [ObservableProperty] private string _timeOfDayText = "-"; + + // Playback context --------------------------------------------------------------------- + [ObservableProperty] private string _shuffleRateText = "-"; + [ObservableProperty] private string _offlineRateText = "-"; + [ObservableProperty] private string _topDeviceText = "-"; + + // Podcasts ----------------------------------------------------------------------------- + [ObservableProperty] private string _podcastTimeText = "-"; + [ObservableProperty] private string _podcastPlaysText = "-"; + [ObservableProperty] private string _uniqueShowsText = "-"; + [ObservableProperty] private string _uniqueEpisodesText = "-"; + [ObservableProperty] private bool _hasPodcastData; + + /// Drives the Podcasts tab's empty state; WPF ships no inverting bool converter. + [ObservableProperty] private bool _hasNoPodcastData = true; // Charts ------------------------------------------------------------------------------ [ObservableProperty] private ISeries[] _tracksByTimeSeries = Array.Empty(); @@ -92,12 +117,33 @@ public sealed partial class MainViewModel : ObservableObject [ObservableProperty] private Axis[] _overTimeX = Array.Empty(); [ObservableProperty] private Axis[] _overTimeY = Array.Empty(); + [ObservableProperty] private ISeries[] _yearSeries = Array.Empty(); + [ObservableProperty] private Axis[] _yearX = Array.Empty(); + [ObservableProperty] private Axis[] _yearY = Array.Empty(); + + [ObservableProperty] private ISeries[] _discoverySeries = Array.Empty(); + [ObservableProperty] private Axis[] _discoveryX = Array.Empty(); + [ObservableProperty] private Axis[] _discoveryY = Array.Empty(); + [ObservableProperty] private ISeries[] _artistShareSeries = Array.Empty(); + [ObservableProperty] private ISeries[] _reasonEndSeries = Array.Empty(); + [ObservableProperty] private ISeries[] _platformSeries = Array.Empty(); + [ObservableProperty] private ISeries[] _countrySeries = Array.Empty(); + + [ObservableProperty] private ISeries[] _showSeries = Array.Empty(); + [ObservableProperty] private Axis[] _showX = Array.Empty(); + [ObservableProperty] private Axis[] _showY = Array.Empty(); + + [ObservableProperty] private ISeries[] _episodeSeries = Array.Empty(); + [ObservableProperty] private Axis[] _episodeX = Array.Empty(); + [ObservableProperty] private Axis[] _episodeY = Array.Empty(); // Heights that drive the scrollable horizontal bar charts (one per ~bar). [ObservableProperty] private double _tracksChartHeight = 480; [ObservableProperty] private double _artistsChartHeight = 480; [ObservableProperty] private double _albumsChartHeight = 480; + [ObservableProperty] private double _showsChartHeight = 220; + [ObservableProperty] private double _episodesChartHeight = 220; // Infinite-scroll paging for the horizontal bar charts: start with one page and // append more bars as the user scrolls toward the bottom of a chart. @@ -141,6 +187,44 @@ private async Task RunAnalysisAsync() await LoadFilesAsync(dialog.FileNames); } + [RelayCommand] + private async Task OpenFolderAsync() + { + var dialog = new OpenFolderDialog + { + Title = "Select the folder that contains your Spotify streaming history", + }; + if (dialog.ShowDialog() != true) + return; + + var files = HistoryParser.FindHistoryFiles(dialog.FolderName); + if (files.Count == 0) + { + StatusText = "No Spotify history JSON files were found in that folder."; + return; + } + + _settings.LastFolder = dialog.FolderName; + _settings.Save(); + await LoadFilesAsync(files); + } + + /// + /// Reopens the folder used last session. Called once at startup; silently does nothing + /// when there is no remembered folder, it has gone away, or the user turned this off. + /// + public async Task RestoreLastFolderAsync() + { + if (!_settings.ReopenLastFolder || string.IsNullOrWhiteSpace(_settings.LastFolder)) + return; + + var files = HistoryParser.FindHistoryFiles(_settings.LastFolder); + if (files.Count == 0) + return; + + await LoadFilesAsync(files); + } + /// Parses the given history files and runs analysis. Also used by drag & drop. public async Task LoadFilesAsync(IReadOnlyList filePaths) { @@ -150,6 +234,19 @@ public async Task LoadFilesAsync(IReadOnlyList filePaths) IsBusy = true; try { + // Re-reading an unchanged export is the common case (relaunching the app, or + // reopening the same folder), and parsing it again costs seconds for nothing. + StatusText = "Checking for cached results..."; + var cached = await Task.Run(() => RecordCache.TryLoad(filePaths)); + if (cached is { Count: > 0 }) + { + _rawRecords = cached.ToList(); + StatusText = $"Loaded {_rawRecords.Count:N0} plays from cache. Crunching numbers..."; + HasData = true; + await RecomputeAsync(); + return; + } + var progress = new Progress(s => StatusText = s); var parsed = await _parser.ParseAsync(filePaths, progress); _rawRecords = parsed.Records; @@ -157,7 +254,9 @@ public async Task LoadFilesAsync(IReadOnlyList filePaths) if (_rawRecords.Count == 0) { HasData = false; - StatusText = "No valid listening data found in the selected files."; + StatusText = parsed.Warnings.Count > 0 + ? $"No valid listening data found. {parsed.Warnings[0]}" + : "No valid listening data found in the selected files."; return; } @@ -166,6 +265,10 @@ public async Task LoadFilesAsync(IReadOnlyList filePaths) StatusText = $"Loaded {_rawRecords.Count:N0} plays from {filePaths.Count} file(s){warn}. Crunching numbers..."; HasData = true; await RecomputeAsync(); + + // Save after analysis so the user isn't waiting on disk I/O to see results. + var toCache = _rawRecords; + _ = Task.Run(() => RecordCache.TrySave(filePaths, toCache)); } finally { @@ -188,6 +291,20 @@ public void SetOverTimeGranularity(ChartBuilder.TimeGranularity granularity) public void ExcludeArtistFromGrid(string artist) => Filters.ExcludeArtist(artist); public void ExcludeTrackFromGrid(string track) => Filters.ExcludeTrack(track); + // ---- Drill-down ------------------------------------------------------------------------ + + /// + /// Builds the focused breakdown for one grid row, under the filters currently in effect. + /// Returns null when there is nothing loaded to drill into. + /// + public async Task BuildDetailAsync(DetailScope scope, string title, string subtitle) + { + if (!HasData || _rawRecords.Count == 0) + return null; + + return await DetailEngine.BuildAsync(_rawRecords, Filters.ToOptions(), scope, title, subtitle); + } + private async Task RecomputeAsync() { if (!HasData) return; @@ -198,31 +315,41 @@ private async Task RecomputeAsync() var token = _analysisCts.Token; var options = Filters.ToOptions(); + bool wasBusy = IsBusy; + IsBusy = true; try { - _result = await AnalysisEngine.AnalyzeAsync(_rawRecords, options, token); + AnalysisResult result; + try + { + result = await AnalysisEngine.AnalyzeAsync(_rawRecords, options, token); + } + catch (OperationCanceledException) + { + return; + } + + // A newer recompute may have started while this one ran; never let a stale + // result overwrite the current one. + if (token.IsCancellationRequested) + return; + _result = result; + + UpdateCollections(); + UpdateSummary(); + UpdateInsights(); + UpdateCharts(); + NotifyExportsChanged(); + + StatusText = _result.TotalPlays == 0 + ? "No plays match the current filters." + : $"Showing {_result.TotalPlays:N0} plays across {_result.UniqueTracks:N0} tracks, " + + $"{_result.UniqueArtists:N0} artists and {_result.UniqueAlbums:N0} albums."; } - catch (OperationCanceledException) + finally { - return; + IsBusy = wasBusy; } - - if (token.IsCancellationRequested) return; - - UpdateCollections(); - UpdateSummary(); - UpdateInsights(); - UpdateCharts(); - - ExportTxtCommand.NotifyCanExecuteChanged(); - ExportTracksCsvCommand.NotifyCanExecuteChanged(); - ExportArtistsCsvCommand.NotifyCanExecuteChanged(); - ExportAlbumsCsvCommand.NotifyCanExecuteChanged(); - - StatusText = _result.TotalPlays == 0 - ? "No plays match the current filters." - : $"Showing {_result.TotalPlays:N0} plays across {_result.UniqueTracks:N0} tracks, " + - $"{_result.UniqueArtists:N0} artists and {_result.UniqueAlbums:N0} albums."; } private void UpdateCollections() @@ -230,7 +357,12 @@ private void UpdateCollections() Tracks = _result.Tracks; Artists = _result.Artists; Albums = _result.Albums; + Years = _result.Years; SkippedTracks = _result.SkippedTracks; + Shows = _result.Shows; + Episodes = _result.Episodes; + HasPodcastData = _result.Shows.Count > 0; + HasNoPodcastData = !HasPodcastData; } private void UpdateSummary() @@ -252,6 +384,10 @@ private void UpdateInsights() ? $"{r.LongestStreakDays} day{(r.LongestStreakDays == 1 ? "" : "s")} ({ss:yyyy-MM-dd} to {se:yyyy-MM-dd})" : "-"; + CurrentStreakText = r.CurrentStreakDays > 0 && r.LastListen is { } lastListen + ? $"{r.CurrentStreakDays} day{(r.CurrentStreakDays == 1 ? "" : "s")} (up to {lastListen:yyyy-MM-dd})" + : "-"; + BiggestDayText = r.BiggestDay is { } bd ? $"{bd:yyyy-MM-dd} ({TimeFormat.Friendly(TimeSpan.FromMilliseconds(r.BiggestDayMs))})" : "-"; @@ -267,6 +403,40 @@ private void UpdateInsights() : "-"; PeakHourText = BuildPeakHourText(r); + + SessionsText = r.SessionCount > 0 ? r.SessionCount.ToString("N0") : "-"; + + AvgSessionText = r.SessionCount > 0 + ? TimeFormat.Friendly(TimeSpan.FromMilliseconds(r.AvgSessionMs)) + : "-"; + + LongestSessionText = r.LongestSessionMs > 0 && r.LongestSessionDate is { } sd + ? $"{TimeFormat.Friendly(TimeSpan.FromMilliseconds(r.LongestSessionMs))} ({sd:yyyy-MM-dd})" + : "-"; + + WeekSplitText = BuildWeekSplitText(r); + TimeOfDayText = BuildTimeOfDayText(r); + + // Playback context. Older exports omit these flags, hence the eligibility check. + ShuffleRateText = r.ShuffleEligiblePlays > 0 + ? $"{r.ShufflePlays * 100.0 / r.ShuffleEligiblePlays:0.#}% ({r.ShufflePlays:N0} of {r.ShuffleEligiblePlays:N0} plays)" + : "-"; + + OfflineRateText = r.ShuffleEligiblePlays > 0 + ? $"{r.OfflinePlays * 100.0 / r.ShuffleEligiblePlays:0.#}% ({r.OfflinePlays:N0} plays)" + : "-"; + + TopDeviceText = r.Platforms.Count > 0 + ? $"{r.Platforms[0].Name} ({TimeFormat.Friendly(r.Platforms[0].TotalTime)})" + : "-"; + + // Podcasts. + PodcastTimeText = r.PodcastMsPlayed > 0 + ? TimeFormat.Friendly(TimeSpan.FromMilliseconds(r.PodcastMsPlayed)) + : "-"; + PodcastPlaysText = r.PodcastPlays > 0 ? r.PodcastPlays.ToString("N0") : "-"; + UniqueShowsText = r.Shows.Count > 0 ? r.Shows.Count.ToString("N0") : "-"; + UniqueEpisodesText = r.Episodes.Count > 0 ? r.Episodes.Count.ToString("N0") : "-"; } private static string BuildPeakHourText(AnalysisResult r) @@ -286,14 +456,57 @@ private static string BuildPeakHourText(AnalysisResult r) return $"{peakHour:00}:00-{(peakHour + 1) % 24:00}:00 on {dayNames[peakDow]}"; } + private static string BuildWeekSplitText(AnalysisResult r) + { + long weekend = r.PlaytimeByDayOfWeek[0] + r.PlaytimeByDayOfWeek[6]; + long total = 0; + foreach (var ms in r.PlaytimeByDayOfWeek) total += ms; + if (total == 0) + return "-"; + + double weekendPct = weekend * 100.0 / total; + return $"{100 - weekendPct:0}% weekdays / {weekendPct:0}% weekends"; + } + + private static string BuildTimeOfDayText(AnalysisResult r) + { + long total = 0; + foreach (var ms in r.PlaytimeByHour) total += ms; + if (total == 0) + return "-"; + + Span segments = stackalloc long[4]; // night, morning, afternoon, evening + for (int h = 0; h < 24; h++) + segments[h / 6] += r.PlaytimeByHour[h]; + + string[] names = { "Night (00-06)", "Morning (06-12)", "Afternoon (12-18)", "Evening (18-24)" }; + int best = 0; + for (int i = 1; i < 4; i++) + if (segments[i] > segments[best]) best = i; + + return $"{names[best]} ({segments[best] * 100.0 / total:0}% of listening)"; + } + private void UpdateCharts() { (HourSeries, HourX, HourY) = ChartBuilder.ByHour(_result); (DayOfWeekSeries, DayOfWeekX, DayOfWeekY) = ChartBuilder.ByDayOfWeek(_result); (HeatSeries, HeatX, HeatY) = ChartBuilder.DowHourHeat(_result); (OverTimeSeries, OverTimeX, OverTimeY) = ChartBuilder.OverTime(_result, _overTimeGranularity); + (YearSeries, YearX, YearY) = ChartBuilder.HoursPerYear(_result); + (DiscoverySeries, DiscoveryX, DiscoveryY) = ChartBuilder.NewArtistsByMonth(_result); (SkippedSeries, SkippedX, SkippedY) = ChartBuilder.TopSkippedTracks(_result); ArtistShareSeries = ChartBuilder.ArtistShare(_result); + ReasonEndSeries = ChartBuilder.ReasonEndShare(_result); + PlatformSeries = ChartBuilder.PlatformShare(_result); + CountrySeries = ChartBuilder.CountryShare(_result); + // Size these to their content: most libraries hold only a handful of shows, and a + // fixed-height chart would space three bars across half a screen. + const int podcastBars = 20; + (ShowSeries, ShowX, ShowY) = ChartBuilder.TopShows(_result, podcastBars); + (EpisodeSeries, EpisodeX, EpisodeY) = ChartBuilder.TopEpisodes(_result, podcastBars); + ShowsChartHeight = BarHeight(Math.Min(podcastBars, _result.Shows.Count)); + EpisodesChartHeight = BarHeight(Math.Min(podcastBars, _result.Episodes.Count)); // Reset the scrollable bar charts to their first page; LoadMore* append the rest. _tracksShown = Math.Min(BarPageSize, MaxTracks); @@ -394,12 +607,24 @@ private async Task ExportAlbumsCsvAsync() StatusText = $"Saved albums CSV to {path}"; } - partial void OnHasDataChanged(bool value) + [RelayCommand(CanExecute = nameof(CanExport))] + private async Task ExportYearsCsvAsync() + { + var path = AskSave("CSV files (*.csv)|*.csv", ".csv", "Sortify_years"); + if (path is null) return; + await ExportService.SaveYearsCsvAsync(path, _result); + StatusText = $"Saved years CSV to {path}"; + } + + partial void OnHasDataChanged(bool value) => NotifyExportsChanged(); + + private void NotifyExportsChanged() { ExportTxtCommand.NotifyCanExecuteChanged(); ExportTracksCsvCommand.NotifyCanExecuteChanged(); ExportArtistsCsvCommand.NotifyCanExecuteChanged(); ExportAlbumsCsvCommand.NotifyCanExecuteChanged(); + ExportYearsCsvCommand.NotifyCanExecuteChanged(); } private static string? AskSave(string filter, string ext, string defaultName) diff --git a/Sortify/Views/DetailWindow.xaml b/Sortify/Views/DetailWindow.xaml new file mode 100644 index 0000000..e2ca364 --- /dev/null +++ b/Sortify/Views/DetailWindow.xaml @@ -0,0 +1,118 @@ + + + + + + + + + + +