diff --git a/Selector.Web/Pages/Now.cshtml b/Selector.Web/Pages/Now.cshtml index f75e347..5622181 100644 --- a/Selector.Web/Pages/Now.cshtml +++ b/Selector.Web/Pages/Now.cshtml @@ -22,19 +22,37 @@ :track="lastfmTrack" :username="playCount.username" v-if="playCount !== null && playCount !== undefined"> + + + + + + - diff --git a/Selector.Web/appsettings.json b/Selector.Web/appsettings.json index 1e809b2..2ba4c58 100644 --- a/Selector.Web/appsettings.json +++ b/Selector.Web/appsettings.json @@ -9,6 +9,11 @@ "Selector": { "Redis": { "enabled": true + }, + "Now": { + "ArtistResampleWindow": "14.00:00:00", + "AlbumResampleWindow": "14.00:00:00", + "TrackResampleWindow": "14.00:00:00" } }, "AllowedHosts": "*" diff --git a/Selector.Web/package-lock.json b/Selector.Web/package-lock.json index a5198e2..d864995 100644 --- a/Selector.Web/package-lock.json +++ b/Selector.Web/package-lock.json @@ -12,9 +12,12 @@ "@microsoft/signalr": "^6.0.0", "bootstrap": "^5.1.3", "chart.js": "^3.6.0", + "chartjs-adapter-luxon": "^1.1.0", + "luxon": "^2.4.0", "vue": "^3.2.22" }, "devDependencies": { + "@types/luxon": "^2.3.2", "clean-webpack-plugin": "^4.0.0", "file-loader": "^6.2.0", "sass": "^1.44.0", @@ -111,6 +114,12 @@ "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "dev": true }, + "node_modules/@types/luxon": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.2.tgz", + "integrity": "sha512-WOehptuhKIXukSUUkRgGbj2c997Uv/iUgYgII8U7XLJqq9W2oF0kQ6frEznRQbdurioz+L/cdaIm4GutTQfgmA==", + "dev": true + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -707,6 +716,15 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.6.2.tgz", "integrity": "sha512-Xz7f/fgtVltfQYWq0zL1Xbv7N2inpG+B54p3D5FSvpCdy3sM+oZhbqa42eNuYXltaVvajgX5UpKCU2GeeJIgxg==" }, + "node_modules/chartjs-adapter-luxon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-luxon/-/chartjs-adapter-luxon-1.1.0.tgz", + "integrity": "sha512-CS+xBWEyXYVLBZ3dSY/MwlSXhz8er4JjkApazY84ft/++oOLsmkt6TaXBCsUFudum7QdoYmpxiL/gSp20+emkw==", + "peerDependencies": { + "chart.js": "^3.0.0", + "luxon": "^1.0.0 || ^2.0.0" + } + }, "node_modules/chokidar": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", @@ -1568,6 +1586,14 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.4.0.tgz", + "integrity": "sha512-w+NAwWOUL5hO0SgwOHsMBAmZ15SoknmQXhSO0hIbJCAmPKSsGeK8MlmhYh2w6Iib38IxN2M+/ooXWLbeis7GuA==", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.25.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", @@ -2879,6 +2905,12 @@ "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "dev": true }, + "@types/luxon": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.2.tgz", + "integrity": "sha512-WOehptuhKIXukSUUkRgGbj2c997Uv/iUgYgII8U7XLJqq9W2oF0kQ6frEznRQbdurioz+L/cdaIm4GutTQfgmA==", + "dev": true + }, "@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -3385,6 +3417,12 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.6.2.tgz", "integrity": "sha512-Xz7f/fgtVltfQYWq0zL1Xbv7N2inpG+B54p3D5FSvpCdy3sM+oZhbqa42eNuYXltaVvajgX5UpKCU2GeeJIgxg==" }, + "chartjs-adapter-luxon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-luxon/-/chartjs-adapter-luxon-1.1.0.tgz", + "integrity": "sha512-CS+xBWEyXYVLBZ3dSY/MwlSXhz8er4JjkApazY84ft/++oOLsmkt6TaXBCsUFudum7QdoYmpxiL/gSp20+emkw==", + "requires": {} + }, "chokidar": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", @@ -4026,6 +4064,11 @@ "yallist": "^4.0.0" } }, + "luxon": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.4.0.tgz", + "integrity": "sha512-w+NAwWOUL5hO0SgwOHsMBAmZ15SoknmQXhSO0hIbJCAmPKSsGeK8MlmhYh2w6Iib38IxN2M+/ooXWLbeis7GuA==" + }, "magic-string": { "version": "0.25.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", diff --git a/Selector.Web/package.json b/Selector.Web/package.json index bd596b1..66b02d2 100644 --- a/Selector.Web/package.json +++ b/Selector.Web/package.json @@ -23,9 +23,12 @@ "@microsoft/signalr": "^6.0.0", "bootstrap": "^5.1.3", "chart.js": "^3.6.0", + "chartjs-adapter-luxon": "^1.1.0", + "luxon": "^2.4.0", "vue": "^3.2.22" }, "devDependencies": { + "@types/luxon": "^2.3.2", "clean-webpack-plugin": "^4.0.0", "file-loader": "^6.2.0", "sass": "^1.44.0", diff --git a/Selector.Web/scripts/Now/ArtistBreakdownGraph.ts b/Selector.Web/scripts/Now/ArtistBreakdownGraph.ts new file mode 100644 index 0000000..506c254 --- /dev/null +++ b/Selector.Web/scripts/Now/ArtistBreakdownGraph.ts @@ -0,0 +1,71 @@ +import * as Vue from "vue"; +import { Chart, DoughnutController, ArcElement } from "chart.js"; +import { PlayCount } from "scripts/HubInterfaces"; + +Chart.register(DoughnutController, ArcElement); + +const pieColours = ['#7a99c2', + '#a34c77', + '#598556', +]; + +export let ArtistBreakdownChartCard: Vue.Component = { + props: ['play_count'], + data() { + return { + + } + }, + computed: { + trackPercent() { + return this.play_count.track * 100 / this.play_count.artist + }, + albumPercent() { + return this.play_count.album * 100 / this.play_count.artist + }, + albumDiff() { + return this.albumPercent - this.trackPercent; + }, + artistPercent() { + return 100 - this.albumDiff + this.trackPercent; + } + }, + template: + ` +
+ + +
+ `, + mounted() { + new Chart(`artist-breakdown`, { + type: "doughnut", + data: { + labels: [ "track", "album", "artist" ], + datasets: [{ + data: [ this.trackPercent, this.albumDiff, this.artistPercent ], + }] + }, + options: { + plugins: { + legend : { + display : true, + labels: { + color: 'white' + } + } + }, + layout: { + padding: 20 + }, + elements: { + arc : { + backgroundColor: pieColours, + borderWidth: 2, + borderColor: 'rgb(0, 0, 0)' + } + } + } + }) + } +} \ No newline at end of file diff --git a/Selector.Web/scripts/Now/PlayCountGraph.ts b/Selector.Web/scripts/Now/PlayCountGraph.ts index ab09b11..aa7182c 100644 --- a/Selector.Web/scripts/Now/PlayCountGraph.ts +++ b/Selector.Web/scripts/Now/PlayCountGraph.ts @@ -1,24 +1,22 @@ import * as Vue from "vue"; import { Chart, PointElement, LineElement, LineController, CategoryScale, LinearScale, TimeSeriesScale } from "chart.js"; +import 'chartjs-adapter-luxon'; import { CountSample } from "scripts/HubInterfaces"; +import { ScrobbleDataSeries } from "scripts/now"; 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', 'link'], + props: ['data_points', 'title', 'chart_id', 'link', 'earliest_date', 'latest_date', 'colour'], 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), + data: this.data_points.map((e: CountSample) => { + return {x: e.timeStamp, y: e.value}; + }), }] } } @@ -43,25 +41,85 @@ export let PlayCountChartCard: Vue.Component = { options: { elements: { line: { - borderWidth: 4, - borderColor: "#a34c77", - backgroundColor: "#727272", + borderWidth: 5, + borderColor: this.colour, borderCapStyle: "round", borderJoinStyle: "round" }, - // point: { - // radius: 4, - // pointStyle: "circle", - // borderColor: "black", - // backgroundColor: "white" - // } + point: { + radius: 0 + } }, scales: { yAxis: { suggestedMin: 0 + }, + xAxis: { + type: 'time', + min: this.earliest_date, + max: this.latest_date } } } }) } -} \ No newline at end of file +} + +export let CombinedPlayCountChartCard: Vue.Component = { + props: ['data_points', 'title', 'chart_id', 'link', 'earliest_date'], + data() { + return { + chartData: { + datasets: this.data_points.map((series: ScrobbleDataSeries) => { + return { + label: series.label, + borderColor: series.colour, + data: series.data.map((e: CountSample) => { + return {x: e.timeStamp, y: e.value}; + }) + } + }) + } + } + }, + computed: { + chartId() { + return "play-count-chart-" + this.chart_id; + } + }, + template: + ` +
+ + +
+ `, + mounted() { + new Chart(`play-count-chart-${this.chart_id}`, { + type: "line", + data: this.chartData, + options: { + elements: { + line: { + borderWidth: 5, + borderColor: "#a34c77", + borderCapStyle: "round", + borderJoinStyle: "round" + }, + point: { + radius: 0 + } + }, + scales: { + yAxis: { + suggestedMin: 0 + }, + xAxis: { + type: 'time', + min: this.earliest_date + } + } + } + }) + } +} diff --git a/Selector.Web/scripts/now.ts b/Selector.Web/scripts/now.ts index c799958..c6a1d0c 100644 --- a/Selector.Web/scripts/now.ts +++ b/Selector.Web/scripts/now.ts @@ -1,11 +1,13 @@ import * as signalR from "@microsoft/signalr"; import * as Vue from "vue"; -import { TrackAudioFeatures, PlayCount, CurrentlyPlayingDTO } from "./HubInterfaces"; +import { TrackAudioFeatures, PlayCount, CurrentlyPlayingDTO, CountSample } from "./HubInterfaces"; import NowPlayingCard from "./Now/NowPlayingCard"; import { AudioFeatureCard, AudioFeatureChartCard, PopularityCard, SpotifyLogoLink } from "./Now/Spotify"; -import { PlayCountChartCard } from "./Now/PlayCountGraph"; +import { PlayCountChartCard, CombinedPlayCountChartCard } from "./Now/PlayCountGraph"; +import { ArtistBreakdownChartCard } from "./Now/ArtistBreakdownGraph"; import { PlayCountCard, LastFmLogoLink } from "./Now/LastFm"; import BaseInfoCard from "./Now/BaseInfoCard"; +import { DateTime } from "luxon"; const connection = new signalR.HubConnectionBuilder() .withUrl("/hub") @@ -28,6 +30,12 @@ interface NowPlaying { cards: InfoCard[] } +export interface ScrobbleDataSeries { + label: string, + colour: string, + data: CountSample[] +} + const app = Vue.createApp({ data() { return { @@ -46,14 +54,6 @@ const app = Vue.createApp({ 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 > 3; }, @@ -62,6 +62,33 @@ const app = Vue.createApp({ }, showTrackChart(){ return this.playCount !== null && this.playCount !== undefined && this.playCount.trackCountData.length > 3; + }, + earliestDate(){ + return this.playCount.artistCountData[0].timeStamp; + }, + latestDate(){ + return this.playCount.artistCountData.at(-1).timeStamp; + }, + trackGraphTitle() { return `${this.currentlyPlaying.track.name} 🎵`}, + albumGraphTitle() { return `${this.currentlyPlaying.track.album.name} 💿`}, + artistGraphTitle() { return `${this.currentlyPlaying.track.artists[0].name} 🎤`}, + combinedData(){ + return [ + { + label: "artist", + colour: "#598556", + data: this.playCount.artistCountData + }, + { + label: "album", + colour: "#a34c77", + data: this.playCount.albumCountData + }, + { + label: "track", + colour: "#7a99c2", + data: this.playCount.trackCountData + }]; } }, created() { @@ -123,4 +150,6 @@ app.component("spotify-logo", SpotifyLogoLink); app.component("lastfm-logo", LastFmLogoLink); app.component("play-count-card", PlayCountCard); app.component("play-count-chart-card", PlayCountChartCard); +app.component("play-count-chart-card-comb", CombinedPlayCountChartCard); +app.component("artist-breakdown", ArtistBreakdownChartCard); const vm = app.mount('#app'); \ No newline at end of file diff --git a/Selector/Options.cs b/Selector/Options.cs index 5bd0279..a135b6a 100644 --- a/Selector/Options.cs +++ b/Selector/Options.cs @@ -5,9 +5,9 @@ namespace Selector { 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 ArtistResampleWindow { get; set; } = TimeSpan.FromDays(7); + public TimeSpan AlbumResampleWindow { get; set; } = TimeSpan.FromDays(7); + public TimeSpan TrackResampleWindow { get; set; } = TimeSpan.FromDays(7); public TimeSpan ArtistDensityWindow { get; set; } = TimeSpan.FromDays(10); public decimal ArtistDensityThreshold { get; set; } = 5;