Merge pull request #52 from Sarsoo/past

Past
This commit is contained in:
andy 2022-11-13 22:14:05 +00:00 committed by GitHub
commit 713c9e065c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 403 additions and 7 deletions

View File

@ -1,4 +1,5 @@
@import "now.scss"; @import "now.scss";
@import "past.scss";
body { body {
background-color: #121212; background-color: #121212;

View File

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

View File

@ -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<IPastHubClient>
{
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> pastOptions;
public PastHub(
IDatabaseAsync cache,
AudioFeaturePuller featurePuller,
ApplicationDbContext db,
IListenRepository listenRepository,
IOptions<PastOptions> 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
});
}
}
}

View File

@ -10,6 +10,7 @@ namespace Selector.Web
config.GetSection(RootOptions.Key).Bind(options); config.GetSection(RootOptions.Key).Bind(options);
config.GetSection(FormatKeys(new[] { RootOptions.Key, RedisOptions.Key })).Bind(options.RedisOptions); 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, NowPlayingOptions.Key })).Bind(options.NowOptions);
config.GetSection(FormatKeys(new[] { RootOptions.Key, PastOptions.Key })).Bind(options.PastOptions);
} }
public static RootOptions ConfigureOptions(IConfiguration config) public static RootOptions ConfigureOptions(IConfiguration config)
@ -45,6 +46,7 @@ namespace Selector.Web
public RedisOptions RedisOptions { get; set; } = new(); public RedisOptions RedisOptions { get; set; } = new();
public NowPlayingOptions NowOptions { get; set; } = new(); public NowPlayingOptions NowOptions { get; set; } = new();
public PastOptions PastOptions { get; set; } = new();
} }

View File

@ -0,0 +1,45 @@
@page
@model PastModel
@{
ViewData["Title"] = "Past - Selector";
}
<div class="text-center">
<h1 class="display-4">Past</h1>
<div id="pastapp" class="app col-12">
<div class="card" style="width: 100%">
<div>
<input v-model="track" class="form-input form-control" placeholder="Track" />
<input v-model="album" class="form-input form-control" placeholder="Album" />
<input v-model="artist" class="form-input form-control" placeholder="Artist" />
</div>
<div>
<label for="from-picker">From</label>
<input type="date" v-model="from" class="form-input form-control" id="from-picker" />
<label for="to-picker">To</label>
<input type="date" v-model="to" class="form-input form-control" id="to-picker" />
</div>
<div>
<button type="button" v-on:click="submit" class="btn btn-primary">Submit</button>
</div>
</div>
<count-card :count="totalCount"></count-card>
<play-count-chart-card :data_points="resampledSeries"
:title="'Time Series'"
:chart_id="'time_series'"
:colour="'#ffffff'"
v-if="resampledSeries.length > 0"></play-count-chart-card>
<div style="width: 100%">
<rank-card :title="'Track'" :entries="trackEntries" v-if="trackEntries.length > 1"></rank-card>
<rank-card :title="'Album'" :entries="albumEntries" v-if="albumEntries.length > 1"></rank-card>
<rank-card :title="'Artist'" :entries="artistEntries" v-if="artistEntries.length > 1"></rank-card>
</div>
</div>
</div>
@section Scripts {
<script type="module" src="~/js/past.bundle.js"></script>
}

View File

@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
namespace Selector.Web.Pages
{
public class PastModel : PageModel
{
private readonly ILogger<PastModel> Logger;
public PastModel(ILogger<PastModel> logger)
{
Logger = logger;
}
public void OnGet()
{
}
}
}

View File

@ -12,11 +12,14 @@
<nav class="navbar navbar-expand-sm navbar-dark navbar-bg separator-border box-shadow mb-3"> <nav class="navbar navbar-expand-sm navbar-dark navbar-bg separator-border box-shadow mb-3">
<div class="container"> <div class="container">
<a class="navbar-brand" asp-area="" asp-page="/Index">Selector</a> <a class="navbar-brand" asp-area="" asp-page="/Index">Selector</a>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between"> @*<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">*@
<ul class="navbar-nav flex-grow-1"> <ul class="navbar-nav flex-grow-1">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" asp-area="" asp-page="/Now">Now</a> <a class="nav-link" asp-area="" asp-page="/Now">Now</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" asp-area="" asp-page="/Past">Past</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="https://sarsoo.xyz/posts/selector/">About</a> <a class="nav-link" href="https://sarsoo.xyz/posts/selector/">About</a>
</li> </li>
@ -24,7 +27,7 @@
<a class="nav-link" href="https://sarsoo.xyz/about">Contact</a> <a class="nav-link" href="https://sarsoo.xyz/about">Contact</a>
</li> </li>
</ul> </ul>
</div> @*</div>*@
<partial name="_LoginPartial" /> <partial name="_LoginPartial" />
</div> </div>
</nav> </nav>

