diff --git a/Selector.Web/CSS/now.scss b/Selector.Web/CSS/now.scss index c697c0e..1565612 100644 --- a/Selector.Web/CSS/now.scss +++ b/Selector.Web/CSS/now.scss @@ -43,6 +43,10 @@ $shadow-color: #1e1e1e; } } +.chart-card { + width: 500px; +} + @media only screen and (min-width: 768px) { .app { display: flex; diff --git a/Selector.Web/Hubs/NowPlayingHub.cs b/Selector.Web/Hubs/NowPlayingHub.cs index 9d4f759..c5234d1 100644 --- a/Selector.Web/Hubs/NowPlayingHub.cs +++ b/Selector.Web/Hubs/NowPlayingHub.cs @@ -1,20 +1,19 @@ using System; +using System.Collections.Generic; +using System.Data.SqlTypes; +using System.Diagnostics; using System.Linq; using System.Text.Json; using System.Threading.Tasks; -using System.Data.SqlTypes; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; - -using SpotifyAPI.Web; -using StackExchange.Redis; - +using Microsoft.Extensions.Options; using Selector.Cache; using Selector.Model; using Selector.Model.Extensions; using Selector.Web.NowPlaying; -using Microsoft.Extensions.Options; -using System.Collections.Generic; +using SpotifyAPI.Web; +using StackExchange.Redis; namespace Selector.Web.Hubs { @@ -70,6 +69,8 @@ namespace Selector.Web.Hubs public async Task SendAudioFeatures(string trackId) { + if (string.IsNullOrWhiteSpace(trackId)) return; + var user = Db.Users .AsNoTracking() .Where(u => u.Id == Context.UserIdentifier) @@ -106,13 +107,34 @@ namespace Selector.Web.Hubs if (user.ScrobbleSavingEnabled()) { - playCount.Artist = ScrobbleRepository.GetAll(userId: user.Id, artistName: artist).Count(); + var artistScrobbles = ScrobbleRepository.GetAll(userId: user.Id, artistName: artist).ToArray(); + + playCount.Artist = artistScrobbles.Length; + + playCount.ArtistCountData = artistScrobbles + //.Resample(nowOptions.Value.ArtistResampleWindow) + .ResampleByMonth() + .ToArray(); + + var postCalc = playCount.ArtistCountData.Select(s => s.Value).Sum(); + Debug.Assert(postCalc == artistScrobbles.Count()); + + playCount.AlbumCountData = artistScrobbles + .Where(s => s.AlbumName.Equals(album, StringComparison.CurrentCultureIgnoreCase)) + //.Resample(nowOptions.Value.AlbumResampleWindow) + .ResampleByMonth() + .ToArray(); + + playCount.TrackCountData = artistScrobbles + .Where(s => s.TrackName.Equals(track, StringComparison.CurrentCultureIgnoreCase)) + //.Resample(nowOptions.Value.TrackResampleWindow) + .ResampleByMonth() + .ToArray(); } - if (playCount is not null) - { - await Clients.Caller.OnNewPlayCount(playCount); - } + + + await Clients.Caller.OnNewPlayCount(playCount); } } } @@ -125,37 +147,49 @@ namespace Selector.Web.Hubs .SingleOrDefault() ?? throw new SqlNullValueException("No user returned"); + await PlayDensityFacts(user, track, artist, album, albumArtist); + } + + public async Task PlayDensityFacts(ApplicationUser user, string track, string artist, string album, string albumArtist) + { if (user.ScrobbleSavingEnabled()) { var artistScrobbles = ScrobbleRepository.GetAll(userId: user.Id, artistName: artist, from: GetMaximumWindow()).ToArray(); - var artistDensity = artistScrobbles.Density(DateTime.UtcNow - nowOptions.Value.ArtistDensityWindow, DateTime.UtcNow); + var artistDensity = artistScrobbles.Density(nowOptions.Value.ArtistDensityWindow); + + var tasks = new List(3); if (artistDensity > nowOptions.Value.ArtistDensityThreshold) { - await Clients.Caller.OnNewCard(new() + tasks.Add(Clients.Caller.OnNewCard(new() { Content = $"You're on a {artist} binge! {artistDensity} plays/day recently" - }); + })); } - var albumDensity = artistScrobbles.Where(s => s.AlbumName.Equals(album, StringComparison.InvariantCultureIgnoreCase)).Density(DateTime.UtcNow - nowOptions.Value.AlbumDensityWindow, DateTime.UtcNow); + var albumDensity = artistScrobbles.Where(s => s.AlbumName.Equals(album, StringComparison.InvariantCultureIgnoreCase)).Density(nowOptions.Value.AlbumDensityWindow); if (albumDensity > nowOptions.Value.AlbumDensityThreshold) { - await Clients.Caller.OnNewCard(new() + tasks.Add(Clients.Caller.OnNewCard(new() { Content = $"You're on a {album} binge! {albumDensity} plays/day recently" - }); + })); } - var trackDensity = artistScrobbles.Where(s => s.TrackName.Equals(track, StringComparison.InvariantCultureIgnoreCase)).Density(DateTime.UtcNow - nowOptions.Value.TrackDensityWindow, DateTime.UtcNow); + var trackDensity = artistScrobbles.Where(s => s.TrackName.Equals(track, StringComparison.InvariantCultureIgnoreCase)).Density(nowOptions.Value.TrackDensityWindow); if (albumDensity > nowOptions.Value.TrackDensityThreshold) { - await Clients.Caller.OnNewCard(new() + tasks.Add(Clients.Caller.OnNewCard(new() { Content = $"You're on a {track} binge! {trackDensity} plays/day recently" - }); + })); + } + + if(tasks.Any()) + { + await Task.WhenAll(tasks); } } } diff --git a/Selector.Web/Options.cs b/Selector.Web/Options.cs index 72347ce..49ba879 100644 --- a/Selector.Web/Options.cs +++ b/Selector.Web/Options.cs @@ -58,6 +58,10 @@ namespace Selector.Web { public const string Key = "Now"; + public TimeSpan ArtistResampleWindow { get; set; } = TimeSpan.FromDays(30); + public TimeSpan AlbumResampleWindow { get; set; } = TimeSpan.FromDays(30); + public TimeSpan TrackResampleWindow { get; set; } = TimeSpan.FromDays(30); + public TimeSpan ArtistDensityWindow { get; set; } = TimeSpan.FromDays(10); public decimal ArtistDensityThreshold { get; set; } = 5; diff --git a/Selector.Web/Pages/Now.cshtml b/Selector.Web/Pages/Now.cshtml index 0b4e8a5..2f0b94f 100644 --- a/Selector.Web/Pages/Now.cshtml +++ b/Selector.Web/Pages/Now.cshtml @@ -7,13 +7,33 @@

