adding play count graphs to now playing page
This commit is contained in:
parent
597db1a42b
commit
2a8b739f8d
@ -43,6 +43,10 @@ $shadow-color: #1e1e1e;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
.app {
|
||||
display: flex;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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 }}
|
||||
|
63
Selector.Web/scripts/Now/PlayCountGraph.ts
Normal file
63
Selector.Web/scripts/Now/PlayCountGraph.ts
Normal 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: {
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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');
|
@ -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)
|
||||
|
@ -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; }
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
97
Selector/Scrobble/Resampler.cs
Normal file
97
Selector/Scrobble/Resampler.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user