diff --git a/Selector.Web/CSS/index.scss b/Selector.Web/CSS/index.scss index b8fcadc..3f30786 100644 --- a/Selector.Web/CSS/index.scss +++ b/Selector.Web/CSS/index.scss @@ -1,4 +1,5 @@ @import "now.scss"; +@import "past.scss"; body { background-color: #121212; diff --git a/Selector.Web/CSS/past.scss b/Selector.Web/CSS/past.scss new file mode 100644 index 0000000..3337708 --- /dev/null +++ b/Selector.Web/CSS/past.scss @@ -0,0 +1,26 @@ +.form-input { + width: calc(100% - 20px); + margin: 10px; + display: block; +} + +@media only screen and (min-width: 768px) { + .form-input { + width: 30%; + display: inline; + } +} + +.rank-card { + width: calc(100% - 10px); + display: block; + margin-top: 20px !important; + margin-bottom: 20px !important; +} + +@media only screen and (min-width: 768px) { + .rank-card { + width: calc(100% - 10px); + // display: inline; + } +} \ No newline at end of file diff --git a/Selector.Web/Hubs/PastHub.cs b/Selector.Web/Hubs/PastHub.cs new file mode 100644 index 0000000..4144680 --- /dev/null +++ b/Selector.Web/Hubs/PastHub.cs @@ -0,0 +1,121 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; +using Selector.Cache; +using Selector.Model; +using StackExchange.Redis; + +namespace Selector.Web.Hubs +{ + public interface IPastHubClient + { + public Task OnRankResult(RankResult result); + } + + public class PastHub: Hub + { + private readonly IDatabaseAsync Cache; + private readonly AudioFeaturePuller AudioFeaturePuller; + private readonly PlayCountPuller PlayCountPuller; + private readonly DBPlayCountPuller DBPlayCountPuller; + private readonly ApplicationDbContext Db; + private readonly IListenRepository ListenRepository; + + private readonly IOptions pastOptions; + + public PastHub( + IDatabaseAsync cache, + AudioFeaturePuller featurePuller, + ApplicationDbContext db, + IListenRepository listenRepository, + IOptions options, + DBPlayCountPuller dbPlayCountPuller, + PlayCountPuller playCountPuller = null + ) + { + Cache = cache; + AudioFeaturePuller = featurePuller; + PlayCountPuller = playCountPuller; + DBPlayCountPuller = dbPlayCountPuller; + Db = db; + ListenRepository = listenRepository; + pastOptions = options; + } + + public async Task OnConnected() + { + + } + + public async Task OnSubmitted(PastParams param) + { + param.Track = string.IsNullOrWhiteSpace(param.Track) ? null : param.Track; + param.Album = string.IsNullOrWhiteSpace(param.Album) ? null : param.Album; + param.Artist = string.IsNullOrWhiteSpace(param.Artist) ? null : param.Artist; + + DateTime? from = param.From is string f && DateTime.TryParse(f, out var fromDate) ? fromDate.ToUniversalTime() : null; + DateTime? to = param.To is string t && DateTime.TryParse(t, out var toDate) ? toDate.ToUniversalTime() : null; + + var listenQuery = ListenRepository.GetAll( + userId: Context.UserIdentifier, + trackName: param.Track, + albumName: param.Album, + artistName: param.Artist, + from: from, + to: to + ).ToArray(); + + var artistGrouped = listenQuery + .GroupBy(x => x.ArtistName) + .Select(x => (x.Key, x.Count())) + .OrderByDescending(x => x.Item2) + .Take(20) + .ToArray(); + + var albumGrouped = listenQuery + .GroupBy(x => (x.AlbumName, x.ArtistName)) + .Select(x => (x.Key, x.Count())) + .OrderByDescending(x => x.Item2) + .Take(20) + .ToArray(); + + var trackGrouped = listenQuery + .GroupBy(x => (x.TrackName, x.ArtistName)) + .Select(x => (x.Key, x.Count())) + .OrderByDescending(x => x.Item2) + .Take(20) + .ToArray(); + + await Clients.Caller.OnRankResult(new() + { + TrackEntries = trackGrouped.Select(x => new ChartEntry() + { + Name = $"{x.Key.TrackName} - {x.Key.ArtistName}", + Value = x.Item2 + }).ToArray(), + + AlbumEntries = albumGrouped.Select(x => new ChartEntry() + { + Name = $"{x.Key.AlbumName} - {x.Key.ArtistName}", + Value = x.Item2 + }).ToArray(), + + ArtistEntries = artistGrouped.Select(x => new ChartEntry() + { + Name = x.Key, + Value = x.Item2 + }).ToArray(), + + ResampledSeries = listenQuery + .Resample(pastOptions.Value.ResampleWindow) + //.ResampleByMonth() + .CumulativeSum() + .ToArray(), + + TotalCount = listenQuery.Length + }); + } + } +} \ No newline at end of file diff --git a/Selector.Web/Options.cs b/Selector.Web/Options.cs index 388c7e7..ae4850c 100644 --- a/Selector.Web/Options.cs +++ b/Selector.Web/Options.cs @@ -10,6 +10,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); + config.GetSection(FormatKeys(new[] { RootOptions.Key, PastOptions.Key })).Bind(options.PastOptions); } public static RootOptions ConfigureOptions(IConfiguration config) @@ -45,6 +46,7 @@ namespace Selector.Web public RedisOptions RedisOptions { get; set; } = new(); public NowPlayingOptions NowOptions { get; set; } = new(); + public PastOptions PastOptions { get; set; } = new(); } diff --git a/Selector.Web/Pages/Past.cshtml b/Selector.Web/Pages/Past.cshtml new file mode 100644 index 0000000..8d865a2 --- /dev/null +++ b/Selector.Web/Pages/Past.cshtml @@ -0,0 +1,45 @@ +@page +@model PastModel +@{ + ViewData["Title"] = "Past - Selector"; +} + +
+

Past

+
+
+
+ + + +
+
+ + + + +
+
+ +
+
+ + + + + +
+ + + +
+
+
+ +@section Scripts { + +} \ No newline at end of file diff --git a/Selector.Web/Pages/Past.cshtml.cs b/Selector.Web/Pages/Past.cshtml.cs new file mode 100644 index 0000000..45adc13 --- /dev/null +++ b/Selector.Web/Pages/Past.cshtml.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; + +namespace Selector.Web.Pages +{ + public class PastModel : PageModel + { + private readonly ILogger Logger; + + public PastModel(ILogger logger) + { + Logger = logger; + } + + public void OnGet() + { + + } + } +} diff --git a/Selector.Web/Pages/Shared/_Layout.cshtml b/Selector.Web/Pages/Shared/_Layout.cshtml index 8c1be46..13730db 100644 --- a/Selector.Web/Pages/Shared/_Layout.cshtml +++ b/Selector.Web/Pages/Shared/_Layout.cshtml @@ -12,11 +12,14 @@ diff --git a/Selector.Web/Past/ChartEntry.cs b/Selector.Web/Past/ChartEntry.cs new file mode 100644 index 0000000..af3fed8 --- /dev/null +++ b/Selector.Web/Past/ChartEntry.cs @@ -0,0 +1,10 @@ +using System; + +namespace Selector.Web; + +public class ChartEntry +{ + public string Name { get; set; } + public int Value { get; set; } +} + diff --git a/Selector.Web/Past/PastParams.cs b/Selector.Web/Past/PastParams.cs new file mode 100644 index 0000000..ae5993d --- /dev/null +++ b/Selector.Web/Past/PastParams.cs @@ -0,0 +1,14 @@ +using System; + +namespace Selector.Web; + +public class PastParams +{ + public string Track { get; set; } + public string Album { get; set; } + public string Artist { get; set; } + + public string From { get; set; } + public string To { get; set; } +} + diff --git a/Selector.Web/Past/RankResult.cs b/Selector.Web/Past/RankResult.cs new file mode 100644 index 0000000..984dd5c --- /dev/null +++ b/Selector.Web/Past/RankResult.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace Selector.Web; + +public class RankResult +{ + public IEnumerable TrackEntries { get; set; } + public IEnumerable AlbumEntries { get; set; } + public IEnumerable ArtistEntries { get; set; } + + public IEnumerable ResampledSeries { get; set; } + + public int TotalCount { get; set; } +} + diff --git a/Selector.Web/Selector.Web.csproj b/Selector.Web/Selector.Web.csproj index 9529b72..ae42787 100644 --- a/Selector.Web/Selector.Web.csproj +++ b/Selector.Web/Selector.Web.csproj @@ -37,10 +37,12 @@ + + diff --git a/Selector.Web/Startup.cs b/Selector.Web/Startup.cs index aea55db..90d6b49 100644 --- a/Selector.Web/Startup.cs +++ b/Selector.Web/Startup.cs @@ -159,7 +159,8 @@ namespace Selector.Web { endpoints.MapRazorPages(); endpoints.MapControllers(); - endpoints.MapHub("/hub"); + endpoints.MapHub("/nowhub"); + endpoints.MapHub("/pasthub"); }); } diff --git a/Selector.Web/scripts/HubInterfaces.ts b/Selector.Web/scripts/HubInterfaces.ts index 9f3906b..58a551e 100644 --- a/Selector.Web/scripts/HubInterfaces.ts +++ b/Selector.Web/scripts/HubInterfaces.ts @@ -29,6 +29,27 @@ export interface PlayCount { listeningEvent: ListeningChangeEventArgs; } +export interface PastParams { + track: string; + album: string; + artist: string; + from: string; + to: string; +} + +export interface RankResult { + trackEntries: RankEntry[]; + albumEntries: RankEntry[]; + artistEntries: RankEntry[]; + totalCount: number; + resampledSeries: CountSample[]; +} + +export interface RankEntry { + name: string; + value: number; +} + export interface CountSample { timeStamp: Date; value: number; diff --git a/Selector.Web/scripts/Now/PlayCountGraph.ts b/Selector.Web/scripts/Now/PlayCountGraph.ts index 3f9aa69..8c56610 100644 --- a/Selector.Web/scripts/Now/PlayCountGraph.ts +++ b/Selector.Web/scripts/Now/PlayCountGraph.ts @@ -31,7 +31,7 @@ export let PlayCountChartCard: Vue.Component = {

{{ title }}

- +
`, mounted() { @@ -56,8 +56,8 @@ export let PlayCountChartCard: Vue.Component = { }, xAxis: { type: 'time', - min: this.earliest_date, - max: this.latest_date + // min: this.earliest_date, + // max: this.latest_date } } } diff --git a/Selector.Web/scripts/Past/CountCard.ts b/Selector.Web/scripts/Past/CountCard.ts new file mode 100644 index 0000000..b39657e --- /dev/null +++ b/Selector.Web/scripts/Past/CountCard.ts @@ -0,0 +1,14 @@ +import * as Vue from "vue"; + +export let CountCard: Vue.Component = { + props: ['count'], + computed: { + + }, + template: + ` +
+

{{ count }}

+
+ ` +} \ No newline at end of file diff --git a/Selector.Web/scripts/Past/RankCard.ts b/Selector.Web/scripts/Past/RankCard.ts new file mode 100644 index 0000000..87fa276 --- /dev/null +++ b/Selector.Web/scripts/Past/RankCard.ts @@ -0,0 +1,19 @@ +import * as Vue from "vue"; + +export let RankCard: Vue.Component = { + props: ['title', 'entries'], + computed: { + + }, + template: + ` +
+

{{ title }}

+
    +
  1. + {{ entry.name }} - {{entry.value}} +
  2. +
+
+ ` +} \ No newline at end of file diff --git a/Selector.Web/scripts/now.ts b/Selector.Web/scripts/now.ts index 507ce1d..1684311 100644 --- a/Selector.Web/scripts/now.ts +++ b/Selector.Web/scripts/now.ts @@ -9,7 +9,7 @@ import { PlayCountCard, LastFmLogoLink } from "./Now/LastFm"; import BaseInfoCard from "./Now/BaseInfoCard"; const connection = new signalR.HubConnectionBuilder() - .withUrl("/hub") + .withUrl("/nowhub") .withAutomaticReconnect() .build(); diff --git a/Selector.Web/scripts/past.ts b/Selector.Web/scripts/past.ts new file mode 100644 index 0000000..f8b2505 --- /dev/null +++ b/Selector.Web/scripts/past.ts @@ -0,0 +1,73 @@ +import * as signalR from "@microsoft/signalr"; +// import { stringifyStyle } from "@vue/shared"; +import * as Vue from "vue"; +import { RankResult, RankEntry, PastParams } from "./HubInterfaces"; +import { RankCard } from "./Past/RankCard"; +import { CountCard } from "./Past/CountCard"; +import { PlayCountChartCard } from "./Now/PlayCountGraph"; +import { LastFmLogoLink } from "./Now/LastFm"; + +const connection = new signalR.HubConnectionBuilder() + .withUrl("/pasthub") + .build(); + +connection.start() +.then(val => { + connection.invoke("OnConnected"); +}) +.catch(err => console.error(err)); + +const app = Vue.createApp({ + data() { + return { + track: "", + album: "", + artist: "", + + from: null, + to: null, + + trackEntries: [], + albumEntries: [], + artistEntries: [], + + resampledSeries: [], + + totalCount: 0 + } + }, + created() { + connection.on("OnRankResult", (result: RankResult) => + { + console.log(result); + + this.trackEntries = result.trackEntries; + this.albumEntries = result.albumEntries; + this.artistEntries = result.artistEntries; + this.resampledSeries = result.resampledSeries; + this.totalCount = result.totalCount; + }); + }, + methods: { + submit() { + let context = { + track: this.track, + album: this.album, + artist: this.artist, + from: this.from, + to: this.to, + } as PastParams; + + console.log(context); + + connection.invoke("OnSubmitted", context); + } + } +}); + +app.component("play-count-chart-card", PlayCountChartCard); +app.component("rank-card", RankCard); +app.component("lastfm-logo", LastFmLogoLink); +app.component("count-card", CountCard); + +const vm = app.mount('#pastapp'); \ No newline at end of file diff --git a/Selector.Web/webpack.common.js b/Selector.Web/webpack.common.js index b880012..698ae87 100644 --- a/Selector.Web/webpack.common.js +++ b/Selector.Web/webpack.common.js @@ -4,6 +4,7 @@ const { CleanWebpackPlugin } = require('clean-webpack-plugin'); module.exports = { entry: { now: './scripts/now.ts', + past: './scripts/past.ts', nowCss: './CSS/index.scss', }, module: { diff --git a/Selector/Options.cs b/Selector/Options.cs index a135b6a..2f1c930 100644 --- a/Selector/Options.cs +++ b/Selector/Options.cs @@ -18,5 +18,12 @@ namespace Selector public TimeSpan TrackDensityWindow { get; set; } = TimeSpan.FromDays(10); public decimal TrackDensityThreshold { get; set; } = 5; } + + public class PastOptions + { + public const string Key = "Past"; + + public TimeSpan ResampleWindow { get; set; } = TimeSpan.FromDays(7); + } }