Now

- - + + - - - - + + + + + + + + +
diff --git a/Selector.Web/scripts/HubInterfaces.ts b/Selector.Web/scripts/HubInterfaces.ts index 87c8d5b..9f3906b 100644 --- a/Selector.Web/scripts/HubInterfaces.ts +++ b/Selector.Web/scripts/HubInterfaces.ts @@ -23,9 +23,17 @@ export interface PlayCount { artist: number | null; user: number | null; username: string; + trackCountData: CountSample[]; + albumCountData: CountSample[]; + artistCountData: CountSample[]; listeningEvent: ListeningChangeEventArgs; } +export interface CountSample { + timeStamp: Date; + value: number; +} + export interface CurrentlyPlayingDTO { context: CurrentlyPlayingContextDTO; username: string; diff --git a/Selector.Web/scripts/Now/NowPlayingCard.ts b/Selector.Web/scripts/Now/NowPlayingCard.ts index 55f9735..b3f3ec9 100644 --- a/Selector.Web/scripts/Now/NowPlayingCard.ts +++ b/Selector.Web/scripts/Now/NowPlayingCard.ts @@ -8,12 +8,21 @@ let component: Vue.Component = { }, IsEpisodePlaying() { return this.episode !== null && this.episode !== undefined; + }, + ImageUrl() { + if(this.track.album.images.length > 0) + { + return this.track.album.images[0].url; + } + else{ + return ""; + } } }, template: `
- +

{{ track.name }}

{{ track.album.name }} diff --git a/Selector.Web/scripts/Now/PlayCountGraph.ts b/Selector.Web/scripts/Now/PlayCountGraph.ts new file mode 100644 index 0000000..59bcaf3 --- /dev/null +++ b/Selector.Web/scripts/Now/PlayCountGraph.ts @@ -0,0 +1,63 @@ +import * as Vue from "vue"; +import { Chart, PointElement, LineElement, LineController, CategoryScale, LinearScale, TimeSeriesScale } from "chart.js"; +import { CountSample } from "scripts/HubInterfaces"; + +Chart.register(LineController, CategoryScale, LinearScale, TimeSeriesScale, PointElement, LineElement); + +const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + +export let PlayCountChartCard: Vue.Component = { + props: ['data_points', 'title', 'chart_id'], + data() { + return { + chartData: { + labels: this.data_points.map((e: CountSample) => { + var date = new Date(e.timeStamp); + + return `${months[date.getMonth()]} ${date.getFullYear()}`; + }), + datasets: [{ + // label: '# of Votes', + data: this.data_points.map((e: CountSample) => e.value), + }] + } + } + }, + computed: { + chartId() { + return "play-count-chart-" + this.chart_id; + } + }, + template: + ` +
+

