diff --git a/Selector.CLI/Command/HostCommand.cs b/Selector.CLI/Command/HostCommand.cs index 1a05f46..ae6f51b 100644 --- a/Selector.CLI/Command/HostCommand.cs +++ b/Selector.CLI/Command/HostCommand.cs @@ -100,6 +100,7 @@ namespace Selector.CLI .ConfigureDb(config); services.AddConsumerFactories(); + services.AddCLIConsumerFactories(); if (config.RedisOptions.Enabled) { Console.WriteLine("> Adding caching consumers..."); diff --git a/Selector.CLI/Consumer/Factory/MappingPersisterFactory.cs b/Selector.CLI/Consumer/Factory/MappingPersisterFactory.cs new file mode 100644 index 0000000..3e4aa8a --- /dev/null +++ b/Selector.CLI/Consumer/Factory/MappingPersisterFactory.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Selector.Model; + +namespace Selector.CLI.Consumer +{ + public interface IMappingPersisterFactory + { + public Task Get(IPlayerWatcher watcher = null); + } + + public class MappingPersisterFactory : IMappingPersisterFactory + { + private readonly ILoggerFactory LoggerFactory; + private readonly IServiceScopeFactory ScopeFactory; + + public MappingPersisterFactory(ILoggerFactory loggerFactory, IServiceScopeFactory scopeFactory = null, LastFmCredentials creds = null) + { + LoggerFactory = loggerFactory; + ScopeFactory = scopeFactory; + } + + public Task Get(IPlayerWatcher watcher = null) + { + return Task.FromResult(new MappingPersister( + watcher, + ScopeFactory, + LoggerFactory.CreateLogger() + )); + } + } +} diff --git a/Selector.CLI/Consumer/MappingPersister.cs b/Selector.CLI/Consumer/MappingPersister.cs new file mode 100644 index 0000000..82463c5 --- /dev/null +++ b/Selector.CLI/Consumer/MappingPersister.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Selector.Model; +using SpotifyAPI.Web; + +namespace Selector.CLI.Consumer +{ + /// + /// Save name -> Spotify URI mappings as new objects come through the watcher without making extra queries of the Spotify API + /// + public class MappingPersister: IPlayerConsumer + { + protected readonly IPlayerWatcher Watcher; + protected readonly IServiceScopeFactory ScopeFactory; + protected readonly ILogger Logger; + + public CancellationToken CancelToken { get; set; } + + public MappingPersister( + IPlayerWatcher watcher, + IServiceScopeFactory scopeFactory, + ILogger logger = null, + CancellationToken token = default + ) + { + Watcher = watcher; + ScopeFactory = scopeFactory; + Logger = logger ?? NullLogger.Instance; + CancelToken = token; + } + + public void Callback(object sender, ListeningChangeEventArgs e) + { + if (e.Current is null) return; + + Task.Run(async () => { + try + { + await AsyncCallback(e); + } + catch (DbUpdateException) + { + Logger.LogWarning("Failed to update database, likely a duplicate Spotify URI"); + } + catch (Exception e) + { + Logger.LogError(e, "Error occured during callback"); + } + }, CancelToken); + } + + public async Task AsyncCallback(ListeningChangeEventArgs e) + { + using var serviceScope = ScopeFactory.CreateScope(); + using var scope = Logger.BeginScope(new Dictionary() { { "spotify_username", e.SpotifyUsername }, { "id", e.Id } }); + + if (e.Current.Item is FullTrack track) + { + var mappingRepo = serviceScope.ServiceProvider.GetRequiredService(); + + if(!mappingRepo.GetTracks().Select(t => t.SpotifyUri).Contains(track.Uri)) + { + mappingRepo.Add(new TrackLastfmSpotifyMapping() + { + SpotifyUri = track.Uri, + LastfmTrackName = track.Name, + LastfmArtistName = track.Artists.FirstOrDefault()?.Name + }); + } + + if (!mappingRepo.GetAlbums().Select(t => t.SpotifyUri).Contains(track.Album.Uri)) + { + mappingRepo.Add(new AlbumLastfmSpotifyMapping() + { + SpotifyUri = track.Album.Uri, + LastfmAlbumName = track.Album.Name, + LastfmArtistName = track.Album.Artists.FirstOrDefault()?.Name + }); + } + + var artistUris = mappingRepo.GetArtists().Select(t => t.SpotifyUri).ToArray(); + foreach (var artist in track.Artists) + { + if (!artistUris.Contains(artist.Uri)) + { + mappingRepo.Add(new ArtistLastfmSpotifyMapping() + { + SpotifyUri = artist.Uri, + LastfmArtistName = artist.Name + }); + } + } + + await mappingRepo.Save(); + + Logger.LogDebug("Adding Spotify <-> Last.fm mapping [{username}]", e.SpotifyUsername); + } + else if (e.Current.Item is FullEpisode episode) + { + Logger.LogDebug("Ignoring podcast episdoe [{episode}]", episode.DisplayString()); + } + else if (e.Current.Item is null) + { + Logger.LogDebug("Skipping play count pulling for null item [{context}]", e.Current.DisplayString()); + } + else + { + Logger.LogError("Unknown item pulled from API [{item}]", 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"); + } + } + } +} + diff --git a/Selector.CLI/Extensions/ServiceExtensions.cs b/Selector.CLI/Extensions/ServiceExtensions.cs index a3b029d..65f678c 100644 --- a/Selector.CLI/Extensions/ServiceExtensions.cs +++ b/Selector.CLI/Extensions/ServiceExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Quartz; using Selector.Cache.Extensions; +using Selector.CLI.Consumer; using Selector.CLI.Jobs; using Selector.Extensions; using Selector.Model; @@ -142,5 +143,13 @@ namespace Selector.CLI.Extensions return services; } + + public static IServiceCollection AddCLIConsumerFactories(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(); + + return services; + } } } diff --git a/Selector.CLI/Options.cs b/Selector.CLI/Options.cs index 0e8abbe..7ffde60 100644 --- a/Selector.CLI/Options.cs +++ b/Selector.CLI/Options.cs @@ -100,7 +100,7 @@ namespace Selector.CLI public enum Consumers { - AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher, PlayCounter + AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher, PlayCounter, MappingPersister } public class RedisOptions diff --git a/Selector.CLI/Selector.CLI.csproj b/Selector.CLI/Selector.CLI.csproj index 9a0be9a..3f5963a 100644 --- a/Selector.CLI/Selector.CLI.csproj +++ b/Selector.CLI/Selector.CLI.csproj @@ -34,6 +34,10 @@ + + + + PreserveNewest @@ -49,4 +53,8 @@ + + + + diff --git a/Selector.CLI/Services/DbWatcherService.cs b/Selector.CLI/Services/DbWatcherService.cs index 49c5997..12cbcee 100644 --- a/Selector.CLI/Services/DbWatcherService.cs +++ b/Selector.CLI/Services/DbWatcherService.cs @@ -13,6 +13,7 @@ using Selector.Model; using Selector.Model.Extensions; using Selector.Events; using System.Collections.Concurrent; +using Selector.CLI.Consumer; namespace Selector.CLI { @@ -35,6 +36,9 @@ namespace Selector.CLI private readonly IPublisherFactory PublisherFactory; private readonly ICacheWriterFactory CacheWriterFactory; + + private readonly IMappingPersisterFactory MappingPersisterFactory; + private ConcurrentDictionary Watchers { get; set; } = new(); public DbWatcherService( @@ -53,6 +57,8 @@ namespace Selector.CLI IPublisherFactory publisherFactory = null, ICacheWriterFactory cacheWriterFactory = null, + IMappingPersisterFactory mappingPersisterFactory = null, + IUserEventFirerFactory userEventFirerFactory = null ) { @@ -71,6 +77,8 @@ namespace Selector.CLI PublisherFactory = publisherFactory; CacheWriterFactory = cacheWriterFactory; + + MappingPersisterFactory = mappingPersisterFactory; } public async Task StartAsync(CancellationToken cancellationToken) @@ -130,6 +138,8 @@ namespace Selector.CLI if (CacheWriterFactory is not null) consumers.Add(await CacheWriterFactory.Get()); if (PublisherFactory is not null) consumers.Add(await PublisherFactory.Get()); + if (MappingPersisterFactory is not null) consumers.Add(await MappingPersisterFactory.Get()); + if (UserEventFirerFactory is not null) consumers.Add(await UserEventFirerFactory.Get()); if (dbWatcher.User.LastFmConnected()) diff --git a/Selector.CLI/Services/LocalWatcherService.cs b/Selector.CLI/Services/LocalWatcherService.cs index 8fd4265..8e3a232 100644 --- a/Selector.CLI/Services/LocalWatcherService.cs +++ b/Selector.CLI/Services/LocalWatcherService.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Selector.Cache; +using Selector.CLI.Consumer; namespace Selector.CLI { @@ -136,6 +137,10 @@ namespace Selector.CLI Logger.LogError("No Last.fm username provided, skipping play counter"); } break; + + case Consumers.MappingPersister: + consumers.Add(await ServiceProvider.GetService().Get()); + break; } } } diff --git a/Selector.CLI/appsettings.json b/Selector.CLI/appsettings.json index a72213f..73ec346 100644 --- a/Selector.CLI/appsettings.json +++ b/Selector.CLI/appsettings.json @@ -4,13 +4,19 @@ "ClientSecret": "", "Equality": "uri", "Watcher": { - "localenabled": false, + "LocalEnabled": false, "Instances": [ + // { + // "Name": "test watcher", + // "type": "playlist", + // "PlaylistUri": "spotify:playlist:4o5IArXmDeJByESaUJoEFS", + // "pollperiod": 2000 + // }, { "type": "player", "lastfmusername": "sarsoo", "pollperiod": 2000, - "consumers": [ "audiofeaturescache", "cachewriter", "publisher", "playcounter" ] + "consumers": [ "audiofeaturescache", "cachewriter", "publisher", "playcounter", "mappingpersister" ] } ] }, diff --git a/Selector/Scrobble/Mapping/ScrobbleAlbumMapping.cs b/Selector/Scrobble/Mapping/ScrobbleAlbumMapping.cs index 7baf390..232cf94 100644 --- a/Selector/Scrobble/Mapping/ScrobbleAlbumMapping.cs +++ b/Selector/Scrobble/Mapping/ScrobbleAlbumMapping.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; namespace Selector { + /// public class ScrobbleAlbumMapping : ScrobbleMapping { public string AlbumName { get; set; } @@ -18,6 +19,7 @@ namespace Selector } private SimpleAlbum result; + public SimpleAlbum Album => result; public override object Result => result; public override string Query => $"{AlbumName} {ArtistName}"; diff --git a/Selector/Scrobble/Mapping/ScrobbleArtistMapping.cs b/Selector/Scrobble/Mapping/ScrobbleArtistMapping.cs index 259358f..4b8c7d5 100644 --- a/Selector/Scrobble/Mapping/ScrobbleArtistMapping.cs +++ b/Selector/Scrobble/Mapping/ScrobbleArtistMapping.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; namespace Selector { + /// public class ScrobbleArtistMapping : ScrobbleMapping { public string ArtistName { get; set; } @@ -18,6 +19,7 @@ namespace Selector } private FullArtist result; + public FullArtist Artist => result; public override object Result => result; public override string Query => ArtistName; diff --git a/Selector/Scrobble/Mapping/ScrobbleMapping.cs b/Selector/Scrobble/Mapping/ScrobbleMapping.cs index a4d8d87..c6c4981 100644 --- a/Selector/Scrobble/Mapping/ScrobbleMapping.cs +++ b/Selector/Scrobble/Mapping/ScrobbleMapping.cs @@ -11,6 +11,9 @@ namespace Selector Track, Album, Artist } + /// + /// Map Last.fm resources to Spotify resources using the Spotify search endpoint before saving mappings to database + /// public abstract class ScrobbleMapping : IOperation { private readonly ILogger logger; diff --git a/Selector/Scrobble/Mapping/ScrobbleTrackMapping.cs b/Selector/Scrobble/Mapping/ScrobbleTrackMapping.cs index 5b95e56..ce2f0be 100644 --- a/Selector/Scrobble/Mapping/ScrobbleTrackMapping.cs +++ b/Selector/Scrobble/Mapping/ScrobbleTrackMapping.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; namespace Selector { + /// public class ScrobbleTrackMapping : ScrobbleMapping { public string TrackName { get; set; } @@ -20,6 +21,7 @@ namespace Selector } private FullTrack result; + public FullTrack Track => result; public override object Result => result; public override string Query => $"{TrackName} {ArtistName}";