Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ indent_size = 4
[*.{json,yml,yaml}]
indent_size = 2

# matches .gitattributes, which checks yml out with LF
[*.{yml,yaml}]
end_of_line = lf

[*.{xml,xaml,csproj,props,targets}]
indent_size = 2

Expand Down
13 changes: 13 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Let git normalize text files in the repo and check them out with CRLF on Windows, so a
# clone on a different machine or CI agent doesn't produce a whole-file diff.
* text=auto

*.cs text eol=crlf
*.xaml text eol=crlf
*.csproj text eol=crlf
*.sln text eol=crlf
*.md text eol=crlf
*.yml text eol=lf

*.png binary
*.ico binary
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,23 @@ jobs:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
cache: true
cache-dependency-path: '**/*.csproj'

- name: Restore
run: dotnet restore

# .editorconfig is the style guide; without this check nothing enforces it.
- name: Check formatting
run: dotnet format --verify-no-changes --no-restore

- name: Build
run: dotnet build --configuration Debug --no-restore

- name: Test
run: dotnet test --configuration Debug --no-build --verbosity normal

# Release turns on self-contained single-file publishing, which fails in ways a Debug
# build never sees. This is what users actually download, so it has to keep working.
- name: Publish (win-x64)
run: dotnet publish Sortify/Sortify.csproj -c Release -r win-x64
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# Build output
bin/
obj/
publish/
TestResults/
*.binlog

# IDE
.vs/
*.user
*.suo

