diff --git a/Selector.CLI/Selector.CLI.csproj b/Selector.CLI/Selector.CLI.csproj index 0f3dbf4..25e7e19 100644 --- a/Selector.CLI/Selector.CLI.csproj +++ b/Selector.CLI/Selector.CLI.csproj @@ -8,7 +8,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -18,8 +18,8 @@ - - + + diff --git a/Selector.Model/Selector.Model.csproj b/Selector.Model/Selector.Model.csproj index 043bdb4..b444fa5 100644 --- a/Selector.Model/Selector.Model.csproj +++ b/Selector.Model/Selector.Model.csproj @@ -12,20 +12,20 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + diff --git a/Selector.Tests/Selector.Tests.csproj b/Selector.Tests/Selector.Tests.csproj index b458347..8d71bca 100644 --- a/Selector.Tests/Selector.Tests.csproj +++ b/Selector.Tests/Selector.Tests.csproj @@ -8,11 +8,11 @@ - - - + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Selector.Web/Hubs/NowPlayingHub.cs b/Selector.Web/Hubs/NowPlayingHub.cs index c63b6c3..9d4f759 100644 --- a/Selector.Web/Hubs/NowPlayingHub.cs +++ b/Selector.Web/Hubs/NowPlayingHub.cs @@ -12,6 +12,9 @@ using StackExchange.Redis; using Selector.Cache; using Selector.Model; using Selector.Model.Extensions; +using Selector.Web.NowPlaying; +using Microsoft.Extensions.Options; +using System.Collections.Generic; namespace Selector.Web.Hubs { @@ -20,6 +23,7 @@ namespace Selector.Web.Hubs public Task OnNewPlaying(CurrentlyPlayingDTO context); public Task OnNewAudioFeature(TrackAudioFeatures features); public Task OnNewPlayCount(PlayCount playCount); + public Task OnNewCard(Card card); } public class NowPlayingHub: Hub @@ -30,11 +34,14 @@ namespace Selector.Web.Hubs private readonly ApplicationDbContext Db; private readonly IScrobbleRepository ScrobbleRepository; + private readonly IOptions nowOptions; + public NowPlayingHub( IDatabaseAsync cache, AudioFeaturePuller featurePuller, ApplicationDbContext db, IScrobbleRepository scrobbleRepository, + IOptions options, PlayCountPuller playCountPuller = null ) { @@ -43,6 +50,7 @@ namespace Selector.Web.Hubs PlayCountPuller = playCountPuller; Db = db; ScrobbleRepository = scrobbleRepository; + nowOptions = options; } public async Task OnConnected() @@ -108,5 +116,51 @@ namespace Selector.Web.Hubs } } } + + public async Task SendFacts(string track, string artist, string album, string albumArtist) + { + var user = Db.Users + .AsNoTracking() + .Where(u => u.Id == Context.UserIdentifier) + .SingleOrDefault() + ?? throw new SqlNullValueException("No user returned"); + + 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); + + if (artistDensity > nowOptions.Value.ArtistDensityThreshold) + { + await 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); + + if (albumDensity > nowOptions.Value.AlbumDensityThreshold) + { + await 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); + + if (albumDensity > nowOptions.Value.TrackDensityThreshold) + { + await Clients.Caller.OnNewCard(new() + { + Content = $"You're on a {track} binge! {trackDensity} plays/day recently" + }); + } + } + } + + private DateTime GetMaximumWindow() => GetMaximumWindow(new TimeSpan[] { nowOptions.Value.ArtistDensityWindow, nowOptions.Value.AlbumDensityWindow, nowOptions.Value.TrackDensityWindow }); + private DateTime GetMaximumWindow(IEnumerable windows) => windows.Select(w => DateTime.UtcNow - w).Min(); } } \ No newline at end of file diff --git a/Selector.Web/NowPlaying/Card.cs b/Selector.Web/NowPlaying/Card.cs new file mode 100644 index 0000000..7041cd7 --- /dev/null +++ b/Selector.Web/NowPlaying/Card.cs @@ -0,0 +1,9 @@ +using System; +namespace Selector.Web.NowPlaying +{ + public class Card + { + public string Content { get; set; } + } +} + diff --git a/Selector.Web/Options.cs b/Selector.Web/Options.cs index c647f6d..72347ce 100644 --- a/Selector.Web/Options.cs +++ b/Selector.Web/Options.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Microsoft.Extensions.Configuration; namespace Selector.Web @@ -8,6 +9,7 @@ namespace Selector.Web { config.GetSection(RootOptions.Key).Bind(options); config.GetSection(FormatKeys(new[] { RootOptions.Key, RedisOptions.Key })).Bind(options.RedisOptions); + config.GetSection(FormatKeys(new[] { RootOptions.Key, NowPlayingOptions.Key })).Bind(options.NowOptions); } public static RootOptions ConfigureOptions(IConfiguration config) @@ -40,6 +42,7 @@ namespace Selector.Web public string LastfmSecret { get; set; } public RedisOptions RedisOptions { get; set; } = new(); + public NowPlayingOptions NowOptions { get; set; } = new(); } @@ -50,4 +53,18 @@ namespace Selector.Web public bool Enabled { get; set; } = false; public string ConnectionString { get; set; } } + + public class NowPlayingOptions + { + public const string Key = "Now"; + + public TimeSpan ArtistDensityWindow { get; set; } = TimeSpan.FromDays(10); + public decimal ArtistDensityThreshold { get; set; } = 5; + + public TimeSpan AlbumDensityWindow { get; set; } = TimeSpan.FromDays(10); + public decimal AlbumDensityThreshold { get; set; } = 5; + + public TimeSpan TrackDensityWindow { get; set; } = TimeSpan.FromDays(10); + public decimal TrackDensityThreshold { get; set; } = 5; + } } diff --git a/Selector.Web/Pages/Now.cshtml b/Selector.Web/Pages/Now.cshtml index fb331fe..0b4e8a5 100644 --- a/Selector.Web/Pages/Now.cshtml +++ b/Selector.Web/Pages/Now.cshtml @@ -14,7 +14,7 @@ - + diff --git a/Selector.Web/Properties/launchSettings.json b/Selector.Web/Properties/launchSettings.json index 0bcfc9f..77588a7 100644 --- a/Selector.Web/Properties/launchSettings.json +++ b/Selector.Web/Properties/launchSettings.json @@ -1,4 +1,4 @@ -{ +{ "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, @@ -17,7 +17,6 @@ }, "Selector.Web": { "commandName": "Project", - "dotnetRunMessages": "true", "launchBrowser": true, "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { @@ -25,4 +24,4 @@ } } } -} +} \ No newline at end of file diff --git a/Selector.Web/Selector.Web.csproj b/Selector.Web/Selector.Web.csproj index 615d766..9f19a3a 100644 --- a/Selector.Web/Selector.Web.csproj +++ b/Selector.Web/Selector.Web.csproj @@ -14,13 +14,13 @@ - - + + - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -28,16 +28,20 @@ - - - - + + + + + + + + PreserveNewest diff --git a/Selector.Web/package-lock.json b/Selector.Web/package-lock.json index 8800112..a5198e2 100644 --- a/Selector.Web/package-lock.json +++ b/Selector.Web/package-lock.json @@ -1011,9 +1011,9 @@ } }, "node_modules/eventsource": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.0.tgz", - "integrity": "sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.1.tgz", + "integrity": "sha512-qV5ZC0h7jYIAOhArFJgSfdyz6rALJyb270714o7ZtNnw2WSJ+eexhKtE0O8LYPRsHZHf2osHKZBxGPvm3kPkCA==", "dependencies": { "original": "^1.0.0" }, @@ -3613,9 +3613,9 @@ "dev": true }, "eventsource": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.0.tgz", - "integrity": "sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.1.tgz", + "integrity": "sha512-qV5ZC0h7jYIAOhArFJgSfdyz6rALJyb270714o7ZtNnw2WSJ+eexhKtE0O8LYPRsHZHf2osHKZBxGPvm3kPkCA==", "requires": { "original": "^1.0.0" } diff --git a/Selector.Web/scripts/now.ts b/Selector.Web/scripts/now.ts index 8f030bb..3819228 100644 --- a/Selector.Web/scripts/now.ts +++ b/Selector.Web/scripts/now.ts @@ -17,7 +17,7 @@ connection.start() .catch(err => console.error(err)); interface InfoCard { - html: string + Content: string } interface NowPlaying { @@ -64,6 +64,12 @@ const app = Vue.createApp({ context.track.album.name, context.track.album.artists[0].name ); + connection.invoke("SendFacts", + context.track.name, + context.track.artists[0].name, + context.track.album.name, + context.track.album.artists[0].name + ); } }); @@ -78,6 +84,12 @@ const app = Vue.createApp({ console.log(count); this.playCount = count; }); + + connection.on("OnNewCard", (card: InfoCard) => { + + console.log(card); + this.cards.push(card); + }); } }); diff --git a/Selector/Scrobble/PlayDensity.cs b/Selector/Scrobble/PlayDensity.cs new file mode 100644 index 0000000..feac9a5 --- /dev/null +++ b/Selector/Scrobble/PlayDensity.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Selector +{ + public static class PlayDensity + { + public static decimal Density(this IEnumerable scrobbles, DateTime from, DateTime to) + { + var filteredScrobbles = scrobbles.Where(s => s.Timestamp > from && s.Timestamp < to); + + var dayDelta = (decimal) (to - from).Days; + + return filteredScrobbles.Count() / dayDelta; + } + + public static decimal Density(this IEnumerable scrobbles) + { + var minDate = scrobbles.Select(s => s.Timestamp).Min(); + var maxDate = scrobbles.Select(s => s.Timestamp).Max(); + + var dayDelta = (decimal) (maxDate - minDate).Days; + + return scrobbles.Count() / dayDelta; + } + } +} +