From bd8238a70f3de83d52b4e3e69ed19e5560e3f81f Mon Sep 17 00:00:00 2001 From: IDGBAN <106408231+IDGBAN@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:38:01 -0400 Subject: [PATCH 01/30] Tidy Chart Axis Text Sizes --- Sortify/Services/ChartBuilder.cs | 57 +++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/Sortify/Services/ChartBuilder.cs b/Sortify/Services/ChartBuilder.cs index 283290e..b58c369 100644 --- a/Sortify/Services/ChartBuilder.cs +++ b/Sortify/Services/ChartBuilder.cs @@ -39,6 +39,15 @@ public static class ChartBuilder private const int PlatformSlices = 8; private const int CountrySlices = 10; + // Axis text in pixels. LiveCharts defaults to 16 for labels and 20 for axis titles, which + // shouts next to the 11-13px UI text around the charts. + private const double AxisTextSize = 12; + private const double DenseAxisTextSize = 11; + + // Month-labelled axes never step by less than this. Anything shorter than the longest + // month can land twice in one month and print the same "yyyy-MM" label twice. + private static readonly long MonthStepTicks = TimeSpan.FromDays(31).Ticks; + // Donut hole radii in pixels. The artist donut is drawn larger, so it gets a bigger hole. private const double ArtistDonutInnerRadius = 75; private const double ContextDonutInnerRadius = 60; @@ -167,7 +176,7 @@ private static ChartData Rows(double[] values, string[] labels, string unit, SKC { Labels = labels, LabelsPaint = Label(), - TextSize = 11, + TextSize = DenseAxisTextSize, MinStep = 1, ForceStepToMin = true, SeparatorsPaint = null, @@ -235,8 +244,19 @@ private static ChartData Columns(double[] values, string[] labels, string unit, return new ChartData { Series = series, - XAxes = new[] { new Axis { Name = xName, Labels = labels, NamePaint = Label(), LabelsPaint = Label() } }, - YAxes = new[] { new Axis { Name = unit, NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }, + XAxes = new[] + { + new Axis + { + Name = xName, + Labels = labels, + NamePaint = Label(), + NameTextSize = AxisTextSize, + LabelsPaint = Label(), + TextSize = AxisTextSize, + }, + }, + YAxes = new[] { ValueAxis(unit) }, }; } @@ -291,7 +311,7 @@ public static ChartData OverTime(AnalysisResult r, TimeGranularity granularity = { Series = series, XAxes = new[] { DateAxis(unitTicks) }, - YAxes = new[] { new Axis { Name = "Hours", NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }, + YAxes = new[] { ValueAxis("Hours") }, }; } @@ -317,15 +337,27 @@ public static ChartData NewArtistsByMonth(AnalysisResult r) { Series = series, XAxes = new[] { DateAxis(TimeSpan.FromDays(30).Ticks) }, - YAxes = new[] { new Axis { Name = "New artists", NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }, + YAxes = new[] { ValueAxis("New artists") }, }; } private static Axis DateAxis(long unitTicks) => new() { LabelsPaint = Label(), + TextSize = AxisTextSize, Labeler = MonthLabel, UnitWidth = unitTicks, + MinStep = MonthStepTicks, + }; + + private static Axis ValueAxis(string name) => new() + { + Name = name, + NamePaint = Label(), + NameTextSize = AxisTextSize, + LabelsPaint = Label(), + TextSize = AxisTextSize, + MinLimit = 0, }; /// @@ -371,7 +403,9 @@ public static ChartData DowHourHeat(AnalysisResult r) { Labels = Enumerable.Range(0, 24).Select(h => h.ToString("00")).ToArray(), LabelsPaint = Label(), - TextSize = 10, + TextSize = DenseAxisTextSize, + MinStep = 1, + ForceStepToMin = true, }, }, YAxes = new[] @@ -380,7 +414,11 @@ public static ChartData DowHourHeat(AnalysisResult r) { Labels = dayNames, LabelsPaint = Label(), - TextSize = 11, + TextSize = DenseAxisTextSize, + // Without this LiveCharts labels every other row and leaves Mon, Wed + // and Fri blank. + MinStep = 1, + ForceStepToMin = true, }, }, }; @@ -542,11 +580,12 @@ public static ChartData DetailByMonth(DetailResult d) { Labeler = MonthLabel, UnitWidth = TimeSpan.FromDays(30).Ticks, + MinStep = MonthStepTicks, LabelsPaint = Label(), - TextSize = 11, + TextSize = AxisTextSize, }, }, - YAxes = new[] { new Axis { Name = "Hours", NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }, + YAxes = new[] { ValueAxis("Hours") }, }; } From 665aa35c66b43e53f2e1b54036d0fb60aa839be2 Mon Sep 17 00:00:00 2001 From: IDGBAN <106408231+IDGBAN@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:48:06 -0400 Subject: [PATCH 02/30] Open the Spotify Export ZIP Without Extracting It --- README.md | 8 +- Sortify.Tests/AppSettingsTests.cs | 22 +++ Sortify.Tests/ExportArchiveTests.cs | 202 +++++++++++++++++++++++++++ Sortify/Services/AppSettings.cs | 15 +- Sortify/Services/ArchivePath.cs | 37 +++++ Sortify/Services/HistoryParser.cs | 105 ++++++++++---- Sortify/Services/RecordCache.cs | 14 +- Sortify/ViewModels/MainViewModel.cs | 45 ++++-- Sortify/Views/MainWindow.xaml | 16 +-- Sortify/Views/MainWindow.xaml.cs | 37 +++-- Sortify/Views/SettingsWindow.xaml | 6 +- Sortify/Views/SettingsWindow.xaml.cs | 4 +- 12 files changed, 440 insertions(+), 71 deletions(-) create mode 100644 Sortify.Tests/ExportArchiveTests.cs create mode 100644 Sortify/Services/ArchivePath.cs diff --git a/README.md b/README.md index 3bbeda9..22f306a 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Sortify reads your Spotify streaming history and turns it into statistics you ca - 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), reopen one of the last eight folders from **Recent**, or just drag & drop the files or the whole folder onto the window. +- Load data your way: open the ZIP Spotify sends without unzipping it, pick individual JSON files (both with Ctrl+O), point at the extracted export folder (**Open Folder**, Ctrl+Shift+O), reopen one of the last eight exports from **Recent**, or just drag & drop the ZIP, 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, 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. @@ -37,7 +37,7 @@ Sortify reads your Spotify streaming history and turns it into statistics you ca ## Keyboard Shortcuts | Shortcut | Action | | --- | --- | -| `Ctrl+O` / `Ctrl+Shift+O` | Open files / open a folder | +| `Ctrl+O` / `Ctrl+Shift+O` | Open the export ZIP or 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) | @@ -48,9 +48,9 @@ Sortify reads your Spotify streaming history and turns it into statistics you ca | `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. +1. Request your [extended streaming history](https://www.spotify.com/ca-en/account/privacy/) from Spotify. When it arrives, download the ZIP. There's no need to extract it. 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 **Open Files** to choose individual JSON files, or drag & drop them onto the window). +3. Drag the ZIP onto the window, or click **Open Files** and pick it. An extracted folder works too: use **Open Folder**, or drop the folder or its JSON files 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. diff --git a/Sortify.Tests/AppSettingsTests.cs b/Sortify.Tests/AppSettingsTests.cs index a7e3595..63e176d 100644 --- a/Sortify.Tests/AppSettingsTests.cs +++ b/Sortify.Tests/AppSettingsTests.cs @@ -68,6 +68,28 @@ public void PruneMissingFolders_DropsPathsThatAreGone() Assert.Equal(Path.GetTempPath(), settings.RecentFolders[0]); } + [Fact] + public void PruneMissingFolders_KeepsAZipThatStillExists() + { + var zip = Path.Combine(Path.GetTempPath(), $"sortify-{Guid.NewGuid():N}.zip"); + File.WriteAllText(zip, "zip"); + try + { + var settings = new AppSettings(); + settings.RememberFolder(Path.Combine(Path.GetTempPath(), "sortify-gone.zip")); + settings.RememberFolder(zip); + + settings.PruneMissingFolders(); + + Assert.Equal(new[] { zip }, settings.RecentFolders); + Assert.Equal(zip, settings.LastFolder); + } + finally + { + File.Delete(zip); + } + } + [Fact] public void Normalize_CleansAHandEditedRecentList() { diff --git a/Sortify.Tests/ExportArchiveTests.cs b/Sortify.Tests/ExportArchiveTests.cs new file mode 100644 index 0000000..94d6b17 --- /dev/null +++ b/Sortify.Tests/ExportArchiveTests.cs @@ -0,0 +1,202 @@ +using System.IO; +using System.IO.Compression; +using Sortify.Services; +using Xunit; + +namespace Sortify.Tests; + +/// Opening the ZIP Spotify sends without extracting it first. +public class ExportArchiveTests : IDisposable +{ + private const string OnePlay = """ + [ + { + "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" + } + ] + """; + + private readonly string _dir; + + public ExportArchiveTests() + { + _dir = Path.Combine(Path.GetTempPath(), "SortifyZip_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private string WriteZip(string name, params (string Entry, string Content)[] entries) + { + var path = Path.Combine(_dir, name); + using var stream = File.Create(path); + using var zip = new ZipArchive(stream, ZipArchiveMode.Create); + foreach (var (entry, content) in entries) + { + using var writer = new StreamWriter(zip.CreateEntry(entry).Open()); + writer.Write(content); + } + return path; + } + + [Fact] + public void FindHistoryFiles_LooksInsideAZip() + { + var zip = WriteZip("my_spotify_data.zip", + ("Spotify Extended Streaming History/Streaming_History_Audio_2023.json", OnePlay), + ("Spotify Extended Streaming History/Streaming_History_Audio_2022.json", OnePlay), + ("Spotify Extended Streaming History/ReadMeFirst_ExtendedStreamingHistory.pdf", "pdf"), + ("Userdata.json", "{}")); + + var files = HistoryParser.FindHistoryFiles(zip); + + Assert.Equal(new[] + { + ArchivePath.Combine(zip, "Spotify Extended Streaming History/Streaming_History_Audio_2022.json"), + ArchivePath.Combine(zip, "Spotify Extended Streaming History/Streaming_History_Audio_2023.json"), + }, files); + } + + [Fact] + public void FindHistoryFiles_ZipFallsBackToTopLevelJson() + { + var zip = WriteZip("renamed.zip", + ("my plays.json", OnePlay), + ("nested/other.json", OnePlay)); + + var files = HistoryParser.FindHistoryFiles(zip); + + Assert.Equal(new[] { ArchivePath.Combine(zip, "my plays.json") }, files); + } + + [Fact] + public void FindHistoryFiles_DamagedZipFindsNothing() + { + var zip = Path.Combine(_dir, "broken.zip"); + File.WriteAllText(zip, "this is not a zip file"); + + Assert.Empty(HistoryParser.FindHistoryFiles(zip)); + } + + [Fact] + public void FindHistoryFiles_MissingZipFindsNothing() + { + Assert.Empty(HistoryParser.FindHistoryFiles(Path.Combine(_dir, "gone.zip"))); + } + + [Fact] + public async Task ParseAsync_ReadsRecordsStraightFromTheZip() + { + var zip = WriteZip("export.zip", + ("Spotify Extended Streaming History/Streaming_History_Audio_2023.json", OnePlay)); + + var result = await new HistoryParser().ParseAsync(HistoryParser.FindHistoryFiles(zip)); + + var record = Assert.Single(result.Records); + Assert.Equal("Song", record.TrackName); + Assert.Equal(215000, record.MsPlayed); + Assert.Empty(result.Warnings); + } + + [Fact] + public async Task ParseAsync_ReadsManyEntriesOfOneZipAtOnce() + { + var entries = Enumerable.Range(0, 12) + .Select(i => ($"Streaming_History_Audio_{2010 + i}.json", OnePlay)) + .ToArray(); + var zip = WriteZip("export.zip", entries); + + var result = await new HistoryParser().ParseAsync(HistoryParser.FindHistoryFiles(zip)); + + Assert.Equal(12, result.Records.Count); + } + + [Fact] + public async Task ParseAsync_SkipsAnEntryThatIsNotThere() + { + var zip = WriteZip("export.zip", ("Streaming_History_Audio_2023.json", OnePlay)); + var missing = ArchivePath.Combine(zip, "Streaming_History_Audio_1999.json"); + + var result = await new HistoryParser().ParseAsync(new[] { missing }); + + Assert.Empty(result.Records); + Assert.Equal(new[] { missing }, result.SkippedFiles); + } + + [Fact] + public async Task ParseAsync_WarnsWhenTheZipIsDamagedAfterItWasScanned() + { + var zip = WriteZip("export.zip", ("Streaming_History_Audio_2023.json", OnePlay)); + var files = HistoryParser.FindHistoryFiles(zip); + + // Cutting the file short loses the archive's directory, the way an interrupted + // download would. + using (var stream = new FileStream(zip, FileMode.Open)) + stream.SetLength(stream.Length / 2); + + var result = await new HistoryParser().ParseAsync(files); + + Assert.Empty(result.Records); + Assert.Contains("ZIP", Assert.Single(result.Warnings)); + } + + [Fact] + public void CacheKey_ChangesWhenTheZipChanges() + { + var zip = WriteZip("export.zip", ("Streaming_History_Audio_2023.json", OnePlay)); + var files = HistoryParser.FindHistoryFiles(zip); + string before = RecordCache.BuildKey(files); + + Assert.Equal(before, RecordCache.BuildKey(files)); + + File.SetLastWriteTimeUtc(zip, DateTime.UtcNow.AddMinutes(5)); + + Assert.NotEqual(before, RecordCache.BuildKey(files)); + } + + [Fact] + public void CacheKey_TellsEntriesOfOneZipApart() + { + var zip = WriteZip("export.zip", + ("Streaming_History_Audio_2022.json", OnePlay), + ("Streaming_History_Audio_2023.json", OnePlay)); + var files = HistoryParser.FindHistoryFiles(zip); + + Assert.NotEqual(RecordCache.BuildKey(files.Take(1)), RecordCache.BuildKey(files.Skip(1))); + } + + [Fact] + public void ArchivePath_RoundTrips() + { + var path = ArchivePath.Combine(@"C:\Downloads\my_spotify_data.zip", "folder/Streaming_History_Audio_2023.json"); + + Assert.True(ArchivePath.TrySplit(path, out var archive, out var entry)); + Assert.Equal(@"C:\Downloads\my_spotify_data.zip", archive); + Assert.Equal("folder/Streaming_History_Audio_2023.json", entry); + Assert.Equal("Streaming_History_Audio_2023.json", Path.GetFileName(path)); + } + + [Theory] + [InlineData(@"C:\Downloads\my_spotify_data.zip", true)] + [InlineData(@"C:\Downloads\MY_SPOTIFY_DATA.ZIP", true)] + [InlineData(@"C:\Downloads\Streaming_History_Audio_2023.json", false)] + [InlineData(@"C:\Downloads\export.zip|Streaming_History_Audio_2023.json", false)] + [InlineData(@"C:\Downloads", false)] + public void ArchivePath_RecognisesOnlyAZipItself(string path, bool expected) + { + Assert.Equal(expected, ArchivePath.IsArchive(path)); + } + + [Fact] + public void ArchivePath_DoesNotSplitAPlainPath() + { + Assert.False(ArchivePath.TrySplit(@"C:\Downloads\Streaming_History_Audio_2023.json", out _, out _)); + } +} diff --git a/Sortify/Services/AppSettings.cs b/Sortify/Services/AppSettings.cs index 3cc45e7..73a5a0c 100644 --- a/Sortify/Services/AppSettings.cs +++ b/Sortify/Services/AppSettings.cs @@ -19,10 +19,12 @@ public sealed class AppSettings // ---- Data ------------------------------------------------------------------------------- - /// Folder passed to Open Folder last time, reopened at startup when it still exists. + /// + /// Export folder (or export ZIP) opened last time, reopened at startup when it still exists. + /// public string? LastFolder { get; set; } - /// Most recently opened folders, newest first. + /// Most recently opened export folders and ZIPs, newest first. public List RecentFolders { get; set; } = new(); /// Whether to reload the last folder automatically on launch. @@ -117,14 +119,17 @@ public void RememberFolder(string folder) LastFolder = folder; } - /// Drops remembered folders that no longer exist on disk. + /// Drops remembered folders and ZIPs that no longer exist on disk. public void PruneMissingFolders() { - RecentFolders.RemoveAll(f => !Directory.Exists(f)); - if (LastFolder is not null && !Directory.Exists(LastFolder)) + RecentFolders.RemoveAll(f => !Exists(f)); + if (LastFolder is not null && !Exists(LastFolder)) LastFolder = null; } + private static bool Exists(string path) => + ArchivePath.IsArchive(path) ? File.Exists(path) : Directory.Exists(path); + /// /// as a TimeSpan. Not persisted: it is derived, and /// writing it out puts a second, read-only copy of the same setting in the file. diff --git a/Sortify/Services/ArchivePath.cs b/Sortify/Services/ArchivePath.cs new file mode 100644 index 0000000..bb7f8a2 --- /dev/null +++ b/Sortify/Services/ArchivePath.cs @@ -0,0 +1,37 @@ +using System.IO; + +namespace Sortify.Services; + +/// +/// Names a file inside a ZIP export as one string ("C:\my_spotify_data.zip|folder/file.json"), +/// so an archive entry can travel through the same file lists, cache keys and reloads as a +/// file on disk. Windows forbids the separator in paths, so it never appears in a real one. +/// +public static class ArchivePath +{ + private const char Separator = '|'; + + /// True for a path that names a ZIP file itself. + public static bool IsArchive(string? path) => + !string.IsNullOrEmpty(path) && + path.IndexOf(Separator) < 0 && + string.Equals(Path.GetExtension(path), ".zip", StringComparison.OrdinalIgnoreCase); + + public static string Combine(string archive, string entry) => archive + Separator + entry; + + /// Splits an entry path back into the archive and the entry name inside it. + public static bool TrySplit(string path, out string archive, out string entry) + { + int at = path.IndexOf(Separator); + if (at <= 0 || at == path.Length - 1) + { + archive = string.Empty; + entry = string.Empty; + return false; + } + + archive = path[..at]; + entry = path[(at + 1)..]; + return true; + } +} diff --git a/Sortify/Services/HistoryParser.cs b/Sortify/Services/HistoryParser.cs index 0e5c957..e7ccbfb 100644 --- a/Sortify/Services/HistoryParser.cs +++ b/Sortify/Services/HistoryParser.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Globalization; using System.IO; +using System.IO.Compression; using System.Text.Json; using Sortify.Models; @@ -33,12 +34,16 @@ public sealed class HistoryParser }; /// - /// 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. + /// Finds Spotify history JSON files under (recursively), or + /// inside it when it is the ZIP Spotify sends. Prefers files matching Spotify's export + /// naming; falls back to any top-level .json files so hand-renamed exports still work. + /// Entries inside a ZIP come back as paths. /// public static IReadOnlyList FindHistoryFiles(string folder) { + if (ArchivePath.IsArchive(folder)) + return File.Exists(folder) ? FindArchiveEntries(folder) : Array.Empty(); + if (!Directory.Exists(folder)) return Array.Empty(); @@ -56,13 +61,7 @@ public static IReadOnlyList FindHistoryFiles(string folder) var named = Directory .EnumerateFiles(folder, "*.json", recursive) - .Where(f => - { - var name = Path.GetFileName(f); - return name.StartsWith("Streaming_History", StringComparison.OrdinalIgnoreCase) - || name.StartsWith("StreamingHistory", StringComparison.OrdinalIgnoreCase) - || name.StartsWith("endsong", StringComparison.OrdinalIgnoreCase); - }) + .Where(f => IsHistoryFileName(Path.GetFileName(f))) .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -75,6 +74,39 @@ public static IReadOnlyList FindHistoryFiles(string folder) .ToList(); } + private static bool IsHistoryFileName(string name) => + name.StartsWith("Streaming_History", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("StreamingHistory", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("endsong", StringComparison.OrdinalIgnoreCase); + + /// The same search as for a folder, run over the entries of a ZIP. + private static IReadOnlyList FindArchiveEntries(string archive) + { + try + { + using var zip = ZipFile.OpenRead(archive); + var json = zip.Entries + .Where(e => e.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + .Select(e => e.FullName) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var named = json.Where(n => IsHistoryFileName(Path.GetFileName(n))).ToList(); + if (named.Count == 0) + named = json.Where(n => n.IndexOfAny(EntrySeparators) < 0).ToList(); + + return named.Select(n => ArchivePath.Combine(archive, n)).ToList(); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) + { + // A damaged or half-downloaded archive is reported the same way as one with no + // history in it; the caller says which ZIP it was. + return Array.Empty(); + } + } + + private static readonly char[] EntrySeparators = { '/', '\\' }; + public async Task ParseAsync( IEnumerable filePaths, IProgress? progress = null, @@ -123,6 +155,19 @@ private static async Task ParseFileAsync(string path, StringPool po { try { + if (ArchivePath.TrySplit(path, out var archive, out var entryName)) + { + // Each file gets its own handle on the archive: ZipArchive isn't safe to share + // between the concurrent parse tasks. + using var zip = ZipFile.OpenRead(archive); + var zipEntry = zip.GetEntry(entryName); + if (zipEntry is null || zipEntry.Length == 0) + return new FileOutcome(null, path, null); + + await using var entryStream = zipEntry.Open(); + return await ReadAsync(path, entryStream, pool, cancellationToken).ConfigureAwait(false); + } + var info = new FileInfo(path); if (!info.Exists || info.Length == 0) return new FileOutcome(null, path, null); @@ -131,26 +176,16 @@ private static async Task ParseFileAsync(string path, StringPool po 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); + return await ReadAsync(path, stream, pool, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { return new FileOutcome(null, null, $"Skipping invalid JSON file: {Path.GetFileName(path)} ({ex.Message})"); } + catch (InvalidDataException ex) + { + return new FileOutcome(null, null, $"Could not unpack {Path.GetFileName(path)} from the ZIP ({ex.Message})"); + } catch (NotSupportedException ex) { return new FileOutcome(null, null, $"Skipping unsupported JSON file: {Path.GetFileName(path)} ({ex.Message})"); @@ -165,6 +200,26 @@ private static async Task ParseFileAsync(string path, StringPool po } } + private static async Task ReadAsync( + string path, Stream stream, StringPool pool, CancellationToken cancellationToken) + { + 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); + } + /// /// 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 diff --git a/Sortify/Services/RecordCache.cs b/Sortify/Services/RecordCache.cs index 33481c7..8de81b2 100644 --- a/Sortify/Services/RecordCache.cs +++ b/Sortify/Services/RecordCache.cs @@ -31,17 +31,25 @@ public static class RecordCache /// 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. The local time zone is part of it too, because the cached timestamps - /// were already converted to local time when the export was parsed. + /// were already converted to local time when the export was parsed. A file inside a ZIP + /// is fingerprinted by the archive it came from plus its name within it. /// internal static string BuildKey(IEnumerable filePaths) { var parts = filePaths .Select(p => { - var info = new FileInfo(p); + string file = p, entry = string.Empty; + if (ArchivePath.TrySplit(p, out var archive, out var entryName)) + { + file = archive; + entry = "|" + entryName; + } + + var info = new FileInfo(file); long length = info.Exists ? info.Length : -1; long ticks = info.Exists ? info.LastWriteTimeUtc.Ticks : -1; - return $"{info.FullName.ToLowerInvariant()}|{length}|{ticks}"; + return $"{info.FullName.ToLowerInvariant()}|{length}|{ticks}{entry}"; }) .OrderBy(s => s, StringComparer.Ordinal) .Prepend($"tz|{TimeZoneInfo.Local.Id}"); diff --git a/Sortify/ViewModels/MainViewModel.cs b/Sortify/ViewModels/MainViewModel.cs index e183eba..f012dec 100644 --- a/Sortify/ViewModels/MainViewModel.cs +++ b/Sortify/ViewModels/MainViewModel.cs @@ -24,7 +24,7 @@ public sealed partial class MainViewModel : ObservableObject /// Files behind the current results, so F5 can re-read them. private IReadOnlyList _loadedFiles = Array.Empty(); - /// Folder those files came from, or null when they were picked individually. + /// Folder or ZIP those files came from, or null when they were picked individually. private string? _loadedFolder; // IsBusy covers two independent things - a parse and any number of overlapping analysis @@ -216,14 +216,30 @@ private async Task RunAnalysisAsync() { var dialog = new OpenFileDialog { - Title = "Select your Spotify streaming history JSON files", - Filter = "JSON files (*.json)|*.json|All files (*.*)|*.*", + Title = "Select your Spotify history: the export ZIP or its JSON files", + Filter = "Spotify history (*.json, *.zip)|*.json;*.zip|JSON files (*.json)|*.json|" + + "ZIP files (*.zip)|*.zip|All files (*.*)|*.*", Multiselect = true, }; if (dialog.ShowDialog() != true) return; - await LoadFilesAsync(dialog.FileNames); + // A ZIP is opened up into the history files inside it; anything else is taken as + // picked, so a renamed export still loads. + var files = dialog.FileNames + .SelectMany(f => ArchivePath.IsArchive(f) ? HistoryParser.FindHistoryFiles(f) : new[] { f }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (files.Count == 0) + { + SetStatus("No Spotify history was found in that ZIP. If it is the file Spotify sent, " + + "it may be damaged; try downloading it again.", isError: true); + return; + } + + // A lone ZIP goes into Recent the way a folder does. + string? archive = dialog.FileNames is [var only] && ArchivePath.IsArchive(only) ? only : null; + await LoadFilesAsync(files, archive); } [RelayCommand] @@ -239,16 +255,21 @@ private async Task OpenFolderAsync() await OpenFolderPathAsync(dialog.FolderName); } - /// Loads a folder by path. Used by Open Folder, the recent list and drag & drop. + /// + /// Loads an export folder, or an export ZIP, by path. Used by Open Folder and the recent list. + /// [RelayCommand] public async Task OpenFolderPathAsync(string? folder) { if (string.IsNullOrWhiteSpace(folder)) return; - if (!Directory.Exists(folder)) + bool isArchive = ArchivePath.IsArchive(folder); + if (isArchive ? !File.Exists(folder) : !Directory.Exists(folder)) { - SetStatus($"That folder no longer exists: {folder}", isError: true); + SetStatus(isArchive + ? $"That file no longer exists: {folder}" + : $"That folder no longer exists: {folder}", isError: true); _settings.PruneMissingFolders(); _settings.Save(); RefreshRecentFolders(); @@ -258,7 +279,10 @@ public async Task OpenFolderPathAsync(string? folder) var files = HistoryParser.FindHistoryFiles(folder); if (files.Count == 0) { - SetStatus("No Spotify history JSON files were found in that folder.", isError: true); + SetStatus(isArchive + ? "No Spotify history was found in that ZIP. If it is the file Spotify sent, " + + "it may be damaged; try downloading it again." + : "No Spotify history JSON files were found in that folder.", isError: true); return; } @@ -306,7 +330,10 @@ private void CancelLoad() SetStatus("Cancelled. Change a filter or press F5 to run again."); } - /// Parses the given history files and runs analysis. Also used by drag & drop. + /// + /// Parses the given history files and runs analysis. Also used by drag & drop. + /// is the folder or ZIP they came from, remembered in Recent. + /// public async Task LoadFilesAsync(IReadOnlyList filePaths, string? folder = null) { if (filePaths.Count == 0 || _isLoading) diff --git a/Sortify/Views/MainWindow.xaml b/Sortify/Views/MainWindow.xaml index 7210482..50fae12 100644 --- a/Sortify/Views/MainWindow.xaml +++ b/Sortify/Views/MainWindow.xaml @@ -104,12 +104,12 @@ ToolTip="Pick the extracted export folder; history files are found automatically (Ctrl+Shift+O)" Command="{Binding OpenFolderCommand}" />