adding play count graphs to now playing page

This commit is contained in:
andy 2022-06-18 10:56:34 +01:00
parent 597db1a42b
commit 2a8b739f8d
12 changed files with 299 additions and 29 deletions

View File

@ -43,6 +43,10 @@ $shadow-color: #1e1e1e;
}
}
.chart-card {
width: 500px;
}
@media only screen and (min-width: 768px) {
.app {
display: flex;

View File

@ -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<Task>(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);
}
}
}

View File

@ -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;

View File

@ -7,13 +7,33 @@
<div class="text-center">
<h1 class="display-4">Now</h1>
<div id="app" class="app col-12">
<now-playing-card :track="currentlyPlaying.track" v-if="currentlyPlaying !== null && currentlyPlaying !== undefined" ></now-playing-card>
<now-playing-card v-else ></now-playing-card>
<now-playing-card :track="currentlyPlaying.track" v-if="currentlyPlaying !== null && currentlyPlaying !== undefined"></now-playing-card>
<now-playing-card v-else></now-playing-card>
<popularity :track="currentlyPlaying.track" v-if="currentlyPlaying !== null && currentlyPlaying !== undefined && currentlyPlaying.track != null && currentlyPlaying.track != undefined" ></popularity>
<audio-feature-card :feature="trackFeatures" v-if="trackFeatures !== null && trackFeatures !== undefined" /></audio-feature-card>
<audio-feature-chart-card :feature="trackFeatures" v-if="trackFeatures !== null && trackFeatures !== undefined" /></audio-feature-chart-card>
<play-count-card :count="playCount" :track="lastfmTrack" :username="playCount.username" v-if="playCount !== null && playCount !== undefined" /></play-count-card>
<popularity :track="currentlyPlaying.track"
v-if="currentlyPlaying !== null && currentlyPlaying !== undefined && currentlyPlaying.track != null && currentlyPlaying.track != undefined"></popularity>
<audio-feature-card :feature="trackFeatures"
v-if="trackFeatures !== null && trackFeatures !== undefined"></audio-feature-card>
<audio-feature-chart-card :feature="trackFeatures"
v-if="trackFeatures !== null && trackFeatures !== undefined"></audio-feature-chart-card>
<play-count-card :count="playCount"
:track="lastfmTrack"
:username="playCount.username"
v-if="playCount !== null && playCount !== undefined"></play-count-card>
<play-count-chart-card :data_points="playCount.trackCountData"
:title="currentlyPlaying.track.name"
:chart_id="'track'"
v-if="showTrackChart"></play-count-chart-card>
<play-count-chart-card :data_points="playCount.albumCountData"
:title="currentlyPlaying.track.album.name"
:chart_id="'album'"
v-if="showAlbumChart"></play-count-chart-card>
<play-count-chart-card :data_points="playCount.artistCountData"
:title="lastfmArtist"
:chart_id="'artist'"
v-if="showArtistChart"></play-count-chart-card>
<info-card v-for="card in cards" :html="card.content"></info-card>
</div>
</div>

View File

@ -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;

View File

@ -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:
`
<div class="card now-playing-card" v-if="IsTrackPlaying">
<img :src="track.album.images[0].url" class="cover-art">
<img :src="ImageUrl" class="cover-art">
<h4>{{ track.name }}</h4>
<h6>
{{ track.album.name }}

View File

@ -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:
`
<div class="card info-card chart-card">
<h1>{{ title }}</h1>
<canvas :id="chartId"></canvas>
</div>
`,
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: {
}
}
})
}
}

View File

@ -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');

View File

@ -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)

View File

@ -208,6 +208,9 @@ namespace Selector
public int? Artist { get; set; }
public int? User { get; set; }
public string Username { get; set; }
public IEnumerable<CountSample> TrackCountData { get; set; }
public IEnumerable<CountSample> AlbumCountData { get; set; }
public IEnumerable<CountSample> ArtistCountData { get; set; }
public ListeningChangeEventArgs ListeningEvent { get; set; }
}

View File

@ -6,6 +6,8 @@ namespace Selector
{
public static class PlayDensity
{
public static decimal Density(this IEnumerable<Scrobble> scrobbles, TimeSpan window) => scrobbles.Density(DateTime.UtcNow - window, DateTime.UtcNow);
public static decimal Density(this IEnumerable<Scrobble> scrobbles, DateTime from, DateTime to)
{
var filteredScrobbles = scrobbles.Where(s => s.Timestamp > from && s.Timestamp < to);

View File

@ -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<CountSample> Resample(this IEnumerable<Scrobble> 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<CountSample> ResampleByMonth(this IEnumerable<Scrobble> 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
};
}
}
}
}