diff --git a/.editorconfig b/.editorconfig index 70db1c5..e2ee683 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2792647 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33dde18..684ffff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index ba7f210..184ffe4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,14 @@ # Build output bin/ obj/ +publish/ +TestResults/ +*.binlog # IDE .vs/ *.user +*.suo # Rider .idea/ diff --git a/README.md b/README.md index 4685cad..3bbeda9 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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//publish/`, where `` 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 diff --git a/Sortify.Tests/AnalysisEngineTests.cs b/Sortify.Tests/AnalysisEngineTests.cs index d427cdd..657fa04 100644 --- a/Sortify.Tests/AnalysisEngineTests.cs +++ b/Sortify.Tests/AnalysisEngineTests.cs @@ -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() { @@ -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) { diff --git a/Sortify.Tests/AppSettingsTests.cs b/Sortify.Tests/AppSettingsTests.cs new file mode 100644 index 0000000..a7e3595 --- /dev/null +++ b/Sortify.Tests/AppSettingsTests.cs @@ -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( + """{ "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(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(json)!; + + Assert.Equal(120.5, restored.WindowLeft); + Assert.Equal(64, restored.WindowTop); + Assert.Equal(1400, restored.WindowWidth); + } +} diff --git a/Sortify.Tests/ChartBuilderTests.cs b/Sortify.Tests/ChartBuilderTests.cs new file mode 100644 index 0000000..b5dc249 --- /dev/null +++ b/Sortify.Tests/ChartBuilderTests.cs @@ -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)); + } +} diff --git a/Sortify.Tests/CultureScope.cs b/Sortify.Tests/CultureScope.cs new file mode 100644 index 0000000..87dfd7a --- /dev/null +++ b/Sortify.Tests/CultureScope.cs @@ -0,0 +1,27 @@ +using System.Globalization; + +namespace Sortify.Tests; + +/// +/// 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. +/// +internal sealed class CultureScope : IDisposable +{ + private readonly CultureInfo _previous = CultureInfo.CurrentCulture; + + private CultureScope(CultureInfo culture) => CultureInfo.CurrentCulture = culture; + + /// Decimal comma and a dot for the time separator, like de-DE and fi-FI between them. + 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; +} diff --git a/Sortify.Tests/DetailEnginePodcastTests.cs b/Sortify.Tests/DetailEnginePodcastTests.cs new file mode 100644 index 0000000..dae932f --- /dev/null +++ b/Sortify.Tests/DetailEnginePodcastTests.cs @@ -0,0 +1,143 @@ +using Sortify.Models; +using Sortify.Services; +using Xunit; + +namespace Sortify.Tests; + +/// +/// When podcasts are folded into the music statistics, AnalysisEngine ranks them under their +/// show and episode names. Drilling into one of those rows has to match on the same names, +/// or the detail window opens on nothing. +/// +public class DetailEnginePodcastTests +{ + private static readonly FilterOptions WithPodcasts = new() { MinMsPlayed = 0, IncludePodcasts = true }; + private static readonly FilterOptions WithoutPodcasts = new() { MinMsPlayed = 0 }; + + private static PlayRecord Episode(string show, string episode, int ms = 600_000, DateTime? ts = null) => new() + { + TrackName = "Unknown Track", + ArtistName = "Unknown Artist", + AlbumName = "Unknown Album", + Kind = ContentKind.Podcast, + ShowName = show, + EpisodeName = episode, + MsPlayed = ms, + Timestamp = ts ?? new DateTime(2024, 3, 4, 9, 0, 0), + }; + + private static PlayRecord Music(string track, string artist, int ms = 180_000, DateTime? ts = null) => new() + { + TrackName = track, + ArtistName = artist, + AlbumName = "Album", + MsPlayed = ms, + Timestamp = ts ?? new DateTime(2024, 3, 4, 9, 0, 0), + }; + + /// The name a show is listed under in the Artists grid is the one the row carries. + private static string ArtistRowName(IReadOnlyList records) + => AnalysisEngine.Analyze(records, WithPodcasts).Artists[0].Artist; + + [Fact] + public void ShowOpenedAsAnArtist_FindsItsPlays() + { + var records = new[] + { + Episode("Radiolab", "Bit Flip"), + Episode("Radiolab", "Stochasticity"), + Music("Song", "Band"), + }; + + var detail = DetailEngine.Build(records, WithPodcasts, DetailScope.Artist, ArtistRowName(records), string.Empty); + + Assert.Equal("Radiolab", detail.Title); + Assert.Equal(2, detail.PlayCount); + Assert.Equal(1_200_000, detail.TotalMsPlayed); + } + + [Fact] + public void ShowOpenedAsAnArtist_ListsEpisodesAsTracks() + { + var records = new[] + { + Episode("Radiolab", "Bit Flip", ms: 900_000), + Episode("Radiolab", "Stochasticity", ms: 300_000), + }; + + var detail = DetailEngine.Build(records, WithPodcasts, DetailScope.Artist, "Radiolab", string.Empty); + + Assert.Equal(new[] { "Bit Flip", "Stochasticity" }, detail.Tracks.Select(t => t.Track)); + Assert.All(detail.Tracks, t => Assert.Equal("Radiolab", t.Artist)); + } + + [Fact] + public void EpisodeOpenedAsATrack_MatchesOnEpisodeAndShow() + { + var records = new[] + { + Episode("Radiolab", "Bit Flip"), + Episode("Reply All", "Bit Flip"), + }; + + var detail = DetailEngine.Build(records, WithPodcasts, DetailScope.Track, "Bit Flip", "Radiolab"); + + Assert.Equal(1, detail.PlayCount); + } + + [Fact] + public void PodcastsStayOut_WhenTheFilterDoesNotCountThem() + { + var records = new[] { Episode("Radiolab", "Bit Flip") }; + + var detail = DetailEngine.Build(records, WithoutPodcasts, DetailScope.Artist, "Radiolab", string.Empty); + + Assert.Equal(0, detail.PlayCount); + } + + [Fact] + public void MusicIsUnaffected_WhenPodcastsAreCounted() + { + var records = new[] + { + Music("Song", "Band"), + Episode("Radiolab", "Bit Flip"), + }; + + var detail = DetailEngine.Build(records, WithPodcasts, DetailScope.Artist, "Band", string.Empty); + + Assert.Equal(1, detail.PlayCount); + Assert.Equal("Song", Assert.Single(detail.Tracks).Track); + } + + [Fact] + public void YearDetail_RanksShowsAlongsideArtists() + { + var records = new[] + { + Music("Song", "Band", ms: 100_000, ts: new DateTime(2024, 1, 2, 8, 0, 0)), + Episode("Radiolab", "Bit Flip", ms: 900_000, ts: new DateTime(2024, 6, 2, 8, 0, 0)), + }; + + var detail = DetailEngine.Build(records, WithPodcasts, DetailScope.Year, "2024", string.Empty); + + Assert.Equal(new[] { "Radiolab", "Band" }, detail.Artists.Select(a => a.Artist)); + } + + [Fact] + public void YearDetail_TotalsAgreeWithTheYearsTab() + { + var records = new[] + { + Music("Song", "Band", ms: 100_000, ts: new DateTime(2024, 1, 2, 8, 0, 0)), + Episode("Radiolab", "Bit Flip", ms: 900_000, ts: new DateTime(2024, 6, 2, 8, 0, 0)), + Music("Other", "Band", ms: 50_000, ts: new DateTime(2023, 6, 2, 8, 0, 0)), + }; + + var year = AnalysisEngine.Analyze(records, WithPodcasts).Years.Single(y => y.Year == 2024); + var detail = DetailEngine.Build(records, WithPodcasts, DetailScope.Year, "2024", string.Empty); + + Assert.Equal(year.TotalMsPlayed, detail.TotalMsPlayed); + Assert.Equal(year.PlayCount, detail.PlayCount); + } +} diff --git a/Sortify.Tests/DetailEngineTests.cs b/Sortify.Tests/DetailEngineTests.cs index f4752f7..be041c7 100644 --- a/Sortify.Tests/DetailEngineTests.cs +++ b/Sortify.Tests/DetailEngineTests.cs @@ -160,6 +160,19 @@ public void WebUrl_UsesTheTrackUri_WhenTheExportCarriedOne() Assert.Equal("https://open.spotify.com/track/abc123", d.WebUrl); } + [Theory] + [InlineData(DetailScope.Artist, "X", "", "https://open.spotify.com/search/X")] + [InlineData(DetailScope.Album, "Al", "X", "https://open.spotify.com/search/Al%20X")] + public void WebUrl_SearchesForArtistsAndAlbums_EvenWithATrackUri( + DetailScope scope, string title, string subtitle, string expected) + { + var records = new[] { Record(track: "A", artist: "X", album: "Al", uri: "spotify:track:abc123") }; + + var d = DetailEngine.Build(records, NoFilter, scope, title, subtitle); + + Assert.Equal(expected, d.WebUrl); + } + [Fact] public void WebUrl_FallsBackToSearch_WhenThereIsNoUri() { @@ -182,6 +195,18 @@ public void WebUrl_EscapesNamesWithSpecialCharacters() Assert.DoesNotContain("#", d.WebUrl); } + [Fact] + public void WebUrl_EscapesTheUriParts() + { + // The URI is read from a file and ends up handed to the shell, so a crafted one + // must not be able to smuggle extra path segments, a query or spaces through. + var records = new[] { Record(track: "A", artist: "X", uri: "spotify:track:abc/../x?y z") }; + + var d = DetailEngine.Build(records, NoFilter, DetailScope.Track, "A", "X"); + + Assert.Equal("https://open.spotify.com/track/abc%2F..%2Fx%3Fy%20z", d.WebUrl); + } + [Fact] public void WebUrl_IgnoresMalformedUris() { diff --git a/Sortify.Tests/DetailEngineYearTests.cs b/Sortify.Tests/DetailEngineYearTests.cs new file mode 100644 index 0000000..f4c6358 --- /dev/null +++ b/Sortify.Tests/DetailEngineYearTests.cs @@ -0,0 +1,130 @@ +using Sortify.Models; +using Sortify.Services; +using Xunit; + +namespace Sortify.Tests; + +/// Covers the year drill-down and the podcast gate shared with the main analysis. +public class DetailEngineYearTests +{ + private static readonly FilterOptions NoFilter = new() { MinMsPlayed = 0 }; + + private static PlayRecord Music(string track, string artist, DateTime ts, int ms = 60_000) + => new() + { + TrackName = track, + ArtistName = artist, + AlbumName = artist + " Album", + MsPlayed = ms, + Timestamp = ts, + Kind = ContentKind.Music, + }; + + private static PlayRecord Podcast(string episode, string show, DateTime ts, int ms = 60_000) + => new() + { + MsPlayed = ms, + Timestamp = ts, + Kind = ContentKind.Podcast, + ShowName = show, + EpisodeName = episode, + }; + + [Fact] + public void YearScope_KeepsOnlyThatYear() + { + var records = new[] + { + Music("A", "X", new DateTime(2022, 6, 1, 12, 0, 0)), + Music("B", "Y", new DateTime(2023, 3, 1, 12, 0, 0)), + Music("C", "Y", new DateTime(2023, 9, 1, 12, 0, 0)), + }; + + var d = DetailEngine.Build(records, NoFilter, DetailScope.Year, "2023", string.Empty); + + Assert.Equal(2, d.PlayCount); + Assert.Equal(120_000, d.TotalMsPlayed); + Assert.Equal(new DateTime(2023, 3, 1, 12, 0, 0), d.FirstPlayed); + Assert.Equal(new DateTime(2023, 9, 1, 12, 0, 0), d.LastPlayed); + } + + [Fact] + public void YearScope_RanksArtists() + { + var records = new[] + { + Music("A", "Loud", new DateTime(2023, 1, 1, 12, 0, 0), ms: 30_000), + Music("B", "Loud", new DateTime(2023, 1, 2, 12, 0, 0), ms: 30_000), + Music("C", "Quiet", new DateTime(2023, 1, 3, 12, 0, 0), ms: 10_000), + }; + + var d = DetailEngine.Build(records, NoFilter, DetailScope.Year, "2023", string.Empty); + + Assert.Equal(2, d.Artists.Count); + Assert.Equal("Loud", d.Artists[0].Artist); + Assert.Equal(60_000, d.Artists[0].TotalMsPlayed); + Assert.Equal("A", d.Artists[0].FirstTrack); + } + + [Fact] + public void OtherScopes_DoNotBuildAnArtistList() + { + var records = new[] { Music("A", "X", new DateTime(2023, 1, 1, 12, 0, 0)) }; + + var d = DetailEngine.Build(records, NoFilter, DetailScope.Artist, "X", string.Empty); + + Assert.Empty(d.Artists); + } + + [Fact] + public void YearScope_IgnoresUndatedPlays() + { + var records = new[] + { + Music("A", "X", DateTime.MinValue), + Music("B", "X", new DateTime(2023, 1, 1, 12, 0, 0)), + }; + + var d = DetailEngine.Build(records, NoFilter, DetailScope.Year, "2023", string.Empty); + + Assert.Equal(1, d.PlayCount); + } + + [Fact] + public void YearScope_HasNoSpotifyLink() + { + var records = new[] { Music("A", "X", new DateTime(2023, 1, 1, 12, 0, 0)) }; + + var d = DetailEngine.Build(records, NoFilter, DetailScope.Year, "2023", string.Empty); + + Assert.Equal(string.Empty, d.WebUrl); + } + + [Fact] + public void PodcastsAreExcludedUnlessTheFilterAsksForThem() + { + var records = new[] + { + Music("A", "X", new DateTime(2023, 1, 1, 12, 0, 0)), + Podcast("Episode 1", "Show", new DateTime(2023, 1, 2, 12, 0, 0)), + }; + + var without = DetailEngine.Build(records, NoFilter, DetailScope.Year, "2023", string.Empty); + var with = DetailEngine.Build( + records, new FilterOptions { MinMsPlayed = 0, IncludePodcasts = true }, + DetailScope.Year, "2023", string.Empty); + + Assert.Equal(1, without.PlayCount); + Assert.Equal(2, with.PlayCount); + } + + [Fact] + public void UnparseableYear_MatchesNothing() + { + var records = new[] { Music("A", "X", new DateTime(2023, 1, 1, 12, 0, 0)) }; + + var d = DetailEngine.Build(records, NoFilter, DetailScope.Year, "not a year", string.Empty); + + Assert.Equal(0, d.PlayCount); + } +} diff --git a/Sortify.Tests/ExportFormatsTests.cs b/Sortify.Tests/ExportFormatsTests.cs new file mode 100644 index 0000000..85802be --- /dev/null +++ b/Sortify.Tests/ExportFormatsTests.cs @@ -0,0 +1,161 @@ +using System.IO; +using System.Text.Json; +using Sortify.Models; +using Sortify.Services; +using Xunit; + +namespace Sortify.Tests; + +/// Covers the Markdown, JSON and shows-CSV exports. +public class ExportFormatsTests : IDisposable +{ + private readonly string _dir; + + public ExportFormatsTests() + { + _dir = Path.Combine(Path.GetTempPath(), "SortifyTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private string PathFor(string name) => Path.Combine(_dir, name); + + private static AnalysisResult SampleResult() => AnalysisEngine.Analyze(new[] + { + new PlayRecord + { + TrackName = "Pipe | Track", ArtistName = "Artist A", AlbumName = "Album A", + MsPlayed = 180_000, Timestamp = new DateTime(2023, 1, 5, 12, 0, 0), ReasonEnd = "trackdone", + }, + new PlayRecord + { + TrackName = "Second", ArtistName = "Artist B", AlbumName = "Album B", + MsPlayed = 60_000, Timestamp = new DateTime(2023, 2, 7, 20, 0, 0), ReasonEnd = "fwdbtn", + }, + new PlayRecord + { + Kind = ContentKind.Podcast, ShowName = "A Show", EpisodeName = "Ep 1", + MsPlayed = 300_000, Timestamp = new DateTime(2023, 3, 1, 9, 0, 0), + }, + }, new FilterOptions { MinMsPlayed = 0 }); + + [Theory] + [InlineData("plain", "plain")] + [InlineData("a|b", "a\\|b")] + public void Md_EscapesTableSeparators(string input, string expected) + { + Assert.Equal(expected, ExportService.Md(input)); + } + + [Fact] + public async Task Markdown_HasTheSummaryAndTheRankings() + { + var path = PathFor("report.md"); + + await ExportService.SaveMarkdownAsync(path, SampleResult()); + var text = await File.ReadAllTextAsync(path); + + Assert.Contains("# Sortify report", text); + Assert.Contains("## Summary", text); + Assert.Contains("## Insights", text); + Assert.Contains("## Top tracks", text); + Assert.Contains("## Top artists", text); + Assert.Contains("## Top albums", text); + Assert.Contains("## By year", text); + Assert.Contains("## Top shows and audiobooks", text); + + // The pipe in the track name must not split its table cell. + Assert.Contains("Pipe \\| Track", text); + } + + [Fact] + public async Task Markdown_OmitsTheShowsTableWhenThereAreNone() + { + var path = PathFor("music-only.md"); + var result = AnalysisEngine.Analyze(new[] + { + new PlayRecord + { + TrackName = "Only", ArtistName = "Artist", AlbumName = "Album", + MsPlayed = 60_000, Timestamp = new DateTime(2023, 1, 1, 12, 0, 0), + }, + }, new FilterOptions { MinMsPlayed = 0 }); + + await ExportService.SaveMarkdownAsync(path, result); + var text = await File.ReadAllTextAsync(path); + + Assert.DoesNotContain("Top shows", text); + } + + [Fact] + public async Task Json_IsValidAndCarriesTheHeadlineNumbers() + { + var path = PathFor("results.json"); + var result = SampleResult(); + + await ExportService.SaveJsonAsync(path, result); + + using var document = JsonDocument.Parse(await File.ReadAllTextAsync(path)); + var root = document.RootElement; + + Assert.Equal(result.TotalPlays, root.GetProperty("summary").GetProperty("totalPlays").GetInt32()); + Assert.Equal(result.UniqueTracks, root.GetProperty("summary").GetProperty("uniqueTracks").GetInt32()); + Assert.Equal(result.CompletedPlays, root.GetProperty("insights").GetProperty("completedPlays").GetInt32()); + Assert.Equal(result.Tracks.Count, root.GetProperty("tracks").GetArrayLength()); + Assert.Equal(24, root.GetProperty("hoursByHourOfDay").GetArrayLength()); + Assert.Equal(7, root.GetProperty("hoursByDayOfWeek").GetArrayLength()); + Assert.Equal(result.Shows.Count, root.GetProperty("shows").GetArrayLength()); + } + + [Fact] + public async Task Json_LeavesNamesReadable() + { + var path = PathFor("unicode.json"); + var result = AnalysisEngine.Analyze(new[] + { + new PlayRecord + { + TrackName = "Café Ström", ArtistName = "Sigur Rós", AlbumName = "Ágætis", + MsPlayed = 60_000, Timestamp = new DateTime(2023, 1, 1, 12, 0, 0), + }, + }, new FilterOptions { MinMsPlayed = 0 }); + + await ExportService.SaveJsonAsync(path, result); + var text = await File.ReadAllTextAsync(path); + + // Escaped as é etc. the file would be unreadable to a human. + Assert.Contains("Café Ström", text); + Assert.Contains("Sigur Rós", text); + } + + [Fact] + public async Task ShowsCsv_HasAHeaderAndOneRowPerShow() + { + var path = PathFor("shows.csv"); + var result = SampleResult(); + + await ExportService.SaveShowsCsvAsync(path, result); + var lines = await File.ReadAllLinesAsync(path); + + Assert.StartsWith("Rank,Show,Kind,", lines[0]); + Assert.Equal(result.Shows.Count + 1, lines.Length); + Assert.Contains("A Show", lines[1]); + Assert.Contains("Podcast", lines[1]); + } + + [Fact] + public async Task TxtSummary_IncludesTheNewInsights() + { + var path = PathFor("results.txt"); + + await ExportService.SaveTxtAsync(path, SampleResult()); + var text = await File.ReadAllTextAsync(path); + + Assert.Contains("Completion rate:", text); + Assert.Contains("Plays per track:", text); + } +} diff --git a/Sortify.Tests/ExportServiceTests.cs b/Sortify.Tests/ExportServiceTests.cs index 8296dc2..3eb68aa 100644 --- a/Sortify.Tests/ExportServiceTests.cs +++ b/Sortify.Tests/ExportServiceTests.cs @@ -53,6 +53,43 @@ public async Task TracksCsv_EscapesFieldsAndOmitsMissingDates() Assert.Equal("1,\"Hello, World\",\"The \"\"Band\"\"\",00:01:00,0.02,1,,", lines[1]); } + [Fact] + public async Task Csv_KeepsItsColumns_UnderADecimalCommaCulture() + { + // Regression: hours were formatted with the current culture, so on a German or + // French machine "1,50" split every row into one column too many. + var result = AnalysisEngine.Analyze(new[] + { + new PlayRecord + { + TrackName = "Song", ArtistName = "Artist", AlbumName = "Album", + MsPlayed = 5_400_000, Timestamp = new DateTime(2023, 5, 10, 14, 30, 0), + }, + }, new FilterOptions { MinMsPlayed = 0 }); + + var paths = new[] { "tracks.csv", "artists.csv", "albums.csv", "years.csv" } + .Select(name => Path.Combine(_dir, name)) + .ToArray(); + + using (CultureScope.Unusual()) + { + await ExportService.SaveTracksCsvAsync(paths[0], result); + await ExportService.SaveArtistsCsvAsync(paths[1], result); + await ExportService.SaveAlbumsCsvAsync(paths[2], result); + await ExportService.SaveYearsCsvAsync(paths[3], result); + } + + foreach (var path in paths) + { + var lines = await File.ReadAllLinesAsync(path); + int headerColumns = lines[0].Split(',').Length; + Assert.Equal(headerColumns, lines[1].Split(',').Length); + Assert.Contains("1.50", lines[1]); + } + + Assert.Contains("2023-05-10 14:30", (await File.ReadAllLinesAsync(paths[0]))[1]); + } + [Fact] public async Task Txt_ContainsSummaryAndRankings() { diff --git a/Sortify.Tests/FilterEngineTests.cs b/Sortify.Tests/FilterEngineTests.cs index bd3391c..f68271a 100644 --- a/Sortify.Tests/FilterEngineTests.cs +++ b/Sortify.Tests/FilterEngineTests.cs @@ -84,6 +84,52 @@ public void ExcludedTrack_IsCaseInsensitive() Assert.Equal("Other", kept[0].TrackName); } + private static PlayRecord Episode(string show, string episode) => new() + { + Kind = ContentKind.Podcast, + ShowName = show, + EpisodeName = episode, + MsPlayed = 60_000, + Timestamp = new DateTime(2023, 5, 10, 14, 0, 0), + }; + + [Fact] + public void ExcludedArtist_MatchesAPodcastByItsShowName() + { + // With podcasts counted, a show sits in the Artists grid under its own name, and + // "Exclude this artist" on that row hands the show name over. + var filter = new FilterOptions { MinMsPlayed = 0, IncludePodcasts = true }; + filter.ExcludedArtists.Add("The Daily"); + + var kept = FilterEngine.Apply(new[] { Episode("The Daily", "Monday"), Episode("Other Show", "Ep 1") }, filter).ToList(); + + Assert.Equal("Other Show", Assert.Single(kept).ShowName); + } + + [Fact] + public void ExcludedTrack_MatchesAPodcastByItsEpisodeName() + { + var filter = new FilterOptions { MinMsPlayed = 0, IncludePodcasts = true }; + filter.ExcludedTracks.Add("Monday"); + + var kept = FilterEngine.Apply(new[] { Episode("The Daily", "Monday"), Episode("The Daily", "Tuesday") }, filter).ToList(); + + Assert.Equal("Tuesday", Assert.Single(kept).EpisodeName); + } + + [Fact] + public void ExcludingUnknownArtist_LeavesPodcastsAlone() + { + // Podcast rows carry the "Unknown Artist" placeholder in ArtistName; excluding the + // music rows that genuinely have no artist must not take every podcast with them. + var filter = new FilterOptions { MinMsPlayed = 0 }; + filter.ExcludedArtists.Add("Unknown Artist"); + + var kept = FilterEngine.Apply(new[] { Record(artist: "Unknown Artist"), Episode("The Daily", "Monday") }, filter).ToList(); + + Assert.Equal(ContentKind.Podcast, Assert.Single(kept).Kind); + } + [Theory] [InlineData(23, true)] [InlineData(1, true)] diff --git a/Sortify.Tests/FilterViewModelTests.cs b/Sortify.Tests/FilterViewModelTests.cs new file mode 100644 index 0000000..47de116 --- /dev/null +++ b/Sortify.Tests/FilterViewModelTests.cs @@ -0,0 +1,132 @@ +using Sortify.Models; +using Sortify.ViewModels; +using Xunit; + +namespace Sortify.Tests; + +/// Covers the chip descriptions shown above the tabs. +public class FilterViewModelTests +{ + [Fact] + public void Describe_IsEmptyForTheDefaults() + { + Assert.Empty(new FilterViewModel().Describe()); + } + + [Fact] + public void Describe_MentionsANonDefaultMinimumDuration() + { + var vm = new FilterViewModel { MinSeconds = 30 }; + + Assert.Contains("Min 30s", vm.Describe()); + } + + [Fact] + public void Describe_MentionsPodcastsOnlyWhenCounted() + { + Assert.DoesNotContain("Podcasts counted", new FilterViewModel().Describe()); + Assert.Contains("Podcasts counted", new FilterViewModel { IncludePodcasts = true }.Describe()); + } + + [Fact] + public void Describe_CollapsesAFullDateRangeIntoOneChip() + { + var vm = new FilterViewModel + { + StartDate = new DateTime(2023, 1, 1), + EndDate = new DateTime(2023, 12, 31), + }; + + Assert.Equal(new[] { "2023-01-01 to 2023-12-31" }, vm.Describe()); + } + + [Fact] + public void Describe_HandlesAnOpenEndedDateRange() + { + Assert.Contains("From 2023-01-01", + new FilterViewModel { StartDate = new DateTime(2023, 1, 1) }.Describe()); + + Assert.Contains("Until 2023-12-31", + new FilterViewModel { EndDate = new DateTime(2023, 12, 31) }.Describe()); + } + + [Fact] + public void Describe_ListsOnlyTheSelectedDays() + { + var vm = new FilterViewModel(); + foreach (var day in vm.Days) + day.IsSelected = day.Label is "Sat" or "Sun"; + + Assert.Contains("Sun, Sat", vm.Describe()); + } + + [Fact] + public void Describe_SaysSoWhenNoDayIsSelected() + { + var vm = new FilterViewModel(); + foreach (var day in vm.Days) + day.IsSelected = false; + + Assert.Contains("No days selected", vm.Describe()); + } + + [Fact] + public void Describe_CountsExclusions() + { + var vm = new FilterViewModel(); + vm.ExcludeArtist("One"); + vm.ExcludeArtist("Two"); + vm.ExcludeTrack("Only"); + + Assert.Contains("2 artists excluded", vm.Describe()); + Assert.Contains("1 track excluded", vm.Describe()); + } + + [Fact] + public void Describe_MentionsANarrowedHourRange() + { + var vm = new FilterViewModel { StartHour = 22, EndHour = 2 }; + + Assert.Contains("22:00-02:59", vm.Describe()); + } + + [Theory] + [InlineData("2023-02-01", "2023-01-01", true)] + [InlineData("2023-01-01", "2023-01-01", false)] + [InlineData("2023-01-01", "2023-02-01", false)] + [InlineData("2023-02-01", null, false)] + public void HasInvalidDateRange_OnlyWhenFromIsAfterTo(string from, string? to, bool expected) + { + var vm = new FilterViewModel + { + StartDate = DateTime.Parse(from, System.Globalization.CultureInfo.InvariantCulture), + EndDate = to is null ? null : DateTime.Parse(to, System.Globalization.CultureInfo.InvariantCulture), + }; + + Assert.Equal(expected, vm.HasInvalidDateRange); + } + + [Fact] + public void HasInvalidDateRange_RaisesChangeNotifications() + { + var vm = new FilterViewModel { StartDate = new DateTime(2023, 2, 1) }; + var changed = new List(); + vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + vm.EndDate = new DateTime(2023, 1, 1); + + Assert.Contains(nameof(FilterViewModel.HasInvalidDateRange), changed); + } + + [Fact] + public void Reset_ClearsEveryChip() + { + var vm = new FilterViewModel { MinSeconds = 60, IncludePodcasts = true, SearchTerm = "x" }; + vm.ExcludeArtist("Someone"); + + vm.ResetCommand.Execute(null); + + Assert.Empty(vm.Describe()); + Assert.Equal(FilterOptions.DefaultMinMs / 1000, vm.MinSeconds); + } +} diff --git a/Sortify.Tests/HistoryParserTests.cs b/Sortify.Tests/HistoryParserTests.cs index 258a9cf..0918f93 100644 --- a/Sortify.Tests/HistoryParserTests.cs +++ b/Sortify.Tests/HistoryParserTests.cs @@ -297,6 +297,18 @@ public async Task FindHistoryFiles_FallsBackToTopLevelJson() Assert.EndsWith("myexport.json", file); } + [Fact] + public async Task FindHistoryFiles_StillFindsHiddenFiles() + { + // EnumerationOptions skips hidden files unless told otherwise; the scan must keep + // finding what the older SearchOption-based enumeration did. + var path = Path.Combine(_dir, "Streaming_History_Audio_2023.json"); + await File.WriteAllTextAsync(path, "[]"); + File.SetAttributes(path, File.GetAttributes(path) | FileAttributes.Hidden); + + Assert.Single(HistoryParser.FindHistoryFiles(_dir)); + } + [Fact] public void FindHistoryFiles_MissingFolderReturnsEmpty() { diff --git a/Sortify.Tests/ImageExporterTests.cs b/Sortify.Tests/ImageExporterTests.cs new file mode 100644 index 0000000..ec28dc0 --- /dev/null +++ b/Sortify.Tests/ImageExporterTests.cs @@ -0,0 +1,41 @@ +using Sortify.Services; +using Xunit; + +namespace Sortify.Tests; + +/// +/// The render scale is the only part of chart export that can be checked without a live +/// visual tree, and it is the part that decides whether a very tall chart allocates a +/// sensible bitmap or an impossible one. +/// +public class ImageExporterTests +{ + private const double MaxPixels = 64_000_000; + + [Theory] + [InlineData(800, 400)] + [InlineData(1920, 1080)] + [InlineData(1200, 3000)] + public void OrdinaryCards_RenderAtFullScale(double width, double height) + { + Assert.Equal(2.0, ImageExporter.ScaleFor(width, height)); + } + + [Fact] + public void AVeryTallChart_ScalesDownInsteadOfOverflowing() + { + // A paged bar chart at the 1000-bar ceiling is roughly this tall. + double scale = ImageExporter.ScaleFor(1300, 34_070); + + Assert.True(scale < 2.0); + Assert.True(scale > 0); + Assert.True(1300 * scale * 34_070 * scale <= MaxPixels + 1); + } + + [Fact] + public void TheScaleNeverExceedsTwo() + { + Assert.Equal(2.0, ImageExporter.ScaleFor(1, 1)); + Assert.Equal(2.0, ImageExporter.ScaleFor(0.5, 0.5)); + } +} diff --git a/Sortify.Tests/InsightsTests.cs b/Sortify.Tests/InsightsTests.cs new file mode 100644 index 0000000..d769fc9 --- /dev/null +++ b/Sortify.Tests/InsightsTests.cs @@ -0,0 +1,212 @@ +using Sortify.Models; +using Sortify.Services; +using Sortify.ViewModels; +using Xunit; + +namespace Sortify.Tests; + +/// Covers the statistics added alongside the streaks and sessions. +public class InsightsTests +{ + 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? reasonEnd = null) + => new() + { + TrackName = track, + ArtistName = artist, + AlbumName = album, + MsPlayed = ms, + Timestamp = ts ?? new DateTime(2023, 5, 10, 14, 0, 0), + ReasonEnd = reasonEnd, + }; + + [Fact] + public void PeakHour_IsTheBusiestCellOfTheWeek_NotTheBusiestHourAndDaySeparately() + { + // Friday wins as a day and 21:00 wins as an hour, but nothing played on Friday at 21:00. + var records = new[] + { + Record(ms: 100 * 60_000, ts: new DateTime(2023, 5, 8, 21, 30, 0)), // Monday + Record(ms: 60 * 60_000, ts: new DateTime(2023, 5, 12, 9, 30, 0)), // Friday + Record(ms: 60 * 60_000, ts: new DateTime(2023, 5, 12, 10, 30, 0)), // Friday + }; + + var r = AnalysisEngine.Analyze(records, NoFilter); + + Assert.Equal("21:00-22:00 on Mondays", MainViewModel.BuildPeakHourText(r)); + } + + [Fact] + public void PeakHour_IsBlank_WhenNoPlayCarriesATimestamp() + { + var r = AnalysisEngine.Analyze(new[] { Record(ts: DateTime.MinValue) }, NoFilter); + + Assert.Equal("-", MainViewModel.BuildPeakHourText(r)); + } + + [Fact] + public void LongestBreak_CountsTheSilentDaysBetweenTwoActiveDays() + { + var records = new[] + { + Record(ts: new DateTime(2023, 1, 1, 12, 0, 0)), + Record(ts: new DateTime(2023, 1, 2, 12, 0, 0)), // no gap + Record(ts: new DateTime(2023, 1, 12, 12, 0, 0)), // 9 silent days + Record(ts: new DateTime(2023, 1, 15, 12, 0, 0)), // 2 silent days + }; + + var r = AnalysisEngine.Analyze(records, NoFilter); + + Assert.Equal(9, r.LongestBreakDays); + Assert.Equal(new DateTime(2023, 1, 2), r.LongestBreakStart); + Assert.Equal(new DateTime(2023, 1, 12), r.LongestBreakEnd); + } + + [Fact] + public void LongestBreak_IsZeroWhenEveryDayIsConsecutive() + { + var records = new[] + { + Record(ts: new DateTime(2023, 1, 1, 12, 0, 0)), + Record(ts: new DateTime(2023, 1, 2, 12, 0, 0)), + Record(ts: new DateTime(2023, 1, 3, 12, 0, 0)), + }; + + var r = AnalysisEngine.Analyze(records, NoFilter); + + Assert.Equal(0, r.LongestBreakDays); + Assert.Null(r.LongestBreakStart); + } + + [Fact] + public void LongestBreak_IsZeroForASingleDay() + { + var r = AnalysisEngine.Analyze(new[] { Record() }, NoFilter); + + Assert.Equal(0, r.LongestBreakDays); + } + + [Fact] + public void CompletedPlays_CountsOnlyTrackdone() + { + var records = new[] + { + Record(reasonEnd: "trackdone"), + Record(reasonEnd: "TRACKDONE"), // Spotify's casing is not guaranteed + Record(reasonEnd: "fwdbtn"), + Record(reasonEnd: null), + }; + + var r = AnalysisEngine.Analyze(records, NoFilter); + + Assert.Equal(2, r.CompletedPlays); + Assert.Equal(4, r.SkipEligiblePlays); + } + + [Fact] + public void CompletedPlays_IncludesPlaysUnderTheDurationCutoff() + { + // Completion rate is the mirror of skip rate, which deliberately relaxes the cutoff. + var records = new[] + { + Record(ms: 1_000, reasonEnd: "trackdone"), + Record(ms: 90_000, reasonEnd: "trackdone"), + }; + + var r = AnalysisEngine.Analyze(records, new FilterOptions { MinMsPlayed = 5_000 }); + + Assert.Equal(2, r.CompletedPlays); + Assert.Equal(1, r.TotalPlays); // only the long one counts toward listening + } + + [Fact] + public void PlaysPerTrack_MeasuresRepetition() + { + var records = new[] + { + Record(track: "A"), Record(track: "A"), Record(track: "A"), + Record(track: "B"), + }; + + var r = AnalysisEngine.Analyze(records, NoFilter); + + Assert.Equal(2, r.UniqueTracks); + Assert.Equal(2.0, r.PlaysPerTrack, 3); + } + + [Fact] + public void PlaysPerTrack_IsZeroWithNoTracks() + { + Assert.Equal(0, AnalysisEngine.Analyze(Array.Empty(), NoFilter).PlaysPerTrack); + } + + [Fact] + public void TopArtistShare_IsAPercentageOfTotalTime() + { + var records = new[] + { + Record(artist: "X", ms: 75_000), + Record(artist: "Y", ms: 25_000), + }; + + var r = AnalysisEngine.Analyze(records, NoFilter); + + Assert.Equal("X", r.Artists[0].Artist); + Assert.Equal(75.0, r.TopArtistSharePercent, 3); + } + + [Fact] + public void TopArtistShare_IsZeroWithNothingLoaded() + { + Assert.Equal(0, AnalysisResult.Empty.TopArtistSharePercent); + } + + [Fact] + public void NewArtistsPerMonth_AveragesOverTheMonthsThatHadDiscoveries() + { + var records = new[] + { + Record(artist: "A", ts: new DateTime(2023, 1, 5, 12, 0, 0)), + Record(artist: "B", ts: new DateTime(2023, 1, 6, 12, 0, 0)), + Record(artist: "C", ts: new DateTime(2023, 2, 5, 12, 0, 0)), + }; + + var r = AnalysisEngine.Analyze(records, NoFilter); + + // Two artists discovered in January, one in February. + Assert.Equal(2, r.NewArtistsByMonth.Count); + Assert.Equal(1.5, r.NewArtistsPerMonth, 3); + } + + [Fact] + public void SessionGap_IsConfigurable() + { + var records = new[] + { + Record(ms: 60_000, ts: new DateTime(2023, 1, 1, 12, 0, 0)), + // Starts 20 minutes after the first play ended. + Record(ms: 60_000, ts: new DateTime(2023, 1, 1, 12, 21, 0)), + }; + + var oneSession = AnalysisEngine.Analyze(records, NoFilter, TimeSpan.FromMinutes(30)); + var twoSessions = AnalysisEngine.Analyze(records, NoFilter, TimeSpan.FromMinutes(5)); + + Assert.Equal(1, oneSession.SessionCount); + Assert.Equal(2, twoSessions.SessionCount); + } + + [Fact] + public void SessionGap_DefaultsToThirtyMinutes() + { + var records = new[] + { + Record(ms: 60_000, ts: new DateTime(2023, 1, 1, 12, 0, 0)), + Record(ms: 60_000, ts: new DateTime(2023, 1, 1, 12, 21, 0)), + }; + + Assert.Equal(1, AnalysisEngine.Analyze(records, NoFilter).SessionCount); + } +} diff --git a/Sortify.Tests/RecordCacheTests.cs b/Sortify.Tests/RecordCacheTests.cs index c843fb1..65b800f 100644 --- a/Sortify.Tests/RecordCacheTests.cs +++ b/Sortify.Tests/RecordCacheTests.cs @@ -184,7 +184,7 @@ public void CorruptCache_IsTreatedAsAMiss() var file = WriteFile("a.json"); RecordCache.TrySave(new[] { file }, new[] { Music() }); - var cachePath = Path.Combine(RecordCache.CacheDirectory, "records.cache"); + var cachePath = Path.Combine(AppPaths.DataDirectory, "records.cache"); var bytes = File.ReadAllBytes(cachePath); File.WriteAllBytes(cachePath, bytes[..(bytes.Length / 2)]); @@ -195,8 +195,105 @@ public void CorruptCache_IsTreatedAsAMiss() 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"); + Directory.CreateDirectory(AppPaths.DataDirectory); + File.WriteAllText(Path.Combine(AppPaths.DataDirectory, "records.cache"), "not a cache file"); + + Assert.Null(RecordCache.TryLoad(new[] { file })); + } + + [Fact] + public void AnImplausibleRecordCount_IsTreatedAsAMiss() + { + var file = WriteFile("a.json"); + RecordCache.TrySave(new[] { file }, new[] { Music() }); + + // Header layout: magic, format version, then the length-prefixed key, then the + // record count. Overwriting the count is what a truncated write or a bit flip does, + // and the reader must not try to allocate a list that size. + var cachePath = Path.Combine(AppPaths.DataDirectory, "records.cache"); + var bytes = File.ReadAllBytes(cachePath); + int keyLength = RecordCache.BuildKey(new[] { file }).Length; + int countOffset = 6 + sizeof(int) + 1 + keyLength; + + BitConverter.GetBytes(int.MaxValue).CopyTo(bytes, countOffset); + File.WriteAllBytes(cachePath, bytes); + + Assert.Null(RecordCache.TryLoad(new[] { file })); + } + + [Fact] + public void ANegativePoolSize_IsTreatedAsAMiss() + { + var file = WriteFile("a.json"); + RecordCache.TrySave(new[] { file }, new[] { Music() }); + + var cachePath = Path.Combine(AppPaths.DataDirectory, "records.cache"); + var bytes = File.ReadAllBytes(cachePath); + int keyLength = RecordCache.BuildKey(new[] { file }).Length; + int poolOffset = 6 + sizeof(int) + 1 + keyLength + sizeof(int); + + BitConverter.GetBytes(-5).CopyTo(bytes, poolOffset); + File.WriteAllBytes(cachePath, bytes); + + Assert.Null(RecordCache.TryLoad(new[] { file })); + } + + [Fact] + public void RoundTrips_TimestampsAsLocalTime() + { + // The parser hands out local-kind timestamps. Losing the kind made a cached load + // export dates without their UTC offset, unlike a fresh parse of the same files. + var file = WriteFile("a.json"); + var dated = new PlayRecord { TrackName = "T", Timestamp = new DateTime(2023, 5, 10, 14, 30, 0, DateTimeKind.Local) }; + var undated = new PlayRecord { TrackName = "U", Timestamp = DateTime.MinValue }; + + RecordCache.TrySave(new[] { file }, new[] { dated, undated }); + var loaded = RecordCache.TryLoad(new[] { file })!; + + Assert.Equal(DateTimeKind.Local, loaded[0].Timestamp.Kind); + Assert.Equal(dated.Timestamp, loaded[0].Timestamp); + Assert.Equal(DateTime.MinValue, loaded[1].Timestamp); + } + + /// Where the first record starts, found by its track/artist/album strings rather than by offset arithmetic. + private static int FirstRecordOffset(byte[] bytes) + { + byte[] marker = { 1, (byte)'T', 1, (byte)'A', 2, (byte)'A', (byte)'l' }; + int index = bytes.AsSpan().IndexOf(marker); + Assert.True(index >= 0, "record marker not found in the cache file"); + return index; + } + + [Fact] + public void AMangledStringLength_IsTreatedAsAMiss() + { + var file = WriteFile("a.json"); + RecordCache.TrySave(new[] { file }, new[] { Music() }); + + // Five continuation bytes are not a valid 7-bit length, which BinaryReader reports + // with a FormatException rather than an IOException. + var cachePath = Path.Combine(AppPaths.DataDirectory, "records.cache"); + var bytes = File.ReadAllBytes(cachePath); + new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }.CopyTo(bytes, FirstRecordOffset(bytes)); + File.WriteAllBytes(cachePath, bytes); + + Assert.Null(RecordCache.TryLoad(new[] { file })); + } + + [Fact] + public void AnUnknownContentKind_IsTreatedAsAMiss() + { + var file = WriteFile("a.json"); + RecordCache.TrySave(new[] { file }, new[] { Music() }); + + // Track, artist and album strings, ms played, ticks, the reason flag plus + // "trackdone", then the skipped flag; the kind byte follows. + var cachePath = Path.Combine(AppPaths.DataDirectory, "records.cache"); + var bytes = File.ReadAllBytes(cachePath); + int kindOffset = FirstRecordOffset(bytes) + 2 + 2 + 3 + sizeof(int) + sizeof(long) + 1 + 10 + 1; + Assert.Equal((byte)ContentKind.Music, bytes[kindOffset]); + bytes[kindOffset] = 200; + File.WriteAllBytes(cachePath, bytes); Assert.Null(RecordCache.TryLoad(new[] { file })); } diff --git a/Sortify.Tests/Sortify.Tests.csproj b/Sortify.Tests/Sortify.Tests.csproj index 5b9e194..1e49322 100644 --- a/Sortify.Tests/Sortify.Tests.csproj +++ b/Sortify.Tests/Sortify.Tests.csproj @@ -6,6 +6,8 @@ enable true false + + $(NoWarn);NU1701 diff --git a/Sortify.Tests/TestDataDirectory.cs b/Sortify.Tests/TestDataDirectory.cs new file mode 100644 index 0000000..38486f5 --- /dev/null +++ b/Sortify.Tests/TestDataDirectory.cs @@ -0,0 +1,24 @@ +using System.IO; +using System.Runtime.CompilerServices; +using Sortify.Services; + +namespace Sortify.Tests; + +internal static class TestDataDirectory +{ + // Runs before any test touches the settings file or the record cache, so a test run + // never reads or clobbers the developer's real %LOCALAPPDATA%\Sortify. + [ModuleInitializer] + internal static void Redirect() + { + var dir = Path.Combine(Path.GetTempPath(), "Sortify.Tests", Environment.ProcessId.ToString()); + Directory.CreateDirectory(dir); + AppPaths.DataDirectory = dir; + AppDomain.CurrentDomain.ProcessExit += (_, _) => + { + try { Directory.Delete(dir, recursive: true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + }; + } +} diff --git a/Sortify.Tests/TimeFormatTests.cs b/Sortify.Tests/TimeFormatTests.cs index 62822e8..288d011 100644 --- a/Sortify.Tests/TimeFormatTests.cs +++ b/Sortify.Tests/TimeFormatTests.cs @@ -34,4 +34,12 @@ public void Timestamp_FormatsAndHandlesNull() 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)); } + + [Fact] + public void Timestamp_IgnoresTheCulturesTimeSeparator() + { + using var _ = CultureScope.Unusual(); + + Assert.Equal("2023-05-10 14:30", TimeFormat.Timestamp(new DateTime(2023, 5, 10, 14, 30, 0))); + } } diff --git a/Sortify.Tests/UiSmokeTests.cs b/Sortify.Tests/UiSmokeTests.cs new file mode 100644 index 0000000..99a6dd4 --- /dev/null +++ b/Sortify.Tests/UiSmokeTests.cs @@ -0,0 +1,157 @@ +using System.Windows; +using System.Windows.Media; +using Sortify.Models; +using Sortify.Services; +using Sortify.Views; +using Xunit; + +namespace Sortify.Tests; + +/// +/// Loads the real XAML on a WPF thread. Unit tests over the engines can't catch a resource +/// key that was renamed or a style that no longer parses - those only fail when a window is +/// constructed, which in a normal run means at launch, in front of the user. +/// +[Collection(WpfCollection.Name)] +public class UiSmokeTests +{ + [Theory] + [InlineData(AppTheme.Dark)] + [InlineData(AppTheme.Light)] + public void EveryWindowLoads(AppTheme theme) + { + WpfTestHost.Run(() => + { + ThemeService.Apply(theme); + + // Constructing each window forces WPF to resolve every StaticResource its XAML + // references and to instantiate every control template. + Assert.NotNull(new MainWindow().Content); + Assert.NotNull(new SettingsWindow(new AppSettings()).Content); + Assert.NotNull(new DetailWindow(SampleDetail()).Content); + }); + } + + [Theory] + [InlineData(AppTheme.Dark)] + [InlineData(AppTheme.Light)] + public void EveryPaletteDefinesEveryBrush(AppTheme theme) + { + // Keys the styles reach for with DynamicResource. A palette missing one of these + // renders that control invisible rather than throwing, so check them explicitly. + string[] required = + { + "BgBrush", "PanelBrush", "PanelBrush2", "HoverBrush", "BorderBrush", "ScrollThumbBrush", + "AccentBrush", "AccentHoverBrush", "Accent2Brush", "DangerBrush", + "TextBrush", "MutedBrush", "OnAccentBrush", "SelectionBrush", + }; + + WpfTestHost.Run(() => + { + ThemeService.Apply(theme); + + foreach (var key in required) + Assert.IsAssignableFrom(Application.Current.Resources[key]); + }); + } + + [Fact] + public void ApplyingTheStartingThemeStillRunsTheSetup() + { + WpfTestHost.Run(() => + { + // Regression: Apply() used to treat "already dark" as a no-op, which skipped + // configuring LiveCharts and left its legends on the library's light default. + ThemeService.Apply(AppTheme.Dark); + ChartPaletteAssert.IsDark(); + + // Re-applying is still harmless. + ThemeService.Apply(AppTheme.Dark); + ChartPaletteAssert.IsDark(); + }); + } + + [Fact] + public void SwitchingThemeChangesTheBackground() + { + WpfTestHost.Run(() => + { + ThemeService.Apply(AppTheme.Dark); + var dark = ((SolidColorBrush)Application.Current.Resources["BgBrush"]).Color; + + ThemeService.Apply(AppTheme.Light); + var light = ((SolidColorBrush)Application.Current.Resources["BgBrush"]).Color; + + Assert.NotEqual(dark, light); + Assert.False(ThemeService.IsDark); + + ThemeService.Apply(AppTheme.Dark); + Assert.True(ThemeService.IsDark); + }); + } + + [Fact] + public void EveryChartColourChangesWithTheTheme() + { + WpfTestHost.Run(() => + { + ThemeService.Apply(AppTheme.Dark); + var dark = ChartColours(); + + ThemeService.Apply(AppTheme.Light); + var light = ChartColours(); + + // A colour left out of one palette would keep the other theme's value, which + // reads as a stray dark bar on a white card. + foreach (var (name, darkValue) in dark) + Assert.True(darkValue != light[name], $"{name} is the same in both themes"); + + ThemeService.Apply(AppTheme.Dark); + }); + } + + private static Dictionary ChartColours() => new() + { + ["Accent"] = ChartPalette.Accent, + ["Accent2"] = ChartPalette.Accent2, + ["Text"] = ChartPalette.Text, + ["Muted"] = ChartPalette.Muted, + ["Warm"] = ChartPalette.Warm, + ["Violet"] = ChartPalette.Violet, + ["Cyan"] = ChartPalette.Cyan, + ["HeatEmpty"] = ChartPalette.HeatEmpty, + ["Categories[0]"] = ChartPalette.Categories[0], + }; + + /// The chart palette is Skia-side, so it is checked separately from the brushes. + private static class ChartPaletteAssert + { + public static void IsDark() + { + Assert.True(ThemeService.IsDark); + // The dark ramp starts on the near-black card colour. + Assert.Equal(24, ChartPalette.HeatEmpty.Red); + } + } + + private static DetailResult SampleDetail() => new() + { + Scope = DetailScope.Artist, + Title = "Test Artist", + TotalMsPlayed = 120_000, + PlayCount = 3, + ActiveDays = 2, + FirstPlayed = new DateTime(2024, 1, 1, 10, 0, 0), + LastPlayed = new DateTime(2024, 1, 2, 11, 0, 0), + }; +} + +/// +/// Serializes the UI tests. They share one Application and one static ThemeService, so +/// running them in parallel would have them fighting over the active palette. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class WpfCollection +{ + public const string Name = "WPF"; +} diff --git a/Sortify.Tests/WpfTestHost.cs b/Sortify.Tests/WpfTestHost.cs new file mode 100644 index 0000000..47b672a --- /dev/null +++ b/Sortify.Tests/WpfTestHost.cs @@ -0,0 +1,82 @@ +using System.Windows; +using System.Windows.Threading; +using Xunit.Sdk; + +namespace Sortify.Tests; + +/// +/// A single STA thread with a running dispatcher and one , shared by +/// every UI test. WPF allows only one Application per process, so this cannot be per-test. +/// +internal static class WpfTestHost +{ + private static readonly object Gate = new(); + private static Dispatcher? _dispatcher; + + /// Runs on the UI thread and rethrows anything it threw. + public static void Run(Action action) + { + var dispatcher = EnsureStarted(); + + Exception? failure = null; + dispatcher.Invoke(() => + { + try { action(); } + catch (Exception ex) { failure = ex; } + }); + + if (failure is not null) + throw new XunitException($"UI test failed on the WPF thread: {failure}"); + } + + private static Dispatcher EnsureStarted() + { + lock (Gate) + { + if (_dispatcher is not null) + return _dispatcher; + + var ready = new ManualResetEventSlim(); + var thread = new Thread(() => + { + // OnExplicitShutdown keeps the app alive even though no window is ever shown. + var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; + LoadApplicationResources(app); + + _dispatcher = Dispatcher.CurrentDispatcher; + ready.Set(); + Dispatcher.Run(); + }) + { + IsBackground = true, + Name = "WPF test host", + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + ready.Wait(TimeSpan.FromSeconds(30)); + + return _dispatcher ?? throw new XunitException("The WPF test host did not start."); + } + } + + /// + /// Mirrors what App.xaml merges. The generated App class runs StartupUri and would open + /// the real window, so the dictionaries are merged by hand instead. + /// + private static void LoadApplicationResources(Application app) + { + foreach (var source in new[] { "Themes/Dark.xaml", "Themes/Controls.xaml" }) + { + app.Resources.MergedDictionaries.Add(new ResourceDictionary + { + Source = new Uri($"pack://application:,,,/Sortify;component/{source}"), + }); + } + + app.Resources["BoolToVis"] = new System.Windows.Controls.BooleanToVisibilityConverter(); + app.Resources["NotBoolToVis"] = new Sortify.Views.Converters.InverseBoolToVisibilityConverter(); + app.Resources["TextToVis"] = new Sortify.Views.Converters.EmptyStringToVisibilityConverter(); + app.Resources["EmptyToVis"] = new Sortify.Views.Converters.EmptyCollectionToVisibilityConverter(); + app.Resources["HhMmSs"] = new Sortify.Views.Converters.DurationConverter(); + } +} diff --git a/Sortify/App.xaml b/Sortify/App.xaml index 82f40ec..438c4e0 100644 --- a/Sortify/App.xaml +++ b/Sortify/App.xaml @@ -1,1153 +1,22 @@ - - - - - #FF121212 - #FF181818 - #FF242424 - #FF2E2E2E - #FF1DB954 - #FF1ED760 - #FFEDEDED - #FF9A9A9A - #FF2C2C2C - #FF4A4A4A - - - - - - - - - - #FF509CF8 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + diff --git a/Sortify/App.xaml.cs b/Sortify/App.xaml.cs index a9c251e..3b92cab 100644 --- a/Sortify/App.xaml.cs +++ b/Sortify/App.xaml.cs @@ -1,6 +1,7 @@ -using System.Windows; -using LiveChartsCore; -using LiveChartsCore.SkiaSharpView; +using System.IO; +using System.Windows; +using System.Windows.Threading; +using Sortify.Services; namespace Sortify; @@ -9,12 +10,55 @@ namespace Sortify; /// public partial class App : Application { + /// Preferences loaded once at startup and shared with the main view model. + public AppSettings Settings { get; } = AppSettings.Load(); + protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); - // Match the charts to the app's dark theme so legends, tooltips and axis - // text render light-on-dark instead of the default light theme. - LiveCharts.Configure(config => config.AddDarkTheme()); + // Applying the theme before the first window is created avoids a visible flash of + // the wrong palette, and configures LiveCharts' own light/dark theme to match. + ThemeService.Apply(Settings.Theme); + ThemeService.WatchSystemTheme(); + + DispatcherUnhandledException += OnDispatcherUnhandledException; + } + + protected override void OnExit(ExitEventArgs e) + { + ThemeService.StopWatchingSystemTheme(); + base.OnExit(e); + } + + /// + /// Last line of defence. Anything that reaches here would otherwise take the whole + /// window down with a Windows crash dialog and no explanation; showing the message and + /// carrying on lets the user save or export what they were looking at. + /// + private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + e.Handled = true; + TryWriteCrashLog(e.Exception); + + MessageBox.Show( + MainWindow, + $"Something went wrong:\n\n{e.Exception.Message}\n\n" + + "The app is still running - if it is behaving oddly, restart it. " + + $"Details were written to the log in {AppPaths.DataDirectory}.", + "Sortify", MessageBoxButton.OK, MessageBoxImage.Warning); + } + + private static void TryWriteCrashLog(Exception exception) + { + try + { + Directory.CreateDirectory(AppPaths.DataDirectory); + File.AppendAllText(AppPaths.ErrorLog, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {exception}{Environment.NewLine}{Environment.NewLine}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Logging must never be the thing that brings the app down. + } } } diff --git a/Sortify/AssemblyInfo.cs b/Sortify/AssemblyInfo.cs index cc29e7f..30a9823 100644 --- a/Sortify/AssemblyInfo.cs +++ b/Sortify/AssemblyInfo.cs @@ -1,10 +1,4 @@ using System.Windows; -[assembly:ThemeInfo( - ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located - //(used if a resource is not found in the page, - // or application resource dictionaries) - ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located - //(used if a resource is not found in the page, - // app, or any theme specific resource dictionaries) -)] +// No theme-specific dictionaries; generic resources live in this assembly. +[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)] diff --git a/Sortify/Models/DetailStats.cs b/Sortify/Models/DetailStats.cs index cff42f4..d71832b 100644 --- a/Sortify/Models/DetailStats.cs +++ b/Sortify/Models/DetailStats.cs @@ -6,6 +6,9 @@ public enum DetailScope Artist, Track, Album, + + /// One calendar year; holds the year number. + Year, } /// @@ -36,6 +39,12 @@ public sealed class DetailResult /// Albums that make up this selection, most listened first. public IReadOnlyList Albums { get; init; } = Array.Empty(); + /// + /// Artists that make up this selection, most listened first. Only populated for scopes + /// that can span more than one artist (currently ). + /// + public IReadOnlyList Artists { get; init; } = Array.Empty(); + /// Listening time per calendar month, oldest first. public IReadOnlyList ByMonth { get; init; } = Array.Empty(); @@ -51,19 +60,25 @@ public sealed class DetailResult 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. + /// Browser URL for this selection. A track opens its own page when the export carried a + /// URI. Artists and albums go through Spotify search instead: the export only records + /// track URIs, and opening one of those would land on a single song. Empty for a year. /// public string WebUrl { get { + if (Scope == DetailScope.Year) + return string.Empty; + // "spotify:track:abc" -> "https://open.spotify.com/track/abc" - if (Uri.StartsWith("spotify:", StringComparison.OrdinalIgnoreCase)) + if (Scope == DetailScope.Track && Uri.StartsWith("spotify:", StringComparison.OrdinalIgnoreCase)) { var parts = Uri.Split(':'); + // The URI comes straight out of a JSON file and ends up on a shell command + // line, so its parts are escaped rather than trusted to be plain ids. if (parts.Length >= 3 && parts[1].Length > 0 && parts[2].Length > 0) - return $"https://open.spotify.com/{parts[1]}/{parts[2]}"; + return $"https://open.spotify.com/{System.Uri.EscapeDataString(parts[1])}/{System.Uri.EscapeDataString(parts[2])}"; } var query = Subtitle.Length > 0 ? $"{Title} {Subtitle}" : Title; diff --git a/Sortify/Models/FilterOptions.cs b/Sortify/Models/FilterOptions.cs index 9626899..a3d8c00 100644 --- a/Sortify/Models/FilterOptions.cs +++ b/Sortify/Models/FilterOptions.cs @@ -24,13 +24,16 @@ public sealed class FilterOptions /// 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. + /// + /// Case-insensitive substring matched against track, artist or album name (show or + /// episode name for podcasts and audiobooks). Empty = no search filter. + /// public string SearchTerm { get; set; } = string.Empty; - /// Artist names to exclude entirely (case-insensitive). + /// Artist names to exclude entirely (case-insensitive). Matches a podcast's show name. public HashSet ExcludedArtists { get; } = new(StringComparer.OrdinalIgnoreCase); - /// Track names to exclude entirely (case-insensitive). + /// Track names to exclude entirely (case-insensitive). Matches a podcast's episode name. public HashSet ExcludedTracks { get; } = new(StringComparer.OrdinalIgnoreCase); /// Inclusive start hour of day (0-23). diff --git a/Sortify/Models/Stats.cs b/Sortify/Models/Stats.cs index 0af60a6..5b78e7b 100644 --- a/Sortify/Models/Stats.cs +++ b/Sortify/Models/Stats.cs @@ -232,10 +232,42 @@ public sealed class AnalysisResult /// Run of consecutive listening days ending on the most recent listening day. public int CurrentStreakDays { get; init; } + /// + /// Longest run of consecutive days with no listening at all, bounded by an active day on + /// either side. Zero when the history never has a gap. + /// + public int LongestBreakDays { get; init; } + + /// Last active day before began. + public DateTime? LongestBreakStart { get; init; } + + /// First active day after the break ended. + public DateTime? LongestBreakEnd { get; init; } + /// The single calendar day with the most listening time. public DateTime? BiggestDay { get; init; } public long BiggestDayMs { get; init; } + /// + /// Plays that ran to the end of the track ("trackdone"), out of + /// . Counted with the duration cutoff relaxed, to match + /// the skip statistics it is the mirror of. + /// + public int CompletedPlays { get; init; } + + /// Average number of plays per distinct track: how repetitive the listening is. + public double PlaysPerTrack => UniqueTracks == 0 ? 0 : (double)TotalPlays / UniqueTracks; + + /// Average number of artists heard for the first time in a month. + public double NewArtistsPerMonth => NewArtistsByMonth.Count == 0 + ? 0 + : NewArtistsByMonth.Sum(p => p.Value) / NewArtistsByMonth.Count; + + /// Share of all listening time that went to the single most-played artist (0-100). + public double TopArtistSharePercent => TotalMsPlayed <= 0 || Artists.Count == 0 + ? 0 + : Artists[0].TotalMsPlayed * 100.0 / TotalMsPlayed; + // ---- Sessions -------------------------------------------------------------------------- // A session is a run of plays where each play starts within AnalysisEngine.SessionGap // of the previous play's end. diff --git a/Sortify/Services/AnalysisEngine.cs b/Sortify/Services/AnalysisEngine.cs index ebf2be0..67d8eea 100644 --- a/Sortify/Services/AnalysisEngine.cs +++ b/Sortify/Services/AnalysisEngine.cs @@ -10,27 +10,39 @@ namespace Sortify.Services; /// public static class AnalysisEngine { - /// Plays separated by more than this gap belong to different listening sessions. + /// + /// Default gap between plays that starts a new listening session. Configurable per run; + /// see the sessionGap parameter on . + /// 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, FilterOptions filter, + TimeSpan? sessionGap = null, CancellationToken cancellationToken = default) { - return Task.Run(() => Analyze(records, filter, cancellationToken), cancellationToken); + return Task.Run(() => Analyze(records, filter, sessionGap, cancellationToken), cancellationToken); } + /// + /// Silence longer than this ends a listening session. Defaults to + /// when null. + /// public static AnalysisResult Analyze( IReadOnlyList records, FilterOptions filter, + TimeSpan? sessionGap = null, CancellationToken cancellationToken = default) { + // Tracks and albums are keyed by (name, artist) so the same title by two artists stays + // two rows. A tuple rather than a joined string: joining allocates a fresh key for + // every play, which on a large history is most of what a pass allocates. 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 tracks = new Dictionary<(string Track, string Artist), TrackStat>(); + var albums = new Dictionary<(string Album, string Artist), AlbumStat>(); + var skips = new Dictionary<(string Track, string Artist), (int Plays, int Skips)>(); var reasons = new Dictionary(StringComparer.OrdinalIgnoreCase); var years = new Dictionary(); var byDay = new Dictionary(); @@ -41,14 +53,16 @@ public static AnalysisResult Analyze( var shows = new Dictionary(StringComparer.Ordinal); var showEpisodeKeys = new Dictionary>(StringComparer.Ordinal); - var episodes = new Dictionary(StringComparer.Ordinal); + var episodes = new Dictionary<(string Episode, string Show), EpisodeStat>(); var platforms = new Dictionary(StringComparer.Ordinal); var countries = new Dictionary(StringComparer.OrdinalIgnoreCase); + var families = new Dictionary(StringComparer.Ordinal); int totalPlays = 0; long totalMs = 0; int skipEligiblePlays = 0; int totalSkips = 0; + int completedPlays = 0; long podcastMs = 0; int podcastPlays = 0; int shufflePlays = 0; @@ -84,7 +98,15 @@ public static AnalysisResult Analyze( if (r.MsPlayed >= filter.MinMsPlayed) { if (r.Platform.Length > 0) - Bump(platforms, PlatformFamily(r.Platform), r.MsPlayed); + { + // Only a few dozen distinct platform strings exist, so classify each once. + if (!families.TryGetValue(r.Platform, out var family)) + { + family = PlatformFamily(r.Platform); + families[r.Platform] = family; + } + Bump(platforms, family, r.MsPlayed); + } if (r.Country.Length > 0) Bump(countries, r.Country, r.MsPlayed); @@ -109,12 +131,14 @@ public static AnalysisResult Analyze( string artistName = isMusic ? r.ArtistName : r.ShowName; string albumName = isMusic ? r.AlbumName : r.ShowName; - var trackKey = trackName + "\n" + artistName; + var trackKey = (trackName, artistName); // ---- Relaxed-duration statistics: skips and end reasons ------------------------- skipEligiblePlays++; if (r.Skipped) totalSkips++; + if (string.Equals(r.ReasonEnd, "trackdone", StringComparison.OrdinalIgnoreCase)) + completedPlays++; // 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. @@ -146,7 +170,6 @@ public static AnalysisResult Analyze( artist.TotalMsPlayed += r.MsPlayed; artist.PlayCount++; - // Track aggregate keyed by "track\nartist" to avoid collisions across artists. if (!tracks.TryGetValue(trackKey, out var track)) { track = new TrackStat { Track = trackName, Artist = artistName }; @@ -155,8 +178,7 @@ public static AnalysisResult Analyze( track.TotalMsPlayed += r.MsPlayed; track.PlayCount++; - // Album aggregate keyed the same way. - var albumKey = albumName + "\n" + artistName; + var albumKey = (albumName, artistName); if (!albums.TryGetValue(albumKey, out var album)) { album = new AlbumStat { Album = albumName, Artist = artistName }; @@ -165,34 +187,35 @@ public static AnalysisResult Analyze( album.TotalMsPlayed += r.MsPlayed; album.PlayCount++; - if (r.Timestamp != DateTime.MinValue) + var ts = r.Timestamp; + if (ts != DateTime.MinValue) { - if (artist.FirstPlayed is null || r.Timestamp < artist.FirstPlayed) + if (artist.FirstPlayed is null || ts < artist.FirstPlayed) { - artist.FirstPlayed = r.Timestamp; + artist.FirstPlayed = ts; artist.FirstTrack = trackName; } - if (artist.LastPlayed is null || r.Timestamp > artist.LastPlayed) artist.LastPlayed = r.Timestamp; + if (artist.LastPlayed is null || ts > artist.LastPlayed) artist.LastPlayed = ts; - 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 (track.FirstPlayed is null || ts < track.FirstPlayed) track.FirstPlayed = ts; + if (track.LastPlayed is null || ts > track.LastPlayed) track.LastPlayed = ts; - 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 (album.FirstPlayed is null || ts < album.FirstPlayed) album.FirstPlayed = ts; + if (album.LastPlayed is null || ts > album.LastPlayed) album.LastPlayed = ts; - if (firstListen is null || r.Timestamp < firstListen) firstListen = r.Timestamp; - if (lastListen is null || r.Timestamp > lastListen) lastListen = r.Timestamp; + if (firstListen is null || ts < firstListen) firstListen = ts; + if (lastListen is null || ts > lastListen) lastListen = ts; - 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; + int hour = ts.Hour; + int dow = (int)ts.DayOfWeek; + CollectionsMarshal.GetValueRefOrAddDefault(byDay, ts.Date, out _) += r.MsPlayed; + byHour[hour] += r.MsPlayed; + byDow[dow] += r.MsPlayed; + byDowHour[dow, hour] += r.MsPlayed; - timedPlays.Add((r.Timestamp, r.MsPlayed)); + timedPlays.Add((ts, r.MsPlayed)); - // Per-year rollup. - int year = r.Timestamp.Year; + int year = ts.Year; if (!years.TryGetValue(year, out var acc)) { acc = new YearAccumulator(); @@ -200,8 +223,8 @@ public static AnalysisResult Analyze( } 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; + CollectionsMarshal.GetValueRefOrAddDefault(acc.ArtistMs, artistName, out _) += r.MsPlayed; + CollectionsMarshal.GetValueRefOrAddDefault(acc.TrackMs, trackKey, out _) += r.MsPlayed; } } @@ -220,11 +243,10 @@ public static AnalysisResult Analyze( { if (counts.Skips == 0) continue; - int sep = key.IndexOf('\n'); skippedList.Add(new SkippedTrackStat { - Track = key[..sep], - Artist = key[(sep + 1)..], + Track = key.Track, + Artist = key.Artist, SkipCount = counts.Skips, PlayCount = counts.Plays, }); @@ -245,6 +267,7 @@ public static AnalysisResult Analyze( var (streakDays, streakStart, streakEnd) = LongestStreak(dayPoints); int currentStreak = CurrentStreak(dayPoints); + var (breakDays, breakStart, breakEnd) = LongestBreak(dayPoints); DateTime? biggestDay = null; long biggestDayMs = 0; @@ -257,7 +280,8 @@ public static AnalysisResult Analyze( } } - var (sessionCount, avgSessionMs, longestSessionMs, longestSessionDate) = ComputeSessions(timedPlays); + var (sessionCount, avgSessionMs, longestSessionMs, longestSessionDate) = + ComputeSessions(timedPlays, sessionGap ?? SessionGap); var newArtistsByMonth = artistList .Where(a => a.FirstPlayed is not null) @@ -308,8 +332,12 @@ public static AnalysisResult Analyze( LongestStreakStart = streakStart, LongestStreakEnd = streakEnd, CurrentStreakDays = currentStreak, + LongestBreakDays = breakDays, + LongestBreakStart = breakStart, + LongestBreakEnd = breakEnd, BiggestDay = biggestDay, BiggestDayMs = biggestDayMs, + CompletedPlays = completedPlays, SessionCount = sessionCount, AvgSessionMs = avgSessionMs, LongestSessionMs = longestSessionMs, @@ -321,7 +349,7 @@ public static AnalysisResult Analyze( private static void AccumulateShow( Dictionary shows, Dictionary> showEpisodeKeys, - Dictionary episodes, + Dictionary<(string Episode, string Show), EpisodeStat> episodes, PlayRecord r) { if (!shows.TryGetValue(r.ShowName, out var show)) @@ -335,7 +363,7 @@ private static void AccumulateShow( if (showEpisodeKeys[r.ShowName].Add(r.EpisodeName)) show.EpisodeCount++; - var episodeKey = r.EpisodeName + "\n" + r.ShowName; + var episodeKey = (r.EpisodeName, r.ShowName); if (!episodes.TryGetValue(episodeKey, out var episode)) { episode = new EpisodeStat { Episode = r.EpisodeName, Show = r.ShowName }; @@ -366,46 +394,69 @@ private static void Bump(Dictionary map, string name, int m /// /// Collapses Spotify's very granular platform strings (which embed OS builds, device - /// models and SDK versions) into a handful of families worth charting. + /// models and SDK versions) into a handful of families worth charting. Matches whole + /// tokens, so "tv" means a TV and not any word that happens to contain those letters. /// internal static string PlatformFamily(string platform) { - if (platform.Contains("web_player", StringComparison.OrdinalIgnoreCase) || - platform.Contains("webplayer", StringComparison.OrdinalIgnoreCase)) + string lower = platform.ToLowerInvariant(); + var tokens = lower.Split(TokenSeparators, StringSplitOptions.RemoveEmptyEntries); + + if (lower.Contains("web_player") || lower.Contains("webplayer")) 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)) + + // TVs, consoles and cars report as "Partner ...", so they have to be matched + // before the catch-all partner token sends them to the speaker bucket. + if (lower.Contains("android_auto") || lower.Contains("android auto") || tokens.Any(CarTokens.Contains)) 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)) + if (tokens.Any(TvTokens.Contains)) + return "TV / console"; + if (tokens.Any(SpeakerTokens.Contains)) + return "Speaker / cast"; + if (tokens.Any(MobileTokens.Contains)) + return "Mobile"; + + // Spotify writes macOS as "OS X 10.15.7 [x86_64]", with a space. + if (tokens.Any(DesktopTokens.Contains) || lower.Contains("os x")) return "Desktop"; return "Other"; } + private static readonly char[] TokenSeparators = + " _-;:,.()[]/\\".ToCharArray(); + + private static readonly HashSet CarTokens = new(StringComparer.Ordinal) + { + "car", "carplay", "automotive", "androidauto", + }; + + private static readonly HashSet TvTokens = new(StringComparer.Ordinal) + { + "tv", "smarttv", "androidtv", "appletv", "firetv", "googletv", "tizen", "webos", "roku", "bravia", + "xbox", "playstation", "ps3", "ps4", "ps5", + }; + + private static readonly HashSet SpeakerTokens = new(StringComparer.Ordinal) + { + "cast", "chromecast", "sonos", "speaker", "echo", "alexa", "homepod", "bose", "partner", + }; + + private static readonly HashSet MobileTokens = new(StringComparer.Ordinal) + { + "android", "ios", "iphone", "ipad", "ipod", + }; + + private static readonly HashSet DesktopTokens = new(StringComparer.Ordinal) + { + "windows", "osx", "macos", "mac", "linux", + }; + private sealed class YearAccumulator { public long Ms; public int Plays; public readonly Dictionary ArtistMs = new(StringComparer.Ordinal); - public readonly Dictionary TrackMs = new(StringComparer.Ordinal); + public readonly Dictionary<(string Track, string Artist), long> TrackMs = new(); } private static YearStat BuildYearStat(int year, YearAccumulator acc) @@ -428,8 +479,7 @@ private static YearStat BuildYearStat(int year, YearAccumulator acc) if (ms > best) { best = ms; - // Track keys are "track\nartist"; show just the track name. - topTrack = key[..key.IndexOf('\n')]; + topTrack = key.Track; } } @@ -478,6 +528,31 @@ private static (int days, DateTime? start, DateTime? end) LongestStreak(IReadOnl return (best, bestStart, bestEnd); } + /// + /// Finds the longest stretch of silence: the biggest gap between two consecutive + /// listening days. Returns the number of days with no listening, plus the active days + /// that bracket it. + /// + private static (int days, DateTime? start, DateTime? end) LongestBreak(IReadOnlyList dayPoints) + { + int best = 0; + DateTime? bestStart = null, bestEnd = null; + + for (int i = 1; i < dayPoints.Count; i++) + { + // Consecutive days are one day apart, which is a gap of zero silent days. + int gap = (int)(dayPoints[i].Date - dayPoints[i - 1].Date).TotalDays - 1; + if (gap > best) + { + best = gap; + bestStart = dayPoints[i - 1].Date; + bestEnd = dayPoints[i].Date; + } + } + + return (best, bestStart, bestEnd); + } + /// Length of the run of consecutive listening days ending on the last listening day. private static int CurrentStreak(IReadOnlyList dayPoints) { @@ -497,10 +572,10 @@ private static int CurrentStreak(IReadOnlyList dayPoints) /// /// Groups timestamped plays into sessions. A play whose start (end time minus duration) - /// falls within of the previous play's end continues the session. + /// 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) + List<(DateTime End, int Ms)> plays, TimeSpan gap) { if (plays.Count == 0) return (0, 0, 0, null); @@ -518,7 +593,7 @@ private static (int count, long avgMs, long longestMs, DateTime? longestDate) Co for (int i = 1; i < plays.Count; i++) { var start = StartOf(plays[i]); - if (start - prevEnd > SessionGap) + if (start - prevEnd > gap) { if (currentMs > longestMs) { diff --git a/Sortify/Services/AppPaths.cs b/Sortify/Services/AppPaths.cs new file mode 100644 index 0000000..b8d47ca --- /dev/null +++ b/Sortify/Services/AppPaths.cs @@ -0,0 +1,13 @@ +using System.IO; + +namespace Sortify.Services; + +/// Where Sortify keeps its own files: settings, the parsed-history cache and the error log. +public static class AppPaths +{ + /// %LOCALAPPDATA%\Sortify, created on demand. Tests point this at a temp folder. + public static string DataDirectory { get; internal set; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Sortify"); + + public static string ErrorLog => Path.Combine(DataDirectory, "error.log"); +} diff --git a/Sortify/Services/AppSettings.cs b/Sortify/Services/AppSettings.cs index be9da89..3cc45e7 100644 --- a/Sortify/Services/AppSettings.cs +++ b/Sortify/Services/AppSettings.cs @@ -1,24 +1,70 @@ using System.IO; using System.Text.Json; +using System.Text.Json.Serialization; 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. +/// User preferences 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 { + /// How many folders to remember in the "recent" list. + public const int MaxRecentFolders = 8; + + /// Bounds on the break between plays that still counts as one session. + public const int MinSessionGapMinutes = 1; + public const int MaxSessionGapMinutes = 240; + + // ---- Data ------------------------------------------------------------------------------- + /// Folder passed to Open Folder last time, reopened at startup when it still exists. public string? LastFolder { get; set; } + /// Most recently opened folders, newest first. + public List RecentFolders { get; set; } = new(); + /// Whether to reload the last folder automatically on launch. public bool ReopenLastFolder { get; set; } = true; + /// Gap between plays that starts a new listening session, in minutes. + public int SessionGapMinutes { get; set; } = 30; + + // ---- Appearance ------------------------------------------------------------------------- + + [JsonConverter(typeof(JsonStringEnumConverter))] + public AppTheme Theme { get; set; } = AppTheme.System; + + /// + /// Chart entry animations. Worth turning off on very large histories, where animating + /// thousands of bars costs more than it adds. + /// + public bool AnimateCharts { get; set; } = true; + + // ---- Window and layout ------------------------------------------------------------------- + + public double WindowWidth { get; set; } + public double WindowHeight { get; set; } + + // Nullable rather than NaN: System.Text.Json refuses to write NaN, which would have + // made the very first save throw. + public double? WindowLeft { get; set; } + public double? WindowTop { get; set; } + + public bool WindowMaximized { get; set; } + + /// Whether the filter sidebar was expanded when the app last closed. + public bool SidebarVisible { get; set; } = true; + + /// Tab index the user was last on. + public int LastTabIndex { get; set; } + + // ---- Persistence ------------------------------------------------------------------------- + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; - private static string SettingsFile => Path.Combine(RecordCache.CacheDirectory, "settings.json"); + private static string SettingsFile => Path.Combine(AppPaths.DataDirectory, "settings.json"); public static AppSettings Load() { @@ -26,8 +72,11 @@ public static AppSettings Load() { if (!File.Exists(SettingsFile)) return new AppSettings(); - return JsonSerializer.Deserialize(File.ReadAllText(SettingsFile), JsonOptions) - ?? new AppSettings(); + + var settings = JsonSerializer.Deserialize(File.ReadAllText(SettingsFile), JsonOptions) + ?? new AppSettings(); + settings.Normalize(); + return settings; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { @@ -39,12 +88,76 @@ public void Save() { try { - Directory.CreateDirectory(RecordCache.CacheDirectory); + Normalize(); + Directory.CreateDirectory(AppPaths.DataDirectory); File.WriteAllText(SettingsFile, JsonSerializer.Serialize(this, JsonOptions)); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or JsonException or ArgumentException or NotSupportedException) { - // Preferences are best-effort. + // Preferences are best-effort: losing them costs the user a re-pick, whereas + // throwing here would interrupt whatever they were actually doing. } } + + /// + /// Records a folder as the newest entry in the recent list. Also keeps + /// in step, since that is what startup restores. + /// + public void RememberFolder(string folder) + { + if (string.IsNullOrWhiteSpace(folder)) + return; + + RecentFolders.RemoveAll(f => string.Equals(f, folder, StringComparison.OrdinalIgnoreCase)); + RecentFolders.Insert(0, folder); + if (RecentFolders.Count > MaxRecentFolders) + RecentFolders.RemoveRange(MaxRecentFolders, RecentFolders.Count - MaxRecentFolders); + + LastFolder = folder; + } + + /// Drops remembered folders that no longer exist on disk. + public void PruneMissingFolders() + { + RecentFolders.RemoveAll(f => !Directory.Exists(f)); + if (LastFolder is not null && !Directory.Exists(LastFolder)) + LastFolder = null; + } + + /// + /// 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. + /// + [JsonIgnore] + public TimeSpan SessionGap => TimeSpan.FromMinutes( + Math.Clamp(SessionGapMinutes, MinSessionGapMinutes, MaxSessionGapMinutes)); + + /// + /// Repairs values that an older settings file, a hand edit or a disconnected monitor + /// could leave in an unusable state. + /// + internal void Normalize() + { + // A hand-edited file can hold nulls, blanks or the same folder twice; any of those + // would show up as a broken entry in the Recent menu. + RecentFolders = (RecentFolders ?? new List()) + .Where(f => !string.IsNullOrWhiteSpace(f)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(MaxRecentFolders) + .ToList(); + SessionGapMinutes = Math.Clamp(SessionGapMinutes, MinSessionGapMinutes, MaxSessionGapMinutes); + LastTabIndex = Math.Max(0, LastTabIndex); + + // Settings written before recent folders existed only have LastFolder. + if (RecentFolders.Count == 0 && !string.IsNullOrWhiteSpace(LastFolder)) + RecentFolders.Add(LastFolder); + + if (WindowWidth is < 600 or > 20000) WindowWidth = 0; + if (WindowHeight is < 400 or > 20000) WindowHeight = 0; + + // A non-finite coordinate can't be written back out, and can't place a window either. + if (WindowLeft is { } left && !double.IsFinite(left)) WindowLeft = null; + if (WindowTop is { } top && !double.IsFinite(top)) WindowTop = null; + } } diff --git a/Sortify/Services/ChartBuilder.cs b/Sortify/Services/ChartBuilder.cs index da244dd..283290e 100644 --- a/Sortify/Services/ChartBuilder.cs +++ b/Sortify/Services/ChartBuilder.cs @@ -1,16 +1,21 @@ using LiveChartsCore; using LiveChartsCore.Defaults; using LiveChartsCore.Drawing; -using DateTimePoint = Sortify.Models.DateTimePoint; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using SkiaSharp; using Sortify.Models; +// Both namespaces define a DateTimePoint; alias each so neither is referred to unqualified. +using DateTimePoint = Sortify.Models.DateTimePoint; using LcDateTimePoint = LiveChartsCore.Defaults.DateTimePoint; namespace Sortify.Services; -/// Builds LiveCharts2 series and axes from an . +/// +/// Builds LiveCharts2 series and axes from an . Colours come +/// from , so every chart follows the active theme once it is +/// rebuilt. +/// public static class ChartBuilder { /// @@ -21,20 +26,22 @@ public static class ChartBuilder /// public const int MaxBars = 1000; - private static readonly SKColor Accent = new(29, 185, 84); // Spotify green - private static readonly SKColor Accent2 = new(80, 156, 248); - private static readonly SKColor Text = new(220, 220, 220); + /// Bar and axis labels longer than this are cut short with an ellipsis. + private const int LabelMaxChars = 22; - private static readonly SKColor[] Palette = - { - new(29, 185, 84), new(80, 156, 248), new(244, 162, 97), new(231, 111, 81), - new(42, 157, 143), new(233, 196, 106), new(155, 93, 229), new(247, 37, 133), - new(76, 201, 240), new(181, 23, 158), new(114, 9, 183), new(58, 134, 255), - new(255, 159, 28), new(46, 196, 182), new(255, 89, 94), new(124, 200, 60), - new(255, 196, 61), new(0, 187, 196), new(220, 80, 100), new(147, 130, 220), - new(72, 191, 145), new(255, 140, 105), new(120, 170, 240), new(210, 130, 215), - new(176, 205, 80), - }; + /// Named artists in the Overview donut; everyone else is folded into "Other". + public const int ArtistShareSlices = 25; + + public const int SkippedTrackBars = 15; + public const int PodcastBars = 20; + + private const int ReasonSlices = 8; + private const int PlatformSlices = 8; + private const int CountrySlices = 10; + + // 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; /// Friendly display names for Spotify "reason_end" codes. private static readonly Dictionary ReasonLabels = new(StringComparer.OrdinalIgnoreCase) @@ -57,70 +64,87 @@ public static class ChartBuilder ["unknown"] = "Unknown", }; - private static SolidColorPaint Label() => new(Text); + private static SolidColorPaint Label() => new(ChartPalette.Text); - private static string ShortLabel(string s, int max = 22) - => s.Length <= max ? s : s[..(max - 1)] + "…"; + internal static string ShortLabel(string s, int max = LabelMaxChars) + { + if (s.Length <= max) + return s; + + // Don't cut an emoji (or any other surrogate pair) in half; the orphaned half renders + // as a replacement box. + int cut = max - 1; + if (char.IsHighSurrogate(s[cut - 1])) + cut--; + return s[..cut] + "…"; + } // ---- Horizontal bar charts (RowSeries) ------------------------------------------------- - public static (ISeries[] series, Axis[] x, Axis[] y) TopTracksByTime(AnalysisResult r, int take) + public static ChartData TopTracksByTime(AnalysisResult r, int take) { var items = r.Tracks.Take(take).Reverse().ToList(); - var values = items.Select(t => Math.Round(t.TotalHours, 2)).ToArray(); - var labels = items.Select(t => ShortLabel(t.Track)).ToArray(); - return Rows(values, labels, "Hours", Accent); + return Rows( + items.Select(t => Math.Round(t.TotalHours, 2)).ToArray(), + items.Select(t => ShortLabel(t.Track)).ToArray(), + "Hours", ChartPalette.Accent); } - public static (ISeries[] series, Axis[] x, Axis[] y) TopTracksByCount(AnalysisResult r, int take) + public static ChartData TopTracksByCount(AnalysisResult r, int take) { 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); + return Rows( + items.Select(t => (double)t.PlayCount).ToArray(), + items.Select(t => ShortLabel(t.Track)).ToArray(), + "Plays", ChartPalette.Accent2); } - public static (ISeries[] series, Axis[] x, Axis[] y) TopArtistsByTime(AnalysisResult r, int take) + public static ChartData TopArtistsByTime(AnalysisResult r, int take) { var items = r.Artists.Take(take).Reverse().ToList(); - var values = items.Select(a => Math.Round(a.TotalHours, 2)).ToArray(); - var labels = items.Select(a => ShortLabel(a.Artist)).ToArray(); - return Rows(values, labels, "Hours", Accent); + return Rows( + items.Select(a => Math.Round(a.TotalHours, 2)).ToArray(), + items.Select(a => ShortLabel(a.Artist)).ToArray(), + "Hours", ChartPalette.Accent); } - public static (ISeries[] series, Axis[] x, Axis[] y) TopArtistsByCount(AnalysisResult r, int take) + public static ChartData TopArtistsByCount(AnalysisResult r, int take) { 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); + return Rows( + items.Select(a => (double)a.PlayCount).ToArray(), + items.Select(a => ShortLabel(a.Artist)).ToArray(), + "Plays", ChartPalette.Accent2); } - public static (ISeries[] series, Axis[] x, Axis[] y) TopAlbumsByTime(AnalysisResult r, int take) + public static ChartData TopAlbumsByTime(AnalysisResult r, int take) { var items = r.Albums.Take(take).Reverse().ToList(); - var values = items.Select(a => Math.Round(a.TotalHours, 2)).ToArray(); - var labels = items.Select(a => ShortLabel(a.Album)).ToArray(); - return Rows(values, labels, "Hours", Accent); + return Rows( + items.Select(a => Math.Round(a.TotalHours, 2)).ToArray(), + items.Select(a => ShortLabel(a.Album)).ToArray(), + "Hours", ChartPalette.Accent); } - public static (ISeries[] series, Axis[] x, Axis[] y) TopAlbumsByCount(AnalysisResult r, int take) + public static ChartData TopAlbumsByCount(AnalysisResult r, int take) { 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); + return Rows( + items.Select(a => (double)a.PlayCount).ToArray(), + items.Select(a => ShortLabel(a.Album)).ToArray(), + "Plays", ChartPalette.Accent2); } - public static (ISeries[] series, Axis[] x, Axis[] y) TopSkippedTracks(AnalysisResult r, int take = 15) + public static ChartData TopSkippedTracks(AnalysisResult r, int take = SkippedTrackBars) { var items = r.SkippedTracks.Take(take).Reverse().ToList(); - var values = items.Select(s => (double)s.SkipCount).ToArray(); - var labels = items.Select(s => ShortLabel(s.Track)).ToArray(); - return Rows(values, labels, "Skips", new SKColor(231, 111, 81)); + return Rows( + items.Select(s => (double)s.SkipCount).ToArray(), + items.Select(s => ShortLabel(s.Track)).ToArray(), + "Skips", ChartPalette.Warm); } - private static (ISeries[], Axis[], Axis[]) Rows(double[] values, string[] labels, string unit, SKColor color) + private static ChartData Rows(double[] values, string[] labels, string unit, SKColor color) { var series = new ISeries[] { @@ -129,7 +153,7 @@ private static (ISeries[], Axis[], Axis[]) Rows(double[] values, string[] labels Values = values, Name = unit, Fill = new SolidColorPaint(color), - DataLabelsPaint = new SolidColorPaint(Text), + DataLabelsPaint = new SolidColorPaint(ChartPalette.Text), DataLabelsPosition = LiveChartsCore.Measure.DataLabelsPosition.Right, DataLabelsFormatter = p => p.Coordinate.PrimaryValue.ToString("0.##"), DataLabelsSize = 11, @@ -170,34 +194,34 @@ private static (ISeries[], Axis[], Axis[]) Rows(double[] values, string[] labels MaxLimit = maxLimit, }, }; - return (series, x, y); + return new ChartData { Series = series, XAxes = x, YAxes = y }; } // ---- Column charts --------------------------------------------------------------------- - public static (ISeries[] series, Axis[] x, Axis[] y) ByHour(AnalysisResult r) + public static ChartData ByHour(AnalysisResult r) { var values = r.PlaytimeByHour.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", Accent); + return Columns(values, labels, "Hours", "Hour of day", ChartPalette.Accent); } - public static (ISeries[] series, Axis[] x, Axis[] y) ByDayOfWeek(AnalysisResult r) + public static ChartData ByDayOfWeek(AnalysisResult r) { string[] names = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; var values = r.PlaytimeByDayOfWeek.Select(ms => Math.Round(ms / 3_600_000d, 2)).ToArray(); - return Columns(values, names, "Hours", "Day of week", Accent2); + return Columns(values, names, "Hours", "Day of week", ChartPalette.Accent2); } /// Total listening time per calendar year. - public static (ISeries[] series, Axis[] x, Axis[] y) HoursPerYear(AnalysisResult r) + public static ChartData 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); + return Columns(values, labels, "Hours", "Year", ChartPalette.Accent); } - private static (ISeries[], Axis[], Axis[]) Columns(double[] values, string[] labels, string unit, string xName, SKColor color) + private static ChartData Columns(double[] values, string[] labels, string unit, string xName, SKColor color) { var series = new ISeries[] { @@ -208,9 +232,12 @@ private static (ISeries[], Axis[], Axis[]) Columns(double[] values, string[] lab Fill = new SolidColorPaint(color), }, }; - var x = new[] { new Axis { Name = xName, Labels = labels, NamePaint = Label(), LabelsPaint = Label() } }; - var y = new[] { new Axis { Name = unit, NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }; - return (series, x, y); + 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 } }, + }; } // ---- Time series ----------------------------------------------------------------------- @@ -218,8 +245,7 @@ private static (ISeries[], Axis[], Axis[]) Columns(double[] values, string[] lab /// Bucket size for the listening-over-time chart. public enum TimeGranularity { Daily, Weekly, Monthly } - public static (ISeries[] series, Axis[] x, Axis[] y) OverTime( - AnalysisResult r, TimeGranularity granularity = TimeGranularity.Daily) + public static ChartData OverTime(AnalysisResult r, TimeGranularity granularity = TimeGranularity.Daily) { IEnumerable source = granularity switch { @@ -256,18 +282,21 @@ public static (ISeries[] series, Axis[] x, Axis[] y) OverTime( { Values = points, Name = unitName, - Fill = new SolidColorPaint(Accent.WithAlpha(40)), - Stroke = new SolidColorPaint(Accent) { StrokeThickness = 2 }, + Fill = new SolidColorPaint(ChartPalette.Accent.WithAlpha(40)), + Stroke = new SolidColorPaint(ChartPalette.Accent) { StrokeThickness = 2 }, GeometrySize = 0, }, }; - var x = new[] { DateAxis(unitTicks) }; - var y = new[] { new Axis { Name = "Hours", NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }; - return (series, x, y); + return new ChartData + { + Series = series, + XAxes = new[] { DateAxis(unitTicks) }, + YAxes = new[] { new Axis { Name = "Hours", NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }, + }; } /// Count of artists heard for the first time, per calendar month. - public static (ISeries[] series, Axis[] x, Axis[] y) NewArtistsByMonth(AnalysisResult r) + public static ChartData NewArtistsByMonth(AnalysisResult r) { var points = r.NewArtistsByMonth .Select(p => new LcDateTimePoint(p.Date, p.Value)) @@ -279,31 +308,42 @@ public static (ISeries[] series, Axis[] x, Axis[] y) NewArtistsByMonth(AnalysisR { Values = points, Name = "New artists", - Fill = new SolidColorPaint(Accent2.WithAlpha(40)), - Stroke = new SolidColorPaint(Accent2) { StrokeThickness = 2 }, + Fill = new SolidColorPaint(ChartPalette.Accent2.WithAlpha(40)), + Stroke = new SolidColorPaint(ChartPalette.Accent2) { StrokeThickness = 2 }, GeometrySize = 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); + return new ChartData + { + Series = series, + XAxes = new[] { DateAxis(TimeSpan.FromDays(30).Ticks) }, + YAxes = new[] { new Axis { Name = "New artists", NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }, + }; } private static Axis DateAxis(long unitTicks) => new() { LabelsPaint = Label(), - Labeler = value => - { - try { return new DateTime((long)value).ToString("yyyy-MM"); } - catch { return string.Empty; } - }, + Labeler = MonthLabel, UnitWidth = unitTicks, }; + /// + /// Formats an axis value that carries DateTime ticks. LiveCharts asks for labels beyond + /// the data range while a chart animates or the user pans, and those can fall outside + /// what a DateTime can hold, so out-of-range values get no label rather than throwing. + /// + private static string MonthLabel(double value) + { + if (value < DateTime.MinValue.Ticks || value > DateTime.MaxValue.Ticks) + return string.Empty; + return new DateTime((long)value).ToString("yyyy-MM"); + } + // ---- Heatmap --------------------------------------------------------------------------- /// Hour-of-day (x) by day-of-week (y) listening heatmap. - public static (ISeries[] series, Axis[] x, Axis[] y) DowHourHeat(AnalysisResult r) + public static ChartData DowHourHeat(AnalysisResult r) { var points = new List(7 * 24); for (int dow = 0; dow < 7; dow++) @@ -316,42 +356,41 @@ public static (ISeries[] series, Axis[] x, Axis[] y) DowHourHeat(AnalysisResult { Values = points, Name = "Hours", - HeatMap = new[] - { - new LvcColor(24, 24, 24), // no listening: blends into the panel - new LvcColor(16, 90, 45), - new LvcColor(29, 185, 84), // Spotify green at the hot end - new LvcColor(30, 215, 96), - }, + HeatMap = ChartPalette.HeatRamp + .Select(c => new LvcColor(c.Red, c.Green, c.Blue)) + .ToArray(), }, }; string[] dayNames = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; - var x = new[] + return new ChartData { - new Axis + Series = series, + XAxes = new[] { - Labels = Enumerable.Range(0, 24).Select(h => h.ToString("00")).ToArray(), - LabelsPaint = Label(), - TextSize = 10, + new Axis + { + Labels = Enumerable.Range(0, 24).Select(h => h.ToString("00")).ToArray(), + LabelsPaint = Label(), + TextSize = 10, + }, }, - }; - var y = new[] - { - new Axis + YAxes = new[] { - Labels = dayNames, - LabelsPaint = Label(), - TextSize = 11, + new Axis + { + Labels = dayNames, + LabelsPaint = Label(), + TextSize = 11, + }, }, }; - return (series, x, y); } // ---- Pie / donut ----------------------------------------------------------------------- public static ISeries[] ArtistShare(AnalysisResult r) { - var top = r.Artists.Take(25).ToList(); + var top = r.Artists.Take(ArtistShareSlices).ToList(); long topMs = top.Sum(a => a.TotalMsPlayed); long otherMs = r.TotalMsPlayed - topMs; double totalMs = r.TotalMsPlayed <= 0 ? 1 : r.TotalMsPlayed; @@ -366,10 +405,10 @@ public static ISeries[] ArtistShare(AnalysisResult r) series.Add(new PieSeries { Values = new double[] { hours }, - Name = $"{ShortLabel(a.Artist, 22)} ({pct:0.#}%)", - Fill = new SolidColorPaint(Palette[i % Palette.Length]), + Name = $"{ShortLabel(a.Artist)} ({pct:0.#}%)", + Fill = new SolidColorPaint(Category(i)), ToolTipLabelFormatter = _ => $"{hours:0.#} h ({pct:0.#}%)", - InnerRadius = 75, + InnerRadius = ArtistDonutInnerRadius, }); i++; } @@ -382,9 +421,9 @@ public static ISeries[] ArtistShare(AnalysisResult r) { Values = new double[] { oHours }, Name = $"Other ({oPct:0.#}%)", - Fill = new SolidColorPaint(new SKColor(110, 110, 110)), + Fill = new SolidColorPaint(ChartPalette.Muted), ToolTipLabelFormatter = _ => $"{oHours:0.#} h ({oPct:0.#}%)", - InnerRadius = 75, + InnerRadius = ArtistDonutInnerRadius, }); } @@ -394,34 +433,39 @@ public static ISeries[] ArtistShare(AnalysisResult r) /// 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)) + foreach (var reason in r.ReasonEnds.Take(ReasonSlices)) { string label = ReasonLabels.TryGetValue(reason.Reason, out var friendly) ? friendly : reason.Reason; - AddCountSlice(series, label, reason.Count, total, Palette[i % Palette.Length]); + AddCountSlice(series, label, reason.Count, total, Category(i)); i++; } - int otherCount = r.ReasonEnds.Skip(maxSlices).Sum(x => x.Count); + int otherCount = r.ReasonEnds.Skip(ReasonSlices).Sum(x => x.Count); if (otherCount > 0) - AddCountSlice(series, "Other", otherCount, total, new SKColor(110, 110, 110)); + AddCountSlice(series, "Other", otherCount, total, ChartPalette.Muted); return series.ToArray(); } /// Donut of listening time by device family. - public static ISeries[] PlatformShare(AnalysisResult r) => ContextShare(r.Platforms, maxSlices: 8); + public static ISeries[] PlatformShare(AnalysisResult r) => ContextShare(r.Platforms, PlatformSlices); /// Donut of listening time by the country each play streamed from. - public static ISeries[] CountryShare(AnalysisResult r) => ContextShare(r.Countries, maxSlices: 10); + public static ISeries[] CountryShare(AnalysisResult r) => ContextShare(r.Countries, CountrySlices); + + private static SKColor Category(int index) + { + var categories = ChartPalette.Categories; + return categories[index % categories.Length]; + } private static ISeries[] ContextShare(IReadOnlyList stats, int maxSlices) { @@ -433,13 +477,13 @@ private static ISeries[] ContextShare(IReadOnlyList stats, int maxS int i = 0; foreach (var stat in stats.Take(maxSlices)) { - AddHoursSlice(series, stat.Name, stat.TotalMsPlayed, total, Palette[i % Palette.Length]); + AddHoursSlice(series, stat.Name, stat.TotalMsPlayed, total, Category(i)); i++; } long otherMs = stats.Skip(maxSlices).Sum(s => s.TotalMsPlayed); if (otherMs > 0) - AddHoursSlice(series, "Other", otherMs, total, new SKColor(110, 110, 110)); + AddHoursSlice(series, "Other", otherMs, total, ChartPalette.Muted); return series.ToArray(); } @@ -451,17 +495,30 @@ private static void AddHoursSlice(List series, string label, long ms, l series.Add(new PieSeries { Values = new double[] { hours }, - Name = $"{ShortLabel(label, 22)} ({pct:0.#}%)", + Name = $"{ShortLabel(label)} ({pct:0.#}%)", Fill = new SolidColorPaint(color), ToolTipLabelFormatter = _ => $"{hours:0.#} h ({pct:0.#}%)", - InnerRadius = 60, + InnerRadius = ContextDonutInnerRadius, + }); + } + + 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 = ContextDonutInnerRadius, }); } // ---- Drill-down detail -------------------------------------------------------------------- - /// Monthly listening time for a single artist, track or album. - public static (ISeries[] series, Axis[] x, Axis[] y) DetailByMonth(DetailResult d) + /// Monthly listening time for a single artist, track, album or year. + public static ChartData DetailByMonth(DetailResult d) { var points = d.ByMonth .Select(p => new LcDateTimePoint(p.Date, Math.Round(p.Value, 2))) @@ -473,59 +530,51 @@ public static (ISeries[] series, Axis[] x, Axis[] y) DetailByMonth(DetailResult { Values = points, Name = "Hours", - Fill = new SolidColorPaint(Accent), + Fill = new SolidColorPaint(ChartPalette.Accent), }, }; - var x = new[] + return new ChartData { - new Axis + Series = series, + XAxes = new[] { - Labeler = value => new DateTime((long)value).ToString("yyyy-MM"), - UnitWidth = TimeSpan.FromDays(30).Ticks, - LabelsPaint = Label(), - TextSize = 11, + new Axis + { + Labeler = MonthLabel, + UnitWidth = TimeSpan.FromDays(30).Ticks, + LabelsPaint = Label(), + TextSize = 11, + }, }, + YAxes = new[] { new Axis { Name = "Hours", NamePaint = Label(), LabelsPaint = Label(), MinLimit = 0 } }, }; - 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) + /// Hour-of-day profile for a single artist, track, album or year. + public static ChartData 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); + return Columns(values, labels, "Hours", "Hour of day", ChartPalette.Accent2); } // ---- Podcasts ---------------------------------------------------------------------------- - public static (ISeries[] series, Axis[] x, Axis[] y) TopShows(AnalysisResult r, int take = 20) + public static ChartData TopShows(AnalysisResult r, int take = PodcastBars) { 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)); + return Rows( + items.Select(s => Math.Round(s.TotalHours, 2)).ToArray(), + items.Select(s => ShortLabel(s.Show)).ToArray(), + "Hours", ChartPalette.Violet); } - public static (ISeries[] series, Axis[] x, Axis[] y) TopEpisodes(AnalysisResult r, int take = 20) + public static ChartData TopEpisodes(AnalysisResult r, int take = PodcastBars) { 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, - }); + return Rows( + items.Select(e => Math.Round(e.TotalHours, 2)).ToArray(), + items.Select(e => ShortLabel(e.Episode)).ToArray(), + "Hours", ChartPalette.Cyan); } } diff --git a/Sortify/Services/ChartData.cs b/Sortify/Services/ChartData.cs new file mode 100644 index 0000000..921395c --- /dev/null +++ b/Sortify/Services/ChartData.cs @@ -0,0 +1,19 @@ +using LiveChartsCore; +using LiveChartsCore.SkiaSharpView; + +namespace Sortify.Services; + +/// +/// One chart's worth of LiveCharts state. Bundling the series with its axes keeps the view +/// model to a single property per chart instead of three, and means a chart can never be +/// left showing new series against stale axes. +/// +public sealed class ChartData +{ + public ISeries[] Series { get; init; } = Array.Empty(); + public Axis[] XAxes { get; init; } = Array.Empty(); + public Axis[] YAxes { get; init; } = Array.Empty(); + + /// Placeholder for a chart with nothing loaded yet. + public static ChartData Empty { get; } = new(); +} diff --git a/Sortify/Services/ChartPalette.cs b/Sortify/Services/ChartPalette.cs new file mode 100644 index 0000000..90aae3c --- /dev/null +++ b/Sortify/Services/ChartPalette.cs @@ -0,0 +1,118 @@ +using SkiaSharp; + +namespace Sortify.Services; + +/// +/// The colours paints with. Skia has no notion of WPF resources, +/// so the values are mirrored here and refreshed by whenever the +/// palette changes; charts are rebuilt afterwards. +/// +public static class ChartPalette +{ + /// Primary accent: listening-time series. + public static SKColor Accent => _active.Accent; + + /// Secondary accent: play-count series. + public static SKColor Accent2 => _active.Accent2; + + /// Axis labels and data labels. + public static SKColor Text => _active.Text; + + /// Fill for the "Other" slice of a donut. + public static SKColor Muted => _active.Muted; + + /// Skip / warning series. + public static SKColor Warm => _active.Warm; + + /// Podcast show bars. + public static SKColor Violet => _active.Violet; + + /// Podcast episode bars. + public static SKColor Cyan => _active.Cyan; + + /// The coldest heatmap stop, chosen to blend into the card behind it. + public static SKColor HeatEmpty => _active.HeatEmpty; + + /// Categorical colours for donut slices, in order. + public static SKColor[] Categories => _active.Categories; + + /// Switches every colour to the requested palette. Called by ThemeService. + public static void Apply(bool dark) => _active = dark ? Dark : Light; + + /// The heatmap ramp, coldest first. + public static SKColor[] HeatRamp => new[] + { + HeatEmpty, + Blend(HeatEmpty, Accent, 0.35f), + Accent, + Blend(Accent, new SKColor(255, 255, 255), 0.15f), + }; + + /// + /// One theme's colours. Declaring them as a set means a colour added here has to be + /// given a value in both themes rather than silently keeping the other one's. + /// + private sealed record Palette( + SKColor Accent, + SKColor Accent2, + SKColor Text, + SKColor Muted, + SKColor Warm, + SKColor Violet, + SKColor Cyan, + SKColor HeatEmpty, + SKColor[] Categories); + + private static readonly SKColor[] DarkCategories = + { + new(29, 185, 84), new(80, 156, 248), new(244, 162, 97), new(231, 111, 81), + new(42, 157, 143), new(233, 196, 106), new(155, 93, 229), new(247, 37, 133), + new(76, 201, 240), new(181, 23, 158), new(114, 9, 183), new(58, 134, 255), + new(255, 159, 28), new(46, 196, 182), new(255, 89, 94), new(124, 200, 60), + new(255, 196, 61), new(0, 187, 196), new(220, 80, 100), new(147, 130, 220), + new(72, 191, 145), new(255, 140, 105), new(120, 170, 240), new(210, 130, 215), + new(176, 205, 80), + }; + + // Same hues, darkened so they hold their own against a white card. + private static readonly SKColor[] LightCategories = + { + new(21, 128, 61), new(47, 111, 208), new(202, 118, 42), new(198, 76, 47), + new(28, 122, 112), new(179, 138, 44), new(120, 63, 191), new(198, 24, 104), + new(23, 149, 189), new(147, 17, 128), new(90, 8, 145), new(37, 99, 205), + new(203, 118, 12), new(24, 150, 139), new(206, 55, 60), new(85, 152, 34), + new(191, 140, 20), new(0, 137, 145), new(178, 51, 71), new(105, 90, 186), + new(38, 145, 105), new(203, 98, 68), new(72, 122, 197), new(160, 88, 166), + new(126, 150, 40), + }; + + private static readonly Palette Dark = new( + Accent: new SKColor(29, 185, 84), + Accent2: new SKColor(80, 156, 248), + Text: new SKColor(220, 220, 220), + Muted: new SKColor(110, 110, 110), + Warm: new SKColor(231, 111, 81), + Violet: new SKColor(155, 93, 229), + Cyan: new SKColor(76, 201, 240), + HeatEmpty: new SKColor(24, 24, 24), + Categories: DarkCategories); + + private static readonly Palette Light = new( + // Matches AccentColor in Themes/Light.xaml. + Accent: new SKColor(21, 128, 61), + Accent2: new SKColor(47, 111, 208), + Text: new SKColor(40, 45, 52), + Muted: new SKColor(150, 157, 165), + Warm: new SKColor(198, 76, 47), + Violet: new SKColor(120, 63, 191), + Cyan: new SKColor(23, 149, 189), + HeatEmpty: new SKColor(244, 246, 248), + Categories: LightCategories); + + private static Palette _active = Dark; + + private static SKColor Blend(SKColor a, SKColor b, float t) => new( + (byte)(a.Red + (b.Red - a.Red) * t), + (byte)(a.Green + (b.Green - a.Green) * t), + (byte)(a.Blue + (b.Blue - a.Blue) * t)); +} diff --git a/Sortify/Services/DetailEngine.cs b/Sortify/Services/DetailEngine.cs index 9197c08..84eb64b 100644 --- a/Sortify/Services/DetailEngine.cs +++ b/Sortify/Services/DetailEngine.cs @@ -26,8 +26,9 @@ public static DetailResult Build( string subtitle, CancellationToken cancellationToken = default) { - var tracks = new Dictionary(StringComparer.Ordinal); - var albums = new Dictionary(StringComparer.Ordinal); + var tracks = new Dictionary<(string Track, string Artist), TrackStat>(); + var albums = new Dictionary<(string Album, string Artist), AlbumStat>(); + var artists = new Dictionary(StringComparer.Ordinal); var byMonth = new Dictionary(); var days = new HashSet(); var byHour = new long[24]; @@ -38,13 +39,27 @@ public static DetailResult Build( DateTime? last = null; string uri = string.Empty; + // A year scope carries its number in the title; parsing it per record would repeat + // the same parse hundreds of thousands of times. + int scopeYear = 0; + if (scope == DetailScope.Year && !int.TryParse(title, out scopeYear)) + return Empty(scope, title, subtitle); + int counter = 0; foreach (var r in FilterEngine.Apply(records, filter)) { if ((++counter & 0x3FFF) == 0) cancellationToken.ThrowIfCancellationRequested(); - if (!IsMatch(r, scope, title, subtitle)) + // Mirror the main analysis: podcasts and audiobooks only feed these aggregates + // when the user asked for them, otherwise a year's totals would disagree with + // the Years tab they were opened from. + if (r.Kind != ContentKind.Music && !filter.IncludePodcasts) + continue; + + var (trackName, artistName, albumName) = DisplayNames(r); + + if (!IsMatch(r, trackName, artistName, albumName, scope, title, subtitle, scopeYear)) continue; totalMs += r.MsPlayed; @@ -54,29 +69,54 @@ public static DetailResult Build( if (uri.Length == 0 && r.Uri.Length > 0) uri = r.Uri; - var trackKey = r.TrackName + "\n" + r.ArtistName; + var trackKey = (trackName, artistName); 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++; - var albumKey = r.AlbumName + "\n" + r.ArtistName; + var albumKey = (albumName, 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; album.PlayCount++; + // Only a year spans multiple artists; for the other scopes this would be one + // row repeating what the header already says. + ArtistStat? artist = null; + if (scope == DetailScope.Year) + { + if (!artists.TryGetValue(artistName, out artist)) + { + artist = new ArtistStat { Artist = artistName, FirstTrack = trackName }; + artists[artistName] = artist; + } + artist.TotalMsPlayed += r.MsPlayed; + artist.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 (artist is not null) + { + if (artist.FirstPlayed is null || r.Timestamp < artist.FirstPlayed) + { + artist.FirstPlayed = r.Timestamp; + artist.FirstTrack = trackName; + } + if (artist.LastPlayed is null || r.Timestamp > artist.LastPlayed) artist.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 (album.FirstPlayed is null || r.Timestamp < album.FirstPlayed) album.FirstPlayed = r.Timestamp; @@ -101,6 +141,7 @@ public static DetailResult Build( ActiveDays = days.Count, Tracks = tracks.Values.OrderByDescending(t => t.TotalMsPlayed).ToList(), Albums = albums.Values.OrderByDescending(a => a.TotalMsPlayed).ToList(), + Artists = artists.Values.OrderByDescending(a => a.TotalMsPlayed).ToList(), ByMonth = byMonth.OrderBy(kv => kv.Key) .Select(kv => new DateTimePoint(kv.Key, kv.Value / 3_600_000d)) .ToList(), @@ -109,13 +150,33 @@ public static DetailResult Build( }; } - private static bool IsMatch(PlayRecord r, DetailScope scope, string title, string subtitle) => scope switch + /// + /// The names a record appears under in the grids. Podcasts and audiobooks stand in with + /// their episode and show titles exactly as folds them in, + /// so a row opened from a grid finds the plays it was built from. + /// + private static (string Track, string Artist, string Album) DisplayNames(PlayRecord r) + => r.Kind == ContentKind.Music + ? (r.TrackName, r.ArtistName, r.AlbumName) + : (r.EpisodeName, r.ShowName, r.ShowName); + + private static bool IsMatch( + PlayRecord r, string track, string artist, string album, + DetailScope scope, string title, string subtitle, int scopeYear) { - 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, - }; + return scope switch + { + DetailScope.Artist => string.Equals(artist, title, StringComparison.Ordinal), + DetailScope.Track => string.Equals(track, title, StringComparison.Ordinal) + && string.Equals(artist, subtitle, StringComparison.Ordinal), + DetailScope.Album => string.Equals(album, title, StringComparison.Ordinal) + && string.Equals(artist, subtitle, StringComparison.Ordinal), + // Undated rows belong to no year. + DetailScope.Year => r.Timestamp != DateTime.MinValue && r.Timestamp.Year == scopeYear, + _ => false, + }; + } + + private static DetailResult Empty(DetailScope scope, string title, string subtitle) + => new() { Scope = scope, Title = title, Subtitle = subtitle }; } diff --git a/Sortify/Services/ExportService.cs b/Sortify/Services/ExportService.cs index b0cfad1..78938ab 100644 --- a/Sortify/Services/ExportService.cs +++ b/Sortify/Services/ExportService.cs @@ -1,12 +1,26 @@ +using System.Globalization; using System.IO; using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; using Sortify.Models; namespace Sortify.Services; -/// Exports analysis results to TXT (parity with the original app) and CSV. +/// Exports analysis results to TXT, CSV, Markdown and JSON. public static class ExportService { + /// How many rows each ranked table in the Markdown report shows. + private const int MarkdownTopN = 25; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + // Track and artist names are full of characters the default (HTML-safe) encoder + // escapes into \uXXXX, which makes the file unreadable for a human. + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + public static async Task SaveTxtAsync(string path, AnalysisResult result) { var sb = new StringBuilder(); @@ -26,10 +40,19 @@ public static async Task SaveTxtAsync(string path, AnalysisResult result) 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.LongestBreakDays > 0 && result.LongestBreakStart is { } bs && result.LongestBreakEnd is { } be) + sb.AppendLine($"Longest break: {result.LongestBreakDays} days ({bs:yyyy-MM-dd} to {be: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))})"); + if (result.SkipEligiblePlays > 0) + { + sb.AppendLine($"Skip rate: {result.TotalSkips * 100.0 / result.SkipEligiblePlays:0.#}%"); + sb.AppendLine($"Completion rate: {result.CompletedPlays * 100.0 / result.SkipEligiblePlays:0.#}%"); + } + if (result.UniqueTracks > 0) + sb.AppendLine($"Plays per track: {result.PlaysPerTrack:0.0}"); sb.AppendLine(); // ---- Rankings ------------------------------------------------------------------ @@ -89,7 +112,7 @@ public static async Task SaveTracksCsvAsync(string path, AnalysisResult result) .Append(Csv(t.Track)).Append(',') .Append(Csv(t.Artist)).Append(',') .Append(TimeFormat.HhMmSs(t.TotalTime)).Append(',') - .Append(t.TotalHours.ToString("0.00")).Append(',') + .Append(t.TotalHours.ToString("0.00", CultureInfo.InvariantCulture)).Append(',') .Append(t.PlayCount).Append(',') .Append(TimeFormat.Timestamp(t.FirstPlayed)).Append(',') .Append(TimeFormat.Timestamp(t.LastPlayed)) @@ -108,7 +131,7 @@ public static async Task SaveArtistsCsvAsync(string path, AnalysisResult result) sb.Append(rank++).Append(',') .Append(Csv(a.Artist)).Append(',') .Append(TimeFormat.HhMmSs(a.TotalTime)).Append(',') - .Append(a.TotalHours.ToString("0.00")).Append(',') + .Append(a.TotalHours.ToString("0.00", CultureInfo.InvariantCulture)).Append(',') .Append(a.PlayCount).Append(',') .Append(TimeFormat.Timestamp(a.FirstPlayed)).Append(',') .Append(Csv(a.FirstTrack)).Append(',') @@ -129,7 +152,7 @@ public static async Task SaveAlbumsCsvAsync(string path, AnalysisResult result) .Append(Csv(a.Album)).Append(',') .Append(Csv(a.Artist)).Append(',') .Append(TimeFormat.HhMmSs(a.TotalTime)).Append(',') - .Append(a.TotalHours.ToString("0.00")).Append(',') + .Append(a.TotalHours.ToString("0.00", CultureInfo.InvariantCulture)).Append(',') .Append(a.PlayCount).Append(',') .Append(TimeFormat.Timestamp(a.FirstPlayed)).Append(',') .Append(TimeFormat.Timestamp(a.LastPlayed)) @@ -146,7 +169,7 @@ public static async Task SaveYearsCsvAsync(string path, AnalysisResult result) { sb.Append(y.Year).Append(',') .Append(TimeFormat.HhMmSs(y.TotalTime)).Append(',') - .Append(y.TotalHours.ToString("0.00")).Append(',') + .Append(y.TotalHours.ToString("0.00", CultureInfo.InvariantCulture)).Append(',') .Append(y.PlayCount).Append(',') .Append(y.UniqueArtists).Append(',') .Append(y.UniqueTracks).Append(',') @@ -157,6 +180,239 @@ public static async Task SaveYearsCsvAsync(string path, AnalysisResult result) await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8).ConfigureAwait(false); } + public static async Task SaveShowsCsvAsync(string path, AnalysisResult result) + { + var sb = new StringBuilder(); + sb.AppendLine("Rank,Show,Kind,TotalTime,Hours,PlayCount,Episodes,FirstPlayed,LastPlayed"); + int rank = 1; + foreach (var s in result.Shows) + { + sb.Append(rank++).Append(',') + .Append(Csv(s.Show)).Append(',') + .Append(s.Kind).Append(',') + .Append(TimeFormat.HhMmSs(s.TotalTime)).Append(',') + .Append(s.TotalHours.ToString("0.00", CultureInfo.InvariantCulture)).Append(',') + .Append(s.PlayCount).Append(',') + .Append(s.EpisodeCount).Append(',') + .Append(TimeFormat.Timestamp(s.FirstPlayed)).Append(',') + .Append(TimeFormat.Timestamp(s.LastPlayed)) + .AppendLine(); + } + await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8).ConfigureAwait(false); + } + + // ---- Markdown ------------------------------------------------------------------------ + + /// + /// A report meant to be read: the summary and insights in full, then the top + /// of each ranking as tables. + /// + public static async Task SaveMarkdownAsync(string path, AnalysisResult r) + { + var sb = new StringBuilder(); + sb.AppendLine("# Sortify report").AppendLine(); + sb.AppendLine($"*Generated {TimeFormat.Timestamp(DateTime.Now)}*").AppendLine(); + + sb.AppendLine("## Summary").AppendLine(); + sb.AppendLine("| Metric | Value |"); + sb.AppendLine("| --- | --- |"); + Row("Total listening time", $"{TimeFormat.Friendly(r.TotalTime)} ({TimeFormat.HhMmSs(r.TotalTime)})"); + Row("Total plays", r.TotalPlays.ToString("N0")); + Row("Unique tracks", r.UniqueTracks.ToString("N0")); + Row("Unique artists", r.UniqueArtists.ToString("N0")); + Row("Unique albums", r.UniqueAlbums.ToString("N0")); + if (r.FirstListen is { } first && r.LastListen is { } last) + Row("Date range", $"{TimeFormat.Timestamp(first)} to {TimeFormat.Timestamp(last)}"); + if (r.ActiveDays > 0) + Row("Active days", r.ActiveDays.ToString("N0")); + sb.AppendLine(); + + sb.AppendLine("## Insights").AppendLine(); + sb.AppendLine("| Metric | Value |"); + sb.AppendLine("| --- | --- |"); + if (r.LongestStreakDays > 0 && r.LongestStreakStart is { } ss && r.LongestStreakEnd is { } se) + Row("Longest streak", $"{r.LongestStreakDays} days ({ss:yyyy-MM-dd} to {se:yyyy-MM-dd})"); + if (r.CurrentStreakDays > 0) + Row("Current streak", $"{r.CurrentStreakDays} days"); + if (r.LongestBreakDays > 0 && r.LongestBreakStart is { } bs && r.LongestBreakEnd is { } be) + Row("Longest break", $"{r.LongestBreakDays} days ({bs:yyyy-MM-dd} to {be:yyyy-MM-dd})"); + if (r.BiggestDay is { } bd) + Row("Biggest day", $"{bd:yyyy-MM-dd} ({TimeFormat.Friendly(TimeSpan.FromMilliseconds(r.BiggestDayMs))})"); + if (r.SessionCount > 0) + { + Row("Listening sessions", r.SessionCount.ToString("N0")); + Row("Average session", TimeFormat.Friendly(TimeSpan.FromMilliseconds(r.AvgSessionMs))); + Row("Longest session", TimeFormat.Friendly(TimeSpan.FromMilliseconds(r.LongestSessionMs))); + } + if (r.SkipEligiblePlays > 0) + { + Row("Skip rate", $"{r.TotalSkips * 100.0 / r.SkipEligiblePlays:0.#}%"); + Row("Completion rate", $"{r.CompletedPlays * 100.0 / r.SkipEligiblePlays:0.#}%"); + } + if (r.UniqueTracks > 0) + Row("Plays per track", r.PlaysPerTrack.ToString("0.0")); + if (r.Artists.Count > 0) + Row("Top artist share", $"{r.Artists[0].Artist} ({r.TopArtistSharePercent:0.#}%)"); + if (r.ShuffleEligiblePlays > 0) + Row("Shuffle rate", $"{r.ShufflePlays * 100.0 / r.ShuffleEligiblePlays:0.#}%"); + sb.AppendLine(); + + Table("Top tracks", "Rank | Track | Artist | Time | Plays", "--- | --- | --- | --- | ---", + r.Tracks.Take(MarkdownTopN).Select((t, i) => + $"{i + 1} | {Md(t.Track)} | {Md(t.Artist)} | {TimeFormat.HhMmSs(t.TotalTime)} | {t.PlayCount:N0}")); + + Table("Top artists", "Rank | Artist | Time | Plays", "--- | --- | --- | ---", + r.Artists.Take(MarkdownTopN).Select((a, i) => + $"{i + 1} | {Md(a.Artist)} | {TimeFormat.HhMmSs(a.TotalTime)} | {a.PlayCount:N0}")); + + Table("Top albums", "Rank | Album | Artist | Time | Plays", "--- | --- | --- | --- | ---", + r.Albums.Take(MarkdownTopN).Select((a, i) => + $"{i + 1} | {Md(a.Album)} | {Md(a.Artist)} | {TimeFormat.HhMmSs(a.TotalTime)} | {a.PlayCount:N0}")); + + if (r.Years.Count > 0) + Table("By year", "Year | Time | Plays | Artists | Tracks | Top artist | Top track", + "--- | --- | --- | --- | --- | --- | ---", + r.Years.Select(y => + $"{y.Year} | {TimeFormat.HhMmSs(y.TotalTime)} | {y.PlayCount:N0} | {y.UniqueArtists:N0} | " + + $"{y.UniqueTracks:N0} | {Md(y.TopArtist)} | {Md(y.TopTrack)}")); + + if (r.Shows.Count > 0) + Table("Top shows and audiobooks", "Rank | Show | Time | Plays | Episodes", + "--- | --- | --- | --- | ---", + r.Shows.Take(MarkdownTopN).Select((s, i) => + $"{i + 1} | {Md(s.Show)} | {TimeFormat.HhMmSs(s.TotalTime)} | {s.PlayCount:N0} | {s.EpisodeCount:N0}")); + + await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8).ConfigureAwait(false); + + void Row(string name, string value) => sb.AppendLine($"| {name} | {value} |"); + + void Table(string heading, string header, string divider, IEnumerable rows) + { + sb.AppendLine($"## {heading}").AppendLine(); + sb.AppendLine($"| {header} |"); + sb.AppendLine($"| {divider} |"); + foreach (var row in rows) + sb.AppendLine($"| {row} |"); + sb.AppendLine(); + } + } + + /// Escapes the pipe that would otherwise split a Markdown table cell. + internal static string Md(string value) => value.Replace("|", "\\|"); + + // ---- JSON ---------------------------------------------------------------------------- + + /// + /// The whole result as machine-readable JSON, for anyone who wants to do their own + /// analysis on top. Shaped explicitly rather than serializing + /// directly, so the file format is stable across refactors. + /// + public static async Task SaveJsonAsync(string path, AnalysisResult r) + { + var payload = new + { + generated = DateTime.Now, + summary = new + { + totalTime = TimeFormat.HhMmSs(r.TotalTime), + totalHours = Math.Round(r.TotalMsPlayed / 3_600_000d, 2), + totalPlays = r.TotalPlays, + uniqueTracks = r.UniqueTracks, + uniqueArtists = r.UniqueArtists, + uniqueAlbums = r.UniqueAlbums, + firstListen = r.FirstListen, + lastListen = r.LastListen, + activeDays = r.ActiveDays, + }, + insights = new + { + longestStreakDays = r.LongestStreakDays, + longestStreakStart = r.LongestStreakStart, + longestStreakEnd = r.LongestStreakEnd, + currentStreakDays = r.CurrentStreakDays, + longestBreakDays = r.LongestBreakDays, + longestBreakStart = r.LongestBreakStart, + longestBreakEnd = r.LongestBreakEnd, + biggestDay = r.BiggestDay, + biggestDayHours = Math.Round(r.BiggestDayMs / 3_600_000d, 2), + sessionCount = r.SessionCount, + avgSessionMinutes = Math.Round(r.AvgSessionMs / 60_000d, 1), + longestSessionMinutes = Math.Round(r.LongestSessionMs / 60_000d, 1), + skipEligiblePlays = r.SkipEligiblePlays, + skips = r.TotalSkips, + completedPlays = r.CompletedPlays, + playsPerTrack = Math.Round(r.PlaysPerTrack, 2), + newArtistsPerMonth = Math.Round(r.NewArtistsPerMonth, 2), + topArtistSharePercent = Math.Round(r.TopArtistSharePercent, 2), + shufflePlays = r.ShufflePlays, + shuffleEligiblePlays = r.ShuffleEligiblePlays, + offlinePlays = r.OfflinePlays, + incognitoPlays = r.IncognitoPlays, + }, + tracks = r.Tracks.Select(t => new + { + track = t.Track, + artist = t.Artist, + hours = Math.Round(t.TotalHours, 4), + plays = t.PlayCount, + firstPlayed = t.FirstPlayed, + lastPlayed = t.LastPlayed, + }), + artists = r.Artists.Select(a => new + { + artist = a.Artist, + hours = Math.Round(a.TotalHours, 4), + plays = a.PlayCount, + firstPlayed = a.FirstPlayed, + lastPlayed = a.LastPlayed, + firstTrack = a.FirstTrack, + }), + albums = r.Albums.Select(a => new + { + album = a.Album, + artist = a.Artist, + hours = Math.Round(a.TotalHours, 4), + plays = a.PlayCount, + firstPlayed = a.FirstPlayed, + lastPlayed = a.LastPlayed, + }), + years = r.Years.Select(y => new + { + year = y.Year, + hours = Math.Round(y.TotalHours, 4), + plays = y.PlayCount, + uniqueArtists = y.UniqueArtists, + uniqueTracks = y.UniqueTracks, + topArtist = y.TopArtist, + topTrack = y.TopTrack, + }), + shows = r.Shows.Select(s => new + { + show = s.Show, + kind = s.Kind.ToString(), + hours = Math.Round(s.TotalHours, 4), + plays = s.PlayCount, + episodes = s.EpisodeCount, + }), + episodes = r.Episodes.Select(e => new + { + episode = e.Episode, + show = e.Show, + hours = Math.Round(e.TotalHours, 4), + plays = e.PlayCount, + }), + hoursByHourOfDay = r.PlaytimeByHour.Select(ms => Math.Round(ms / 3_600_000d, 4)), + hoursByDayOfWeek = r.PlaytimeByDayOfWeek.Select(ms => Math.Round(ms / 3_600_000d, 4)), + platforms = r.Platforms.Select(p => new { name = p.Name, hours = Math.Round(p.TotalHours, 4), plays = p.PlayCount }), + countries = r.Countries.Select(c => new { name = c.Name, hours = Math.Round(c.TotalHours, 4), plays = c.PlayCount }), + reasonEnds = r.ReasonEnds.Select(x => new { reason = x.Reason, count = x.Count }), + }; + + await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, + 64 * 1024, FileOptions.Asynchronous); + await JsonSerializer.SerializeAsync(stream, payload, JsonOptions).ConfigureAwait(false); + } + /// Quotes a CSV field when it contains a delimiter, quote or line break. internal static string Csv(string value) { diff --git a/Sortify/Services/FilterEngine.cs b/Sortify/Services/FilterEngine.cs index 8acd391..08a851a 100644 --- a/Sortify/Services/FilterEngine.cs +++ b/Sortify/Services/FilterEngine.cs @@ -33,10 +33,15 @@ public static IEnumerable Apply( if (filter.EndDate is { } end && r.Timestamp > end) continue; - if (hasExcludedArtists && filter.ExcludedArtists.Contains(r.ArtistName)) + // Podcasts show up in the grids under their show and episode names, so that is + // what an exclusion picked from a grid row holds. Their ArtistName/TrackName are + // placeholders that would never match it. + bool isMusic = r.Kind == ContentKind.Music; + + if (hasExcludedArtists && filter.ExcludedArtists.Contains(isMusic ? r.ArtistName : r.ShowName)) continue; - if (hasExcludedTracks && filter.ExcludedTracks.Contains(r.TrackName)) + if (hasExcludedTracks && filter.ExcludedTracks.Contains(isMusic ? r.TrackName : r.EpisodeName)) continue; // Time-of-day / day-of-week only apply when a real timestamp exists. diff --git a/Sortify/Services/HistoryParser.cs b/Sortify/Services/HistoryParser.cs index 0fc6307..0e5c957 100644 --- a/Sortify/Services/HistoryParser.cs +++ b/Sortify/Services/HistoryParser.cs @@ -14,6 +14,12 @@ public sealed class ParseResult public List Warnings { get; } = new(); } +/// How far a parse has got, for a determinate progress bar and a status line. +public readonly record struct ParseProgress(int Completed, int Total, string Message) +{ + public double Percent => Total <= 0 ? 0 : Completed * 100.0 / Total; +} + /// /// Reads selected Spotify streaming history JSON files into normalized PlayRecords. /// Handles both the extended history and the older account-data format. Empty or @@ -36,8 +42,20 @@ public static IReadOnlyList FindHistoryFiles(string folder) if (!Directory.Exists(folder)) return Array.Empty(); + // SearchOption.AllDirectories throws on the first subfolder the user can't read, + // which takes the whole scan down with it (a Downloads folder is enough to hit this). + // AttributesToSkip is cleared because EnumerationOptions skips hidden files by + // default, which the SearchOption overloads never did. + var recursive = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = 0, + }; + var topLevel = new EnumerationOptions { IgnoreInaccessible = true, AttributesToSkip = 0 }; + var named = Directory - .EnumerateFiles(folder, "*.json", SearchOption.AllDirectories) + .EnumerateFiles(folder, "*.json", recursive) .Where(f => { var name = Path.GetFileName(f); @@ -52,14 +70,14 @@ public static IReadOnlyList FindHistoryFiles(string folder) return named; return Directory - .EnumerateFiles(folder, "*.json", SearchOption.TopDirectoryOnly) + .EnumerateFiles(folder, "*.json", topLevel) .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) .ToList(); } public async Task ParseAsync( IEnumerable filePaths, - IProgress? progress = null, + IProgress? progress = null, CancellationToken cancellationToken = default) { var result = new ParseResult(); @@ -77,7 +95,7 @@ public async Task ParseAsync( { var outcome = await ParseFileAsync(path, pool, cancellationToken).ConfigureAwait(false); int n = Interlocked.Increment(ref completed); - progress?.Report($"Read {n} of {paths.Count} file(s)..."); + progress?.Report(new ParseProgress(n, paths.Count, $"Read {n} of {paths.Count} file(s)...")); return outcome; }, cancellationToken)) .ToList(); diff --git a/Sortify/Services/ImageExporter.cs b/Sortify/Services/ImageExporter.cs new file mode 100644 index 0000000..03cfc32 --- /dev/null +++ b/Sortify/Services/ImageExporter.cs @@ -0,0 +1,100 @@ +using System.IO; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace Sortify.Services; + +/// +/// Renders a piece of the live UI - a chart card - to a PNG, so a chart can be dropped +/// into a document without a screenshot tool. +/// +public static class ImageExporter +{ + /// Rendered at 2x so the result stays sharp when scaled or printed. + private const double Scale = 2.0; + + private const double BaseDpi = 96.0; + + /// + /// Ceiling on the pixels of a single export. A RenderTargetBitmap allocates four bytes + /// per pixel up front, so an unbounded scale-up of a tall paged bar chart is an + /// out-of-memory crash rather than a slow save; 64 MP is ~256 MB and well past any + /// chart worth looking at. + /// + private const double MaxPixels = 64_000_000; + + /// + /// Renders onto and returns the + /// bitmap, or null when the element has no size yet (never laid out, or collapsed). + /// + public static BitmapSource? Render(FrameworkElement element, Brush? background) + { + double width = element.ActualWidth; + double height = element.ActualHeight; + if (width <= 0 || height <= 0 || !double.IsFinite(width) || !double.IsFinite(height)) + return null; + + // A VisualBrush of the live element captures whatever it currently shows, including + // the SkiaSharp surface the charts draw into. + var visual = new DrawingVisual(); + using (var context = visual.RenderOpen()) + { + var bounds = new Rect(0, 0, width, height); + if (background is not null) + context.DrawRectangle(background, null, bounds); + context.DrawRectangle(new VisualBrush(element), null, bounds); + } + + double scale = ScaleFor(width, height); + var bitmap = new RenderTargetBitmap( + (int)Math.Ceiling(width * scale), + (int)Math.Ceiling(height * scale), + BaseDpi * scale, BaseDpi * scale, + PixelFormats.Pbgra32); + bitmap.Render(visual); + bitmap.Freeze(); + return bitmap; + } + + /// + /// , backed off far enough to keep the bitmap under + /// . An element large enough to need it downsamples rather than + /// failing, which beats no image at all. + /// + internal static double ScaleFor(double width, double height) + { + double pixels = width * Scale * height * Scale; + if (pixels <= MaxPixels) + return Scale; + + // Both dimensions shrink, so the area shrinks by the square of the factor. + return Scale * Math.Sqrt(MaxPixels / pixels); + } + + /// Renders and writes a PNG. Returns false when there was nothing to render. + public static bool SavePng(FrameworkElement element, Brush? background, string path) + { + var bitmap = Render(element, background); + if (bitmap is null) + return false; + + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); + encoder.Save(stream); + return true; + } + + /// Renders onto the clipboard. Returns false when there was nothing to render. + public static bool CopyToClipboard(FrameworkElement element, Brush? background) + { + var bitmap = Render(element, background); + if (bitmap is null) + return false; + + Clipboard.SetImage(bitmap); + return true; + } +} diff --git a/Sortify/Services/RecordCache.cs b/Sortify/Services/RecordCache.cs index a3cff7b..33481c7 100644 --- a/Sortify/Services/RecordCache.cs +++ b/Sortify/Services/RecordCache.cs @@ -14,18 +14,24 @@ 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(); + /// + /// Upper bounds on the two counts read out of the file header, so a truncated or + /// corrupt cache asks for a plausible allocation rather than an enormous one. Both are + /// far past any real export: Spotify's own limit is a few hundred thousand plays, and + /// the pool only ever holds device and country strings. + /// + private const int MaxRecords = 50_000_000; + private const int MaxPoolEntries = 100_000; - /// %LOCALAPPDATA%\Sortify — created on demand. - public static string CacheDirectory { get; } = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Sortify"); + private static readonly byte[] Magic = "SRTFY\0"u8.ToArray(); - private static string CacheFile => Path.Combine(CacheDirectory, "records.cache"); + private static string CacheFile => Path.Combine(AppPaths.DataDirectory, "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. + /// 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. /// internal static string BuildKey(IEnumerable filePaths) { @@ -37,7 +43,8 @@ internal static string BuildKey(IEnumerable filePaths) long ticks = info.Exists ? info.LastWriteTimeUtc.Ticks : -1; return $"{info.FullName.ToLowerInvariant()}|{length}|{ticks}"; }) - .OrderBy(s => s, StringComparer.Ordinal); + .OrderBy(s => s, StringComparer.Ordinal) + .Prepend($"tz|{TimeZoneInfo.Local.Id}"); var joined = string.Join("\n", parts); var hash = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(joined)); @@ -69,12 +76,16 @@ internal static string BuildKey(IEnumerable filePaths) return null; int count = reader.ReadInt32(); - if (count < 0) + if (count is < 0 or > MaxRecords) 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()]; + int poolSize = reader.ReadInt32(); + if (poolSize is < 0 or > MaxPoolEntries) + return null; + + var pool = new string[poolSize]; for (int i = 0; i < pool.Length; i++) pool[i] = reader.ReadString(); @@ -87,10 +98,10 @@ internal static string BuildKey(IEnumerable filePaths) ArtistName = reader.ReadString(), AlbumName = reader.ReadString(), MsPlayed = reader.ReadInt32(), - Timestamp = new DateTime(reader.ReadInt64()), + Timestamp = ReadTimestamp(reader), ReasonEnd = reader.ReadBoolean() ? reader.ReadString() : null, Skipped = reader.ReadBoolean(), - Kind = (ContentKind)reader.ReadByte(), + Kind = ReadKind(reader), ShowName = reader.ReadString(), EpisodeName = reader.ReadString(), Uri = reader.ReadString(), @@ -104,15 +115,36 @@ internal static string BuildKey(IEnumerable filePaths) } return records; } + // FormatException is what BinaryReader.ReadString throws on a mangled length prefix. catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or EndOfStreamException or ArgumentException - or IndexOutOfRangeException or OverflowException) + or IndexOutOfRangeException or OverflowException + or FormatException or InvalidDataException) { // A corrupt or partially written cache must never break loading. return null; } } + /// + /// The parser hands out local-time timestamps, and DateTime.MinValue for rows with none. Ticks + /// alone lose that Kind, which would make a cached load serialize dates differently + /// (no UTC offset) from a fresh parse of the same export. + /// + private static DateTime ReadTimestamp(BinaryReader reader) + { + long ticks = reader.ReadInt64(); + return ticks == DateTime.MinValue.Ticks + ? DateTime.MinValue + : new DateTime(ticks, DateTimeKind.Local); + } + + private static ContentKind ReadKind(BinaryReader reader) + { + var kind = (ContentKind)reader.ReadByte(); + return Enum.IsDefined(kind) ? kind : throw new InvalidDataException($"Unknown content kind {(int)kind}."); + } + /// /// Writes the cache for these files. Best-effort: a failure here costs nothing but the /// speed-up next time, so it is swallowed. @@ -122,7 +154,7 @@ public static void TrySave(IEnumerable filePaths, IReadOnlyListRemoves the cache file, e.g. after a format change or on user request. public static void Clear() => TryDelete(CacheFile); + /// Size of the cache on disk in bytes, or zero when there is none. + public static long SizeBytes() + { + try + { + var info = new FileInfo(CacheFile); + return info.Exists ? info.Length : 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return 0; + } + } + private static void TryDelete(string path) { try diff --git a/Sortify/Services/ThemeService.cs b/Sortify/Services/ThemeService.cs new file mode 100644 index 0000000..ecfebb3 --- /dev/null +++ b/Sortify/Services/ThemeService.cs @@ -0,0 +1,140 @@ +using System.IO; +using System.Windows; +using LiveChartsCore; +using LiveChartsCore.SkiaSharpView; +using Microsoft.Win32; + +namespace Sortify.Services; + +/// Which palette the user asked for. +public enum AppTheme +{ + /// Follow the Windows "app mode" setting. + System, + Dark, + Light, +} + +/// +/// Swaps the active palette dictionary at runtime. Every style reaches its colours through +/// DynamicResource, so replacing the merged dictionary at repaints +/// the whole window without recreating any windows. +/// +public static class ThemeService +{ + /// Index of the palette inside App.xaml's merged dictionaries. + private const int PaletteSlot = 0; + + // Absolute pack URIs rather than relative ones: a relative URI resolves against whatever + // assembly happens to be running the show, which is not Sortify under a test host. + private static readonly Uri DarkUri = new("pack://application:,,,/Sortify;component/Themes/Dark.xaml"); + private static readonly Uri LightUri = new("pack://application:,,,/Sortify;component/Themes/Light.xaml"); + + /// Raised after the palette changes, so charts can be rebuilt in the new colours. + public static event EventHandler? Changed; + + public static AppTheme Current { get; private set; } = AppTheme.Dark; + + /// True when the palette currently in effect is the dark one. + public static bool IsDark { get; private set; } = true; + + /// + /// False until the first . App.xaml merges the dark palette itself, + /// so the state above already matches at startup - but LiveCharts has not been told + /// which theme to paint its legends and tooltips with, and skipping the first call as + /// redundant would leave them on the library's light default. + /// + private static bool _applied; + + /// + /// Applies a theme. Safe to call before any window exists, and a no-op when the + /// resolved palette is already the one in effect. + /// + public static void Apply(AppTheme theme) + { + bool dark = theme switch + { + AppTheme.Dark => true, + AppTheme.Light => false, + _ => SystemPrefersDark(), + }; + + bool sameTheme = Current == theme; + Current = theme; + + if (_applied && sameTheme && dark == IsDark) + return; + + _applied = true; + IsDark = dark; + SwapDictionary(dark); + ChartPalette.Apply(dark); + + // LiveCharts paints legends, tooltips and default series from its own theme. + LiveCharts.Configure(config => + { + if (dark) config.AddDarkTheme(); + else config.AddLightTheme(); + }); + + Changed?.Invoke(null, EventArgs.Empty); + } + + /// The theme that follows this one when the toggle button is pressed. + public static AppTheme Next() => IsDark ? AppTheme.Light : AppTheme.Dark; + + /// + /// Keeps in step with Windows while the app runs, rather than + /// only reading the setting at launch. Pair with : + /// SystemEvents holds its handlers statically and leaks anything left subscribed. + /// + public static void WatchSystemTheme() => SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged; + + public static void StopWatchingSystemTheme() => SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; + + private static void OnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) + { + // Switching light/dark mode in Windows arrives as General; this can be raised off the + // UI thread, and Apply touches application resources. + if (e.Category != UserPreferenceCategory.General) + return; + + Application.Current?.Dispatcher.BeginInvoke(() => + { + if (Current == AppTheme.System) + Apply(AppTheme.System); + }); + } + + private static void SwapDictionary(bool dark) + { + var app = Application.Current; + if (app is null) + return; + + var dictionary = new ResourceDictionary { Source = dark ? DarkUri : LightUri }; + var merged = app.Resources.MergedDictionaries; + if (merged.Count > PaletteSlot) + merged[PaletteSlot] = dictionary; + else + merged.Insert(0, dictionary); + } + + /// + /// Reads the Windows "choose your mode" setting. Defaults to dark when the value is + /// missing or unreadable, which matches how the app has always looked. + /// + public static bool SystemPrefersDark() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + return key?.GetValue("AppsUseLightTheme") is not int light || light == 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + return true; + } + } +} diff --git a/Sortify/Services/TimeFormat.cs b/Sortify/Services/TimeFormat.cs index aa30629..6af97f6 100644 --- a/Sortify/Services/TimeFormat.cs +++ b/Sortify/Services/TimeFormat.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace Sortify.Services; /// Shared formatting helpers for durations and timestamps. @@ -20,7 +22,11 @@ public static string Friendly(TimeSpan td) return $"{td.Minutes}m {td.Seconds}s"; } - public static string Timestamp(DateTime dt) => dt.ToString("yyyy-MM-dd HH:mm"); + /// + /// Invariant because ':' in a custom format is the culture's time separator, and these + /// strings end up in CSV files that other tools parse. + /// + public static string Timestamp(DateTime dt) => dt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); /// 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/Sortify.csproj b/Sortify/Sortify.csproj index 6f54ac1..3fac700 100644 --- a/Sortify/Sortify.csproj +++ b/Sortify/Sortify.csproj @@ -8,14 +8,18 @@ true Sortify Sortify - 3.2.0 + 3.3.0 Parsa Rasouli Baghi Sortify app.manifest + + $(NoWarn);NU1701 - win-x64 + + win-x64;win-x86 true true true diff --git a/Sortify/Themes/Controls.xaml b/Sortify/Themes/Controls.xaml new file mode 100644 index 0000000..10236e4 --- /dev/null +++ b/Sortify/Themes/Controls.xaml @@ -0,0 +1,1366 @@ + + + + + 0:0:0.15 + 0:0:0.22 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sortify/Themes/Dark.xaml b/Sortify/Themes/Dark.xaml new file mode 100644 index 0000000..15907ae --- /dev/null +++ b/Sortify/Themes/Dark.xaml @@ -0,0 +1,47 @@ + + + + + #FF121212 + #FF181818 + #FF242424 + #FF2E2E2E + #FF2C2C2C + #FF4A4A4A + + + #FF1DB954 + #FF1ED760 + #FF509CF8 + #FFE76F51 + + + #FFEDEDED + #FF9A9A9A + #FF06140B + + + + + + + + + + + + + + + + + + + + + diff --git a/Sortify/Themes/Light.xaml b/Sortify/Themes/Light.xaml new file mode 100644 index 0000000..6a1034c --- /dev/null +++ b/Sortify/Themes/Light.xaml @@ -0,0 +1,47 @@ + + + + + #FFF3F5F7 + #FFFFFFFF + #FFEDF0F3 + #FFE0E5EA + #FFD8DDE3 + #FFB6BEC7 + + + + #FF15803D + #FF166534 + #FF2F6FD0 + #FFC2410C + + + #FF16191D + #FF64707D + #FFFFFFFF + + + + + + + + + + + + + + + + + + + + + diff --git a/Sortify/ViewModels/FilterViewModel.cs b/Sortify/ViewModels/FilterViewModel.cs index a0eee1d..1508f08 100644 --- a/Sortify/ViewModels/FilterViewModel.cs +++ b/Sortify/ViewModels/FilterViewModel.cs @@ -17,8 +17,13 @@ public sealed partial class FilterViewModel : ObservableObject [ObservableProperty] private int _minSeconds = FilterOptions.DefaultMinMs / 1000; [ObservableProperty] private bool _includePodcasts; - [ObservableProperty] private DateTime? _startDate; - [ObservableProperty] private DateTime? _endDate; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasInvalidDateRange))] + private DateTime? _startDate; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasInvalidDateRange))] + private DateTime? _endDate; [ObservableProperty] private string _searchTerm = string.Empty; [ObservableProperty] private int _startHour; [ObservableProperty] private int _endHour = 23; @@ -31,6 +36,12 @@ public sealed partial class FilterViewModel : ObservableObject public DayToggle[] Days { get; } + /// + /// True when the From date is after the To date. The filter still applies as asked (and + /// matches nothing); this only exists so the panel can say why everything vanished. + /// + public bool HasInvalidDateRange => StartDate is { } start && EndDate is { } end && start.Date > end.Date; + public FilterViewModel() { Days = new[] @@ -81,6 +92,42 @@ public FilterOptions ToOptions() return opts; } + /// + /// One short phrase per filter that is currently narrowing the results, for the chip + /// row above the tabs. Empty when nothing but the defaults are in effect. + /// + public IEnumerable Describe() + { + if (MinSeconds != FilterOptions.DefaultMinMs / 1000) + yield return $"Min {MinSeconds}s"; + + if (IncludePodcasts) + yield return "Podcasts counted"; + + if (StartDate is { } start && EndDate is { } end) + yield return $"{start:yyyy-MM-dd} to {end:yyyy-MM-dd}"; + else if (StartDate is { } from) + yield return $"From {from:yyyy-MM-dd}"; + else if (EndDate is { } to) + yield return $"Until {to:yyyy-MM-dd}"; + + if (!string.IsNullOrWhiteSpace(SearchTerm)) + yield return $"Search “{SearchTerm.Trim()}”"; + + if (StartHour != 0 || EndHour != 23) + yield return $"{StartHour:00}:00-{EndHour:00}:59"; + + var days = Days.Where(d => d.IsSelected).Select(d => d.Label).ToList(); + if (days.Count < Days.Length) + yield return days.Count == 0 ? "No days selected" : string.Join(", ", days); + + if (ExcludedArtists.Count > 0) + yield return $"{ExcludedArtists.Count} artist{(ExcludedArtists.Count == 1 ? "" : "s")} excluded"; + + if (ExcludedTracks.Count > 0) + yield return $"{ExcludedTracks.Count} track{(ExcludedTracks.Count == 1 ? "" : "s")} excluded"; + } + /// Adds an artist exclusion programmatically (e.g. from a grid context menu). public void ExcludeArtist(string? name) { diff --git a/Sortify/ViewModels/MainViewModel.cs b/Sortify/ViewModels/MainViewModel.cs index 11b3ad6..e183eba 100644 --- a/Sortify/ViewModels/MainViewModel.cs +++ b/Sortify/ViewModels/MainViewModel.cs @@ -1,8 +1,9 @@ +using System.Collections.ObjectModel; +using System.IO; using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LiveChartsCore; -using LiveChartsCore.SkiaSharpView; using Microsoft.Win32; using Sortify.Models; using Sortify.Services; @@ -12,13 +13,26 @@ namespace Sortify.ViewModels; public sealed partial class MainViewModel : ObservableObject { private readonly HistoryParser _parser = new(); - private readonly AppSettings _settings = AppSettings.Load(); + private readonly AppSettings _settings; private readonly DispatcherTimer _debounce; private List _rawRecords = new(); private AnalysisResult _result = AnalysisResult.Empty; private CancellationTokenSource? _analysisCts; + private CancellationTokenSource? _loadCts; private ChartBuilder.TimeGranularity _overTimeGranularity = ChartBuilder.TimeGranularity.Daily; + /// 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. + private string? _loadedFolder; + + // IsBusy covers two independent things - a parse and any number of overlapping analysis + // passes - so it is derived from both rather than saved and restored, which loses track + // as soon as two recomputes overlap. + private bool _isLoading; + private int _pendingAnalyses; + public FilterViewModel Filters { get; } = new(); // Grid item sources. Swapped wholesale after each analysis pass instead of using @@ -32,10 +46,41 @@ public sealed partial class MainViewModel : ObservableObject [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 (or their folder) here to begin."; + // Top-five lists shown on the Overview tab. + [ObservableProperty] private IReadOnlyList _topTracks = Array.Empty(); + [ObservableProperty] private IReadOnlyList _topArtists = Array.Empty(); + [ObservableProperty] private IReadOnlyList _topAlbums = Array.Empty(); + + // Per-grid quick filters. These narrow the rows already on screen without re-running + // analysis, which is what you want when hunting for one row in fifty thousand. + [ObservableProperty] private string _trackQuickFilter = string.Empty; + [ObservableProperty] private string _artistQuickFilter = string.Empty; + [ObservableProperty] private string _albumQuickFilter = string.Empty; + [ObservableProperty] private string _showQuickFilter = string.Empty; + + [ObservableProperty] + private string _statusText = "Open your Spotify history to begin - or just drop the files here."; + [ObservableProperty] private bool _statusIsError; [ObservableProperty] private bool _isBusy; [ObservableProperty] private bool _hasData; + [ObservableProperty] private double _progressValue; + [ObservableProperty] private bool _isProgressIndeterminate = true; + + /// Filter sidebar visibility, restored from and saved to settings. + [ObservableProperty] private bool _sidebarVisible = true; + + /// Human-readable list of the filters currently narrowing the results. + public ObservableCollection ActiveFilters { get; } = new(); + + [ObservableProperty] private bool _hasActiveFilters; + + /// Folders opened previously, newest first; bound to the Open Recent menu. + public ObservableCollection RecentFolders { get; } = new(); + + [ObservableProperty] private string _themeGlyph = ""; + [ObservableProperty] private string _themeTooltip = "Switch to the light theme"; + // Summary (overview) ------------------------------------------------------------------ [ObservableProperty] private string _totalTimeText = "-"; [ObservableProperty] private string _totalPlaysText = "-"; @@ -46,10 +91,15 @@ public sealed partial class MainViewModel : ObservableObject // Insights ------------------------------------------------------------------------------ [ObservableProperty] private string _longestStreakText = "-"; [ObservableProperty] private string _currentStreakText = "-"; + [ObservableProperty] private string _longestBreakText = "-"; [ObservableProperty] private string _biggestDayText = "-"; [ObservableProperty] private string _activeDaysText = "-"; [ObservableProperty] private string _avgPerDayText = "-"; [ObservableProperty] private string _skipRateText = "-"; + [ObservableProperty] private string _completionRateText = "-"; + [ObservableProperty] private string _repeatRateText = "-"; + [ObservableProperty] private string _discoveryRateText = "-"; + [ObservableProperty] private string _topArtistShareText = "-"; [ObservableProperty] private string _peakHourText = "-"; [ObservableProperty] private string _sessionsText = "-"; [ObservableProperty] private string _avgSessionText = "-"; @@ -69,74 +119,36 @@ public sealed partial class MainViewModel : ObservableObject [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(); - [ObservableProperty] private Axis[] _tracksByTimeX = Array.Empty(); - [ObservableProperty] private Axis[] _tracksByTimeY = Array.Empty(); - - [ObservableProperty] private ISeries[] _tracksByCountSeries = Array.Empty(); - [ObservableProperty] private Axis[] _tracksByCountX = Array.Empty(); - [ObservableProperty] private Axis[] _tracksByCountY = Array.Empty(); - - [ObservableProperty] private ISeries[] _artistsByTimeSeries = Array.Empty(); - [ObservableProperty] private Axis[] _artistsByTimeX = Array.Empty(); - [ObservableProperty] private Axis[] _artistsByTimeY = Array.Empty(); - - [ObservableProperty] private ISeries[] _artistsByCountSeries = Array.Empty(); - [ObservableProperty] private Axis[] _artistsByCountX = Array.Empty(); - [ObservableProperty] private Axis[] _artistsByCountY = Array.Empty(); - - [ObservableProperty] private ISeries[] _albumsByTimeSeries = Array.Empty(); - [ObservableProperty] private Axis[] _albumsByTimeX = Array.Empty(); - [ObservableProperty] private Axis[] _albumsByTimeY = Array.Empty(); - - [ObservableProperty] private ISeries[] _albumsByCountSeries = Array.Empty(); - [ObservableProperty] private Axis[] _albumsByCountX = Array.Empty(); - [ObservableProperty] private Axis[] _albumsByCountY = Array.Empty(); - - [ObservableProperty] private ISeries[] _skippedSeries = Array.Empty(); - [ObservableProperty] private Axis[] _skippedX = Array.Empty(); - [ObservableProperty] private Axis[] _skippedY = Array.Empty(); - - [ObservableProperty] private ISeries[] _hourSeries = Array.Empty(); - [ObservableProperty] private Axis[] _hourX = Array.Empty(); - [ObservableProperty] private Axis[] _hourY = Array.Empty(); - - [ObservableProperty] private ISeries[] _dayOfWeekSeries = Array.Empty(); - [ObservableProperty] private Axis[] _dayOfWeekX = Array.Empty(); - [ObservableProperty] private Axis[] _dayOfWeekY = Array.Empty(); - - [ObservableProperty] private ISeries[] _heatSeries = Array.Empty(); - [ObservableProperty] private Axis[] _heatX = Array.Empty(); - [ObservableProperty] private Axis[] _heatY = Array.Empty(); - - [ObservableProperty] private ISeries[] _overTimeSeries = Array.Empty(); - [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 ChartData _tracksByTime = ChartData.Empty; + [ObservableProperty] private ChartData _tracksByCount = ChartData.Empty; + [ObservableProperty] private ChartData _artistsByTime = ChartData.Empty; + [ObservableProperty] private ChartData _artistsByCount = ChartData.Empty; + [ObservableProperty] private ChartData _albumsByTime = ChartData.Empty; + [ObservableProperty] private ChartData _albumsByCount = ChartData.Empty; + [ObservableProperty] private ChartData _skipped = ChartData.Empty; + [ObservableProperty] private ChartData _byHour = ChartData.Empty; + [ObservableProperty] private ChartData _byDayOfWeek = ChartData.Empty; + [ObservableProperty] private ChartData _heat = ChartData.Empty; + [ObservableProperty] private ChartData _overTime = ChartData.Empty; + [ObservableProperty] private ChartData _byYear = ChartData.Empty; + [ObservableProperty] private ChartData _discovery = ChartData.Empty; + [ObservableProperty] private ChartData _showsChart = ChartData.Empty; + [ObservableProperty] private ChartData _episodesChart = ChartData.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(); + /// Entry animation length for charts when animations are on. + private static readonly TimeSpan ChartAnimationDuration = TimeSpan.FromMilliseconds(500); - [ObservableProperty] private ISeries[] _episodeSeries = Array.Empty(); - [ObservableProperty] private Axis[] _episodeX = Array.Empty(); - [ObservableProperty] private Axis[] _episodeY = Array.Empty(); + /// How long filter edits settle before analysis re-runs. + private static readonly TimeSpan FilterDebounce = TimeSpan.FromMilliseconds(300); + + /// Entry animation length for charts; zero when the user turned animations off. + [ObservableProperty] private TimeSpan _chartAnimationSpeed = ChartAnimationDuration; // Heights that drive the scrollable horizontal bar charts (one per ~bar). [ObservableProperty] private double _tracksChartHeight = 480; @@ -148,6 +160,11 @@ public sealed partial class MainViewModel : ObservableObject // 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. private const int BarPageSize = 60; + + // Scrollable bar chart sizing, in device-independent pixels. + private const double BarRowHeight = 34; + private const double BarAxisPadding = 70; + private const double MinBarChartHeight = 220; private int _tracksShown; private int _artistsShown; private int _albumsShown; @@ -156,9 +173,18 @@ public sealed partial class MainViewModel : ObservableObject private int MaxArtists => Math.Min(ChartBuilder.MaxBars, _result.Artists.Count); private int MaxAlbums => Math.Min(ChartBuilder.MaxBars, _result.Albums.Count); - public MainViewModel() + public MainViewModel() : this(null) { } + + public MainViewModel(AppSettings? settings) { - _debounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; + _settings = settings ?? AppSettings.Load(); + _settings.PruneMissingFolders(); + SidebarVisible = _settings.SidebarVisible; + ChartAnimationSpeed = _settings.AnimateCharts ? ChartAnimationDuration : TimeSpan.Zero; + RefreshRecentFolders(); + RefreshThemeButton(); + + _debounce = new DispatcherTimer { Interval = FilterDebounce }; _debounce.Tick += async (_, _) => { _debounce.Stop(); @@ -166,12 +192,25 @@ public MainViewModel() }; Filters.FiltersChanged += (_, _) => { + RefreshActiveFilters(); if (!HasData) return; _debounce.Stop(); _debounce.Start(); }; + + // Charts bake their colours into Skia paints, so they have to be rebuilt rather + // than repainted when the palette changes. + ThemeService.Changed += (_, _) => + { + RefreshThemeButton(); + if (HasData) UpdateCharts(); + }; + + RefreshActiveFilters(); } + // ---- Loading --------------------------------------------------------------------------- + [RelayCommand] private async Task RunAnalysisAsync() { @@ -197,16 +236,33 @@ private async Task OpenFolderAsync() if (dialog.ShowDialog() != true) return; - var files = HistoryParser.FindHistoryFiles(dialog.FolderName); + await OpenFolderPathAsync(dialog.FolderName); + } + + /// Loads a folder by path. Used by Open Folder, the recent list and drag & drop. + [RelayCommand] + public async Task OpenFolderPathAsync(string? folder) + { + if (string.IsNullOrWhiteSpace(folder)) + return; + + if (!Directory.Exists(folder)) + { + SetStatus($"That folder no longer exists: {folder}", isError: true); + _settings.PruneMissingFolders(); + _settings.Save(); + RefreshRecentFolders(); + return; + } + + var files = HistoryParser.FindHistoryFiles(folder); if (files.Count == 0) { - StatusText = "No Spotify history JSON files were found in that folder."; + SetStatus("No Spotify history JSON files were found in that folder.", isError: true); return; } - _settings.LastFolder = dialog.FolderName; - _settings.Save(); - await LoadFilesAsync(files); + await LoadFilesAsync(files, folder); } /// @@ -222,60 +278,121 @@ public async Task RestoreLastFolderAsync() if (files.Count == 0) return; - await LoadFilesAsync(files); + await LoadFilesAsync(files, _settings.LastFolder); + } + + /// Re-reads the files behind the current results, bypassing the cache. + [RelayCommand(CanExecute = nameof(HasData))] + private async Task ReloadAsync() + { + if (_loadedFiles.Count == 0) + return; + + RecordCache.Clear(); + await LoadFilesAsync(_loadedFiles, _loadedFolder); + } + + /// + /// Stops whatever the status bar is currently reporting: a parse, an analysis or both. + /// The status is set here rather than where the cancellation lands, because a recompute + /// is also cancelled every time a newer one supersedes it, and that is not worth + /// announcing. + /// + [RelayCommand(CanExecute = nameof(IsBusy))] + private void CancelLoad() + { + _loadCts?.Cancel(); + _analysisCts?.Cancel(); + SetStatus("Cancelled. Change a filter or press F5 to run again."); } /// Parses the given history files and runs analysis. Also used by drag & drop. - public async Task LoadFilesAsync(IReadOnlyList filePaths) + public async Task LoadFilesAsync(IReadOnlyList filePaths, string? folder = null) { - if (filePaths.Count == 0 || IsBusy) + if (filePaths.Count == 0 || _isLoading) return; - IsBusy = true; + _loadCts?.Dispose(); + _loadCts = new CancellationTokenSource(); + var token = _loadCts.Token; + + _isLoading = true; + RefreshBusy(); + IsProgressIndeterminate = true; + ProgressValue = 0; try { + _loadedFolder = folder; + if (folder is not null) + { + _settings.RememberFolder(folder); + _settings.Save(); + RefreshRecentFolders(); + } + // 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)); + SetStatus("Checking for cached results..."); + var cached = await Task.Run(() => RecordCache.TryLoad(filePaths), token); if (cached is { Count: > 0 }) { _rawRecords = cached.ToList(); - StatusText = $"Loaded {_rawRecords.Count:N0} plays from cache. Crunching numbers..."; + _loadedFiles = filePaths; + SetStatus($"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); + IsProgressIndeterminate = false; + var progress = new Progress(p => + { + ProgressValue = p.Percent; + StatusText = p.Message; + StatusIsError = false; + }); + + var parsed = await _parser.ParseAsync(filePaths, progress, token); _rawRecords = parsed.Records; + _loadedFiles = filePaths; if (_rawRecords.Count == 0) { HasData = false; - StatusText = parsed.Warnings.Count > 0 + SetStatus(parsed.Warnings.Count > 0 ? $"No valid listening data found. {parsed.Warnings[0]}" - : "No valid listening data found in the selected files."; + : "No valid listening data found in the selected files.", isError: true); return; } int problems = parsed.Warnings.Count + parsed.SkippedFiles.Count; string warn = problems > 0 ? $" ({problems} file(s) skipped)" : string.Empty; - StatusText = $"Loaded {_rawRecords.Count:N0} plays from {filePaths.Count} file(s){warn}. Crunching numbers..."; + IsProgressIndeterminate = true; + SetStatus($"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)); + _ = Task.Run(() => RecordCache.TrySave(filePaths, toCache), CancellationToken.None); + } + catch (OperationCanceledException) + { + SetStatus("Loading cancelled."); } finally { - IsBusy = false; + _isLoading = false; + RefreshBusy(); + IsProgressIndeterminate = true; + ProgressValue = 0; } } + private void RefreshBusy() => IsBusy = _isLoading || _pendingAnalyses > 0; + + // ---- Chart options --------------------------------------------------------------------- + /// Changes the bucket size of the listening-over-time chart. public void SetOverTimeGranularity(ChartBuilder.TimeGranularity granularity) { @@ -283,7 +400,57 @@ public void SetOverTimeGranularity(ChartBuilder.TimeGranularity granularity) return; _overTimeGranularity = granularity; if (HasData) - (OverTimeSeries, OverTimeX, OverTimeY) = ChartBuilder.OverTime(_result, granularity); + OverTime = ChartBuilder.OverTime(_result, granularity); + } + + // ---- Appearance and preferences ---------------------------------------------------------- + + [RelayCommand] + private void ToggleTheme() + { + var next = ThemeService.Next(); + ThemeService.Apply(next); + _settings.Theme = next; + _settings.Save(); + } + + [RelayCommand] + private void ToggleSidebar() + { + SidebarVisible = !SidebarVisible; + _settings.SidebarVisible = SidebarVisible; + _settings.Save(); + } + + /// Re-reads preferences after the settings dialog closes and applies them. + public async Task ApplySettingsChangesAsync() + { + ChartAnimationSpeed = _settings.AnimateCharts ? ChartAnimationDuration : TimeSpan.Zero; + RefreshRecentFolders(); + if (HasData) + await RecomputeAsync(); + } + + private void RefreshRecentFolders() + { + RecentFolders.Clear(); + foreach (var folder in _settings.RecentFolders) + RecentFolders.Add(folder); + } + + private void RefreshThemeButton() + { + // Segoe MDL2 Assets: E706 is a sun, E708 a moon. + ThemeGlyph = ThemeService.IsDark ? "" : ""; + ThemeTooltip = ThemeService.IsDark ? "Switch to the light theme" : "Switch to the dark theme"; + } + + private void RefreshActiveFilters() + { + ActiveFilters.Clear(); + foreach (var description in Filters.Describe()) + ActiveFilters.Add(description); + HasActiveFilters = ActiveFilters.Count > 0; } // ---- Grid-driven exclusions ---------------------------------------------------------- @@ -305,29 +472,37 @@ public void SetOverTimeGranularity(ChartBuilder.TimeGranularity granularity) return await DetailEngine.BuildAsync(_rawRecords, Filters.ToOptions(), scope, title, subtitle); } + // ---- Analysis -------------------------------------------------------------------------- + private async Task RecomputeAsync() { if (!HasData) return; - _analysisCts?.Cancel(); - _analysisCts?.Dispose(); + // Cancel the previous pass but keep its source alive: the running task may still be + // reading the token, and disposing it out from under that is a race. + var previous = _analysisCts; _analysisCts = new CancellationTokenSource(); + previous?.Cancel(); var token = _analysisCts.Token; var options = Filters.ToOptions(); - bool wasBusy = IsBusy; - IsBusy = true; + _pendingAnalyses++; + RefreshBusy(); try { AnalysisResult result; try { - result = await AnalysisEngine.AnalyzeAsync(_rawRecords, options, token); + result = await AnalysisEngine.AnalyzeAsync(_rawRecords, options, _settings.SessionGap, token); } catch (OperationCanceledException) { return; } + finally + { + previous?.Dispose(); + } // A newer recompute may have started while this one ran; never let a stale // result overwrite the current one. @@ -341,30 +516,91 @@ private async Task RecomputeAsync() UpdateCharts(); NotifyExportsChanged(); - StatusText = _result.TotalPlays == 0 - ? "No plays match the current filters." + SetStatus(_result.TotalPlays == 0 + ? Filters.HasInvalidDateRange + ? "No plays match: the From date is after the To date." + : "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."; + $"{_result.UniqueArtists:N0} artists and {_result.UniqueAlbums:N0} albums."); } finally { - IsBusy = wasBusy; + _pendingAnalyses--; + RefreshBusy(); } } private void UpdateCollections() { - Tracks = _result.Tracks; - Artists = _result.Artists; - Albums = _result.Albums; + ApplyTrackQuickFilter(); + ApplyArtistQuickFilter(); + ApplyAlbumQuickFilter(); + ApplyShowQuickFilter(); + Years = _result.Years; SkippedTracks = _result.SkippedTracks; - Shows = _result.Shows; - Episodes = _result.Episodes; HasPodcastData = _result.Shows.Count > 0; - HasNoPodcastData = !HasPodcastData; + + TopTracks = _result.Tracks.Take(5) + .Select((t, i) => new RankedItem(i + 1, t.Track, t.Artist, TimeFormat.Friendly(t.TotalTime), t.PlayCount)) + .ToList(); + TopArtists = _result.Artists.Take(5) + .Select((a, i) => new RankedItem(i + 1, a.Artist, string.Empty, TimeFormat.Friendly(a.TotalTime), a.PlayCount)) + .ToList(); + TopAlbums = _result.Albums.Take(5) + .Select((a, i) => new RankedItem(i + 1, a.Album, a.Artist, TimeFormat.Friendly(a.TotalTime), a.PlayCount)) + .ToList(); + } + + // ---- Quick filters ------------------------------------------------------------------------ + + partial void OnTrackQuickFilterChanged(string value) => ApplyTrackQuickFilter(); + partial void OnArtistQuickFilterChanged(string value) => ApplyArtistQuickFilter(); + partial void OnAlbumQuickFilterChanged(string value) => ApplyAlbumQuickFilter(); + partial void OnShowQuickFilterChanged(string value) => ApplyShowQuickFilter(); + + private void ApplyTrackQuickFilter() => + Tracks = Narrow(_result.Tracks, TrackQuickFilter, t => t.Track, t => t.Artist); + + private void ApplyArtistQuickFilter() => + Artists = Narrow(_result.Artists, ArtistQuickFilter, a => a.Artist); + + private void ApplyAlbumQuickFilter() => + Albums = Narrow(_result.Albums, AlbumQuickFilter, a => a.Album, a => a.Artist); + + private void ApplyShowQuickFilter() + { + Shows = Narrow(_result.Shows, ShowQuickFilter, s => s.Show); + Episodes = Narrow(_result.Episodes, ShowQuickFilter, e => e.Episode, e => e.Show); } + /// + /// Case-insensitive substring match over the given fields. Returns the original list + /// untouched when nothing is typed, so the common case allocates nothing. + /// + private static IReadOnlyList Narrow(IReadOnlyList source, string term, params Func[] fields) + { + if (string.IsNullOrWhiteSpace(term)) + return source; + + string needle = term.Trim(); + var matches = new List(); + foreach (var item in source) + { + foreach (var field in fields) + { + if (field(item).Contains(needle, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(item); + break; + } + } + } + return matches; + } + + // ---- Summary text ------------------------------------------------------------------------- + private void UpdateSummary() { TotalTimeText = TimeFormat.Friendly(_result.TotalTime); @@ -388,6 +624,10 @@ private void UpdateInsights() ? $"{r.CurrentStreakDays} day{(r.CurrentStreakDays == 1 ? "" : "s")} (up to {lastListen:yyyy-MM-dd})" : "-"; + LongestBreakText = r.LongestBreakDays > 0 && r.LongestBreakStart is { } bs && r.LongestBreakEnd is { } be + ? $"{r.LongestBreakDays} day{(r.LongestBreakDays == 1 ? "" : "s")} ({bs:yyyy-MM-dd} to {be:yyyy-MM-dd})" + : "-"; + BiggestDayText = r.BiggestDay is { } bd ? $"{bd:yyyy-MM-dd} ({TimeFormat.Friendly(TimeSpan.FromMilliseconds(r.BiggestDayMs))})" : "-"; @@ -402,6 +642,22 @@ private void UpdateInsights() ? $"{r.TotalSkips * 100.0 / r.SkipEligiblePlays:0.#}% ({r.TotalSkips:N0} of {r.SkipEligiblePlays:N0} plays)" : "-"; + CompletionRateText = r.SkipEligiblePlays > 0 + ? $"{r.CompletedPlays * 100.0 / r.SkipEligiblePlays:0.#}% ({r.CompletedPlays:N0} plays)" + : "-"; + + RepeatRateText = r.UniqueTracks > 0 + ? $"{r.PlaysPerTrack:0.0} plays per track" + : "-"; + + DiscoveryRateText = r.NewArtistsByMonth.Count > 0 + ? $"{r.NewArtistsPerMonth:0.#} new artists / month" + : "-"; + + TopArtistShareText = r.Artists.Count > 0 + ? $"{r.Artists[0].Artist} ({r.TopArtistSharePercent:0.#}% of your time)" + : "-"; + PeakHourText = BuildPeakHourText(r); SessionsText = r.SessionCount > 0 ? r.SessionCount.ToString("N0") : "-"; @@ -439,18 +695,22 @@ private void UpdateInsights() UniqueEpisodesText = r.Episodes.Count > 0 ? r.Episodes.Count.ToString("N0") : "-"; } - private static string BuildPeakHourText(AnalysisResult r) + /// The busiest hour of the week, read off the same day-by-hour grid as the heatmap. + internal static string BuildPeakHourText(AnalysisResult r) { - if (r.TotalMsPlayed == 0) - return "-"; - - int peakHour = 0; - for (int h = 1; h < 24; h++) - if (r.PlaytimeByHour[h] > r.PlaytimeByHour[peakHour]) peakHour = h; + int peakDow = 0, peakHour = 0; + for (int d = 0; d < 7; d++) + { + for (int h = 0; h < 24; h++) + { + if (r.PlaytimeByDowHour[d, h] > r.PlaytimeByDowHour[peakDow, peakHour]) + (peakDow, peakHour) = (d, h); + } + } - int peakDow = 0; - for (int d = 1; d < 7; d++) - if (r.PlaytimeByDayOfWeek[d] > r.PlaytimeByDayOfWeek[peakDow]) peakDow = d; + // Undated plays count toward the totals but never reach the grid. + if (r.PlaytimeByDowHour[peakDow, peakHour] == 0) + return "-"; string[] dayNames = { "Sundays", "Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays" }; return $"{peakHour:00}:00-{(peakHour + 1) % 24:00}:00 on {dayNames[peakDow]}"; @@ -487,26 +747,28 @@ private static string BuildTimeOfDayText(AnalysisResult r) return $"{names[best]} ({segments[best] * 100.0 / total:0}% of listening)"; } + // ---- Charts ------------------------------------------------------------------------------ + 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); + ByHour = ChartBuilder.ByHour(_result); + ByDayOfWeek = ChartBuilder.ByDayOfWeek(_result); + Heat = ChartBuilder.DowHourHeat(_result); + OverTime = ChartBuilder.OverTime(_result, _overTimeGranularity); + ByYear = ChartBuilder.HoursPerYear(_result); + Discovery = ChartBuilder.NewArtistsByMonth(_result); + Skipped = 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)); + ShowsChart = ChartBuilder.TopShows(_result); + EpisodesChart = ChartBuilder.TopEpisodes(_result); + ShowsChartHeight = BarHeight(Math.Min(ChartBuilder.PodcastBars, _result.Shows.Count)); + EpisodesChartHeight = BarHeight(Math.Min(ChartBuilder.PodcastBars, _result.Episodes.Count)); // Reset the scrollable bar charts to their first page; LoadMore* append the rest. _tracksShown = Math.Min(BarPageSize, MaxTracks); @@ -517,31 +779,26 @@ private void UpdateCharts() BuildAlbumCharts(); } - private static double BarHeight(int bars) - { - const double perBar = 34; - const double axisPadding = 70; - return Math.Max(220, bars * perBar + axisPadding); - } + private static double BarHeight(int bars) => Math.Max(MinBarChartHeight, bars * BarRowHeight + BarAxisPadding); private void BuildTrackCharts() { - (TracksByTimeSeries, TracksByTimeX, TracksByTimeY) = ChartBuilder.TopTracksByTime(_result, _tracksShown); - (TracksByCountSeries, TracksByCountX, TracksByCountY) = ChartBuilder.TopTracksByCount(_result, _tracksShown); + TracksByTime = ChartBuilder.TopTracksByTime(_result, _tracksShown); + TracksByCount = ChartBuilder.TopTracksByCount(_result, _tracksShown); TracksChartHeight = BarHeight(_tracksShown); } private void BuildArtistCharts() { - (ArtistsByTimeSeries, ArtistsByTimeX, ArtistsByTimeY) = ChartBuilder.TopArtistsByTime(_result, _artistsShown); - (ArtistsByCountSeries, ArtistsByCountX, ArtistsByCountY) = ChartBuilder.TopArtistsByCount(_result, _artistsShown); + ArtistsByTime = ChartBuilder.TopArtistsByTime(_result, _artistsShown); + ArtistsByCount = ChartBuilder.TopArtistsByCount(_result, _artistsShown); ArtistsChartHeight = BarHeight(_artistsShown); } private void BuildAlbumCharts() { - (AlbumsByTimeSeries, AlbumsByTimeX, AlbumsByTimeY) = ChartBuilder.TopAlbumsByTime(_result, _albumsShown); - (AlbumsByCountSeries, AlbumsByCountX, AlbumsByCountY) = ChartBuilder.TopAlbumsByCount(_result, _albumsShown); + AlbumsByTime = ChartBuilder.TopAlbumsByTime(_result, _albumsShown); + AlbumsByCount = ChartBuilder.TopAlbumsByCount(_result, _albumsShown); AlbumsChartHeight = BarHeight(_albumsShown); } @@ -569,72 +826,108 @@ public void LoadMoreAlbums() BuildAlbumCharts(); } + // ---- Exports ------------------------------------------------------------------------------- + private bool CanExport() => HasData && _result.TotalPlays > 0; + private bool CanExportPodcasts() => HasData && _result.Shows.Count > 0; + [RelayCommand(CanExecute = nameof(CanExport))] - private async Task ExportTxtAsync() - { - var path = AskSave("Text files (*.txt)|*.txt", ".txt", "Sortify_results"); - if (path is null) return; - await ExportService.SaveTxtAsync(path, _result); - StatusText = $"Saved results to {path}"; - } + private Task ExportTxtAsync() => + ExportAsync("Text files (*.txt)|*.txt", ".txt", "Sortify_results", + path => ExportService.SaveTxtAsync(path, _result)); [RelayCommand(CanExecute = nameof(CanExport))] - private async Task ExportTracksCsvAsync() - { - var path = AskSave("CSV files (*.csv)|*.csv", ".csv", "Sortify_tracks"); - if (path is null) return; - await ExportService.SaveTracksCsvAsync(path, _result); - StatusText = $"Saved tracks CSV to {path}"; - } + private Task ExportMarkdownAsync() => + ExportAsync("Markdown files (*.md)|*.md", ".md", "Sortify_report", + path => ExportService.SaveMarkdownAsync(path, _result)); [RelayCommand(CanExecute = nameof(CanExport))] - private async Task ExportArtistsCsvAsync() - { - var path = AskSave("CSV files (*.csv)|*.csv", ".csv", "Sortify_artists"); - if (path is null) return; - await ExportService.SaveArtistsCsvAsync(path, _result); - StatusText = $"Saved artists CSV to {path}"; - } + private Task ExportJsonAsync() => + ExportAsync("JSON files (*.json)|*.json", ".json", "Sortify_results", + path => ExportService.SaveJsonAsync(path, _result)); + + [RelayCommand(CanExecute = nameof(CanExport))] + private Task ExportTracksCsvAsync() => + ExportAsync("CSV files (*.csv)|*.csv", ".csv", "Sortify_tracks", + path => ExportService.SaveTracksCsvAsync(path, _result)); + + [RelayCommand(CanExecute = nameof(CanExport))] + private Task ExportArtistsCsvAsync() => + ExportAsync("CSV files (*.csv)|*.csv", ".csv", "Sortify_artists", + path => ExportService.SaveArtistsCsvAsync(path, _result)); + + [RelayCommand(CanExecute = nameof(CanExport))] + private Task ExportAlbumsCsvAsync() => + ExportAsync("CSV files (*.csv)|*.csv", ".csv", "Sortify_albums", + path => ExportService.SaveAlbumsCsvAsync(path, _result)); [RelayCommand(CanExecute = nameof(CanExport))] - private async Task ExportAlbumsCsvAsync() + private Task ExportYearsCsvAsync() => + ExportAsync("CSV files (*.csv)|*.csv", ".csv", "Sortify_years", + path => ExportService.SaveYearsCsvAsync(path, _result)); + + [RelayCommand(CanExecute = nameof(CanExportPodcasts))] + private Task ExportShowsCsvAsync() => + ExportAsync("CSV files (*.csv)|*.csv", ".csv", "Sortify_shows", + path => ExportService.SaveShowsCsvAsync(path, _result)); + + /// + /// Shared save-file plumbing. A failed write is reported in the status bar: the disk + /// being full or the folder being read-only is the user's problem to fix, not a reason + /// to tear the app down. + /// + private async Task ExportAsync(string filter, string extension, string defaultName, Func write) { - var path = AskSave("CSV files (*.csv)|*.csv", ".csv", "Sortify_albums"); - if (path is null) return; - await ExportService.SaveAlbumsCsvAsync(path, _result); - StatusText = $"Saved albums CSV to {path}"; + var dialog = new SaveFileDialog + { + Filter = filter, + DefaultExt = extension, + FileName = defaultName + extension, + }; + if (dialog.ShowDialog() != true) + return; + + try + { + await write(dialog.FileName); + SetStatus($"Saved {Path.GetFileName(dialog.FileName)} to {Path.GetDirectoryName(dialog.FileName)}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + SetStatus($"Could not save that file: {ex.Message}", isError: true); + } } - [RelayCommand(CanExecute = nameof(CanExport))] - private async Task ExportYearsCsvAsync() + partial void OnHasDataChanged(bool value) { - 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}"; + NotifyExportsChanged(); + ReloadCommand.NotifyCanExecuteChanged(); } - partial void OnHasDataChanged(bool value) => NotifyExportsChanged(); + partial void OnIsBusyChanged(bool value) => CancelLoadCommand.NotifyCanExecuteChanged(); + + partial void OnSidebarVisibleChanged(bool value) => _settings.SidebarVisible = value; private void NotifyExportsChanged() { ExportTxtCommand.NotifyCanExecuteChanged(); + ExportMarkdownCommand.NotifyCanExecuteChanged(); + ExportJsonCommand.NotifyCanExecuteChanged(); ExportTracksCsvCommand.NotifyCanExecuteChanged(); ExportArtistsCsvCommand.NotifyCanExecuteChanged(); ExportAlbumsCsvCommand.NotifyCanExecuteChanged(); ExportYearsCsvCommand.NotifyCanExecuteChanged(); + ExportShowsCsvCommand.NotifyCanExecuteChanged(); } - private static string? AskSave(string filter, string ext, string defaultName) + /// Sets the status line, and whether it should be shown as a problem. + public void SetStatus(string text, bool isError = false) { - var dialog = new SaveFileDialog - { - Filter = filter, - DefaultExt = ext, - FileName = defaultName + ext, - }; - return dialog.ShowDialog() == true ? dialog.FileName : null; + StatusText = text; + StatusIsError = isError; } } + +/// One row of an Overview top-five list. +public sealed record RankedItem(int Rank, string Name, string Secondary, string Time, int Plays); diff --git a/Sortify/Views/Converters.cs b/Sortify/Views/Converters.cs new file mode 100644 index 0000000..ed724ab --- /dev/null +++ b/Sortify/Views/Converters.cs @@ -0,0 +1,50 @@ +using System.Collections; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace Sortify.Views.Converters; + +/// Collapses when the bound bool is true. WPF ships no inverting equivalent. +public sealed class InverseBoolToVisibilityConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? Visibility.Collapsed : Visibility.Visible; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is Visibility.Collapsed or Visibility.Hidden; +} + +/// Formats a TimeSpan as total HH:MM:SS, where hours may exceed 24. +public sealed class DurationConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is TimeSpan span ? Services.TimeFormat.HhMmSs(span) : string.Empty; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +/// Collapses when the bound string is null, empty or whitespace. +public sealed class EmptyStringToVisibilityConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => string.IsNullOrWhiteSpace(value as string) ? Visibility.Collapsed : Visibility.Visible; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +/// +/// Visible only when the bound collection has no items, for the "nothing here" message that +/// sits behind a grid. The grid item sources are swapped wholesale rather than mutated, so +/// a plain property binding is enough to keep this current. +/// +public sealed class EmptyCollectionToVisibilityConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is ICollection { Count: > 0 } ? Visibility.Collapsed : Visibility.Visible; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/Sortify/Views/DetailWindow.xaml b/Sortify/Views/DetailWindow.xaml index e2ca364..6845127 100644 --- a/Sortify/Views/DetailWindow.xaml +++ b/Sortify/Views/DetailWindow.xaml @@ -4,8 +4,8 @@ xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF" Title="Details" Height="760" Width="1080" WindowStartupLocation="CenterOwner" - Background="{StaticResource BgBrush}" - Foreground="{StaticResource TextBrush}"> + Background="{DynamicResource BgBrush}" + Foreground="{DynamicResource TextBrush}"> @@ -15,12 +15,16 @@ - + + + +