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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Sortify/Views/DetailWindow.xaml.cs b/Sortify/Views/DetailWindow.xaml.cs
new file mode 100644
index 0000000..5d6100d
--- /dev/null
+++ b/Sortify/Views/DetailWindow.xaml.cs
@@ -0,0 +1,71 @@
+using System.Diagnostics;
+using System.Windows;
+using Sortify.Models;
+using Sortify.Services;
+
+namespace Sortify.Views;
+
+///
+/// Drill-down view for a single artist, track or album. Populated directly rather than
+/// through a view model: it is read-only and lives only as long as the dialog.
+///
+public partial class DetailWindow : Window
+{
+ private readonly DetailResult _detail;
+
+ public DetailWindow(DetailResult detail)
+ {
+ InitializeComponent();
+ _detail = detail;
+
+ TitleText.Text = detail.Title;
+ SubtitleText.Text = detail.Subtitle.Length > 0
+ ? $"{detail.Scope} - {detail.Subtitle}"
+ : detail.Scope.ToString();
+ Title = detail.Subtitle.Length > 0
+ ? $"{detail.Title} - {detail.Subtitle}"
+ : detail.Title;
+
+ TotalTimeText.Text = TimeFormat.Friendly(detail.TotalTime);
+ PlaysText.Text = detail.PlayCount.ToString("N0");
+ ActiveDaysText.Text = detail.ActiveDays.ToString("N0");
+ FirstPlayedText.Text = detail.FirstPlayed is { } f ? TimeFormat.Timestamp(f) : "-";
+ LastPlayedText.Text = detail.LastPlayed is { } l ? TimeFormat.Timestamp(l) : "-";
+
+ var (monthSeries, monthX, monthY) = ChartBuilder.DetailByMonth(detail);
+ MonthChart.Series = monthSeries;
+ MonthChart.XAxes = monthX;
+ MonthChart.YAxes = monthY;
+
+ var (hourSeries, hourX, hourY) = ChartBuilder.DetailByHour(detail);
+ HourChart.Series = hourSeries;
+ HourChart.XAxes = hourX;
+ HourChart.YAxes = hourY;
+
+ TracksGrid.ItemsSource = detail.Tracks;
+ AlbumsGrid.ItemsSource = detail.Albums;
+
+ // Opening a single track already shows that one track in the header; the one-row
+ // grid underneath would just repeat it.
+ if (detail.Scope == DetailScope.Track)
+ TracksCard.Visibility = Visibility.Collapsed;
+ if (detail.Scope == DetailScope.Album)
+ AlbumsCard.Visibility = Visibility.Collapsed;
+ }
+
+ private void OnOpenInSpotify(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ // UseShellExecute hands the URL to the default browser (or the Spotify app,
+ // when it has registered itself as the handler).
+ Process.Start(new ProcessStartInfo(_detail.WebUrl) { UseShellExecute = true });
+ }
+ catch (Exception)
+ {
+ // No handler registered, or the shell refused; nothing useful to recover to.
+ MessageBox.Show(this, "Could not open a browser for that link.", "Sortify",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ }
+}
diff --git a/Sortify/Views/FilterPanel.xaml b/Sortify/Views/FilterPanel.xaml
index ae736b3..9d51195 100644
--- a/Sortify/Views/FilterPanel.xaml
+++ b/Sortify/Views/FilterPanel.xaml
@@ -23,6 +23,13 @@
+
+
+
+
+
@@ -31,7 +38,7 @@
-
+
diff --git a/Sortify/Views/MainWindow.xaml b/Sortify/Views/MainWindow.xaml
index 9522798..f0ded34 100644
--- a/Sortify/Views/MainWindow.xaml
+++ b/Sortify/Views/MainWindow.xaml
@@ -13,6 +13,11 @@
WindowStartupLocation="CenterScreen" Opacity="0"
AllowDrop="True" Drop="OnFileDrop" DragOver="OnFileDragOver">
+
+
+
+
+
@@ -42,11 +47,16 @@
+
+
+ PreviewMouseRightButtonDown="OnGridRightClick"
+ MouseDoubleClick="OnTracksGridDoubleClick">
+
+
+
@@ -254,9 +268,12 @@
+ PreviewMouseRightButtonDown="OnGridRightClick"
+ MouseDoubleClick="OnArtistsGridDoubleClick">
+
+
@@ -326,9 +343,13 @@
+ PreviewMouseRightButtonDown="OnGridRightClick"
+ MouseDoubleClick="OnAlbumsGridDoubleClick">
+
+
+
@@ -350,6 +371,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -391,6 +556,16 @@
TooltipPosition="Hidden" />
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -461,6 +645,49 @@
Foreground="{StaticResource MutedBrush}" FontSize="10" Margin="0,3,0,0" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -472,6 +699,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
();
- return files
- .Where(f => string.Equals(Path.GetExtension(f), ".json", StringComparison.OrdinalIgnoreCase))
- .ToArray();
+
+ var files = new List();
+ foreach (var item in items)
+ {
+ if (Directory.Exists(item))
+ files.AddRange(HistoryParser.FindHistoryFiles(item));
+ else if (string.Equals(Path.GetExtension(item), ".json", StringComparison.OrdinalIgnoreCase))
+ files.Add(item);
+ }
+ return files.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private void OnFileDragOver(object sender, DragEventArgs e)
{
- e.Effects = DroppedJsonFiles(e).Length > 0 ? DragDropEffects.Copy : DragDropEffects.None;
+ // DragOver fires continuously while hovering, so keep this check cheap:
+ // accept folders and .json files without scanning folder contents yet.
+ bool accept = e.Data.GetData(DataFormats.FileDrop) is string[] items &&
+ items.Any(i => Directory.Exists(i) ||
+ string.Equals(Path.GetExtension(i), ".json", StringComparison.OrdinalIgnoreCase));
+ e.Effects = accept ? DragDropEffects.Copy : DragDropEffects.None;
e.Handled = true;
}
@@ -170,6 +192,82 @@ private void OnExcludeArtistFromAlbums(object sender, RoutedEventArgs e)
ViewModel?.ExcludeArtistFromGrid(a.Artist);
}
+ // ---- Drill-down --------------------------------------------------------------------------
+
+ private async void OnTracksGridDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (TracksGrid.SelectedItem is TrackStat t)
+ await ShowDetailAsync(DetailScope.Track, t.Track, t.Artist);
+ }
+
+ private async void OnArtistsGridDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (ArtistsGrid.SelectedItem is ArtistStat a)
+ await ShowDetailAsync(DetailScope.Artist, a.Artist, string.Empty);
+ }
+
+ private async void OnAlbumsGridDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (AlbumsGrid.SelectedItem is AlbumStat a)
+ await ShowDetailAsync(DetailScope.Album, a.Album, a.Artist);
+ }
+
+ private async Task ShowDetailAsync(DetailScope scope, string title, string subtitle)
+ {
+ if (ViewModel is not { } vm)
+ return;
+
+ var detail = await vm.BuildDetailAsync(scope, title, subtitle);
+ if (detail is null || detail.PlayCount == 0)
+ return;
+
+ new DetailWindow(detail) { Owner = this }.ShowDialog();
+ }
+
+ // ---- Copy to clipboard -------------------------------------------------------------------
+
+ private static void TryCopy(string text)
+ {
+ try
+ {
+ Clipboard.SetDataObject(text);
+ }
+ catch (Exception)
+ {
+ // The clipboard can be locked by another process; copying is best-effort.
+ }
+ }
+
+ private void OnCopyTrackFromTracks(object sender, RoutedEventArgs e)
+ {
+ if (TracksGrid.SelectedItem is TrackStat t)
+ TryCopy($"{t.Track} - {t.Artist}");
+ }
+
+ private void OnCopyArtistFromTracks(object sender, RoutedEventArgs e)
+ {
+ if (TracksGrid.SelectedItem is TrackStat t)
+ TryCopy(t.Artist);
+ }
+
+ private void OnCopyArtistFromArtists(object sender, RoutedEventArgs e)
+ {
+ if (ArtistsGrid.SelectedItem is ArtistStat a)
+ TryCopy(a.Artist);
+ }
+
+ private void OnCopyAlbumFromAlbums(object sender, RoutedEventArgs e)
+ {
+ if (AlbumsGrid.SelectedItem is AlbumStat a)
+ TryCopy($"{a.Album} - {a.Artist}");
+ }
+
+ private void OnCopyArtistFromAlbums(object sender, RoutedEventArgs e)
+ {
+ if (AlbumsGrid.SelectedItem is AlbumStat a)
+ TryCopy(a.Artist);
+ }
+
// ---- Listening-over-time granularity toggle ---------------------------------------------
private void OnGranularityChecked(object sender, RoutedEventArgs e)
From 9b08db1e0d5157ca4026abb592203cc7e146a531 Mon Sep 17 00:00:00 2001
From: IDGBAN <106408231+IDGBAN@users.noreply.github.com>
Date: Tue, 21 Jul 2026 18:58:38 -0400
Subject: [PATCH 2/4] Add GitHub Actions CI Workflow
---
.github/workflows/ci.yml | 27 ++
Sortify.Tests/AnalysisEngineTests.cs | 468 +++++++++++++++++++++++++++
Sortify.Tests/DetailEngineTests.cs | 194 +++++++++++
Sortify.Tests/ExportServiceTests.cs | 100 ++++++
Sortify.Tests/FilterEngineTests.cs | 137 ++++++++
Sortify.Tests/HistoryParserTests.cs | 305 +++++++++++++++++
Sortify.Tests/RecordCacheTests.cs | 215 ++++++++++++
Sortify.Tests/Sortify.Tests.csproj | 21 ++
Sortify.Tests/TimeFormatTests.cs | 37 +++
Sortify.sln | 14 +
10 files changed, 1518 insertions(+)
create mode 100644 .github/workflows/ci.yml
create mode 100644 Sortify.Tests/AnalysisEngineTests.cs
create mode 100644 Sortify.Tests/DetailEngineTests.cs
create mode 100644 Sortify.Tests/ExportServiceTests.cs
create mode 100644 Sortify.Tests/FilterEngineTests.cs
create mode 100644 Sortify.Tests/HistoryParserTests.cs
create mode 100644 Sortify.Tests/RecordCacheTests.cs
create mode 100644 Sortify.Tests/Sortify.Tests.csproj
create mode 100644 Sortify.Tests/TimeFormatTests.cs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..33dde18
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+on:
+ push:
+ branches: [main, "update-*"]
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ build-and-test:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --configuration Debug --no-restore
+
+ - name: Test
+ run: dotnet test --configuration Debug --no-build --verbosity normal
diff --git a/Sortify.Tests/AnalysisEngineTests.cs b/Sortify.Tests/AnalysisEngineTests.cs
new file mode 100644
index 0000000..d427cdd
--- /dev/null
+++ b/Sortify.Tests/AnalysisEngineTests.cs
@@ -0,0 +1,468 @@
+using Sortify.Models;
+using Sortify.Services;
+using Xunit;
+
+namespace Sortify.Tests;
+
+public class AnalysisEngineTests
+{
+ private static readonly FilterOptions NoFilter = new() { MinMsPlayed = 0 };
+
+ private static PlayRecord Record(
+ string track = "Track", string artist = "Artist", string album = "Album",
+ int ms = 60_000, DateTime? ts = null, bool skipped = false, string? reasonEnd = null)
+ => new()
+ {
+ TrackName = track,
+ ArtistName = artist,
+ AlbumName = album,
+ MsPlayed = ms,
+ Timestamp = ts ?? new DateTime(2023, 5, 10, 14, 0, 0),
+ Skipped = skipped,
+ ReasonEnd = reasonEnd,
+ };
+
+ [Fact]
+ public void Aggregates_TotalsAndUniqueCounts()
+ {
+ var records = new[]
+ {
+ Record(track: "A", artist: "X", ms: 100_000),
+ Record(track: "A", artist: "X", ms: 50_000),
+ Record(track: "B", artist: "Y", ms: 25_000),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(3, r.TotalPlays);
+ Assert.Equal(175_000, r.TotalMsPlayed);
+ Assert.Equal(2, r.UniqueTracks);
+ Assert.Equal(2, r.UniqueArtists);
+ Assert.Equal("X", r.Artists[0].Artist); // most listened first
+ Assert.Equal(150_000, r.Artists[0].TotalMsPlayed);
+ Assert.Equal(2, r.Artists[0].PlayCount);
+ }
+
+ [Fact]
+ public void SameTrackName_DifferentArtists_AreSeparateTracks()
+ {
+ var records = new[]
+ {
+ Record(track: "Intro", artist: "X"),
+ Record(track: "Intro", artist: "Y"),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(2, r.UniqueTracks);
+ }
+
+ [Fact]
+ public void ByPlayCountViews_AreSortedByPlays()
+ {
+ var records = new[]
+ {
+ Record(track: "Long", artist: "X", ms: 500_000),
+ Record(track: "Short", artist: "Y", ms: 10_000),
+ Record(track: "Short", artist: "Y", ms: 10_000),
+ Record(track: "Short", artist: "Y", ms: 10_000),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal("Long", r.Tracks[0].Track); // by time
+ Assert.Equal("Short", r.TracksByPlayCount[0].Track); // by plays
+ Assert.Equal(3, r.TracksByPlayCount[0].PlayCount);
+ }
+
+ [Fact]
+ public void RecordsWithoutTimestamp_HaveNullFirstAndLastPlayed()
+ {
+ var records = new[] { Record(ts: DateTime.MinValue) };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Single(r.Tracks);
+ Assert.Null(r.Tracks[0].FirstPlayed);
+ Assert.Null(r.Tracks[0].LastPlayed);
+ Assert.Null(r.FirstListen);
+ Assert.Null(r.LastListen);
+ }
+
+ [Fact]
+ public void Streaks_LongestAndCurrent()
+ {
+ var records = new[]
+ {
+ Record(ts: new DateTime(2023, 5, 1, 10, 0, 0)),
+ Record(ts: new DateTime(2023, 5, 2, 10, 0, 0)),
+ Record(ts: new DateTime(2023, 5, 3, 10, 0, 0)),
+ Record(ts: new DateTime(2023, 5, 5, 10, 0, 0)),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(3, r.LongestStreakDays);
+ Assert.Equal(new DateTime(2023, 5, 1), r.LongestStreakStart);
+ Assert.Equal(new DateTime(2023, 5, 3), r.LongestStreakEnd);
+ Assert.Equal(1, r.CurrentStreakDays);
+ Assert.Equal(4, r.ActiveDays);
+ }
+
+ [Fact]
+ public void BiggestDay_PicksDayWithMostListening()
+ {
+ var records = new[]
+ {
+ Record(ms: 10_000, ts: new DateTime(2023, 5, 1, 10, 0, 0)),
+ Record(ms: 100_000, ts: new DateTime(2023, 5, 2, 10, 0, 0)),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(new DateTime(2023, 5, 2), r.BiggestDay);
+ Assert.Equal(100_000, r.BiggestDayMs);
+ }
+
+ [Fact]
+ public void Sessions_SplitOnGapsAndTrackLongest()
+ {
+ var records = new[]
+ {
+ // Session 1: two plays ending 10:00 and 10:20 (second starts 10:19).
+ Record(ms: 60_000, ts: new DateTime(2023, 5, 1, 10, 0, 0)),
+ Record(ms: 60_000, ts: new DateTime(2023, 5, 1, 10, 20, 0)),
+ // Session 2: starts 11:59, more than 30 min after 10:20.
+ Record(ms: 60_000, ts: new DateTime(2023, 5, 1, 12, 0, 0)),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(2, r.SessionCount);
+ Assert.Equal(120_000, r.LongestSessionMs);
+ Assert.Equal(90_000, r.AvgSessionMs);
+ Assert.Equal(new DateTime(2023, 5, 1), r.LongestSessionDate);
+ }
+
+ [Fact]
+ public void Years_RollUpWithTopArtistAndTrack()
+ {
+ var records = new[]
+ {
+ Record(track: "Song A", artist: "X", ms: 100_000, ts: new DateTime(2022, 3, 1, 10, 0, 0)),
+ Record(track: "Song B", artist: "Y", ms: 40_000, ts: new DateTime(2022, 6, 1, 10, 0, 0)),
+ Record(track: "Song C", artist: "Z", ms: 70_000, ts: new DateTime(2023, 1, 1, 10, 0, 0)),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(2, r.Years.Count);
+ var y2022 = r.Years[0];
+ Assert.Equal(2022, y2022.Year);
+ Assert.Equal(140_000, y2022.TotalMsPlayed);
+ Assert.Equal(2, y2022.PlayCount);
+ Assert.Equal(2, y2022.UniqueArtists);
+ Assert.Equal(2, y2022.UniqueTracks);
+ Assert.Equal("X", y2022.TopArtist);
+ Assert.Equal("Song A", y2022.TopTrack);
+ Assert.Equal(2023, r.Years[1].Year);
+ }
+
+ [Fact]
+ public void SkipStats_IncludeShortPlaysDroppedFromTotals()
+ {
+ var filter = new FilterOptions { MinMsPlayed = 5_000 };
+ var records = new[]
+ {
+ Record(track: "S", ms: 1_000, skipped: true),
+ Record(track: "S", ms: 60_000),
+ };
+
+ var r = AnalysisEngine.Analyze(records, filter);
+
+ Assert.Equal(1, r.TotalPlays); // short play excluded from totals
+ Assert.Equal(2, r.SkipEligiblePlays); // but counted for skip stats
+ Assert.Equal(1, r.TotalSkips);
+ var skipStat = Assert.Single(r.SkippedTracks);
+ Assert.Equal(50, skipStat.SkipRate);
+ }
+
+ [Fact]
+ public void ReasonEnds_CountAndGroupMissingAsUnknown()
+ {
+ var records = new[]
+ {
+ Record(reasonEnd: "trackdone"),
+ Record(reasonEnd: "trackdone"),
+ Record(reasonEnd: "fwdbtn"),
+ Record(reasonEnd: null),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(3, r.ReasonEnds.Count);
+ Assert.Equal("trackdone", r.ReasonEnds[0].Reason);
+ Assert.Equal(2, r.ReasonEnds[0].Count);
+ Assert.Contains(r.ReasonEnds, x => x.Reason == "unknown" && x.Count == 1);
+ }
+
+ [Fact]
+ public void NewArtistsByMonth_CountsFirstListens()
+ {
+ var records = new[]
+ {
+ Record(artist: "X", ts: new DateTime(2023, 1, 5, 10, 0, 0)),
+ Record(artist: "X", ts: new DateTime(2023, 2, 5, 10, 0, 0)), // repeat, not new
+ Record(artist: "Y", ts: new DateTime(2023, 1, 20, 10, 0, 0)),
+ Record(artist: "Z", ts: new DateTime(2023, 3, 5, 10, 0, 0)),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(2, r.NewArtistsByMonth.Count);
+ Assert.Equal(new DateTime(2023, 1, 1), r.NewArtistsByMonth[0].Date);
+ Assert.Equal(2, r.NewArtistsByMonth[0].Value);
+ Assert.Equal(new DateTime(2023, 3, 1), r.NewArtistsByMonth[1].Date);
+ Assert.Equal(1, r.NewArtistsByMonth[1].Value);
+ }
+
+ [Fact]
+ public void EmptyInput_ProducesEmptyResult()
+ {
+ var r = AnalysisEngine.Analyze(Array.Empty(), NoFilter);
+
+ Assert.Equal(0, r.TotalPlays);
+ Assert.Equal(0, r.SessionCount);
+ Assert.Equal(0, r.LongestStreakDays);
+ Assert.Equal(0, r.CurrentStreakDays);
+ Assert.Null(r.BiggestDay);
+ Assert.Empty(r.Years);
+ }
+
+ [Fact]
+ public void FirstTrack_TracksArtistsEarliestListen()
+ {
+ var records = new[]
+ {
+ Record(track: "Later", artist: "X", ts: new DateTime(2023, 5, 2, 10, 0, 0)),
+ Record(track: "Earlier", artist: "X", ts: new DateTime(2023, 5, 1, 10, 0, 0)),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal("Earlier", r.Artists[0].FirstTrack);
+ Assert.Equal(new DateTime(2023, 5, 1, 10, 0, 0), r.Artists[0].FirstPlayed);
+ Assert.Equal(new DateTime(2023, 5, 2, 10, 0, 0), r.Artists[0].LastPlayed);
+ }
+
+ // ---- Podcasts and audiobooks ---------------------------------------------------------
+
+ private static PlayRecord Podcast(
+ string episode = "Ep 1", string show = "Show", int ms = 900_000,
+ DateTime? ts = null, ContentKind kind = ContentKind.Podcast)
+ => new()
+ {
+ Kind = kind,
+ ShowName = show,
+ EpisodeName = episode,
+ MsPlayed = ms,
+ Timestamp = ts ?? new DateTime(2023, 5, 10, 14, 0, 0),
+ };
+
+ [Fact]
+ public void Podcasts_AreExcludedFromMusicStats_ByDefault()
+ {
+ var records = new[]
+ {
+ Record(track: "A", artist: "X", ms: 100_000),
+ Podcast(episode: "Ep 1", show: "Show", ms: 900_000),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(1, r.TotalPlays);
+ Assert.Equal(100_000, r.TotalMsPlayed);
+ Assert.Equal(1, r.UniqueTracks);
+ Assert.DoesNotContain(r.Artists, a => a.Artist == "Show");
+ }
+
+ [Fact]
+ public void Podcasts_AreAggregatedSeparately_RegardlessOfToggle()
+ {
+ var records = new[]
+ {
+ Record(track: "A", artist: "X", ms: 100_000),
+ Podcast(episode: "Ep 1", show: "Show", ms: 900_000),
+ Podcast(episode: "Ep 2", show: "Show", ms: 600_000),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ var show = Assert.Single(r.Shows);
+ Assert.Equal("Show", show.Show);
+ Assert.Equal(1_500_000, show.TotalMsPlayed);
+ Assert.Equal(2, show.PlayCount);
+ Assert.Equal(2, show.EpisodeCount);
+ Assert.Equal(2, r.Episodes.Count);
+ Assert.Equal(1_500_000, r.PodcastMsPlayed);
+ Assert.Equal(2, r.PodcastPlays);
+ }
+
+ [Fact]
+ public void EpisodeCount_CountsDistinctEpisodes_NotPlays()
+ {
+ var records = new[]
+ {
+ Podcast(episode: "Ep 1", show: "Show"),
+ Podcast(episode: "Ep 1", show: "Show"),
+ Podcast(episode: "Ep 1", show: "Show"),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ var show = Assert.Single(r.Shows);
+ Assert.Equal(3, show.PlayCount);
+ Assert.Equal(1, show.EpisodeCount);
+ }
+
+ [Fact]
+ public void IncludePodcasts_FoldsShowsIntoMusicStats_AsArtistAndTrack()
+ {
+ var records = new[]
+ {
+ Record(track: "A", artist: "X", ms: 100_000),
+ Podcast(episode: "Ep 1", show: "Show", ms: 900_000),
+ };
+
+ var r = AnalysisEngine.Analyze(records, new FilterOptions { MinMsPlayed = 0, IncludePodcasts = true });
+
+ Assert.Equal(2, r.TotalPlays);
+ Assert.Equal(1_000_000, r.TotalMsPlayed);
+ Assert.Equal("Show", r.Artists[0].Artist); // the longer listen ranks first
+ Assert.Contains(r.Tracks, t => t.Track == "Ep 1" && t.Artist == "Show");
+ }
+
+ [Fact]
+ public void Audiobooks_AreReportedAsShows_WithTheirOwnKind()
+ {
+ var records = new[]
+ {
+ Podcast(episode: "Chapter 1", show: "A Long Book", kind: ContentKind.Audiobook),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ var show = Assert.Single(r.Shows);
+ Assert.Equal(ContentKind.Audiobook, show.Kind);
+ Assert.Equal("A Long Book", show.Show);
+ }
+
+ // ---- Playback context -------------------------------------------------------------------
+
+ private static PlayRecord Context(
+ string platform = "", string country = "", bool shuffle = false,
+ bool offline = false, bool incognito = false, bool hasFlags = true, int ms = 60_000)
+ => new()
+ {
+ TrackName = "T",
+ ArtistName = "A",
+ AlbumName = "Al",
+ MsPlayed = ms,
+ Timestamp = new DateTime(2023, 5, 10, 14, 0, 0),
+ Platform = platform,
+ Country = country,
+ Shuffle = shuffle,
+ Offline = offline,
+ Incognito = incognito,
+ HasPlaybackFlags = hasFlags,
+ };
+
+ [Theory]
+ [InlineData("windows 10 (10.0.19043; x64; AppX)", "Desktop")]
+ [InlineData("OS X 13.0 [x86_64]", "Desktop")]
+ [InlineData("android-tablet", "Mobile")]
+ [InlineData("iOS 16.1 (iPhone14,5)", "Mobile")]
+ [InlineData("web_player linux undefined;chrome", "Web player")]
+ [InlineData("Partner sonos_bose", "Speaker / cast")]
+ [InlineData("something unknown", "Other")]
+ public void PlatformFamily_BucketsSpotifyPlatformStrings(string raw, string expected)
+ {
+ Assert.Equal(expected, AnalysisEngine.PlatformFamily(raw));
+ }
+
+ [Fact]
+ public void Platforms_AreGroupedByFamily()
+ {
+ var records = new[]
+ {
+ Context(platform: "windows 10 (10.0.19043; x64; AppX)", ms: 100_000),
+ Context(platform: "OS X 13.0", ms: 50_000),
+ Context(platform: "android", ms: 30_000),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(2, r.Platforms.Count);
+ Assert.Equal("Desktop", r.Platforms[0].Name);
+ Assert.Equal(150_000, r.Platforms[0].TotalMsPlayed);
+ Assert.Equal(2, r.Platforms[0].PlayCount);
+ Assert.Equal("Mobile", r.Platforms[1].Name);
+ }
+
+ [Fact]
+ public void Countries_AreAggregated()
+ {
+ var records = new[]
+ {
+ Context(country: "CA", ms: 100_000),
+ Context(country: "CA", ms: 50_000),
+ Context(country: "US", ms: 30_000),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(2, r.Countries.Count);
+ Assert.Equal("CA", r.Countries[0].Name);
+ Assert.Equal(150_000, r.Countries[0].TotalMsPlayed);
+ }
+
+ [Fact]
+ public void ShuffleAndOffline_CountOnlyRowsCarryingTheFlags()
+ {
+ var records = new[]
+ {
+ Context(shuffle: true, offline: true),
+ Context(shuffle: false),
+ // Legacy rows carry no context at all and must not dilute the denominator.
+ Context(hasFlags: false),
+ Context(hasFlags: false),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ Assert.Equal(2, r.ShuffleEligiblePlays);
+ Assert.Equal(1, r.ShufflePlays);
+ Assert.Equal(1, r.OfflinePlays);
+ Assert.Equal(0, r.IncognitoPlays);
+ }
+
+ [Fact]
+ public void SkippedTracks_OnlyIncludeTracksThatWereActuallySkipped()
+ {
+ var records = new[]
+ {
+ Record(track: "Skipped", artist: "X", skipped: true),
+ Record(track: "Skipped", artist: "X", skipped: false),
+ Record(track: "Never", artist: "X", skipped: false),
+ };
+
+ var r = AnalysisEngine.Analyze(records, NoFilter);
+
+ var skipped = Assert.Single(r.SkippedTracks);
+ Assert.Equal("Skipped", skipped.Track);
+ Assert.Equal("X", skipped.Artist);
+ Assert.Equal(1, skipped.SkipCount);
+ Assert.Equal(2, skipped.PlayCount); // denominator keeps the unskipped play
+ Assert.Equal(50.0, skipped.SkipRate);
+ }
+}
diff --git a/Sortify.Tests/DetailEngineTests.cs b/Sortify.Tests/DetailEngineTests.cs
new file mode 100644
index 0000000..f4752f7
--- /dev/null
+++ b/Sortify.Tests/DetailEngineTests.cs
@@ -0,0 +1,194 @@
+using Sortify.Models;
+using Sortify.Services;
+using Xunit;
+
+namespace Sortify.Tests;
+
+public class DetailEngineTests
+{
+ private static readonly FilterOptions NoFilter = new() { MinMsPlayed = 0 };
+
+ private static PlayRecord Record(
+ string track = "Track", string artist = "Artist", string album = "Album",
+ int ms = 60_000, DateTime? ts = null, string uri = "")
+ => new()
+ {
+ TrackName = track,
+ ArtistName = artist,
+ AlbumName = album,
+ MsPlayed = ms,
+ Timestamp = ts ?? new DateTime(2023, 5, 10, 14, 0, 0),
+ Uri = uri,
+ };
+
+ [Fact]
+ public void Artist_AggregatesOnlyThatArtistsPlays()
+ {
+ var records = new[]
+ {
+ Record(track: "A", artist: "X", album: "Al1", ms: 100_000),
+ Record(track: "B", artist: "X", album: "Al2", ms: 50_000),
+ Record(track: "C", artist: "Y", ms: 999_000),
+ };
+
+ var d = DetailEngine.Build(records, NoFilter, DetailScope.Artist, "X", string.Empty);
+
+ Assert.Equal(150_000, d.TotalMsPlayed);
+ Assert.Equal(2, d.PlayCount);
+ Assert.Equal(2, d.Tracks.Count);
+ Assert.Equal(2, d.Albums.Count);
+ Assert.Equal("A", d.Tracks[0].Track); // most listened first
+ Assert.DoesNotContain(d.Tracks, t => t.Track == "C");
+ }
+
+ [Fact]
+ public void Track_DistinguishesSameTitleByDifferentArtists()
+ {
+ var records = new[]
+ {
+ Record(track: "Intro", artist: "X", ms: 100_000),
+ Record(track: "Intro", artist: "Y", ms: 50_000),
+ };
+
+ var d = DetailEngine.Build(records, NoFilter, DetailScope.Track, "Intro", "X");
+
+ Assert.Equal(100_000, d.TotalMsPlayed);
+ Assert.Equal(1, d.PlayCount);
+ }
+
+ [Fact]
+ public void Album_MatchesOnAlbumAndArtist()
+ {
+ var records = new[]
+ {
+ Record(track: "A", artist: "X", album: "Greatest Hits", ms: 100_000),
+ Record(track: "B", artist: "Y", album: "Greatest Hits", ms: 50_000),
+ };
+
+ var d = DetailEngine.Build(records, NoFilter, DetailScope.Album, "Greatest Hits", "X");
+
+ Assert.Equal(100_000, d.TotalMsPlayed);
+ Assert.Equal(1, d.PlayCount);
+ }
+
+ [Fact]
+ public void RespectsActiveFilters()
+ {
+ var records = new[]
+ {
+ Record(artist: "X", ms: 100_000, ts: new DateTime(2023, 1, 1, 10, 0, 0)),
+ Record(artist: "X", ms: 100_000, ts: new DateTime(2024, 1, 1, 10, 0, 0)),
+ };
+ var filter = new FilterOptions { MinMsPlayed = 0, EndDate = new DateTime(2023, 12, 31, 23, 59, 59) };
+
+ var d = DetailEngine.Build(records, filter, DetailScope.Artist, "X", string.Empty);
+
+ Assert.Equal(1, d.PlayCount);
+ }
+
+ [Fact]
+ public void TracksFirstAndLastPlayed_SpanAllPlays()
+ {
+ var records = new[]
+ {
+ Record(track: "A", artist: "X", ts: new DateTime(2023, 5, 2, 10, 0, 0)),
+ Record(track: "A", artist: "X", ts: new DateTime(2023, 5, 1, 10, 0, 0)),
+ Record(track: "A", artist: "X", ts: new DateTime(2023, 5, 3, 10, 0, 0)),
+ };
+
+ var d = DetailEngine.Build(records, NoFilter, DetailScope.Artist, "X", string.Empty);
+
+ Assert.Equal(new DateTime(2023, 5, 1, 10, 0, 0), d.FirstPlayed);
+ Assert.Equal(new DateTime(2023, 5, 3, 10, 0, 0), d.LastPlayed);
+ Assert.Equal(3, d.ActiveDays);
+ }
+
+ [Fact]
+ public void ByMonth_BucketsPlaysIntoCalendarMonths()
+ {
+ var records = new[]
+ {
+ Record(artist: "X", ms: 3_600_000, ts: new DateTime(2023, 1, 5, 10, 0, 0)),
+ Record(artist: "X", ms: 3_600_000, ts: new DateTime(2023, 1, 20, 10, 0, 0)),
+ Record(artist: "X", ms: 3_600_000, ts: new DateTime(2023, 3, 1, 10, 0, 0)),
+ };
+
+ var d = DetailEngine.Build(records, NoFilter, DetailScope.Artist, "X", string.Empty);
+
+ Assert.Equal(2, d.ByMonth.Count);
+ Assert.Equal(new DateTime(2023, 1, 1), d.ByMonth[0].Date);
+ Assert.Equal(2.0, d.ByMonth[0].Value);
+ Assert.Equal(new DateTime(2023, 3, 1), d.ByMonth[1].Date);
+ }
+
+ [Fact]
+ public void ByHour_UsesLocalHourOfDay()
+ {
+ var records = new[]
+ {
+ Record(artist: "X", ms: 60_000, ts: new DateTime(2023, 5, 1, 14, 0, 0)),
+ Record(artist: "X", ms: 60_000, ts: new DateTime(2023, 5, 2, 14, 30, 0)),
+ };
+
+ var d = DetailEngine.Build(records, NoFilter, DetailScope.Artist, "X", string.Empty);
+
+ Assert.Equal(120_000, d.ByHour[14]);
+ Assert.Equal(0, d.ByHour[13]);
+ }
+
+ [Fact]
+ public void NoMatches_ProducesEmptyResult()
+ {
+ var records = new[] { Record(artist: "X") };
+
+ var d = DetailEngine.Build(records, NoFilter, DetailScope.Artist, "Nobody", string.Empty);
+
+ Assert.Equal(0, d.PlayCount);
+ Assert.Empty(d.Tracks);
+ Assert.Null(d.FirstPlayed);
+ }
+
+ // ---- Spotify links ---------------------------------------------------------------------
+
+ [Fact]
+ public void WebUrl_UsesTheTrackUri_WhenTheExportCarriedOne()
+ {
+ var records = new[] { Record(track: "A", artist: "X", uri: "spotify:track:abc123") };
+
+ var d = DetailEngine.Build(records, NoFilter, DetailScope.Track, "A", "X");
+
+ Assert.Equal("https://open.spotify.com/track/abc123", d.WebUrl);
+ }
+
+ [Fact]
+ public void WebUrl_FallsBackToSearch_WhenThereIsNoUri()
+ {
+ var records = new[] { Record(track: "A", artist: "X") };
+
+ var d = DetailEngine.Build(records, NoFilter, DetailScope.Track, "A", "X");
+
+ Assert.Equal("https://open.spotify.com/search/A%20X", d.WebUrl);
+ }
+
+ [Fact]
+ public void WebUrl_EscapesNamesWithSpecialCharacters()
+ {
+ var records = new[] { Record(track: "Q&A / Part #1", artist: "X") };
+
+ var d = DetailEngine.Build(records, NoFilter, DetailScope.Track, "Q&A / Part #1", "X");
+
+ Assert.StartsWith("https://open.spotify.com/search/", d.WebUrl);
+ Assert.DoesNotContain("/Part", d.WebUrl["https://open.spotify.com/search/".Length..]);
+ Assert.DoesNotContain("#", d.WebUrl);
+ }
+
+ [Fact]
+ public void WebUrl_IgnoresMalformedUris()
+ {
+ var records = new[] { Record(track: "A", artist: "X", uri: "spotify:track:") };
+
+ var d = DetailEngine.Build(records, NoFilter, DetailScope.Track, "A", "X");
+
+ Assert.StartsWith("https://open.spotify.com/search/", d.WebUrl);
+ }
+}
diff --git a/Sortify.Tests/ExportServiceTests.cs b/Sortify.Tests/ExportServiceTests.cs
new file mode 100644
index 0000000..8296dc2
--- /dev/null
+++ b/Sortify.Tests/ExportServiceTests.cs
@@ -0,0 +1,100 @@
+using System.IO;
+using Sortify.Models;
+using Sortify.Services;
+using Xunit;
+
+namespace Sortify.Tests;
+
+public class ExportServiceTests : IDisposable
+{
+ private readonly string _dir;
+
+ public ExportServiceTests()
+ {
+ _dir = Path.Combine(Path.GetTempPath(), "SortifyTests_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_dir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch { }
+ }
+
+ [Theory]
+ [InlineData("plain", "plain")]
+ [InlineData("has,comma", "\"has,comma\"")]
+ [InlineData("has\"quote", "\"has\"\"quote\"")]
+ [InlineData("has\nnewline", "\"has\nnewline\"")]
+ [InlineData("has\rreturn", "\"has\rreturn\"")]
+ public void Csv_QuotesOnlyWhenNeeded(string input, string expected)
+ {
+ Assert.Equal(expected, ExportService.Csv(input));
+ }
+
+ [Fact]
+ public async Task TracksCsv_EscapesFieldsAndOmitsMissingDates()
+ {
+ var result = AnalysisEngine.Analyze(new[]
+ {
+ new PlayRecord
+ {
+ TrackName = "Hello, World",
+ ArtistName = "The \"Band\"",
+ MsPlayed = 60_000,
+ Timestamp = DateTime.MinValue, // no timestamp
+ },
+ }, new FilterOptions { MinMsPlayed = 0 });
+
+ var path = Path.Combine(_dir, "tracks.csv");
+ await ExportService.SaveTracksCsvAsync(path, result);
+
+ var lines = await File.ReadAllLinesAsync(path);
+ Assert.Equal("Rank,Track,Artist,TotalTime,Hours,PlayCount,FirstPlayed,LastPlayed", lines[0]);
+ Assert.Equal("1,\"Hello, World\",\"The \"\"Band\"\"\",00:01:00,0.02,1,,", lines[1]);
+ }
+
+ [Fact]
+ public async Task Txt_ContainsSummaryAndRankings()
+ {
+ var result = AnalysisEngine.Analyze(new[]
+ {
+ new PlayRecord
+ {
+ TrackName = "Song",
+ ArtistName = "Artist",
+ AlbumName = "Album",
+ MsPlayed = 3_600_000,
+ Timestamp = new DateTime(2023, 5, 10, 14, 0, 0),
+ },
+ }, new FilterOptions { MinMsPlayed = 0 });
+
+ var path = Path.Combine(_dir, "results.txt");
+ await ExportService.SaveTxtAsync(path, result);
+
+ var text = await File.ReadAllTextAsync(path);
+ Assert.Contains("Sortify Results", text);
+ Assert.Contains("Total plays: 1", text);
+ Assert.Contains("Tracks Ranked by Time Listened:", text);
+ Assert.Contains("1. 01:00:00 - Song - Artist", text);
+ Assert.Contains("Listening by Year:", text);
+ Assert.Contains("2023:", text);
+ }
+
+ [Fact]
+ public async Task YearsCsv_WritesOneRowPerYear()
+ {
+ var result = AnalysisEngine.Analyze(new[]
+ {
+ new PlayRecord { TrackName = "A", ArtistName = "X", MsPlayed = 60_000, Timestamp = new DateTime(2022, 1, 1, 12, 0, 0) },
+ new PlayRecord { TrackName = "B", ArtistName = "Y", MsPlayed = 60_000, Timestamp = new DateTime(2023, 1, 1, 12, 0, 0) },
+ }, new FilterOptions { MinMsPlayed = 0 });
+
+ var path = Path.Combine(_dir, "years.csv");
+ await ExportService.SaveYearsCsvAsync(path, result);
+
+ var lines = await File.ReadAllLinesAsync(path);
+ Assert.Equal(3, lines.Length); // header + 2 years
+ Assert.StartsWith("2022,", lines[1]);
+ Assert.StartsWith("2023,", lines[2]);
+ }
+}
diff --git a/Sortify.Tests/FilterEngineTests.cs b/Sortify.Tests/FilterEngineTests.cs
new file mode 100644
index 0000000..bd3391c
--- /dev/null
+++ b/Sortify.Tests/FilterEngineTests.cs
@@ -0,0 +1,137 @@
+using Sortify.Models;
+using Sortify.Services;
+using Xunit;
+
+namespace Sortify.Tests;
+
+public class FilterEngineTests
+{
+ private static PlayRecord Record(
+ string track = "Track", string artist = "Artist", string album = "Album",
+ int ms = 60_000, DateTime? ts = null)
+ => new()
+ {
+ TrackName = track,
+ ArtistName = artist,
+ AlbumName = album,
+ MsPlayed = ms,
+ Timestamp = ts ?? new DateTime(2023, 5, 10, 14, 0, 0),
+ };
+
+ [Fact]
+ public void MinDuration_DropsShortPlays()
+ {
+ var records = new[] { Record(ms: 4_999), Record(ms: 5_000) };
+ var filter = new FilterOptions { MinMsPlayed = 5_000 };
+
+ var kept = FilterEngine.Apply(records, filter).ToList();
+
+ Assert.Single(kept);
+ Assert.Equal(5_000, kept[0].MsPlayed);
+ }
+
+ [Fact]
+ public void MinDuration_IgnoredWhenRelaxed()
+ {
+ var records = new[] { Record(ms: 100) };
+ var filter = new FilterOptions { MinMsPlayed = 5_000 };
+
+ Assert.Single(FilterEngine.Apply(records, filter, ignoreMinDuration: true));
+ }
+
+ [Fact]
+ public void DateRange_IsInclusive()
+ {
+ var inside = Record(ts: new DateTime(2023, 5, 10));
+ var before = Record(ts: new DateTime(2023, 5, 9, 23, 59, 59));
+ var after = Record(ts: new DateTime(2023, 5, 11, 0, 0, 1));
+ var filter = new FilterOptions
+ {
+ MinMsPlayed = 0,
+ StartDate = new DateTime(2023, 5, 10),
+ EndDate = new DateTime(2023, 5, 11),
+ };
+
+ var kept = FilterEngine.Apply(new[] { inside, before, after }, filter).ToList();
+
+ Assert.Single(kept);
+ Assert.Equal(inside.Timestamp, kept[0].Timestamp);
+ }
+
+ [Fact]
+ public void ExcludedArtist_IsCaseInsensitive()
+ {
+ var records = new[] { Record(artist: "Daft Punk"), Record(artist: "Queen") };
+ var filter = new FilterOptions { MinMsPlayed = 0 };
+ filter.ExcludedArtists.Add("daft punk");
+
+ var kept = FilterEngine.Apply(records, filter).ToList();
+
+ Assert.Single(kept);
+ Assert.Equal("Queen", kept[0].ArtistName);
+ }
+
+ [Fact]
+ public void ExcludedTrack_IsCaseInsensitive()
+ {
+ var records = new[] { Record(track: "One More Time"), Record(track: "Other") };
+ var filter = new FilterOptions { MinMsPlayed = 0 };
+ filter.ExcludedTracks.Add("ONE MORE TIME");
+
+ var kept = FilterEngine.Apply(records, filter).ToList();
+
+ Assert.Single(kept);
+ Assert.Equal("Other", kept[0].TrackName);
+ }
+
+ [Theory]
+ [InlineData(23, true)]
+ [InlineData(1, true)]
+ [InlineData(2, true)]
+ [InlineData(12, false)]
+ [InlineData(21, false)]
+ public void HourRange_WrapsPastMidnight(int hour, bool expected)
+ {
+ var record = Record(ts: new DateTime(2023, 5, 10, hour, 30, 0));
+ var filter = new FilterOptions { MinMsPlayed = 0, StartHour = 22, EndHour = 2 };
+
+ Assert.Equal(expected, FilterEngine.Apply(new[] { record }, filter).Any());
+ }
+
+ [Fact]
+ public void DayOfWeek_FiltersOutUncheckedDays()
+ {
+ // 2023-05-10 is a Wednesday (index 3).
+ var wednesday = Record(ts: new DateTime(2023, 5, 10, 12, 0, 0));
+ var filter = new FilterOptions { MinMsPlayed = 0 };
+ filter.IncludedDaysOfWeek[3] = false;
+
+ Assert.Empty(FilterEngine.Apply(new[] { wednesday }, filter));
+
+ filter.IncludedDaysOfWeek[3] = true;
+ Assert.Single(FilterEngine.Apply(new[] { wednesday }, filter));
+ }
+
+ [Fact]
+ public void Search_MatchesTrackArtistOrAlbum()
+ {
+ var byTrack = Record(track: "Sunset Drive");
+ var byArtist = Record(artist: "Sunset Collective");
+ var byAlbum = Record(album: "Sunsets Forever");
+ var noMatch = Record();
+ var filter = new FilterOptions { MinMsPlayed = 0, SearchTerm = " sunset " };
+
+ var kept = FilterEngine.Apply(new[] { byTrack, byArtist, byAlbum, noMatch }, filter).ToList();
+
+ Assert.Equal(3, kept.Count);
+ }
+
+ [Fact]
+ public void RecordsWithoutTimestamp_BypassTimeOfDayFilters()
+ {
+ var untimed = Record(ts: DateTime.MinValue);
+ var filter = new FilterOptions { MinMsPlayed = 0, StartHour = 9, EndHour = 10 };
+
+ Assert.Single(FilterEngine.Apply(new[] { untimed }, filter));
+ }
+}
diff --git a/Sortify.Tests/HistoryParserTests.cs b/Sortify.Tests/HistoryParserTests.cs
new file mode 100644
index 0000000..258a9cf
--- /dev/null
+++ b/Sortify.Tests/HistoryParserTests.cs
@@ -0,0 +1,305 @@
+using System.Globalization;
+using System.IO;
+using Sortify.Models;
+using Sortify.Services;
+using Xunit;
+
+namespace Sortify.Tests;
+
+public class HistoryParserTests : IDisposable
+{
+ private readonly string _dir;
+
+ public HistoryParserTests()
+ {
+ _dir = Path.Combine(Path.GetTempPath(), "SortifyTests_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_dir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch { }
+ }
+
+ private async Task WriteFileAsync(string name, string content)
+ {
+ var path = Path.Combine(_dir, name);
+ await File.WriteAllTextAsync(path, content);
+ return path;
+ }
+
+ [Fact]
+ public async Task Parses_ExtendedHistoryEntries()
+ {
+ var path = await WriteFileAsync("Streaming_History_Audio_2023.json", """
+ [
+ {
+ "ts": "2023-05-01T10:00:00Z",
+ "ms_played": 215000,
+ "master_metadata_track_name": "Song",
+ "master_metadata_album_artist_name": "Artist",
+ "master_metadata_album_album_name": "Album",
+ "reason_end": "trackdone",
+ "skipped": false
+ }
+ ]
+ """);
+
+ var result = await new HistoryParser().ParseAsync(new[] { path });
+
+ var record = Assert.Single(result.Records);
+ Assert.Equal("Song", record.TrackName);
+ Assert.Equal("Artist", record.ArtistName);
+ Assert.Equal("Album", record.AlbumName);
+ Assert.Equal(215000, record.MsPlayed);
+ Assert.Equal("trackdone", record.ReasonEnd);
+ Assert.False(record.Skipped);
+
+ var expected = DateTime.Parse("2023-05-01T10:00:00Z", CultureInfo.InvariantCulture,
+ DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal).ToLocalTime();
+ Assert.Equal(expected, record.Timestamp);
+ }
+
+ [Fact]
+ public async Task Parses_LegacyAccountDataEntries()
+ {
+ var path = await WriteFileAsync("StreamingHistory0.json", """
+ [
+ {
+ "endTime": "2021-01-20 14:33",
+ "artistName": "Legacy Artist",
+ "trackName": "Legacy Song",
+ "msPlayed": 123456
+ }
+ ]
+ """);
+
+ var result = await new HistoryParser().ParseAsync(new[] { path });
+
+ var record = Assert.Single(result.Records);
+ Assert.Equal("Legacy Song", record.TrackName);
+ Assert.Equal("Legacy Artist", record.ArtistName);
+ Assert.Equal(123456, record.MsPlayed);
+ Assert.NotEqual(DateTime.MinValue, record.Timestamp);
+ }
+
+ [Fact]
+ public async Task Skips_RowsWithNoUsableMetadata()
+ {
+ var path = await WriteFileAsync("Streaming_History_Audio.json", """
+ [
+ { "ts": "2023-05-01T10:00:00Z", "ms_played": 900000,
+ "master_metadata_track_name": null, "master_metadata_album_artist_name": null },
+ { "ts": "2023-05-01T11:00:00Z", "ms_played": 1000,
+ "master_metadata_track_name": "Real Song", "master_metadata_album_artist_name": "Artist" }
+ ]
+ """);
+
+ var result = await new HistoryParser().ParseAsync(new[] { path });
+
+ var record = Assert.Single(result.Records);
+ Assert.Equal("Real Song", record.TrackName);
+ }
+
+ [Fact]
+ public async Task Parses_PodcastEpisodes_AsPodcastKind()
+ {
+ var path = await WriteFileAsync("Streaming_History_Audio.json", """
+ [
+ { "ts": "2023-05-01T10:00:00Z", "ms_played": 900000,
+ "master_metadata_track_name": null, "master_metadata_album_artist_name": null,
+ "episode_name": "Ep 12: Deep Dive", "episode_show_name": "Some Show",
+ "spotify_episode_uri": "spotify:episode:abc123" }
+ ]
+ """);
+
+ var result = await new HistoryParser().ParseAsync(new[] { path });
+
+ var record = Assert.Single(result.Records);
+ Assert.Equal(ContentKind.Podcast, record.Kind);
+ Assert.Equal("Some Show", record.ShowName);
+ Assert.Equal("Ep 12: Deep Dive", record.EpisodeName);
+ Assert.Equal("spotify:episode:abc123", record.Uri);
+ }
+
+ [Fact]
+ public async Task Parses_Audiobooks_AsAudiobookKind()
+ {
+ var path = await WriteFileAsync("Streaming_History_Audio.json", """
+ [
+ { "ts": "2023-05-01T10:00:00Z", "ms_played": 900000,
+ "audiobook_title": "A Long Book", "audiobook_chapter_title": "Chapter 3" }
+ ]
+ """);
+
+ var result = await new HistoryParser().ParseAsync(new[] { path });
+
+ var record = Assert.Single(result.Records);
+ Assert.Equal(ContentKind.Audiobook, record.Kind);
+ Assert.Equal("A Long Book", record.ShowName);
+ Assert.Equal("Chapter 3", record.EpisodeName);
+ }
+
+ [Fact]
+ public async Task Skipped_FallsBackToReasonEnd_WhenFlagIsAbsent()
+ {
+ // Real exports leave "skipped" null across long stretches; reason_end still tells us.
+ var path = await WriteFileAsync("Streaming_History_Audio.json", """
+ [
+ { "ms_played": 3000, "master_metadata_track_name": "A",
+ "master_metadata_album_artist_name": "X", "reason_end": "fwdbtn" },
+ { "ms_played": 200000, "master_metadata_track_name": "B",
+ "master_metadata_album_artist_name": "X", "reason_end": "trackdone" }
+ ]
+ """);
+
+ var result = await new HistoryParser().ParseAsync(new[] { path });
+
+ Assert.True(result.Records[0].Skipped);
+ Assert.False(result.Records[1].Skipped);
+ }
+
+ [Fact]
+ public async Task Skipped_ExplicitFlagWins_OverReasonEnd()
+ {
+ var path = await WriteFileAsync("Streaming_History_Audio.json", """
+ [
+ { "ms_played": 3000, "master_metadata_track_name": "A",
+ "master_metadata_album_artist_name": "X", "reason_end": "fwdbtn", "skipped": false }
+ ]
+ """);
+
+ var result = await new HistoryParser().ParseAsync(new[] { path });
+
+ Assert.False(Assert.Single(result.Records).Skipped);
+ }
+
+ [Fact]
+ public async Task Parses_PlaybackContextFields()
+ {
+ var path = await WriteFileAsync("Streaming_History_Audio.json", """
+ [
+ { "ms_played": 200000, "master_metadata_track_name": "A",
+ "master_metadata_album_artist_name": "X",
+ "platform": "android", "conn_country": "CA",
+ "shuffle": true, "offline": true, "incognito_mode": false,
+ "spotify_track_uri": "spotify:track:xyz" }
+ ]
+ """);
+
+ var result = await new HistoryParser().ParseAsync(new[] { path });
+
+ var record = Assert.Single(result.Records);
+ Assert.Equal("android", record.Platform);
+ Assert.Equal("CA", record.Country);
+ Assert.True(record.Shuffle);
+ Assert.True(record.Offline);
+ Assert.False(record.Incognito);
+ Assert.Equal("spotify:track:xyz", record.Uri);
+ }
+
+ [Fact]
+ public async Task PoolsRepeatedPlatformStrings_AcrossRecords()
+ {
+ var path = await WriteFileAsync("Streaming_History_Audio.json", """
+ [
+ { "ms_played": 1000, "master_metadata_track_name": "A",
+ "master_metadata_album_artist_name": "X", "platform": "windows 10" },
+ { "ms_played": 1000, "master_metadata_track_name": "B",
+ "master_metadata_album_artist_name": "X", "platform": "windows 10" }
+ ]
+ """);
+
+ var result = await new HistoryParser().ParseAsync(new[] { path });
+
+ Assert.Same(result.Records[0].Platform, result.Records[1].Platform);
+ }
+
+ [Fact]
+ public async Task ClampsNegativeAndMissingValues()
+ {
+ var path = await WriteFileAsync("Streaming_History_Audio.json", """
+ [
+ { "ms_played": -500, "master_metadata_track_name": "T", "master_metadata_album_artist_name": "A" }
+ ]
+ """);
+
+ var result = await new HistoryParser().ParseAsync(new[] { path });
+
+ var record = Assert.Single(result.Records);
+ Assert.Equal(0, record.MsPlayed);
+ Assert.Equal(DateTime.MinValue, record.Timestamp);
+ Assert.Equal("Unknown Album", record.AlbumName);
+ }
+
+ [Fact]
+ public async Task InvalidJson_ProducesWarningNotCrash()
+ {
+ var path = await WriteFileAsync("Streaming_History_Audio.json", "{ not valid json ]");
+
+ var result = await new HistoryParser().ParseAsync(new[] { path });
+
+ Assert.Empty(result.Records);
+ Assert.Single(result.Warnings);
+ }
+
+ [Fact]
+ public async Task EmptyAndMissingFiles_AreSkipped()
+ {
+ var empty = await WriteFileAsync("empty.json", "");
+ var missing = Path.Combine(_dir, "does_not_exist.json");
+
+ var result = await new HistoryParser().ParseAsync(new[] { empty, missing });
+
+ Assert.Empty(result.Records);
+ Assert.Equal(2, result.SkippedFiles.Count);
+ }
+
+ [Fact]
+ public async Task MergesMultipleFiles_InSelectionOrder()
+ {
+ var first = await WriteFileAsync("a.json", """
+ [ { "ms_played": 1000, "master_metadata_track_name": "First", "master_metadata_album_artist_name": "A" } ]
+ """);
+ var second = await WriteFileAsync("b.json", """
+ [ { "ms_played": 1000, "master_metadata_track_name": "Second", "master_metadata_album_artist_name": "A" } ]
+ """);
+
+ var result = await new HistoryParser().ParseAsync(new[] { first, second });
+
+ Assert.Equal(2, result.Records.Count);
+ Assert.Equal("First", result.Records[0].TrackName);
+ Assert.Equal("Second", result.Records[1].TrackName);
+ }
+
+ [Fact]
+ public async Task FindHistoryFiles_PrefersSpotifyNamedFiles()
+ {
+ var sub = Path.Combine(_dir, "Spotify Extended Streaming History");
+ Directory.CreateDirectory(sub);
+ await File.WriteAllTextAsync(Path.Combine(sub, "Streaming_History_Audio_2023_1.json"), "[]");
+ await File.WriteAllTextAsync(Path.Combine(_dir, "unrelated.json"), "[]");
+
+ var files = HistoryParser.FindHistoryFiles(_dir);
+
+ var file = Assert.Single(files);
+ Assert.EndsWith("Streaming_History_Audio_2023_1.json", file);
+ }
+
+ [Fact]
+ public async Task FindHistoryFiles_FallsBackToTopLevelJson()
+ {
+ await File.WriteAllTextAsync(Path.Combine(_dir, "myexport.json"), "[]");
+
+ var files = HistoryParser.FindHistoryFiles(_dir);
+
+ var file = Assert.Single(files);
+ Assert.EndsWith("myexport.json", file);
+ }
+
+ [Fact]
+ public void FindHistoryFiles_MissingFolderReturnsEmpty()
+ {
+ Assert.Empty(HistoryParser.FindHistoryFiles(Path.Combine(_dir, "nope")));
+ }
+}
diff --git a/Sortify.Tests/RecordCacheTests.cs b/Sortify.Tests/RecordCacheTests.cs
new file mode 100644
index 0000000..c843fb1
--- /dev/null
+++ b/Sortify.Tests/RecordCacheTests.cs
@@ -0,0 +1,215 @@
+using System.IO;
+using Sortify.Models;
+using Sortify.Services;
+using Xunit;
+
+namespace Sortify.Tests;
+
+///
+/// The cache writes to a single per-user file, so these tests share it and must not run in
+/// parallel with each other.
+///
+[Collection(nameof(RecordCacheTests))]
+public class RecordCacheTests : IDisposable
+{
+ private readonly string _dir;
+
+ public RecordCacheTests()
+ {
+ _dir = Path.Combine(Path.GetTempPath(), "SortifyCache_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_dir);
+ RecordCache.Clear();
+ }
+
+ public void Dispose()
+ {
+ RecordCache.Clear();
+ try { Directory.Delete(_dir, recursive: true); } catch { }
+ }
+
+ private string WriteFile(string name, string content = "[]")
+ {
+ var path = Path.Combine(_dir, name);
+ File.WriteAllText(path, content);
+ return path;
+ }
+
+ private static PlayRecord Music(string track = "T", string artist = "A") => new()
+ {
+ TrackName = track,
+ ArtistName = artist,
+ AlbumName = "Al",
+ MsPlayed = 123_456,
+ Timestamp = new DateTime(2023, 5, 10, 14, 30, 0),
+ ReasonEnd = "trackdone",
+ Skipped = false,
+ Kind = ContentKind.Music,
+ Uri = "spotify:track:abc",
+ Platform = "windows 10",
+ Country = "CA",
+ Shuffle = true,
+ Offline = false,
+ Incognito = false,
+ HasPlaybackFlags = true,
+ };
+
+ [Fact]
+ public void Miss_WhenNothingCached()
+ {
+ var file = WriteFile("a.json");
+ Assert.Null(RecordCache.TryLoad(new[] { file }));
+ }
+
+ [Fact]
+ public void RoundTrips_AllRecordFields()
+ {
+ var file = WriteFile("a.json");
+ var original = Music();
+
+ RecordCache.TrySave(new[] { file }, new[] { original });
+ var loaded = RecordCache.TryLoad(new[] { file });
+
+ var r = Assert.Single(loaded!);
+ Assert.Equal(original.TrackName, r.TrackName);
+ Assert.Equal(original.ArtistName, r.ArtistName);
+ Assert.Equal(original.AlbumName, r.AlbumName);
+ Assert.Equal(original.MsPlayed, r.MsPlayed);
+ Assert.Equal(original.Timestamp, r.Timestamp);
+ Assert.Equal(original.ReasonEnd, r.ReasonEnd);
+ Assert.Equal(original.Skipped, r.Skipped);
+ Assert.Equal(original.Kind, r.Kind);
+ Assert.Equal(original.Uri, r.Uri);
+ Assert.Equal(original.Platform, r.Platform);
+ Assert.Equal(original.Country, r.Country);
+ Assert.Equal(original.Shuffle, r.Shuffle);
+ Assert.Equal(original.Offline, r.Offline);
+ Assert.Equal(original.Incognito, r.Incognito);
+ Assert.Equal(original.HasPlaybackFlags, r.HasPlaybackFlags);
+ }
+
+ [Fact]
+ public void RoundTrips_PodcastRecords()
+ {
+ var file = WriteFile("a.json");
+ var original = new PlayRecord
+ {
+ Kind = ContentKind.Audiobook,
+ ShowName = "A Long Book",
+ EpisodeName = "Chapter 3",
+ MsPlayed = 900_000,
+ Timestamp = new DateTime(2023, 1, 1),
+ };
+
+ RecordCache.TrySave(new[] { file }, new[] { original });
+
+ var r = Assert.Single(RecordCache.TryLoad(new[] { file })!);
+ Assert.Equal(ContentKind.Audiobook, r.Kind);
+ Assert.Equal("A Long Book", r.ShowName);
+ Assert.Equal("Chapter 3", r.EpisodeName);
+ }
+
+ [Fact]
+ public void RoundTrips_NullReasonEnd()
+ {
+ var file = WriteFile("a.json");
+ var original = new PlayRecord { TrackName = "T", ArtistName = "A", ReasonEnd = null };
+
+ RecordCache.TrySave(new[] { file }, new[] { original });
+
+ Assert.Null(Assert.Single(RecordCache.TryLoad(new[] { file })!).ReasonEnd);
+ }
+
+ [Fact]
+ public void RestoresStringSharing_ForPlatformAndCountry()
+ {
+ var file = WriteFile("a.json");
+ var records = new[] { Music("A"), Music("B") };
+
+ RecordCache.TrySave(new[] { file }, records);
+ var loaded = RecordCache.TryLoad(new[] { file })!;
+
+ Assert.Same(loaded[0].Platform, loaded[1].Platform);
+ Assert.Same(loaded[0].Country, loaded[1].Country);
+ }
+
+ [Fact]
+ public void Invalidated_WhenAFileChanges()
+ {
+ var file = WriteFile("a.json");
+ RecordCache.TrySave(new[] { file }, new[] { Music() });
+ Assert.NotNull(RecordCache.TryLoad(new[] { file }));
+
+ // Rewriting the file changes its length and write time.
+ File.WriteAllText(file, "[{\"ms_played\": 1}]");
+ File.SetLastWriteTimeUtc(file, DateTime.UtcNow.AddSeconds(5));
+
+ Assert.Null(RecordCache.TryLoad(new[] { file }));
+ }
+
+ [Fact]
+ public void Invalidated_WhenAFileIsAdded()
+ {
+ var first = WriteFile("a.json");
+ RecordCache.TrySave(new[] { first }, new[] { Music() });
+
+ var second = WriteFile("b.json");
+
+ Assert.Null(RecordCache.TryLoad(new[] { first, second }));
+ }
+
+ [Fact]
+ public void Invalidated_WhenAFileIsRemoved()
+ {
+ var first = WriteFile("a.json");
+ var second = WriteFile("b.json");
+ RecordCache.TrySave(new[] { first, second }, new[] { Music() });
+
+ Assert.Null(RecordCache.TryLoad(new[] { first }));
+ }
+
+ [Fact]
+ public void Key_IsIndependentOfFileOrder()
+ {
+ var first = WriteFile("a.json");
+ var second = WriteFile("b.json");
+
+ Assert.Equal(
+ RecordCache.BuildKey(new[] { first, second }),
+ RecordCache.BuildKey(new[] { second, first }));
+ }
+
+ [Fact]
+ public void CorruptCache_IsTreatedAsAMiss()
+ {
+ var file = WriteFile("a.json");
+ RecordCache.TrySave(new[] { file }, new[] { Music() });
+
+ var cachePath = Path.Combine(RecordCache.CacheDirectory, "records.cache");
+ var bytes = File.ReadAllBytes(cachePath);
+ File.WriteAllBytes(cachePath, bytes[..(bytes.Length / 2)]);
+
+ Assert.Null(RecordCache.TryLoad(new[] { file }));
+ }
+
+ [Fact]
+ public void GarbageCache_IsTreatedAsAMiss()
+ {
+ var file = WriteFile("a.json");
+ Directory.CreateDirectory(RecordCache.CacheDirectory);
+ File.WriteAllText(Path.Combine(RecordCache.CacheDirectory, "records.cache"), "not a cache file");
+
+ Assert.Null(RecordCache.TryLoad(new[] { file }));
+ }
+
+ [Fact]
+ public void Clear_RemovesTheCache()
+ {
+ var file = WriteFile("a.json");
+ RecordCache.TrySave(new[] { file }, new[] { Music() });
+ Assert.NotNull(RecordCache.TryLoad(new[] { file }));
+
+ RecordCache.Clear();
+
+ Assert.Null(RecordCache.TryLoad(new[] { file }));
+ }
+}
diff --git a/Sortify.Tests/Sortify.Tests.csproj b/Sortify.Tests/Sortify.Tests.csproj
new file mode 100644
index 0000000..5b9e194
--- /dev/null
+++ b/Sortify.Tests/Sortify.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0-windows
+ enable
+ enable
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Sortify.Tests/TimeFormatTests.cs b/Sortify.Tests/TimeFormatTests.cs
new file mode 100644
index 0000000..62822e8
--- /dev/null
+++ b/Sortify.Tests/TimeFormatTests.cs
@@ -0,0 +1,37 @@
+using Sortify.Services;
+using Xunit;
+
+namespace Sortify.Tests;
+
+public class TimeFormatTests
+{
+ [Fact]
+ public void HhMmSs_HoursCanExceed24()
+ {
+ var span = new TimeSpan(2, 3, 4, 5); // 2 days, 3 hours -> 51 hours
+ Assert.Equal("51:04:05", TimeFormat.HhMmSs(span));
+ }
+
+ [Fact]
+ public void HhMmSs_PadsSmallValues()
+ {
+ Assert.Equal("00:05:09", TimeFormat.HhMmSs(new TimeSpan(0, 5, 9)));
+ }
+
+ [Theory]
+ [InlineData(2, 4, 30, 0, "2d 4h 30m")]
+ [InlineData(0, 3, 15, 0, "3h 15m")]
+ [InlineData(0, 0, 12, 40, "12m 40s")]
+ public void Friendly_PicksTheRightGranularity(int d, int h, int m, int s, string expected)
+ {
+ Assert.Equal(expected, TimeFormat.Friendly(new TimeSpan(d, h, m, s)));
+ }
+
+ [Fact]
+ public void Timestamp_FormatsAndHandlesNull()
+ {
+ Assert.Equal("2023-05-10 14:30", TimeFormat.Timestamp(new DateTime(2023, 5, 10, 14, 30, 0)));
+ Assert.Equal("2023-05-10 14:30", TimeFormat.Timestamp((DateTime?)new DateTime(2023, 5, 10, 14, 30, 0)));
+ Assert.Equal(string.Empty, TimeFormat.Timestamp((DateTime?)null));
+ }
+}
diff --git a/Sortify.sln b/Sortify.sln
index 4def9a1..20d2d01 100644
--- a/Sortify.sln
+++ b/Sortify.sln
@@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sortify", "Sortify\Sortify.csproj", "{5EFDA1FE-DCAE-46F7-9AA4-D03E4E06BF77}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sortify.Tests", "Sortify.Tests\Sortify.Tests.csproj", "{C511E705-5C88-4E5E-82C2-75C0258D4608}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -27,6 +29,18 @@ Global
{5EFDA1FE-DCAE-46F7-9AA4-D03E4E06BF77}.Release|x64.Build.0 = Release|Any CPU
{5EFDA1FE-DCAE-46F7-9AA4-D03E4E06BF77}.Release|x86.ActiveCfg = Release|Any CPU
{5EFDA1FE-DCAE-46F7-9AA4-D03E4E06BF77}.Release|x86.Build.0 = Release|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Debug|x64.Build.0 = Debug|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Debug|x86.Build.0 = Debug|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Release|x64.ActiveCfg = Release|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Release|x64.Build.0 = Release|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Release|x86.ActiveCfg = Release|Any CPU
+ {C511E705-5C88-4E5E-82C2-75C0258D4608}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
From 06428a46bd9259fd79839e64cb803be330bc1f8e Mon Sep 17 00:00:00 2001
From: IDGBAN <106408231+IDGBAN@users.noreply.github.com>
Date: Tue, 21 Jul 2026 18:59:33 -0400
Subject: [PATCH 3/4] Add .editorconfig Matching the Existing Style
---
.editorconfig | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 54 insertions(+)
create mode 100644 .editorconfig
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..70db1c5
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,54 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = crlf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 4
+
+[*.{json,yml,yaml}]
+indent_size = 2
+
+[*.{xml,xaml,csproj,props,targets}]
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.cs]
+csharp_style_namespace_declarations = file_scoped:warning
+csharp_style_expression_bodied_methods = when_on_single_line:suggestion
+csharp_style_expression_bodied_properties = true:suggestion
+csharp_style_expression_bodied_accessors = true:suggestion
+
+csharp_style_var_for_built_in_types = false:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+csharp_style_var_elsewhere = false:suggestion
+
+csharp_style_prefer_pattern_matching = true:suggestion
+csharp_style_prefer_not_pattern = true:suggestion
+csharp_style_prefer_switch_expression = true:suggestion
+csharp_prefer_braces = when_multiline:suggestion
+
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_indent_case_contents = true
+csharp_indent_switch_labels = true
+csharp_space_after_keywords_in_control_flow_statements = true
+
+dotnet_sort_system_directives_first = true
+dotnet_separate_import_directive_groups = false
+
+dotnet_naming_rule.private_fields_underscore.symbols = private_fields
+dotnet_naming_rule.private_fields_underscore.style = underscore_camel_case
+dotnet_naming_rule.private_fields_underscore.severity = suggestion
+
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private
+
+dotnet_naming_style.underscore_camel_case.required_prefix = _
+dotnet_naming_style.underscore_camel_case.capitalization = camel_case
From a1cb03db19cd772f67f4a699dc96ce4089aa5853 Mon Sep 17 00:00:00 2001
From: IDGBAN <106408231+IDGBAN@users.noreply.github.com>
Date: Tue, 21 Jul 2026 19:00:27 -0400
Subject: [PATCH 4/4] Update README and .csproj File to Include New Features
---
README.md | 41 ++++++++++++++++++++++++++++++++---------
Sortify/Sortify.csproj | 6 +++++-
2 files changed, 37 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index 1c56bca..4685cad 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,40 @@
# Sortify
-Sortify reads your Spotify extended streaming history and turns it into statistics you can explore. It shows your top tracks and artists, how your listening changed over time, and how it breaks down by hour of the day and day of the week. It ships as a single portable `.exe` so there is no installation.
+
+[](https://github.com/IDGBAN/Sortify/actions/workflows/ci.yml)
+[](https://github.com/IDGBAN/Sortify/releases/latest)
+[](https://github.com/IDGBAN/Sortify/releases)
+[](https://dotnet.microsoft.com/download)
+
+[](LICENSE)
+
+Sortify reads your Spotify streaming history and turns it into statistics you can explore. It shows your top tracks, artists and albums, how your listening changed over time and year by year, and how it breaks down by hour of the day and day of the week. It ships as a single portable `.exe` so there is no installation.
## Features
-- Top tracks and artists, ranked by listening time, play count, or the first time you played them.
-- Charts throughout the app: bar charts for top tracks and artists, a donut showing each artist's share of your listening, a line chart of listening over time, and breakdowns by hour and by day of the week.
-- Sortable tables. Click any column header to reorder by that field.
+- Top tracks, artists and albums, ranked by listening time, play count, or the first time you played them.
+- Charts throughout the app: bar charts for top tracks, artists and albums, a donut showing each artist's share of your listening, a line chart of listening over time (daily, weekly or monthly), a day-of-week vs. hour-of-day heatmap, and breakdowns by hour and by day of the week.
+- A **Years** tab with a per-year rollup: listening time, plays, unique artists and tracks, and your top artist and track for every year.
+- A **Podcasts** tab covering podcasts and audiobooks: total time and plays, how many shows and episodes, top shows and top episodes, and sortable tables for both.
+- Insights: longest and current listening streaks, your biggest day, listening sessions (count, average and longest), weekday vs. weekend split, favorite time of day, skip rate, most skipped tracks, a chart of new artists discovered per month, and a donut of why plays ended (finished, skipped, and so on).
+- Playback context, also in Insights: your shuffle rate, how much you listened offline, and donuts breaking listening down by device (desktop, mobile, web player, speaker, car) and by country.
+- Sortable tables. Click any column header to reorder by that field; right-click a row to copy it or exclude that track/artist.
+- **Double-click any track, artist or album row** to open a detail view: listening time, plays, active days, first and last listen, a monthly chart, an hour-of-day profile, its tracks and albums, and a button that opens it in Spotify.
- Filters that update every chart and table as you change them:
- Minimum play duration. The default is five seconds, which drops skips.
+ - Whether podcasts and audiobooks count toward your track, artist and album statistics. Off by default, since a few long shows will otherwise outrank your music. The Podcasts tab shows them either way.
- Date range.
- - Search by track or artist name.
+ - Search by track, artist or album name.
- An exclude list for specific artists or tracks.
- Time of day and day of week. Time ranges may cross midnight, for example 22:00 to 02:00.
-- Export to TXT, or export tracks and artists to CSV.
+- Load data your way: pick individual JSON files (Ctrl+O), point at the extracted export folder (**Open Folder**, Ctrl+Shift+O), or just drag & drop the files or the whole folder onto the window.
+- Reads both the extended streaming history (`Streaming_History_Audio_*.json`) and the older account-data export (`StreamingHistory*.json`).
+- Reopens the folder you used last time when it starts, and caches the parsed history so an unchanged export loads instantly instead of being re-read.
+- Export to TXT (with a summary section), or export tracks, artists, albums and years to CSV.
## Get Started
1. Request your [extended streaming history](https://www.spotify.com/ca-en/account/privacy/) from Spotify. When it arrives, download and extract the ZIP.
2. Download the latest `Sortify.exe` from the [releases page](https://github.com/IDGBAN/Sortify/releases/) and run it.
-3. Click **Run Analysis** and choose the JSON files you want to include.
-4. Browse the Overview, Tracks, Artists, and Trends tabs, adjust the filters on the left, and export your results if you want a copy.
+3. Click **Open Folder** and pick the extracted folder (or click **Run Analysis** to choose individual JSON files, or drag & drop them onto the window).
+4. Browse the Overview, Tracks, Artists, Albums, Years, Podcasts, Trends and Insights tabs, adjust the filters on the left, and export your results if you want a copy.
Analysis is quick unless your history is unusually large.
@@ -32,9 +49,15 @@ dotnet publish Sortify/Sortify.csproj -c Release -r win-x64
The published `Sortify.exe` lands in `Sortify/bin/Release/net8.0-windows/win-x64/publish/`.
+To run the test suite:
+
+```bash
+dotnet test
+```
+
## Disclaimer
- Sortify is not affiliated with Spotify.
-- Everything runs locally on your machine.
+- Everything runs locally on your machine. The only files it writes outside your exports are a settings file and a cache of your parsed history, both in `%LOCALAPPDATA%\Sortify`; deleting that folder resets both.
- Please **review the code** before you download and run it.
## License
diff --git a/Sortify/Sortify.csproj b/Sortify/Sortify.csproj
index c0d177b..6f54ac1 100644
--- a/Sortify/Sortify.csproj
+++ b/Sortify/Sortify.csproj
@@ -8,7 +8,7 @@
true
Sortify
Sortify
- 3.0.0
+ 3.2.0
Parsa Rasouli Baghi
Sortify
app.manifest
@@ -27,4 +27,8 @@
+
+
+
+