diff --git a/Selector.CLI/Options.cs b/Selector.CLI/Options.cs index fb8c84f..3a97462 100644 --- a/Selector.CLI/Options.cs +++ b/Selector.CLI/Options.cs @@ -71,7 +71,7 @@ namespace Selector.CLI enum Consumers { - AudioFeatures, CacheWriter, Publisher + AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher } class DatabaseOptions { diff --git a/Selector.CLI/WatcherService.cs b/Selector.CLI/WatcherService.cs index b366a94..3edefec 100644 --- a/Selector.CLI/WatcherService.cs +++ b/Selector.CLI/WatcherService.cs @@ -114,6 +114,11 @@ namespace Selector.CLI consumers.Add(await featureInjector.Get(spotifyFactory)); break; + case Consumers.AudioFeaturesCache: + var featureInjectorCache = new CachingAudioFeatureInjectorFactory(LoggerFactory, Cache); + consumers.Add(await featureInjectorCache.Get(spotifyFactory)); + break; + case Consumers.CacheWriter: var cacheWriter = new CacheWriterFactory(Cache, LoggerFactory); consumers.Add(await cacheWriter.Get()); diff --git a/Selector.CLI/appsettings.json b/Selector.CLI/appsettings.json index d1761be..fd20bb4 100644 --- a/Selector.CLI/appsettings.json +++ b/Selector.CLI/appsettings.json @@ -9,7 +9,7 @@ "name": "Player Watcher", "type": "player", "pollperiod": 2000, - "consumers": [ "audiofeatures", "cachewriter" ] + "consumers": [ "audiofeaturescache", "cachewriter", "publisher" ] } ] }, diff --git a/Selector.Cache/Consumer/AudioInjectorCaching.cs b/Selector.Cache/Consumer/AudioInjectorCaching.cs new file mode 100644 index 0000000..4dd297d --- /dev/null +++ b/Selector.Cache/Consumer/AudioInjectorCaching.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +using SpotifyAPI.Web; +using StackExchange.Redis; + +namespace Selector.Cache +{ + public class CachingAudioFeatureInjector : AudioFeatureInjector + { + private readonly IDatabaseAsync Db; + public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromDays(1); + + public CachingAudioFeatureInjector( + IPlayerWatcher watcher, + IDatabaseAsync db, + ITracksClient trackClient, + ILogger logger = null, + CancellationToken token = default + ) : base(watcher, trackClient, logger, token) { + + Db = db; + + NewFeature += CacheCallback; + } + + public void CacheCallback(object sender, AnalysedTrack e) + { + Task.Run(() => { return AsyncCacheCallback(e); }, CancelToken); + } + + public async Task AsyncCacheCallback(AnalysedTrack e) + { + var payload = JsonSerializer.Serialize(e); + + Logger.LogTrace($"Caching current for [{e.Track.DisplayString()}]"); + + var resp = await Db.StringSetAsync(Key.AudioFeature(e.Track.Id), payload, expiry: CacheExpiry); + + Logger.LogDebug($"Cached audio feature for [{e.Track.DisplayString()}], {(resp ? "value set" : "value NOT set")}"); + } + } +} diff --git a/Selector.Cache/CacheWriterConsumer.cs b/Selector.Cache/Consumer/CacheWriterConsumer.cs similarity index 84% rename from Selector.Cache/CacheWriterConsumer.cs rename to Selector.Cache/Consumer/CacheWriterConsumer.cs index ab69a69..5ee713c 100644 --- a/Selector.Cache/CacheWriterConsumer.cs +++ b/Selector.Cache/Consumer/CacheWriterConsumer.cs @@ -5,7 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using SpotifyAPI.Web; + using StackExchange.Redis; namespace Selector.Cache @@ -33,14 +33,20 @@ namespace Selector.Cache public void Callback(object sender, ListeningChangeEventArgs e) { if (e.Current is null) return; - + Task.Run(() => { return AsyncCallback(e); }, CancelToken); } public async Task AsyncCallback(ListeningChangeEventArgs e) { - var payload = JsonSerializer.Serialize(e); - await Db.StringSetAsync(Key.CurrentlyPlaying(e.Username), payload); + var payload = JsonSerializer.Serialize((CurrentlyPlayingDTO) e); + + Logger.LogTrace($"Caching current for [{e.Username}]"); + + var resp = await Db.StringSetAsync(Key.CurrentlyPlaying(e.Username), payload); + + Logger.LogDebug($"Cached current for [{e.Username}], {(resp ? "value set" : "value NOT set")}"); + } public void Subscribe(IWatcher watch = null) diff --git a/Selector.Cache/Consumer/Factory/AudioInjectorCaching.cs b/Selector.Cache/Consumer/Factory/AudioInjectorCaching.cs new file mode 100644 index 0000000..13c9055 --- /dev/null +++ b/Selector.Cache/Consumer/Factory/AudioInjectorCaching.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +using SpotifyAPI.Web; +using StackExchange.Redis; + +namespace Selector.Cache +{ + public interface ICachingAudioFeatureInjectorFactory + { + public Task Get(ISpotifyConfigFactory spotifyFactory, IPlayerWatcher watcher); + } + + public class CachingAudioFeatureInjectorFactory: ICachingAudioFeatureInjectorFactory { + + private readonly ILoggerFactory LoggerFactory; + private readonly IDatabaseAsync Db; + + public CachingAudioFeatureInjectorFactory( + ILoggerFactory loggerFactory, + IDatabaseAsync db + ) { + LoggerFactory = loggerFactory; + Db = db; + } + + public async Task Get(ISpotifyConfigFactory spotifyFactory, IPlayerWatcher watcher = null) + { + var config = await spotifyFactory.GetConfig(); + var client = new SpotifyClient(config); + + return new CachingAudioFeatureInjector( + watcher, + Db, + client.Tracks, + LoggerFactory.CreateLogger() + ); + } + } +} diff --git a/Selector.Cache/Factory/CacheWriterFactory.cs b/Selector.Cache/Consumer/Factory/CacheWriterFactory.cs similarity index 100% rename from Selector.Cache/Factory/CacheWriterFactory.cs rename to Selector.Cache/Consumer/Factory/CacheWriterFactory.cs diff --git a/Selector.Cache/Factory/PublisherFactory.cs b/Selector.Cache/Consumer/Factory/PublisherFactory.cs similarity index 100% rename from Selector.Cache/Factory/PublisherFactory.cs rename to Selector.Cache/Consumer/Factory/PublisherFactory.cs diff --git a/Selector.Cache/PublisherConsumer.cs b/Selector.Cache/Consumer/PublisherConsumer.cs similarity index 85% rename from Selector.Cache/PublisherConsumer.cs rename to Selector.Cache/Consumer/PublisherConsumer.cs index abbceaa..4e8c7f0 100644 --- a/Selector.Cache/PublisherConsumer.cs +++ b/Selector.Cache/Consumer/PublisherConsumer.cs @@ -5,7 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using SpotifyAPI.Web; + using StackExchange.Redis; namespace Selector.Cache @@ -39,8 +39,13 @@ namespace Selector.Cache public async Task AsyncCallback(ListeningChangeEventArgs e) { - var payload = JsonSerializer.Serialize(e); - await Subscriber.PublishAsync(Key.CurrentlyPlaying(e.Username), payload); + var payload = JsonSerializer.Serialize((CurrentlyPlayingDTO) e); + + Logger.LogTrace($"Publishing current for [{e.Username}]"); + + var receivers = await Subscriber.PublishAsync(Key.CurrentlyPlaying(e.Username), payload); + + Logger.LogDebug($"Published current for [{e.Username}], {receivers} receivers"); } public void Subscribe(IWatcher watch = null) diff --git a/Selector.Cache/DTO.cs b/Selector.Cache/DTO.cs new file mode 100644 index 0000000..7338e2f --- /dev/null +++ b/Selector.Cache/DTO.cs @@ -0,0 +1,40 @@ +using System; + +using SpotifyAPI.Web; + +namespace Selector.Cache { + + public class CurrentlyPlayingDTO { + public CurrentlyPlayingContext Context { get; set; } + public string Username { get; set; } + + public FullTrack Track { get; set; } + public FullEpisode Episode { get; set; } + + public static explicit operator CurrentlyPlayingDTO(ListeningChangeEventArgs e) + { + if(e.Current.Item is FullTrack track) + { + return new() + { + Context = e.Current, + Username = e.Username, + Track = track + }; + } + else if (e.Current.Item is FullEpisode episode) + { + return new() + { + Context = e.Current, + Username = e.Username, + Episode = episode + }; + } + else + { + throw new ArgumentException("Unknown item item"); + } + } + } +} \ No newline at end of file diff --git a/Selector.Cache/Key.cs b/Selector.Cache/Key.cs index 09ed02d..3242b3c 100644 --- a/Selector.Cache/Key.cs +++ b/Selector.Cache/Key.cs @@ -7,8 +7,11 @@ namespace Selector.Cache public class Key { public const string CurrentlyPlayingName = "CurrentlyPlaying"; + public const string TrackName = "Track"; + public const string AudioFeatureName = "AudioFeature"; public static string CurrentlyPlaying(string user) => Namespace(new[] { user, CurrentlyPlayingName }); + public static string AudioFeature(string trackId) => Namespace(new[] { TrackName, trackId, AudioFeatureName }); public static string Namespace(string[] args) => string.Join(":", args); } diff --git a/Selector/Consumers/AudioFeatureInjector.cs b/Selector/Consumers/AudioFeatureInjector.cs index 18d5d9f..75feb98 100644 --- a/Selector/Consumers/AudioFeatureInjector.cs +++ b/Selector/Consumers/AudioFeatureInjector.cs @@ -11,9 +11,11 @@ namespace Selector { public class AudioFeatureInjector : IConsumer { - private readonly IPlayerWatcher Watcher; - private readonly ITracksClient TrackClient; - private readonly ILogger Logger; + protected readonly IPlayerWatcher Watcher; + protected readonly ITracksClient TrackClient; + protected readonly ILogger Logger; + + protected event EventHandler NewFeature; public CancellationToken CancelToken { get; set; } @@ -47,7 +49,10 @@ namespace Selector var audioFeatures = await TrackClient.GetAudioFeatures(track.Id); Logger.LogDebug($"Adding audio features [{track.DisplayString()}]: [{audioFeatures.DisplayString()}]"); - Timeline.Add(AnalysedTrack.From(track, audioFeatures), DateHelper.FromUnixMilli(e.Current.Timestamp)); + var analysedTrack = AnalysedTrack.From(track, audioFeatures); + + Timeline.Add(analysedTrack, DateHelper.FromUnixMilli(e.Current.Timestamp)); + OnNewFeature(analysedTrack); } catch (APIUnauthorizedException ex) { @@ -102,6 +107,11 @@ namespace Selector throw new ArgumentException("Provided watcher is not a PlayerWatcher"); } } + + protected virtual void OnNewFeature(AnalysedTrack args) + { + NewFeature?.Invoke(this, args); + } } public class AnalysedTrack { diff --git a/Selector/Watcher/Interfaces/Events.cs b/Selector/Watcher/Interfaces/Events.cs index b647346..1326ad7 100644 --- a/Selector/Watcher/Interfaces/Events.cs +++ b/Selector/Watcher/Interfaces/Events.cs @@ -4,16 +4,18 @@ using SpotifyAPI.Web; namespace Selector { public class ListeningChangeEventArgs: EventArgs { - public CurrentlyPlayingContext Previous; - public CurrentlyPlayingContext Current; - public string Username; + public CurrentlyPlayingContext Previous { get; set; } + public CurrentlyPlayingContext Current { get; set; } + public string Username { get; set; } + PlayerTimeline Timeline { get; set; } - public static ListeningChangeEventArgs From(CurrentlyPlayingContext previous, CurrentlyPlayingContext current, string username = null) + public static ListeningChangeEventArgs From(CurrentlyPlayingContext previous, CurrentlyPlayingContext current, PlayerTimeline timeline, string username = null) { return new ListeningChangeEventArgs() { Previous = previous, Current = current, + Timeline = timeline, Username = username }; } diff --git a/Selector/Watcher/PlayerWatcher.cs b/Selector/Watcher/PlayerWatcher.cs index b95ec0e..1c9ab9a 100644 --- a/Selector/Watcher/PlayerWatcher.cs +++ b/Selector/Watcher/PlayerWatcher.cs @@ -74,14 +74,14 @@ namespace Selector && (Live.Item is FullTrack || Live.Item is FullEpisode)) { Logger.LogDebug($"Playback started: {Live.DisplayString()}"); - OnPlayingChange(ListeningChangeEventArgs.From(previous, Live, Username)); + OnPlayingChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); } // STOPPED PLAYBACK else if((previous.Item is FullTrack || previous.Item is FullEpisode) && Live is null) { Logger.LogDebug($"Playback stopped: {previous.DisplayString()}"); - OnPlayingChange(ListeningChangeEventArgs.From(previous, Live, Username)); + OnPlayingChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); } // CONTINUING PLAYBACK else { @@ -92,17 +92,17 @@ namespace Selector { if(!eq.IsEqual(previousTrack, currentTrack)) { Logger.LogDebug($"Track changed: {previousTrack.DisplayString()} -> {currentTrack.DisplayString()}"); - OnItemChange(ListeningChangeEventArgs.From(previous, Live, Username)); + OnItemChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); } if(!eq.IsEqual(previousTrack.Album, currentTrack.Album)) { Logger.LogDebug($"Album changed: {previousTrack.Album.DisplayString()} -> {currentTrack.Album.DisplayString()}"); - OnAlbumChange(ListeningChangeEventArgs.From(previous, Live, Username)); + OnAlbumChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); } if(!eq.IsEqual(previousTrack.Artists[0], currentTrack.Artists[0])) { Logger.LogDebug($"Artist changed: {previousTrack.Artists.DisplayString()} -> {currentTrack.Artists.DisplayString()}"); - OnArtistChange(ListeningChangeEventArgs.From(previous, Live, Username)); + OnArtistChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); } } // CHANGED CONTENT @@ -110,8 +110,8 @@ namespace Selector || (previous.Item is FullEpisode && Live.Item is FullTrack)) { Logger.LogDebug($"Media type changed: {previous.Item}, {previous.Item}"); - OnContentChange(ListeningChangeEventArgs.From(previous, Live, Username)); - OnItemChange(ListeningChangeEventArgs.From(previous, Live, Username)); + OnContentChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); + OnItemChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); } // PODCASTS else if(previous.Item is FullEpisode previousEp @@ -119,7 +119,7 @@ namespace Selector { if(!eq.IsEqual(previousEp, currentEp)) { Logger.LogDebug($"Podcast changed: {previousEp.DisplayString()} -> {currentEp.DisplayString()}"); - OnItemChange(ListeningChangeEventArgs.From(previous, Live, Username)); + OnItemChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); } } else { @@ -129,25 +129,25 @@ namespace Selector // CONTEXT if(!eq.IsEqual(previous.Context, Live.Context)) { Logger.LogDebug($"Context changed: {previous.Context.DisplayString()} -> {Live.Context.DisplayString()}"); - OnContextChange(ListeningChangeEventArgs.From(previous, Live, Username)); + OnContextChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); } // DEVICE if(!eq.IsEqual(previous?.Device, Live?.Device)) { Logger.LogDebug($"Device changed: {previous?.Device.DisplayString()} -> {Live?.Device.DisplayString()}"); - OnDeviceChange(ListeningChangeEventArgs.From(previous, Live, Username)); + OnDeviceChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); } // IS PLAYING if(previous.IsPlaying != Live.IsPlaying) { Logger.LogDebug($"Playing state changed: {previous.IsPlaying} -> {Live.IsPlaying}"); - OnPlayingChange(ListeningChangeEventArgs.From(previous, Live, Username)); + OnPlayingChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); } // VOLUME if(previous.Device.VolumePercent != Live.Device.VolumePercent) { Logger.LogDebug($"Volume changed: {previous.Device.VolumePercent}% -> {Live.Device.VolumePercent}%"); - OnVolumeChange(ListeningChangeEventArgs.From(previous, Live, Username)); + OnVolumeChange(ListeningChangeEventArgs.From(previous, Live, Past, Username)); } } }