diff --git a/Selector.CLI/Options.cs b/Selector.CLI/Options.cs index 3a97462..2b25719 100644 --- a/Selector.CLI/Options.cs +++ b/Selector.CLI/Options.cs @@ -34,6 +34,8 @@ namespace Selector.CLI /// Spotify app secret /// public string ClientSecret { get; set; } + public string LastfmClient { get; set; } + public string LastfmSecret { get; set; } public WatcherOptions WatcherOptions { get; set; } = new(); public DatabaseOptions DatabaseOptions { get; set; } = new(); public RedisOptions RedisOptions { get; set; } = new(); @@ -60,6 +62,7 @@ namespace Selector.CLI public string Name { get; set; } public string AccessKey { get; set; } public string RefreshKey { get; set; } + public string LastFmUsername { get; set; } public int PollPeriod { get; set; } = 5000; public WatcherType Type { get; set; } = WatcherType.Player; public List Consumers { get; set; } = default; @@ -71,7 +74,7 @@ namespace Selector.CLI enum Consumers { - AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher + AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher, PlayCounter } class DatabaseOptions { diff --git a/Selector.CLI/Program.cs b/Selector.CLI/Program.cs index 1125a9a..578b6b3 100644 --- a/Selector.CLI/Program.cs +++ b/Selector.CLI/Program.cs @@ -9,6 +9,7 @@ using NLog.Extensions.Logging; using Selector.Model; using Selector.Cache; +using IF.Lastfm.Core.Api; using StackExchange.Redis; namespace Selector.CLI @@ -45,6 +46,18 @@ namespace Selector.CLI //services.AddSingleton(); services.AddSingleton(); + if(config.LastfmClient is not null) + { + Console.WriteLine("> Adding Last.fm credentials..."); + + var lastAuth = new LastAuth(config.LastfmClient, config.LastfmSecret); + services.AddSingleton(lastAuth); + } + else + { + Console.WriteLine("> No Last.fm credentials, skipping init..."); + } + // DB if (config.DatabaseOptions.Enabled) { diff --git a/Selector.CLI/WatcherService.cs b/Selector.CLI/WatcherService.cs index 3edefec..7474e97 100644 --- a/Selector.CLI/WatcherService.cs +++ b/Selector.CLI/WatcherService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using IF.Lastfm.Core.Api; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -25,6 +26,7 @@ namespace Selector.CLI private readonly IWatcherFactory WatcherFactory; private readonly IWatcherCollectionFactory WatcherCollectionFactory; private readonly IRefreshTokenFactoryProvider SpotifyFactory; + private readonly LastAuth LastAuth; private readonly IDatabaseAsync Cache; private readonly ISubscriber Subscriber; @@ -37,6 +39,7 @@ namespace Selector.CLI IRefreshTokenFactoryProvider spotifyFactory, ILoggerFactory loggerFactory, IOptions config, + LastAuth lastAuth = null, IDatabaseAsync cache = null, ISubscriber subscriber = null ) { @@ -46,6 +49,7 @@ namespace Selector.CLI WatcherFactory = watcherFactory; WatcherCollectionFactory = watcherCollectionFactory; SpotifyFactory = spotifyFactory; + LastAuth = lastAuth; Cache = cache; Subscriber = subscriber; @@ -128,6 +132,22 @@ namespace Selector.CLI var pub = new PublisherFactory(Subscriber, LoggerFactory); consumers.Add(await pub.Get()); break; + + case Consumers.PlayCounter: + if(!string.IsNullOrWhiteSpace(watcherOption.LastFmUsername)) + { + if(LastAuth is null) throw new ArgumentNullException("No Last Auth Injected"); + + var client = new LastfmClient(LastAuth); + + var playCount = new PlayCounterFactory(LoggerFactory, client: client, creds: new(){ Username = watcherOption.LastFmUsername }); + consumers.Add(await playCount.Get()); + } + else + { + Logger.LogError("No Last.fm usernmae provided, skipping play counter"); + } + break; } } diff --git a/Selector.CLI/appsettings.json b/Selector.CLI/appsettings.json index fd20bb4..b589bb0 100644 --- a/Selector.CLI/appsettings.json +++ b/Selector.CLI/appsettings.json @@ -8,8 +8,9 @@ { "name": "Player Watcher", "type": "player", + "lastfmusername": "sarsoo", "pollperiod": 2000, - "consumers": [ "audiofeaturescache", "cachewriter", "publisher" ] + "consumers": [ "audiofeaturescache", "cachewriter", "publisher", "playcounter" ] } ] }, diff --git a/Selector/Consumers/AudioFeatureInjectorFactory.cs b/Selector/Consumers/Factory/AudioFeatureInjectorFactory.cs similarity index 100% rename from Selector/Consumers/AudioFeatureInjectorFactory.cs rename to Selector/Consumers/Factory/AudioFeatureInjectorFactory.cs diff --git a/Selector/Consumers/Factory/PlayCounterFactory.cs b/Selector/Consumers/Factory/PlayCounterFactory.cs new file mode 100644 index 0000000..0640e9d --- /dev/null +++ b/Selector/Consumers/Factory/PlayCounterFactory.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +using IF.Lastfm.Core.Api; + +namespace Selector +{ + public interface IPlayCounterFactory + { + public Task Get(LastfmClient fmClient = null, LastFmCredentials creds = null, IPlayerWatcher watcher = null); + } + + public class PlayCounterFactory: IPlayCounterFactory { + + private readonly ILoggerFactory LoggerFactory; + private readonly LastfmClient Client; + private readonly LastFmCredentials Creds; + + public PlayCounterFactory(ILoggerFactory loggerFactory, LastfmClient client = null, LastFmCredentials creds = null) + { + LoggerFactory = loggerFactory; + Client = client; + Creds = creds; + } + + public async Task Get(LastfmClient fmClient = null, LastFmCredentials creds = null, IPlayerWatcher watcher = null) + { + var client = fmClient ?? Client; + + if(client is null) + { + throw new ArgumentNullException("No Last.fm client provided"); + } + + return new PlayCounter( + watcher, + client.Track, + client.Album, + client.Artist, + client.User, + credentials: creds ?? Creds, + LoggerFactory.CreateLogger() + ); + } + } +} diff --git a/Selector/Consumers/PlayCounter.cs b/Selector/Consumers/PlayCounter.cs new file mode 100644 index 0000000..73627c2 --- /dev/null +++ b/Selector/Consumers/PlayCounter.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +using SpotifyAPI.Web; +using IF.Lastfm.Core.Api; + +namespace Selector +{ + public class PlayCounter : IConsumer + { + protected readonly IPlayerWatcher Watcher; + protected readonly ITrackApi TrackClient; + protected readonly IAlbumApi AlbumClient; + protected readonly IArtistApi ArtistClient; + protected readonly IUserApi UserClient; + protected readonly LastFmCredentials Credentials; + protected readonly ILogger Logger; + + protected event EventHandler NewPlayCount; + + public CancellationToken CancelToken { get; set; } + + public AnalysedTrackTimeline Timeline { get; set; } = new(); + + public PlayCounter( + IPlayerWatcher watcher, + ITrackApi trackClient, + IAlbumApi albumClient, + IArtistApi artistClient, + IUserApi userClient, + LastFmCredentials credentials = null, + ILogger logger = null, + CancellationToken token = default + ) + { + Watcher = watcher; + TrackClient = trackClient; + AlbumClient = albumClient; + ArtistClient = artistClient; + UserClient = userClient; + Credentials = credentials; + Logger = logger ?? NullLogger.Instance; + CancelToken = token; + } + + 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) + { + if (e.Current.Item is FullTrack track) + { + 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 artistInfo = ArtistClient.GetInfoAsync(track.Artists[0].Name); + // TODO: Null checking on credentials + var userInfo = UserClient.GetInfoAsync(Credentials.Username); + + await Task.WhenAll(new Task[] { trackInfo, albumInfo, artistInfo, userInfo }); + + int? trackCount = null, albumCount = null, artistCount = null, userCount = null; + + if (trackInfo.IsCompletedSuccessfully) + { + if (trackInfo.Result.Success) + { + trackCount = trackInfo.Result.Content.UserPlayCount; + } + else + { + Logger.LogDebug($"Track info error [{e.Username}] [{trackInfo.Result.Status}]"); + } + } + else + { + Logger.LogError(trackInfo.Exception, $"Track info task faulted, [{e.Username}] [{e.Current.DisplayString()}]"); + } + + if (albumInfo.IsCompletedSuccessfully) + { + if (albumInfo.Result.Success) + { + albumCount = albumInfo.Result.Content.UserPlayCount; + } + else + { + Logger.LogDebug($"Album info error [{e.Username}] [{albumInfo.Result.Status}]"); + } + } + else + { + Logger.LogError(albumInfo.Exception, $"Album info task faulted, [{e.Username}] [{e.Current.DisplayString()}]"); + } + + //TODO: Add artist count + + if (userInfo.IsCompletedSuccessfully) + { + if (userInfo.Result.Success) + { + userCount = userInfo.Result.Content.Playcount; + } + else + { + Logger.LogDebug($"User info error [{e.Username}] [{userInfo.Result.Status}]"); + } + } + else + { + Logger.LogError(userInfo.Exception, $"User info task faulted, [{e.Username}] [{e.Current.DisplayString()}]"); + } + + Logger.LogDebug($"Adding Last.fm data [{Credentials.Username}/{e.Username}] [{track.DisplayString()}], track: {trackCount}, album: {albumCount}, artist: {artistCount}, user: {userCount}"); + + OnNewPlayCount(new() + { + Track = trackCount, + Album = albumCount, + Artist = artistCount, + User = userCount, + }); + } + else if (e.Current.Item is FullEpisode episode) + { + Logger.LogDebug($"Ignoring podcast episdoe [{episode.DisplayString()}]"); + } + else + { + Logger.LogError($"Unknown item pulled from API [{e.Current.Item}]"); + } + } + + public void Subscribe(IWatcher watch = null) + { + var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided"); + + if (watcher is IPlayerWatcher watcherCast) + { + watcherCast.ItemChange += Callback; + } + else + { + throw new ArgumentException("Provided watcher is not a PlayerWatcher"); + } + } + + public void Unsubscribe(IWatcher watch = null) + { + var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided"); + + if (watcher is IPlayerWatcher watcherCast) + { + watcherCast.ItemChange -= Callback; + } + else + { + throw new ArgumentException("Provided watcher is not a PlayerWatcher"); + } + } + + protected virtual void OnNewPlayCount(PlayCount args) + { + NewPlayCount?.Invoke(this, args); + } + } + + public class PlayCount + { + public int? Track { get; set; } + public int? Album { get; set; } + public int? Artist { get; set; } + public int? User { get; set; } + } + + public class LastFmCredentials + { + public string Username { get; set; } + } +} diff --git a/Selector/Helpers/SpotifyExtensions.cs b/Selector/Helpers/SpotifyExtensions.cs index abcb6da..a2b6a8d 100644 --- a/Selector/Helpers/SpotifyExtensions.cs +++ b/Selector/Helpers/SpotifyExtensions.cs @@ -35,7 +35,7 @@ namespace Selector public static string DisplayString(this Context context) => $"{context.Type}, {context.Uri}"; public static string DisplayString(this Device device) => $"{device.Name} ({device.Id}) {device.VolumePercent}%"; - public static string DisplayString(this TrackAudioFeatures feature) => $"Acou. {feature.Acousticness}, Dance {feature.Danceability}, Energy {feature.Energy}, Instru. {feature.Instrumentalness}, Key {feature.Key}, Live {feature.Liveness}, Loud {feature.Loudness}dB, Mode {feature.Mode}, Speech {feature.Speechiness}, Tempo {feature.Tempo}BPM, Time Sig. {feature.TimeSignature}, Valence {feature.Valence}"; + public static string DisplayString(this TrackAudioFeatures feature) => $"Acou. {feature.Acousticness}, Dance {feature.Danceability}, Energy {feature.Energy}, Instru. {feature.Instrumentalness}, Key {feature.Key}, Live {feature.Liveness}, Loud {feature.Loudness} dB, Mode {feature.Mode}, Speech {feature.Speechiness}, Tempo {feature.Tempo} BPM, Time Sig. {feature.TimeSignature}, Valence {feature.Valence}"; public static string DisplayString(this IEnumerable artists) => string.Join(", ", artists.Select(a => a.DisplayString()));