# Rider
.idea/
44 changes: 33 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,45 @@ Sortify reads your Spotify streaming history and turns it into statistics you ca
## Features
- 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.
- An **Overview** tab that opens with your headline totals and your top five tracks, artists and albums.
- 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).
- Insights: longest and current listening streaks, your longest break, your biggest day, listening sessions (count, average and longest), weekday vs. weekend split, favorite time of day, skip rate, completion rate, repeat rate, discovery rate, how much of your time goes to a single artist, 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:
- **Light and dark themes**, switchable from the toolbar (Ctrl+T) or set to follow Windows (including when you switch Windows' mode while Sortify is open).
- Sortable tables. Click any column header to reorder by that field; right-click a row to copy it or exclude that track/artist. Each table has its own filter box that narrows the rows instantly, without re-running the analysis.
- **Double-click any track, artist, album or year 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.
- **Right-click any chart** to copy it to the clipboard or save it as a PNG.
- Filters that update every chart and table as you change them, with a chip row showing which ones are active:
- 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, 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.
- 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.
- Load data your way: pick individual JSON files (Ctrl+O), point at the extracted export folder (**Open Folder**, Ctrl+Shift+O), reopen one of the last eight folders from **Recent**, 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.
- Reopens the folder you used last time when it starts, remembers your window size and layout, and caches the parsed history so an unchanged export loads instantly instead of being re-read.
- Export a text summary, a Markdown report, everything as JSON, or tracks, artists, albums, years and shows as CSV.
- Settings for the theme, chart animations, how long a break has to be before it starts a new listening session, and clearing the cache.

## Keyboard Shortcuts
| Shortcut | Action |
| --- | --- |
| `Ctrl+O` / `Ctrl+Shift+O` | Open files / open a folder |
| `F5` | Re-read the files from disk, ignoring the cache |
| `Ctrl+1` … `Ctrl+8` | Jump to a tab |
| `Ctrl+F` | Focus the current table's filter box (`Esc` clears it) |
| `Enter` | Open the detail view for the selected track, artist, album or year row |
| `Ctrl+E` / `Ctrl+M` / `Ctrl+J` | Export text / Markdown / JSON |
| `Ctrl+B` | Show or hide the filter sidebar |
| `Ctrl+T` | Switch between the light and dark themes |
| `Ctrl+R` | Reset every filter |

## 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 **Open Folder** and pick the extracted folder (or click **Run Analysis** to choose individual JSON files, or drag & drop them onto the window).
3. Click **Open Folder** and pick the extracted folder (or click **Open Files** 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.
Expand All @@ -44,20 +61,25 @@ You need the [.NET 8 SDK](https://dotnet.microsoft.com/download).
```bash
dotnet run --project Sortify/Sortify.csproj

# 64-bit (recommended for most systems)
dotnet publish Sortify/Sortify.csproj -c Release -r win-x64

# 32-bit (for older or 32-bit-only Windows installs)
dotnet publish Sortify/Sortify.csproj -c Release -r win-x86
```

The published `Sortify.exe` lands in `Sortify/bin/Release/net8.0-windows/win-x64/publish/`.
The published `Sortify.exe` lands in `Sortify/bin/Release/net8.0-windows/<rid>/publish/`, where `<rid>` is `win-x64` or `win-x86` depending on which you built.

To run the test suite:
To run the test suite, and to check formatting the way CI does:

```bash
dotnet test
dotnet format --verify-no-changes
```

## Disclaimer
- Sortify is not affiliated with Spotify.
- 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.
- Everything runs locally on your machine. The only files it writes outside your exports are a settings file, a cache of your parsed history and an error log, all in `%LOCALAPPDATA%\Sortify`; deleting that folder resets everything. **Settings → Open data folder** takes you there.
- Please **review the code** before you download and run it.

## License
Expand Down
32 changes: 32 additions & 0 deletions Sortify.Tests/AnalysisEngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ public void SameTrackName_DifferentArtists_AreSeparateTracks()
Assert.Equal(2, r.UniqueTracks);
}

[Fact]
public void NamesContainingLineBreaks_StayIntact()
{
// Track keys used to be "track\nartist" strings split back apart on the first
// newline, so a name with one in it came out truncated or merged with another.
var records = new[]
{
Record(track: "Line\nBreak", artist: "X", skipped: true),
Record(track: "Line", artist: "Break\nX", skipped: true),
};

var r = AnalysisEngine.Analyze(records, NoFilter);

Assert.Equal(2, r.UniqueTracks);
Assert.Contains(r.SkippedTracks, s => s.Track == "Line\nBreak" && s.Artist == "X");
Assert.Contains(r.SkippedTracks, s => s.Track == "Line" && s.Artist == "Break\nX");
Assert.Contains(r.Years[0].TopTrack, new[] { "Line\nBreak", "Line" });
}

[Fact]
public void ByPlayCountViews_AreSortedByPlays()
{
Expand Down Expand Up @@ -384,6 +403,19 @@ private static PlayRecord Context(
[InlineData("iOS 16.1 (iPhone14,5)", "Mobile")]
[InlineData("web_player linux undefined;chrome", "Web player")]
[InlineData("Partner sonos_bose", "Speaker / cast")]
[InlineData("Partner google cast_audio Google;Chromecast_Audio", "Speaker / cast")]
[InlineData("Partner samsung_tv Samsung;UN55", "TV / console")]
[InlineData("Partner android_tv Sony;BRAVIA 4K", "TV / console")]
[InlineData("Partner ps4 Sony;PS4", "TV / console")]
[InlineData("Partner xbox_one Microsoft;Xbox One", "TV / console")]
[InlineData("Partner android_auto", "Car")]
[InlineData("Android OS 11 API 30 (samsung, SM-G991U)", "Mobile")]
[InlineData("WebPlayer (websocket RFC6455)", "Web player")]
[InlineData("web_player windows 10;chrome 118.0;desktop", "Web player")]
[InlineData("Windows 10 (10.0.19045; x64; AppX)", "Desktop")]
[InlineData("OS X 10.15.7 [x86_64]", "Desktop")]
[InlineData("linux", "Desktop")]
[InlineData("Scarlett", "Other")]
[InlineData("something unknown", "Other")]
public void PlatformFamily_BucketsSpotifyPlatformStrings(string raw, string expected)
{
Expand Down
140 changes: 140 additions & 0 deletions Sortify.Tests/AppSettingsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
using System.IO;
using System.Text.Json;
using Sortify.Services;
using Xunit;

namespace Sortify.Tests;

public class AppSettingsTests
{
[Fact]
public void RememberFolder_PutsTheNewestFirstWithoutDuplicating()
{
var settings = new AppSettings();

settings.RememberFolder(@"C:\one");
settings.RememberFolder(@"C:\two");
settings.RememberFolder(@"C:\one");

Assert.Equal(new[] { @"C:\one", @"C:\two" }, settings.RecentFolders);
Assert.Equal(@"C:\one", settings.LastFolder);
}

[Fact]
public void RememberFolder_MatchesPathsCaseInsensitively()
{
var settings = new AppSettings();

settings.RememberFolder(@"C:\Music\Export");
settings.RememberFolder(@"c:\music\export");

Assert.Single(settings.RecentFolders);
}

[Fact]
public void RememberFolder_CapsTheList()
{
var settings = new AppSettings();

for (int i = 0; i < AppSettings.MaxRecentFolders + 5; i++)
settings.RememberFolder($@"C:\folder{i}");

Assert.Equal(AppSettings.MaxRecentFolders, settings.RecentFolders.Count);
// The most recent survives; the oldest are dropped.
Assert.Equal($@"C:\folder{AppSettings.MaxRecentFolders + 4}", settings.RecentFolders[0]);
}

[Fact]
public void RememberFolder_IgnoresBlankPaths()
{
var settings = new AppSettings();

settings.RememberFolder(" ");

Assert.Empty(settings.RecentFolders);
Assert.Null(settings.LastFolder);
}

[Fact]
public void PruneMissingFolders_DropsPathsThatAreGone()
{
var settings = new AppSettings();
settings.RememberFolder(Path.GetTempPath());
settings.RememberFolder(Path.Combine(Path.GetTempPath(), "sortify-does-not-exist"));

settings.PruneMissingFolders();

Assert.Single(settings.RecentFolders);
Assert.Equal(Path.GetTempPath(), settings.RecentFolders[0]);
}

[Fact]
public void Normalize_CleansAHandEditedRecentList()
{
var settings = JsonSerializer.Deserialize<AppSettings>(
"""{ "RecentFolders": [ "C:\\one", null, " ", "c:\\ONE", "C:\\two" ] }""")!;

settings.Normalize();

Assert.Equal(new[] { @"C:\one", @"C:\two" }, settings.RecentFolders);
}

[Fact]
public void SessionGap_IsClampedToASensibleRange()
{
Assert.Equal(TimeSpan.FromMinutes(AppSettings.MaxSessionGapMinutes),
new AppSettings { SessionGapMinutes = 10_000 }.SessionGap);

Assert.Equal(TimeSpan.FromMinutes(AppSettings.MinSessionGapMinutes),
new AppSettings { SessionGapMinutes = -5 }.SessionGap);
}

[Fact]
public void SessionGap_IsNotWrittenToTheSettingsFile()
{
// It is derived from SessionGapMinutes and has no setter, so persisting it would
// put a value in the file that loading silently ignores.
var json = System.Text.Json.JsonSerializer.Serialize(new AppSettings());

Assert.DoesNotContain("\"SessionGap\"", json);
Assert.Contains("\"SessionGapMinutes\"", json);
}

[Fact]
public void SessionGap_DefaultsToThirtyMinutes()
{
Assert.Equal(TimeSpan.FromMinutes(30), new AppSettings().SessionGap);
}

[Fact]
public void DefaultSettings_CanBeSerialized()
{
// Regression: an unset window position was once double.NaN, which System.Text.Json
// refuses to write - so the first save on a fresh install threw.
var json = JsonSerializer.Serialize(new AppSettings());

var restored = JsonSerializer.Deserialize<AppSettings>(json);

Assert.NotNull(restored);
Assert.Null(restored!.WindowLeft);
Assert.Null(restored.WindowTop);
}

[Fact]
public void WindowPosition_SurvivesARoundTrip()
{
var json = JsonSerializer.Serialize(new AppSettings
{
WindowLeft = 120.5,
WindowTop = 64,
WindowWidth = 1400,
WindowHeight = 900,
});

var restored = JsonSerializer.Deserialize<AppSettings>(json)!;

Assert.Equal(120.5, restored.WindowLeft);
Assert.Equal(64, restored.WindowTop);
Assert.Equal(1400, restored.WindowWidth);
}
}
32 changes: 32 additions & 0 deletions Sortify.Tests/ChartBuilderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Sortify.Services;
using Xunit;

namespace Sortify.Tests;

public class ChartBuilderTests
{
[Fact]
public void ShortLabel_LeavesShortNamesAlone()
{
Assert.Equal("Short", ChartBuilder.ShortLabel("Short", max: 10));
}

[Fact]
public void ShortLabel_TruncatesWithAnEllipsis()
{
Assert.Equal("abcdefghi…", ChartBuilder.ShortLabel("abcdefghijklmnop", max: 10));
}

[Fact]
public void ShortLabel_NeverSplitsASurrogatePair()
{
// The emoji is two UTF-16 units straddling the cut; keeping only its first half
// renders as a replacement box.
string name = "abcdefgh\U0001F3B5xyz";

string label = ChartBuilder.ShortLabel(name, max: 10);

Assert.Equal("abcdefgh…", label);
Assert.DoesNotContain(label, c => char.IsSurrogate(c));
}
}
27 changes: 27 additions & 0 deletions Sortify.Tests/CultureScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Globalization;

namespace Sortify.Tests;

/// <summary>
/// Runs a test under a culture that formats numbers and times differently from en-US, so
/// output that is meant to be culture-invariant actually gets checked. CurrentCulture flows
/// with the async context, so this is safe across awaits and doesn't leak into other tests.
/// </summary>
internal sealed class CultureScope : IDisposable
{
private readonly CultureInfo _previous = CultureInfo.CurrentCulture;

private CultureScope(CultureInfo culture) => CultureInfo.CurrentCulture = culture;

/// <summary>Decimal comma and a dot for the time separator, like de-DE and fi-FI between them.</summary>
public static CultureScope Unusual()
{
var culture = (CultureInfo)CultureInfo.InvariantCulture.Clone();
culture.NumberFormat.NumberDecimalSeparator = ",";
culture.NumberFormat.NumberGroupSeparator = ".";
culture.DateTimeFormat.TimeSeparator = ".";
return new CultureScope(culture);
}

public void Dispose() => CultureInfo.CurrentCulture = _previous;
}
Loading
Loading