diff --git a/Selector.CLI/Program.cs b/Selector.CLI/Program.cs index 7a324c1..72d790c 100644 --- a/Selector.CLI/Program.cs +++ b/Selector.CLI/Program.cs @@ -55,6 +55,7 @@ namespace Selector.CLI Console.WriteLine("> Adding Last.fm credentials..."); services.AddLastFm(config.LastfmClient, config.LastfmSecret); + services.AddCachingLastFm(); } else { diff --git a/Selector.Cache/Consumer/AudioInjectorCaching.cs b/Selector.Cache/Consumer/AudioInjectorCaching.cs index ce4abd0..9f5e4a6 100644 --- a/Selector.Cache/Consumer/AudioInjectorCaching.cs +++ b/Selector.Cache/Consumer/AudioInjectorCaching.cs @@ -30,7 +30,7 @@ namespace Selector.Cache public void CacheCallback(object sender, AnalysedTrack e) { - Task.Run(() => { return AsyncCacheCallback(e); }, CancelToken); + Task.Run(async () => { await AsyncCacheCallback(e); }, CancelToken); } public async Task AsyncCacheCallback(AnalysedTrack e) diff --git a/Selector.Cache/Consumer/CacheWriterConsumer.cs b/Selector.Cache/Consumer/CacheWriterConsumer.cs index cb67656..ec55bb9 100644 --- a/Selector.Cache/Consumer/CacheWriterConsumer.cs +++ b/Selector.Cache/Consumer/CacheWriterConsumer.cs @@ -15,7 +15,7 @@ namespace Selector.Cache private readonly IPlayerWatcher Watcher; private readonly IDatabaseAsync Db; private readonly ILogger Logger; - public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromMinutes(10); + public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromMinutes(20); public CancellationToken CancelToken { get; set; } @@ -35,7 +35,7 @@ namespace Selector.Cache { if (e.Current is null) return; - Task.Run(() => { return AsyncCallback(e); }, CancelToken); + Task.Run(async () => { await AsyncCallback(e); }, CancelToken); } public async Task AsyncCallback(ListeningChangeEventArgs e) @@ -57,6 +57,7 @@ namespace Selector.Cache if (watcher is IPlayerWatcher watcherCast) { watcherCast.ItemChange += Callback; + watcherCast.PlayingChange += Callback; } else { @@ -71,6 +72,7 @@ namespace Selector.Cache if (watcher is IPlayerWatcher watcherCast) { watcherCast.ItemChange -= Callback; + watcherCast.PlayingChange -= Callback; } else { diff --git a/Selector.Cache/Consumer/PlayCounterCaching.cs b/Selector.Cache/Consumer/PlayCounterCaching.cs index 1df8cea..6ff7edb 100644 --- a/Selector.Cache/Consumer/PlayCounterCaching.cs +++ b/Selector.Cache/Consumer/PlayCounterCaching.cs @@ -37,7 +37,7 @@ namespace Selector.Cache public void CacheCallback(object sender, PlayCount e) { - Task.Run(() => { return AsyncCacheCallback(e); }, CancelToken); + Task.Run(async () => { await AsyncCacheCallback(e); }, CancelToken); } public async Task AsyncCacheCallback(PlayCount e) diff --git a/Selector.Cache/Consumer/PublisherConsumer.cs b/Selector.Cache/Consumer/PublisherConsumer.cs index 2a99bd5..996aff7 100644 --- a/Selector.Cache/Consumer/PublisherConsumer.cs +++ b/Selector.Cache/Consumer/PublisherConsumer.cs @@ -34,7 +34,7 @@ namespace Selector.Cache { if (e.Current is null) return; - Task.Run(() => { return AsyncCallback(e); }, CancelToken); + Task.Run(async () => { await AsyncCallback(e); }, CancelToken); } public async Task AsyncCallback(ListeningChangeEventArgs e) diff --git a/Selector.Cache/Extensions/ServiceExtensions.cs b/Selector.Cache/Extensions/ServiceExtensions.cs index e066286..75a0ff8 100644 --- a/Selector.Cache/Extensions/ServiceExtensions.cs +++ b/Selector.Cache/Extensions/ServiceExtensions.cs @@ -42,5 +42,10 @@ namespace Selector.Cache.Extensions { services.AddSingleton(); } + + public static void AddCachingLastFm(this IServiceCollection services) + { + services.AddSingleton(); + } } } diff --git a/Selector.Cache/AudioFeaturePuller.cs b/Selector.Cache/Services/AudioFeaturePuller.cs similarity index 100% rename from Selector.Cache/AudioFeaturePuller.cs rename to Selector.Cache/Services/AudioFeaturePuller.cs diff --git a/Selector.Cache/Services/PlayCountPuller.cs b/Selector.Cache/Services/PlayCountPuller.cs new file mode 100644 index 0000000..1bba903 --- /dev/null +++ b/Selector.Cache/Services/PlayCountPuller.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +using IF.Lastfm.Core.Api; +using IF.Lastfm.Core.Api.Helpers; +using IF.Lastfm.Core.Objects; +using StackExchange.Redis; + +namespace Selector.Cache +{ + public class PlayCountPuller + { + private readonly IDatabaseAsync Cache; + private readonly ILogger Logger; + + protected readonly ITrackApi TrackClient; + protected readonly IAlbumApi AlbumClient; + protected readonly IArtistApi ArtistClient; + protected readonly IUserApi UserClient; + + public PlayCountPuller( + IDatabaseAsync cache, + ILogger logger, + + ITrackApi trackClient, + IAlbumApi albumClient, + IArtistApi artistClient, + IUserApi userClient + ) + { + Cache = cache; + Logger = logger; + + TrackClient = trackClient; + AlbumClient = albumClient; + ArtistClient = artistClient; + UserClient = userClient; + } + + public async Task Get(string username, string track, string artist, string album, string albumArtist) + { + if (string.IsNullOrWhiteSpace(username)) throw new ArgumentNullException("No username provided"); + + var trackCache = Cache.StringGetAsync(Key.TrackPlayCount(track, artist)); + var albumCache = Cache.StringGetAsync(Key.AlbumPlayCount(album, albumArtist)); + var artistCache = Cache.StringGetAsync(Key.ArtistPlayCount(artist)); + var userCache = Cache.StringGetAsync(Key.UserPlayCount(username)); + + var cacheTasks = new Task[] { trackCache, albumCache, artistCache, userCache }; + + await Task.WhenAll(cacheTasks); + + PlayCount playCount = new() + { + Username = username + }; + + Task> trackHttp = null; + Task> albumHttp = null; + Task> artistHttp = null; + Task> userHttp = null; + + if (trackCache.IsCompletedSuccessfully) + { + if(trackCache.Result == RedisValue.Null) + { + trackHttp = TrackClient.GetInfoAsync(track, artist, username); + } + else + { + playCount.Track = (int) trackCache.Result; + } + } + else + { + trackHttp = TrackClient.GetInfoAsync(track, artist, username); + } + + if (albumCache.IsCompletedSuccessfully) + { + if (albumCache.Result == RedisValue.Null) + { + albumHttp = AlbumClient.GetInfoAsync(albumArtist, album, username: username); + } + else + { + playCount.Album = (int)albumCache.Result; + } + } + else + { + albumHttp = AlbumClient.GetInfoAsync(albumArtist, album, username: username); + } + + if (artistCache.IsCompletedSuccessfully) + { + if (artistCache.Result == RedisValue.Null) + { + artistHttp = ArtistClient.GetInfoAsync(artist); + } + else + { + playCount.Artist = (int)artistCache.Result; + } + } + else + { + artistHttp = ArtistClient.GetInfoAsync(artist); + } + + if (userCache.IsCompletedSuccessfully) + { + if (userCache.Result == RedisValue.Null) + { + userHttp = UserClient.GetInfoAsync(username); + } + else + { + playCount.User = (int)userCache.Result; + } + } + else + { + userHttp = UserClient.GetInfoAsync(username); + } + + await Task.WhenAll(new Task[] {trackHttp, albumHttp, artistHttp, userHttp}.Where(t => t is not null)); + + if (trackHttp is not null && trackHttp.IsCompletedSuccessfully) + { + if (trackHttp.Result.Success) + { + playCount.Track = trackHttp.Result.Content.UserPlayCount; + } + else + { + Logger.LogDebug($"Track info error [{username}] [{trackHttp.Result.Status}]"); + } + } + + if (albumHttp is not null && albumHttp.IsCompletedSuccessfully) + { + if (albumHttp.Result.Success) + { + playCount.Album = albumHttp.Result.Content.UserPlayCount; + } + else + { + Logger.LogDebug($"Album info error [{username}] [{albumHttp.Result.Status}]"); + } + } + + //TODO: Add artist count + + if (userHttp is not null && userHttp.IsCompletedSuccessfully) + { + if (userHttp.Result.Success) + { + playCount.User = userHttp.Result.Content.Playcount; + } + else + { + Logger.LogDebug($"User info error [{username}] [{userHttp.Result.Status}]"); + } + } + + return playCount; + } + } +} diff --git a/Selector.Web/Hubs/NowPlayingHub.cs b/Selector.Web/Hubs/NowPlayingHub.cs index 2f0cc1e..4bcf135 100644 --- a/Selector.Web/Hubs/NowPlayingHub.cs +++ b/Selector.Web/Hubs/NowPlayingHub.cs @@ -18,18 +18,26 @@ namespace Selector.Web.Hubs { public Task OnNewPlaying(CurrentlyPlayingDTO context); public Task OnNewAudioFeature(TrackAudioFeatures features); + public Task OnNewPlayCount(PlayCount playCount); } public class NowPlayingHub: Hub { private readonly IDatabaseAsync Cache; private readonly AudioFeaturePuller AudioFeaturePuller; + private readonly PlayCountPuller PlayCountPuller; private readonly ApplicationDbContext Db; - public NowPlayingHub(IDatabaseAsync cache, AudioFeaturePuller puller, ApplicationDbContext db) + public NowPlayingHub( + IDatabaseAsync cache, + AudioFeaturePuller featurePuller, + ApplicationDbContext db, + PlayCountPuller playCountPuller = null + ) { Cache = cache; - AudioFeaturePuller = puller; + AudioFeaturePuller = featurePuller; + PlayCountPuller = playCountPuller; Db = db; } @@ -69,5 +77,27 @@ namespace Selector.Web.Hubs await Clients.Caller.OnNewAudioFeature(feature); } } + + public async Task SendPlayCount(string track, string artist, string album, string albumArtist) + { + if(PlayCountPuller is not null) + { + var user = Db.Users + .AsNoTracking() + .Where(u => u.Id == Context.UserIdentifier) + .SingleOrDefault() + ?? throw new SqlNullValueException("No user returned"); + + if(!string.IsNullOrWhiteSpace(user.LastFmUsername)) + { + var playCount = await PlayCountPuller.Get(user.LastFmUsername, track, artist, album, albumArtist); + + if (playCount is not null) + { + await Clients.Caller.OnNewPlayCount(playCount); + } + } + } + } } } \ No newline at end of file diff --git a/Selector.Web/Options.cs b/Selector.Web/Options.cs index 218cd94..c647f6d 100644 --- a/Selector.Web/Options.cs +++ b/Selector.Web/Options.cs @@ -36,6 +36,8 @@ namespace Selector.Web /// Spotify callback for authentication /// public string SpotifyCallback { get; set; } + public string LastfmClient { get; set; } + public string LastfmSecret { get; set; } public RedisOptions RedisOptions { get; set; } = new(); diff --git a/Selector.Web/Pages/Now.cshtml b/Selector.Web/Pages/Now.cshtml index a3cceda..c290027 100644 --- a/Selector.Web/Pages/Now.cshtml +++ b/Selector.Web/Pages/Now.cshtml @@ -13,6 +13,7 @@ + diff --git a/Selector.Web/Startup.cs b/Selector.Web/Startup.cs index b9b055f..767c14f 100644 --- a/Selector.Web/Startup.cs +++ b/Selector.Web/Startup.cs @@ -99,6 +99,8 @@ namespace Selector.Web services.AddSpotify(); services.AddCachingSpotify(); + ConfigureLastFm(config, services); + services.AddCacheHubProxy(); } @@ -131,5 +133,20 @@ namespace Selector.Web endpoints.MapHub("/hub"); }); } + + public static void ConfigureLastFm(RootOptions config, IServiceCollection services) + { + if (config.LastfmClient is not null) + { + Console.WriteLine("> Adding Last.fm credentials..."); + + services.AddLastFm(config.LastfmClient, config.LastfmSecret); + services.AddCachingLastFm(); + } + else + { + Console.WriteLine("> No Last.fm credentials, skipping init..."); + } + } } } diff --git a/Selector.Web/scripts/HubInterfaces.ts b/Selector.Web/scripts/HubInterfaces.ts index 46b5992..87c8d5b 100644 --- a/Selector.Web/scripts/HubInterfaces.ts +++ b/Selector.Web/scripts/HubInterfaces.ts @@ -10,12 +10,22 @@ export interface nowPlayingProxy { export interface NowPlayingHubClient { OnNewPlaying: (context: CurrentlyPlayingDTO) => void; OnNewAudioFeature: (features: TrackAudioFeatures) => void; + OnNewPlayCount: (playCount: PlayCount) => void; } export interface NowPlayingHub { SendNewPlaying(context: CurrentlyPlayingDTO): void; } +export interface PlayCount { + track: number | null; + album: number | null; + artist: number | null; + user: number | null; + username: string; + listeningEvent: ListeningChangeEventArgs; +} + export interface CurrentlyPlayingDTO { context: CurrentlyPlayingContextDTO; username: string; diff --git a/Selector.Web/scripts/Now/LastFm.ts b/Selector.Web/scripts/Now/LastFm.ts new file mode 100644 index 0000000..cc4a503 --- /dev/null +++ b/Selector.Web/scripts/Now/LastFm.ts @@ -0,0 +1,14 @@ +import * as Vue from "vue"; + +export let PlayCountCard: Vue.Component = { + props: ['count'], + template: + ` +
+
Track: {{ count.track.toLocaleString() }}
+
Album: {{ count.album.toLocaleString() }}
+
Artist: {{ count.artist.toLocaleString() }}
+
User: {{ count.user.toLocaleString() }}
+
+ ` +} \ No newline at end of file diff --git a/Selector.Web/scripts/now.ts b/Selector.Web/scripts/now.ts index f2a1723..d5ddd21 100644 --- a/Selector.Web/scripts/now.ts +++ b/Selector.Web/scripts/now.ts @@ -1,8 +1,9 @@ import * as signalR from "@microsoft/signalr"; import * as Vue from "vue"; -import { TrackAudioFeatures, CurrentlyPlayingDTO } from "./HubInterfaces"; +import { TrackAudioFeatures, PlayCount, CurrentlyPlayingDTO } from "./HubInterfaces"; import NowPlayingCard from "./Now/NowPlayingCard"; import { AudioFeatureCard, AudioFeatureChartCard, PopularityCard, SpotifyLogoLink } from "./Now/Spotify"; +import { PlayCountCard } from "./Now/LastFm"; import BaseInfoCard from "./Now/BaseInfoCard"; const connection = new signalR.HubConnectionBuilder() @@ -22,6 +23,7 @@ interface InfoCard { interface NowPlaying { currentlyPlaying?: CurrentlyPlayingDTO, trackFeatures?: TrackAudioFeatures, + playCount?: PlayCount, cards: InfoCard[] } @@ -30,6 +32,7 @@ const app = Vue.createApp({ return { currentlyPlaying: undefined, trackFeatures: undefined, + playCount: undefined, cards: [] } as NowPlaying }, @@ -39,11 +42,18 @@ const app = Vue.createApp({ console.log(context); this.currentlyPlaying = context; this.trackFeatures = null; + this.playCount = null; this.cards = []; if(context.track !== null && context.track !== undefined) { connection.invoke("SendAudioFeatures", context.track.id); + connection.invoke("SendPlayCount", + context.track.name, + context.track.artists[0].name, + context.track.album.name, + context.track.album.artists[0].name + ); } }); @@ -52,6 +62,12 @@ const app = Vue.createApp({ console.log(feature); this.trackFeatures = feature; }); + + connection.on("OnNewPlayCount", (count: PlayCount) => { + + console.log(count); + this.playCount = count; + }); } }); @@ -61,4 +77,5 @@ app.component("audio-feature-chart-card", AudioFeatureChartCard); app.component("info-card", BaseInfoCard); app.component("popularity", PopularityCard); app.component("spotify-logo", SpotifyLogoLink); +app.component("play-count-card", PlayCountCard); const vm = app.mount('#app'); \ No newline at end of file diff --git a/Selector/Consumers/AudioFeatureInjector.cs b/Selector/Consumers/AudioFeatureInjector.cs index 75feb98..d850fe6 100644 --- a/Selector/Consumers/AudioFeatureInjector.cs +++ b/Selector/Consumers/AudioFeatureInjector.cs @@ -37,7 +37,7 @@ namespace Selector { if (e.Current is null) return; - Task.Run(() => { return AsyncCallback(e); }, CancelToken); + Task.Run(async () => { await AsyncCallback(e); }, CancelToken); } public async Task AsyncCallback(ListeningChangeEventArgs e) diff --git a/Selector/Consumers/PlayCounter.cs b/Selector/Consumers/PlayCounter.cs index a0c52d9..f1d5c37 100644 --- a/Selector/Consumers/PlayCounter.cs +++ b/Selector/Consumers/PlayCounter.cs @@ -52,7 +52,7 @@ namespace Selector { if (e.Current is null) return; - Task.Run(() => { return AsyncCallback(e); }, CancelToken); + Task.Run(async () => { await AsyncCallback(e); }, CancelToken); } public async Task AsyncCallback(ListeningChangeEventArgs e) @@ -62,7 +62,7 @@ namespace Selector Logger.LogTrace("Making Last.fm call"); var trackInfo = TrackClient.GetInfoAsync(track.Name, track.Artists[0].Name, username: Credentials?.Username); - var albumInfo = AlbumClient.GetInfoAsync(track.Album.Name, track.Album.Artists[0].Name, username: Credentials?.Username); + var albumInfo = AlbumClient.GetInfoAsync(track.Album.Artists[0].Name, track.Album.Name, username: Credentials?.Username); var artistInfo = ArtistClient.GetInfoAsync(track.Artists[0].Name); // TODO: Null checking on credentials var userInfo = UserClient.GetInfoAsync(Credentials.Username); diff --git a/Selector/Extensions/ServiceExtensions.cs b/Selector/Extensions/ServiceExtensions.cs index 061a822..e322422 100644 --- a/Selector/Extensions/ServiceExtensions.cs +++ b/Selector/Extensions/ServiceExtensions.cs @@ -29,6 +29,18 @@ namespace Selector.Extensions var lastAuth = new LastAuth(client, secret); services.AddSingleton(lastAuth); services.AddTransient(sp => new LastfmClient(sp.GetService())); + + services.AddTransient(sp => sp.GetService().Track); + services.AddTransient(sp => sp.GetService().Album); + services.AddTransient(sp => sp.GetService().Artist); + + services.AddTransient(sp => sp.GetService().User); + + services.AddTransient(sp => sp.GetService().Chart); + services.AddTransient(sp => sp.GetService().Library); + services.AddTransient(sp => sp.GetService().Tag); + + } public static void AddWatcher(this IServiceCollection services) diff --git a/Selector.Cache/DTO.cs b/Selector/Spotify/CurrentlyPlayingDTO.cs similarity index 98% rename from Selector.Cache/DTO.cs rename to Selector/Spotify/CurrentlyPlayingDTO.cs index 85bcd50..094861d 100644 --- a/Selector.Cache/DTO.cs +++ b/Selector/Spotify/CurrentlyPlayingDTO.cs @@ -2,7 +2,7 @@ using System; using SpotifyAPI.Web; -namespace Selector.Cache { +namespace Selector { public class CurrentlyPlayingDTO { public CurrentlyPlayingContextDTO Context { get; set; }