View File

@ -0,0 +1,10 @@
using System;
namespace Selector.Web;
public class ChartEntry
{
public string Name { get; set; }
public int Value { get; set; }
}

View File

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

View File

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
namespace Selector.Web;
public class RankResult
{
public IEnumerable<ChartEntry> TrackEntries { get; set; }
public IEnumerable<ChartEntry> AlbumEntries { get; set; }
public IEnumerable<ChartEntry> ArtistEntries { get; set; }
public IEnumerable<CountSample> ResampledSeries { get; set; }
public int TotalCount { get; set; }
}

View File

@ -37,10 +37,12 @@
<ItemGroup> <ItemGroup>
<Folder Include="wwwroot\" /> <Folder Include="wwwroot\" />
<Folder Include="NowPlaying\" /> <Folder Include="NowPlaying\" />
<Folder Include="Past\" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Remove="NowPlaying\" /> <None Remove="NowPlaying\" />
<None Remove="Past\" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="appsettings.Development.json" Condition="Exists('appsettings.Development.json')"> <None Update="appsettings.Development.json" Condition="Exists('appsettings.Development.json')">

View File

@ -159,7 +159,8 @@ namespace Selector.Web
{ {
endpoints.MapRazorPages(); endpoints.MapRazorPages();
endpoints.MapControllers(); endpoints.MapControllers();
endpoints.MapHub<NowPlayingHub>("/hub"); endpoints.MapHub<NowPlayingHub>("/nowhub");
endpoints.MapHub<PastHub>("/pasthub");
}); });
} }

View File

@ -29,6 +29,27 @@ export interface PlayCount {
listeningEvent: ListeningChangeEventArgs; 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 { export interface CountSample {
timeStamp: Date; timeStamp: Date;
value: number; value: number;

View File

@ -31,7 +31,7 @@ export let PlayCountChartCard: Vue.Component = {
<div class="chart-card card"> <div class="chart-card card">
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
<canvas :id="chartId"></canvas> <canvas :id="chartId"></canvas>
<lastfm-logo :link="link" /> <lastfm-logo :link="link" v-if="link" />
</div> </div>
`, `,
mounted() { mounted() {
@ -56,8 +56,8 @@ export let PlayCountChartCard: Vue.Component = {
}, },
xAxis: { xAxis: {
type: 'time', type: 'time',
min: this.earliest_date, // min: this.earliest_date,
max: this.latest_date // max: this.latest_date
} }
} }
} }

View File

@ -0,0 +1,14 @@
import * as Vue from "vue";
export let CountCard: Vue.Component = {
props: ['count'],
computed: {
},
template:
`
<div class="card">
<h2>{{ count }}</h2>
</div>
`
}

View File

@ -0,0 +1,19 @@
import * as Vue from "vue";
export let RankCard: Vue.Component = {
props: ['title', 'entries'],
computed: {
},
template:
`
<div class="rank-card card">
<h2>{{ title }}</h2>
<ol>
<li v-for="entry in entries">
{{ entry.name }} - <b>{{entry.value}}</b>
</li>
</ol>
</div>
`
}

View File

@ -9,7 +9,7 @@ import { PlayCountCard, LastFmLogoLink } from "./Now/LastFm";
import BaseInfoCard from "./Now/BaseInfoCard"; import BaseInfoCard from "./Now/BaseInfoCard";
const connection = new signalR.HubConnectionBuilder() const connection = new signalR.HubConnectionBuilder()
.withUrl("/hub") .withUrl("/nowhub")
.withAutomaticReconnect() .withAutomaticReconnect()
.build(); .build();

View File

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

View File

@ -4,6 +4,7 @@ const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = { module.exports = {
entry: { entry: {
now: './scripts/now.ts', now: './scripts/now.ts',
past: './scripts/past.ts',
nowCss: './CSS/index.scss', nowCss: './CSS/index.scss',
}, },
module: { module: {

View File

@ -18,5 +18,12 @@ namespace Selector
public TimeSpan TrackDensityWindow { get; set; } = TimeSpan.FromDays(10); public TimeSpan TrackDensityWindow { get; set; } = TimeSpan.FromDays(10);
public decimal TrackDensityThreshold { get; set; } = 5; public decimal TrackDensityThreshold { get; set; } = 5;
} }
public class PastOptions
{
public const string Key = "Past";
public TimeSpan ResampleWindow { get; set; } = TimeSpan.FromDays(7);
}
} }