{{ title }}

+ +
+ `, + mounted() { + new Chart(`play-count-chart-${this.chart_id}`, { + type: "line", + data: this.chartData, + options: { + elements: { + line: { + borderWidth: 4, + borderColor: "#a34c77", + backgroundColor: "#727272", + borderCapStyle: "round", + borderJoinStyle: "round" + }, + // point: { + // radius: 4, + // pointStyle: "circle", + // borderColor: "black", + // backgroundColor: "white" + // } + }, + scales: { + } + } + }) + } +} \ No newline at end of file diff --git a/Selector.Web/scripts/now.ts b/Selector.Web/scripts/now.ts index 3819228..5b82a97 100644 --- a/Selector.Web/scripts/now.ts +++ b/Selector.Web/scripts/now.ts @@ -3,6 +3,7 @@ import * as Vue from "vue"; import { TrackAudioFeatures, PlayCount, CurrentlyPlayingDTO } from "./HubInterfaces"; import NowPlayingCard from "./Now/NowPlayingCard"; import { AudioFeatureCard, AudioFeatureChartCard, PopularityCard, SpotifyLogoLink } from "./Now/Spotify"; +import { PlayCountChartCard } from "./Now/PlayCountGraph"; import { PlayCountCard, LastFmLogoLink } from "./Now/LastFm"; import BaseInfoCard from "./Now/BaseInfoCard"; @@ -44,6 +45,23 @@ const app = Vue.createApp({ album: this.currentlyPlaying.track.album.name, album_artist: this.currentlyPlaying.track.album.artists[0].name, }; + }, + lastfmArtist(){ + + // if(this.currentlyPlaying.track.artists[0].length > 0) + { + return this.currentlyPlaying.track.artists[0].name; + } + return ""; + }, + showArtistChart(){ + return this.playCount !== null && this.playCount !== undefined && this.playCount.artistCountData.length > 0; + }, + showAlbumChart() { + return this.playCount !== null && this.playCount !== undefined && this.playCount.albumCountData.length > 0; + }, + showTrackChart(){ + return this.playCount !== null && this.playCount !== undefined && this.playCount.trackCountData.length > 0; } }, created() { @@ -57,7 +75,10 @@ const app = Vue.createApp({ if(context.track !== null && context.track !== undefined) { - connection.invoke("SendAudioFeatures", context.track.id); + if(context.track.id !== null) + { + connection.invoke("SendAudioFeatures", context.track.id); + } connection.invoke("SendPlayCount", context.track.name, context.track.artists[0].name, @@ -101,4 +122,5 @@ app.component("popularity", PopularityCard); app.component("spotify-logo", SpotifyLogoLink); app.component("lastfm-logo", LastFmLogoLink); app.component("play-count-card", PlayCountCard); +app.component("play-count-chart-card", PlayCountChartCard); const vm = app.mount('#app'); \ No newline at end of file diff --git a/Selector/Consumers/AudioFeatureInjector.cs b/Selector/Consumers/AudioFeatureInjector.cs index 3ea72ce..eca32c0 100644 --- a/Selector/Consumers/AudioFeatureInjector.cs +++ b/Selector/Consumers/AudioFeatureInjector.cs @@ -53,6 +53,8 @@ namespace Selector { if (e.Current.Item is FullTrack track) { + if (string.IsNullOrWhiteSpace(track.Id)) return; + try { Logger.LogTrace("Making Spotify call"); var audioFeatures = await TrackClient.GetAudioFeatures(track.Id); @@ -81,6 +83,8 @@ namespace Selector } else if (e.Current.Item is FullEpisode episode) { + if (string.IsNullOrWhiteSpace(episode.Id)) return; + Logger.LogDebug($"Ignoring podcast episdoe [{episode.DisplayString()}]"); } else if (e.Current.Item is null) diff --git a/Selector/Consumers/PlayCounter.cs b/Selector/Consumers/PlayCounter.cs index 08a488d..9352f94 100644 --- a/Selector/Consumers/PlayCounter.cs +++ b/Selector/Consumers/PlayCounter.cs @@ -208,6 +208,9 @@ namespace Selector public int? Artist { get; set; } public int? User { get; set; } public string Username { get; set; } + public IEnumerable TrackCountData { get; set; } + public IEnumerable AlbumCountData { get; set; } + public IEnumerable ArtistCountData { get; set; } public ListeningChangeEventArgs ListeningEvent { get; set; } } diff --git a/Selector/Scrobble/PlayDensity.cs b/Selector/Scrobble/PlayDensity.cs index feac9a5..1622e11 100644 --- a/Selector/Scrobble/PlayDensity.cs +++ b/Selector/Scrobble/PlayDensity.cs @@ -6,6 +6,8 @@ namespace Selector { public static class PlayDensity { + public static decimal Density(this IEnumerable scrobbles, TimeSpan window) => scrobbles.Density(DateTime.UtcNow - window, DateTime.UtcNow); + public static decimal Density(this IEnumerable scrobbles, DateTime from, DateTime to) { var filteredScrobbles = scrobbles.Where(s => s.Timestamp > from && s.Timestamp < to); diff --git a/Selector/Scrobble/Resampler.cs b/Selector/Scrobble/Resampler.cs new file mode 100644 index 0000000..d469f28 --- /dev/null +++ b/Selector/Scrobble/Resampler.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Selector +{ + public record struct CountSample { + public DateTime TimeStamp { get; set; } + public int Value { get; set; } + } + + public static class Resampler + { + public static IEnumerable Resample(this IEnumerable scrobbles, TimeSpan window) + { + var sortedScrobbles = scrobbles.OrderBy(s => s.Timestamp).ToList(); + + if (!sortedScrobbles.Any()) + { + yield break; + } + + var sortedScrobblesIter = sortedScrobbles.GetEnumerator(); + sortedScrobblesIter.MoveNext(); + + var earliest = sortedScrobbles.First().Timestamp; + var latest = sortedScrobbles.Last().Timestamp; + + for (var counter = earliest; counter <= latest; counter += window) + { + var windowEnd = counter + window; + + var count = 0; + + if (sortedScrobblesIter.Current is not null) + { + count++; + } + + while (sortedScrobblesIter.MoveNext() && counter <= sortedScrobblesIter.Current.Timestamp && sortedScrobblesIter.Current.Timestamp < windowEnd) + { + count++; + } + + yield return new CountSample() + { + TimeStamp = counter + (window / 2), + Value = count + }; + } + } + + public static IEnumerable ResampleByMonth(this IEnumerable scrobbles) + { + var sortedScrobbles = scrobbles.OrderBy(s => s.Timestamp).ToList(); + + if (!sortedScrobbles.Any()) + { + yield break; + } + + var sortedScrobblesIter = sortedScrobbles.GetEnumerator(); + sortedScrobblesIter.MoveNext(); + + var earliest = sortedScrobbles.First().Timestamp; + var latest = sortedScrobbles.Last().Timestamp; + var latestPlusMonth = latest.AddMonths(1); + + var periodStart = new DateTime(earliest.Year, earliest.Month, 1); + var periodEnd = new DateTime(latestPlusMonth.Year, latestPlusMonth.Month, 1); + + for (var counter = periodStart; counter <= periodEnd; counter = counter.AddMonths(1)) + { + var count = 0; + + if (sortedScrobblesIter.Current is not null) + { + count++; + } + + while (sortedScrobblesIter.MoveNext() + && sortedScrobblesIter.Current.Timestamp.Year == counter.Year + && sortedScrobblesIter.Current.Timestamp.Month == counter.Month) + { + count++; + } + + yield return new CountSample() + { + TimeStamp = counter, + Value = count + }; + } + } + } +} +