From df986e86eeea0d63029696c07cbb6b1fd679b010 Mon Sep 17 00:00:00 2001
From: Andy Pack <andy@sarsoo.xyz>
Date: Mon, 31 Mar 2025 21:33:01 +0100
Subject: [PATCH] splitting spotify and last.fm into separate projects, adding
 apple listen database type

---
 Selector.AppleMusic/AppleMusicApi.cs          |   2 +-
 .../{Watcher => }/Consumer/IConsumer.cs       |   2 +-
 Selector.AppleMusic/Events.cs                 |   7 +-
 Selector.AppleMusic/Watcher/WatcherFactory.cs |  18 +-
 Selector.CLI/Command/HostCommand.cs           |  47 +-
 .../Factory/MappingPersisterFactory.cs        |   2 +
 Selector.CLI/Consumer/MappingPersister.cs     |   7 +-
 .../Extensions/CommandContextExtensions.cs    |  31 +-
 Selector.CLI/Extensions/ServiceExtensions.cs  |  30 +-
 Selector.CLI/ScrobbleMapper.cs                |  43 +-
 Selector.CLI/Services/DbWatcherService.cs     |  27 +-
 Selector.CLI/Services/LocalWatcherService.cs  |  27 +-
 .../AppleMusic/CacheWriterConsumer.cs         |  93 +++
 .../Consumer/AppleMusic/PublisherConsumer.cs  |   5 +-
 .../Consumer/Factory/AudioInjectorCaching.cs  |   4 +
 .../Consumer/Factory/CacheWriterFactory.cs    |  21 +-
 .../Factory/PlayCounterCachingFactory.cs      |   3 +
 .../Consumer/Factory/PublisherFactory.cs      |   4 +-
 .../Consumer/Spotify/AudioInjectorCaching.cs  |   5 +-
 .../Consumer/Spotify/CacheWriterConsumer.cs   |  20 +-
 .../{ => Spotify}/PlayCounterCaching.cs       |   5 +-
 .../Consumer/Spotify/PublisherConsumer.cs     |  13 +-
 .../Extensions/ServiceExtensions.cs           |  12 +-
 Selector.Cache/Selector.Cache.csproj          |   1 +
 Selector.Cache/Services/AudioFeaturePuller.cs |  11 +-
 Selector.Cache/Services/DurationPuller.cs     |  32 +-
 Selector.Cache/Services/PlayCountPuller.cs    |  24 +-
 Selector.Event/CacheJsonContext.cs            |   5 +-
 .../CacheMappings/AppleMusicMapping.cs        |  16 +-
 Selector.Event/CacheMappings/LastfmMapping.cs |  16 +-
 .../CacheMappings/NowPlayingMapping.cs        |  69 ++-
 .../CacheMappings/SpotifyMapping.cs           |  16 +-
 .../Consumers/AppleUserEventFirer.cs          |   2 +-
 .../Consumers/SpotifyUserEventFirer.cs        |   6 +-
 .../Consumers/UserEventFirerFactory.cs        |   1 +
 Selector.Event/UserEventBus.cs                |   1 +
 .../Extensions/ServiceExtensions.cs           |  26 +
 .../Mapping/ScrobbleAlbumMapping.cs           |  10 +-
 .../Mapping/ScrobbleArtistMapping.cs          |  12 +-
 .../Mapping/ScrobbleMapping.cs                |  22 +-
 .../Mapping/ScrobbleTrackMapping.cs           |  16 +-
 .../Scrobble => Selector.LastFm}/Scrobble.cs  |  20 +-
 .../ScrobbleMatcher.cs                        |  13 +-
 .../ScrobbleRequest.cs                        |  36 +-
 Selector.LastFm/Selector.LastFm.csproj        |  20 +
 Selector.MAUI/App.xaml.cs                     |  14 +-
 Selector.MAUI/Selector.MAUI.csproj            |   2 +
 Selector.MAUI/Shared/PlayCountCard.razor      |   4 +-
 Selector.Model/ApplicationDbContext.cs        |  12 +
 .../Extensions/ServiceExtensions.cs           |  11 +-
 Selector.Model/Listen/AppleMusicListen.cs     |  26 +
 ...0250331203003_add_apple_listen.Designer.cs | 548 ++++++++++++++++++
 .../20250331203003_add_apple_listen.cs        |  52 ++
 .../ApplicationDbContextModelSnapshot.cs      |  48 ++
 Selector.Model/Scrobble/DBPlayCountPuller.cs  |   6 +-
 Selector.Model/Selector.Model.csproj          |   4 +-
 Selector.SignalR/INow.cs                      |   7 +-
 Selector.SignalR/NowHubCache.cs               |  50 +-
 Selector.SignalR/NowHubClient.cs              |  25 +-
 Selector.SignalR/Selector.SignalR.csproj      |   1 +
 .../ConfigFactory/ISpotifyConfigFactory.cs    |   7 +-
 .../ConfigFactory/RefreshTokenFactory.cs      |  15 +-
 .../Consumer}/AudioFeatureInjector.cs         |  13 +-
 .../Consumer}/DummyAudioFeatureInjector.cs    |  10 +-
 .../Factory/AudioFeatureInjectorFactory.cs    |   6 +-
 .../Consumer}/Factory/PlayCounterFactory.cs   |   6 +-
 .../Consumer}/Factory/WebHookFactory.cs       |   6 +-
 Selector.Spotify/Consumer/IConsumer.cs        |   9 +
 .../Consumer}/PlayCounter.cs                  |  16 +-
 .../Consumer}/WebHook.cs                      |  17 +-
 Selector.Spotify/Credentials.cs               |  17 +
 .../CurrentlyPlayingDTO.cs                    |  19 +-
 .../Equality/PlayableItemEqualityComparer.cs  |  12 +-
 .../Equality/StringComparers.cs               |  58 +-
 .../Equality/StringEqual.cs                   |   6 +-
 .../Equality/UriEqual.cs                      |  20 +-
 .../Interfaces => Selector.Spotify}/Events.cs |  30 +-
 .../Extensions/LoggingExtensions.cs           |  12 +
 .../Extensions/ServiceExtensions.cs           |  30 +
 .../Extensions/SpotifyExtensions.cs           |  45 +-
 .../CachingRefreshTokenFactoryProvider.cs     |  16 +-
 .../IRefreshTokenFactoryProvider.cs           |   6 +-
 .../RefreshTokenFactoryProvider.cs            |  16 +-
 {Selector => Selector.Spotify}/JsonContext.cs |   8 +-
 Selector.Spotify/Selector.Spotify.csproj      |  20 +
 .../Timeline/AnalysedTrackTimeline.cs         |  13 +-
 .../Timeline}/ITrackTimeline.cs               |  10 +-
 .../Timeline/PlayerTimeline.cs                |  24 +-
 .../Watcher/BaseSpotifyWatcher.cs             |   4 +-
 .../Watcher/DummySpotifyPlayerWatcher.cs      |   9 +-
 .../Watcher/Interfaces/IPlaylistWatcher.cs    |   7 +-
 .../Interfaces/ISpotifyPlayerWatcher.cs       |  30 +
 .../Interfaces/ISpotifyWatcherFactory.cs      |   4 +-
 .../Watcher/PlaylistWatcher.cs                |  12 +-
 .../Watcher/SpotifyPlayerWatcher.cs           |  50 +-
 .../Watcher/SpotifyWatcherFactory.cs          |   7 +-
 Selector.Tests/Consumer/AudioInjector.cs      |  22 +-
 .../Consumer/AudioInjectorFactory.cs          |  17 +-
 Selector.Tests/Consumer/WebHook.cs            |  10 +-
 Selector.Tests/Equality/Equal.cs              | 243 ++++----
 Selector.Tests/Equality/UriEqual.cs           | 213 +++----
 Selector.Tests/Selector.Tests.csproj          |   2 +
 Selector.Tests/Timeline/PlayerTimeline.cs     | 141 ++---
 Selector.Tests/Watcher/PlayerWatcher.cs       |   8 +-
 Selector.Tests/Watcher/PlaylistWatcher.cs     |  99 ++--
 .../Pages/Account/Manage/_Layout.cshtml       |   3 +-
 Selector.Web/Hubs/NowPlayingHub.cs            |   5 +-
 Selector.Web/Startup.cs                       |  52 +-
 Selector.sln                                  |  12 +
 Selector/Consumers/IConsumer.cs               |   8 -
 Selector/Equality/Equal.cs                    |   8 +-
 Selector/Events.cs                            |  12 +
 Selector/Extensions/LoggingExtensions.cs      |  12 -
 Selector/Extensions/ServiceExtensions.cs      |  45 +-
 Selector/Listen/PlayDensity.cs                |  31 +
 Selector/Scrobble/PlayDensity.cs              |  31 -
 Selector/Selector.csproj                      |   2 -
 Selector/Spotify/Credentials.cs               |  14 -
 .../Watcher/Collection/WatcherCollection.cs   |  26 +-
 .../Interfaces/ISpotifyPlayerWatcher.cs       |  30 -
 120 files changed, 2112 insertions(+), 1137 deletions(-)
 rename Selector.AppleMusic/{Watcher => }/Consumer/IConsumer.cs (63%)
 create mode 100644 Selector.Cache/Consumer/AppleMusic/CacheWriterConsumer.cs
 rename Selector.Cache/Consumer/{ => Spotify}/PlayCounterCaching.cs (93%)
 create mode 100644 Selector.LastFm/Extensions/ServiceExtensions.cs
 rename {Selector/Scrobble => Selector.LastFm}/Mapping/ScrobbleAlbumMapping.cs (85%)
 rename {Selector/Scrobble => Selector.LastFm}/Mapping/ScrobbleArtistMapping.cs (80%)
 rename {Selector/Scrobble => Selector.LastFm}/Mapping/ScrobbleMapping.cs (90%)
 rename {Selector/Scrobble => Selector.LastFm}/Mapping/ScrobbleTrackMapping.cs (77%)
 rename {Selector/Scrobble => Selector.LastFm}/Scrobble.cs (62%)
 rename {Selector/Scrobble => Selector.LastFm}/ScrobbleMatcher.cs (92%)
 rename {Selector/Scrobble => Selector.LastFm}/ScrobbleRequest.cs (78%)
 create mode 100644 Selector.LastFm/Selector.LastFm.csproj
 create mode 100644 Selector.Model/Listen/AppleMusicListen.cs
 create mode 100644 Selector.Model/Migrations/20250331203003_add_apple_listen.Designer.cs
 create mode 100644 Selector.Model/Migrations/20250331203003_add_apple_listen.cs
 rename {Selector/Spotify => Selector.Spotify}/ConfigFactory/ISpotifyConfigFactory.cs (59%)
 rename {Selector/Spotify => Selector.Spotify}/ConfigFactory/RefreshTokenFactory.cs (88%)
 rename {Selector/Consumers => Selector.Spotify/Consumer}/AudioFeatureInjector.cs (94%)
 rename {Selector/Consumers => Selector.Spotify/Consumer}/DummyAudioFeatureInjector.cs (93%)
 rename {Selector/Consumers => Selector.Spotify/Consumer}/Factory/AudioFeatureInjectorFactory.cs (91%)
 rename {Selector/Consumers => Selector.Spotify/Consumer}/Factory/PlayCounterFactory.cs (93%)
 rename {Selector/Consumers => Selector.Spotify/Consumer}/Factory/WebHookFactory.cs (87%)
 create mode 100644 Selector.Spotify/Consumer/IConsumer.cs
 rename {Selector/Consumers => Selector.Spotify/Consumer}/PlayCounter.cs (95%)
 rename {Selector/Consumers => Selector.Spotify/Consumer}/WebHook.cs (91%)
 create mode 100644 Selector.Spotify/Credentials.cs
 rename {Selector/Spotify => Selector.Spotify}/CurrentlyPlayingDTO.cs (87%)
 rename {Selector => Selector.Spotify}/Equality/PlayableItemEqualityComparer.cs (61%)
 rename {Selector => Selector.Spotify}/Equality/StringComparers.cs (51%)
 rename {Selector => Selector.Spotify}/Equality/StringEqual.cs (92%)
 rename {Selector => Selector.Spotify}/Equality/UriEqual.cs (58%)
 rename {Selector/Watcher/Interfaces => Selector.Spotify}/Events.cs (68%)
 create mode 100644 Selector.Spotify/Extensions/LoggingExtensions.cs
 create mode 100644 Selector.Spotify/Extensions/ServiceExtensions.cs
 rename {Selector => Selector.Spotify}/Extensions/SpotifyExtensions.cs (68%)
 rename {Selector/Spotify => Selector.Spotify}/FactoryProvider/CachingRefreshTokenFactoryProvider.cs (82%)
 rename {Selector/Spotify => Selector.Spotify}/FactoryProvider/IRefreshTokenFactoryProvider.cs (62%)
 rename {Selector/Spotify => Selector.Spotify}/FactoryProvider/RefreshTokenFactoryProvider.cs (56%)
 rename {Selector => Selector.Spotify}/JsonContext.cs (53%)
 create mode 100644 Selector.Spotify/Selector.Spotify.csproj
 rename {Selector => Selector.Spotify}/Timeline/AnalysedTrackTimeline.cs (87%)
 rename {Selector/Timeline/Interfaces => Selector.Spotify/Timeline}/ITrackTimeline.cs (79%)
 rename {Selector => Selector.Spotify}/Timeline/PlayerTimeline.cs (84%)
 rename {Selector => Selector.Spotify}/Watcher/BaseSpotifyWatcher.cs (87%)
 rename {Selector => Selector.Spotify}/Watcher/DummySpotifyPlayerWatcher.cs (96%)
 rename {Selector => Selector.Spotify}/Watcher/Interfaces/IPlaylistWatcher.cs (87%)
 create mode 100644 Selector.Spotify/Watcher/Interfaces/ISpotifyPlayerWatcher.cs
 rename {Selector => Selector.Spotify}/Watcher/Interfaces/ISpotifyWatcherFactory.cs (74%)
 rename {Selector => Selector.Spotify}/Watcher/PlaylistWatcher.cs (97%)
 rename {Selector => Selector.Spotify}/Watcher/SpotifyPlayerWatcher.cs (84%)
 rename {Selector => Selector.Spotify}/Watcher/SpotifyWatcherFactory.cs (96%)
 create mode 100644 Selector/Events.cs
 delete mode 100644 Selector/Extensions/LoggingExtensions.cs
 create mode 100644 Selector/Listen/PlayDensity.cs
 delete mode 100644 Selector/Scrobble/PlayDensity.cs
 delete mode 100644 Selector/Spotify/Credentials.cs
 delete mode 100644 Selector/Watcher/Interfaces/ISpotifyPlayerWatcher.cs

diff --git a/Selector.AppleMusic/AppleMusicApi.cs b/Selector.AppleMusic/AppleMusicApi.cs
index a61f26c..cd69df2 100644
--- a/Selector.AppleMusic/AppleMusicApi.cs
+++ b/Selector.AppleMusic/AppleMusicApi.cs
@@ -41,7 +41,7 @@ public class AppleMusicApi(HttpClient client, string developerToken, string user
 
     public async Task<RecentlyPlayedTracksResponse> GetRecentlyPlayedTracks()
     {
-        var response = await MakeRequest(HttpMethod.Get, "/me/recent/played/tracks?types=songs");
+        var response = await MakeRequest(HttpMethod.Get, "/me/recent/played/tracks");
 
         CheckResponse(response);
 
diff --git a/Selector.AppleMusic/Watcher/Consumer/IConsumer.cs b/Selector.AppleMusic/Consumer/IConsumer.cs
similarity index 63%
rename from Selector.AppleMusic/Watcher/Consumer/IConsumer.cs
rename to Selector.AppleMusic/Consumer/IConsumer.cs
index 27dce20..9f7ce76 100644
--- a/Selector.AppleMusic/Watcher/Consumer/IConsumer.cs
+++ b/Selector.AppleMusic/Consumer/IConsumer.cs
@@ -1,4 +1,4 @@
-namespace Selector.AppleMusic.Watcher.Consumer;
+namespace Selector.AppleMusic.Consumer;
 
 public interface IApplePlayerConsumer : IConsumer<AppleListeningChangeEventArgs>
 {
diff --git a/Selector.AppleMusic/Events.cs b/Selector.AppleMusic/Events.cs
index 649fff1..3d5121c 100644
--- a/Selector.AppleMusic/Events.cs
+++ b/Selector.AppleMusic/Events.cs
@@ -2,16 +2,11 @@ using Selector.AppleMusic.Watcher;
 
 namespace Selector.AppleMusic;
 
-public class AppleListeningChangeEventArgs : EventArgs
+public class AppleListeningChangeEventArgs : ListeningChangeEventArgs
 {
     public AppleMusicCurrentlyPlayingContext Previous { get; set; }
     public AppleMusicCurrentlyPlayingContext Current { get; set; }
 
-    /// <summary>
-    /// String Id for watcher, used to hold user Db Id
-    /// </summary>
-    /// <value></value>
-    public string Id { get; set; }
     // AppleTimeline Timeline { get; set; }
 
     public static AppleListeningChangeEventArgs From(AppleMusicCurrentlyPlayingContext previous,
diff --git a/Selector.AppleMusic/Watcher/WatcherFactory.cs b/Selector.AppleMusic/Watcher/WatcherFactory.cs
index fb0b41c..22beb71 100644
--- a/Selector.AppleMusic/Watcher/WatcherFactory.cs
+++ b/Selector.AppleMusic/Watcher/WatcherFactory.cs
@@ -6,7 +6,7 @@ namespace Selector.AppleMusic.Watcher
     public interface IAppleMusicWatcherFactory
     {
         Task<IWatcher> Get<T>(AppleMusicApiProvider appleMusicProvider, string developerToken, string teamId,
-            string keyId, string userToken, int pollPeriod = 3000)
+            string keyId, string userToken, string id = null, int pollPeriod = 3000)
             where T : class, IWatcher;
     }
 
@@ -22,7 +22,7 @@ namespace Selector.AppleMusic.Watcher
         }
 
         public async Task<IWatcher> Get<T>(AppleMusicApiProvider appleMusicProvider, string developerToken,
-            string teamId, string keyId, string userToken, int pollPeriod = 3000)
+            string teamId, string keyId, string userToken, string id = null, int pollPeriod = 3000)
             where T : class, IWatcher
         {
             if (typeof(T).IsAssignableFrom(typeof(AppleMusicPlayerWatcher)))
@@ -36,18 +36,14 @@ namespace Selector.AppleMusic.Watcher
                         LoggerFactory?.CreateLogger<AppleMusicPlayerWatcher>() ??
                         NullLogger<AppleMusicPlayerWatcher>.Instance,
                         pollPeriod: pollPeriod
-                    );
+                    )
+                    {
+                        Id = id
+                    };
                 }
                 else
                 {
-                    return new DummySpotifyPlayerWatcher(
-                        Equal,
-                        LoggerFactory?.CreateLogger<DummySpotifyPlayerWatcher>() ??
-                        NullLogger<DummySpotifyPlayerWatcher>.Instance,
-                        pollPeriod: pollPeriod
-                    )
-                    {
-                    };
+                    throw new NotImplementedException();
                 }
             }
             else
diff --git a/Selector.CLI/Command/HostCommand.cs b/Selector.CLI/Command/HostCommand.cs
index 0fb6934..54a6778 100644
--- a/Selector.CLI/Command/HostCommand.cs
+++ b/Selector.CLI/Command/HostCommand.cs
@@ -1,19 +1,21 @@
-using Microsoft.Extensions.DependencyInjection;
+using System;
+using System.CommandLine;
+using System.CommandLine.Invocation;
+using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
 using NLog.Extensions.Logging;
+using Selector.AppleMusic.Extensions;
 using Selector.Cache.Extensions;
 using Selector.CLI.Extensions;
 using Selector.Events;
 using Selector.Extensions;
-using System;
-using System.CommandLine;
-using System.CommandLine.Invocation;
-using Selector.AppleMusic.Extensions;
+using Selector.Model.Extensions;
+using Selector.Spotify;
 
 namespace Selector.CLI
 {
-    public class HostRootCommand: RootCommand
+    public class HostRootCommand : RootCommand
     {
         public HostRootCommand()
         {
@@ -32,7 +34,7 @@ namespace Selector.CLI
         {
             try
             {
-                var host = CreateHostBuilder(Environment.GetCommandLineArgs(),ConfigureDefault, ConfigureDefaultNlog)
+                var host = CreateHostBuilder(Environment.GetCommandLineArgs(), ConfigureDefault, ConfigureDefaultNlog)
                     .Build();
 
                 var logger = host.Services.GetRequiredService<ILogger<HostCommand>>();
@@ -41,7 +43,7 @@ namespace Selector.CLI
 
                 host.Run();
             }
-            catch(Exception ex)
+            catch (Exception ex)
             {
                 Console.WriteLine(ex);
                 return 1;
@@ -54,7 +56,7 @@ namespace Selector.CLI
         {
             AppDomain.CurrentDomain.UnhandledException += (_, e) =>
             {
-                if(e.ExceptionObject is Exception ex)
+                if (e.ExceptionObject is Exception ex)
                 {
                     logger.LogError(ex, "Unhandled exception thrown");
 
@@ -97,7 +99,7 @@ namespace Selector.CLI
             Console.WriteLine("> Adding Services...");
             // SERVICES
             services.AddHttpClient()
-                    .ConfigureDb(config);
+                .ConfigureDb(config);
 
             services.AddConsumerFactories();
             services.AddCLIConsumerFactories();
@@ -108,13 +110,14 @@ namespace Selector.CLI
             }
 
             services.AddWatcher()
-                    .AddEvents()
-                    .AddSpotify()
-                    .AddAppleMusic();
+                .AddSpotifyWatcher()
+                .AddEvents()
+                .AddSpotify()
+                .AddAppleMusic();
 
             services.ConfigureLastFm(config)
-                    .ConfigureEqual(config)
-                    .ConfigureJobs(config);
+                .ConfigureEqual(config)
+                .ConfigureJobs(config);
 
             if (config.RedisOptions.Enabled)
             {
@@ -123,8 +126,8 @@ namespace Selector.CLI
 
                 Console.WriteLine("> Adding cache event maps...");
                 services.AddTransient<IEventMapping, FromPubSub.SpotifyLink>()
-                        .AddTransient<IEventMapping, FromPubSub.AppleMusicLink>()
-                        .AddTransient<IEventMapping, FromPubSub.Lastfm>();
+                    .AddTransient<IEventMapping, FromPubSub.AppleMusicLink>()
+                    .AddTransient<IEventMapping, FromPubSub.Lastfm>();
 
                 Console.WriteLine("> Adding caching Spotify consumers...");
                 services.AddCachingSpotify();
@@ -150,14 +153,16 @@ namespace Selector.CLI
         public static void ConfigureDefaultNlog(HostBuilderContext context, ILoggingBuilder builder)
         {
             builder.ClearProviders()
-                   .SetMinimumLevel(LogLevel.Trace)
-                   .AddNLog(context.Configuration);
+                .SetMinimumLevel(LogLevel.Trace)
+                .AddNLog(context.Configuration);
         }
 
-        static IHostBuilder CreateHostBuilder(string[] args, Action<HostBuilderContext, IServiceCollection> buildServices, Action<HostBuilderContext, ILoggingBuilder> buildLogs)
+        static IHostBuilder CreateHostBuilder(string[] args,
+            Action<HostBuilderContext, IServiceCollection> buildServices,
+            Action<HostBuilderContext, ILoggingBuilder> buildLogs)
             => Host.CreateDefaultBuilder(args)
                 .UseSystemd()
                 .ConfigureServices((context, services) => buildServices(context, services))
                 .ConfigureLogging((context, builder) => buildLogs(context, builder));
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.CLI/Consumer/Factory/MappingPersisterFactory.cs b/Selector.CLI/Consumer/Factory/MappingPersisterFactory.cs
index 0a43b1a..113a4de 100644
--- a/Selector.CLI/Consumer/Factory/MappingPersisterFactory.cs
+++ b/Selector.CLI/Consumer/Factory/MappingPersisterFactory.cs
@@ -1,6 +1,8 @@
 using System.Threading.Tasks;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 
 namespace Selector.CLI.Consumer
 {
diff --git a/Selector.CLI/Consumer/MappingPersister.cs b/Selector.CLI/Consumer/MappingPersister.cs
index 4eb9de5..378c0fd 100644
--- a/Selector.CLI/Consumer/MappingPersister.cs
+++ b/Selector.CLI/Consumer/MappingPersister.cs
@@ -7,7 +7,10 @@ using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
+using Selector.Extensions;
 using Selector.Model;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using SpotifyAPI.Web;
 
 namespace Selector.CLI.Consumer
@@ -36,7 +39,7 @@ namespace Selector.CLI.Consumer
             CancelToken = token;
         }
 
-        public void Callback(object sender, ListeningChangeEventArgs e)
+        public void Callback(object sender, SpotifyListeningChangeEventArgs e)
         {
             if (e.Current is null) return;
 
@@ -57,7 +60,7 @@ namespace Selector.CLI.Consumer
             }, CancelToken);
         }
 
-        public async Task AsyncCallback(ListeningChangeEventArgs e)
+        public async Task AsyncCallback(SpotifyListeningChangeEventArgs e)
         {
             using var serviceScope = ScopeFactory.CreateScope();
             using var scope = Logger.BeginScope(new Dictionary<string, object>()
diff --git a/Selector.CLI/Extensions/CommandContextExtensions.cs b/Selector.CLI/Extensions/CommandContextExtensions.cs
index 249eeb0..2d37d2e 100644
--- a/Selector.CLI/Extensions/CommandContextExtensions.cs
+++ b/Selector.CLI/Extensions/CommandContextExtensions.cs
@@ -1,12 +1,13 @@
-using IF.Lastfm.Core.Api;
+using System.Linq;
+using IF.Lastfm.Core.Api;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
 using Selector.Model;
+using Selector.Spotify.ConfigFactory;
 using SpotifyAPI.Web;
 using StackExchange.Redis;
-using System.Linq;
 
 namespace Selector.CLI.Extensions
 {
@@ -16,8 +17,8 @@ namespace Selector.CLI.Extensions
         {
             var configBuild = new ConfigurationBuilder();
             configBuild.AddJsonFile("appsettings.json", optional: true)
-                       .AddJsonFile("appsettings.Development.json", optional: true)
-                       .AddJsonFile("appsettings.Production.json", optional: true);
+                .AddJsonFile("appsettings.Development.json", optional: true)
+                .AddJsonFile("appsettings.Production.json", optional: true);
             context.Config = configBuild.Build().ConfigureOptions();
 
             return context;
@@ -28,10 +29,7 @@ namespace Selector.CLI.Extensions
             context.Logger = LoggerFactory.Create(builder =>
             {
                 //builder.AddConsole(a => a.);
-                builder.AddSimpleConsole(options =>
-                {
-                    options.SingleLine = true;
-                });
+                builder.AddSimpleConsole(options => { options.SingleLine = true; });
                 builder.SetMinimumLevel(LogLevel.Trace);
             });
 
@@ -46,7 +44,9 @@ namespace Selector.CLI.Extensions
             }
 
             context.DatabaseConfig = new DbContextOptionsBuilder<ApplicationDbContext>();
-            context.DatabaseConfig.UseNpgsql(string.IsNullOrWhiteSpace(connectionString) ?  context.Config.DatabaseOptions.ConnectionString : connectionString);
+            context.DatabaseConfig.UseNpgsql(string.IsNullOrWhiteSpace(connectionString)
+                ? context.Config.DatabaseOptions.ConnectionString
+                : connectionString);
 
             return context;
         }
@@ -58,7 +58,8 @@ namespace Selector.CLI.Extensions
                 context.WithConfig();
             }
 
-            context.LastFmClient = new LastfmClient(new LastAuth(context.Config.LastfmClient, context.Config.LastfmSecret));
+            context.LastFmClient =
+                new LastfmClient(new LastAuth(context.Config.LastfmClient, context.Config.LastfmSecret));
 
             return context;
         }
@@ -72,21 +73,23 @@ namespace Selector.CLI.Extensions
 
             var refreshToken = context.Config.RefreshToken;
 
-            if(string.IsNullOrWhiteSpace(refreshToken))
+            if (string.IsNullOrWhiteSpace(refreshToken))
             {
                 if (context.DatabaseConfig is null)
                 {
                     context.WithDb();
                 }
 
-                using var db = new ApplicationDbContext(context.DatabaseConfig.Options, NullLogger<ApplicationDbContext>.Instance);
+                using var db = new ApplicationDbContext(context.DatabaseConfig.Options,
+                    NullLogger<ApplicationDbContext>.Instance);
 
                 var user = db.Users.FirstOrDefault(u => u.UserName == "sarsoo");
 
                 refreshToken = user?.SpotifyRefreshToken;
             }
 
-            var configFactory = new RefreshTokenFactory(context.Config.ClientId, context.Config.ClientSecret, refreshToken);
+            var configFactory =
+                new RefreshTokenFactory(context.Config.ClientId, context.Config.ClientSecret, refreshToken);
 
             context.Spotify = new SpotifyClient(configFactory.GetConfig().Result);
 
@@ -109,4 +112,4 @@ namespace Selector.CLI.Extensions
             return context;
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.CLI/Extensions/ServiceExtensions.cs b/Selector.CLI/Extensions/ServiceExtensions.cs
index a2bc1cd..ab4e593 100644
--- a/Selector.CLI/Extensions/ServiceExtensions.cs
+++ b/Selector.CLI/Extensions/ServiceExtensions.cs
@@ -1,4 +1,5 @@
-using Microsoft.EntityFrameworkCore;
+using System;
+using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.DependencyInjection;
 using Quartz;
 using Selector.Cache.Extensions;
@@ -7,7 +8,7 @@ using Selector.CLI.Jobs;
 using Selector.Extensions;
 using Selector.Model;
 using Selector.Model.Services;
-using System;
+using Selector.Spotify.Equality;
 
 namespace Selector.CLI.Extensions
 {
@@ -41,16 +42,13 @@ namespace Selector.CLI.Extensions
             {
                 Console.WriteLine("> Adding Jobs...");
 
-                services.AddQuartz(options => {
-
+                services.AddQuartz(options =>
+                {
                     options.UseMicrosoftDependencyInjectionJobFactory();
 
                     options.UseSimpleTypeLoader();
                     options.UseInMemoryStore();
-                    options.UseDefaultThreadPool(tp =>
-                    {
-                        tp.MaxConcurrency = 5;
-                    });
+                    options.UseDefaultThreadPool(tp => { tp.MaxConcurrency = 5; });
 
                     if (config.JobOptions.Scrobble.Enabled)
                     {
@@ -68,7 +66,8 @@ namespace Selector.CLI.Extensions
                             .WithIdentity("scrobble-watcher-agile-trigger")
                             .ForJob(scrobbleKey)
                             .StartNow()
-                            .WithSimpleSchedule(x => x.WithInterval(config.JobOptions.Scrobble.InterJobDelay).RepeatForever())
+                            .WithSimpleSchedule(x =>
+                                x.WithInterval(config.JobOptions.Scrobble.InterJobDelay).RepeatForever())
                             .WithDescription("Periodic trigger for scrobble watcher")
                         );
 
@@ -86,17 +85,14 @@ namespace Selector.CLI.Extensions
                             .WithCronSchedule(config.JobOptions.Scrobble.FullScrobbleCron)
                             .WithDescription("Periodic trigger for scrobble watcher")
                         );
-                    }   
+                    }
                     else
                     {
                         Console.WriteLine("> Skipping Scrobble Jobs...");
                     }
                 });
 
-                services.AddQuartzHostedService(options => {
-
-                    options.WaitForJobsToComplete = true;
-                });
+                services.AddQuartzHostedService(options => { options.WaitForJobsToComplete = true; });
 
                 services.AddTransient<ScrobbleWatcherJob>();
                 services.AddTransient<IJob, ScrobbleWatcherJob>();
@@ -115,7 +111,7 @@ namespace Selector.CLI.Extensions
                 );
 
                 services.AddTransient<IScrobbleRepository, ScrobbleRepository>()
-                        .AddTransient<ISpotifyListenRepository, SpotifyListenRepository>();
+                    .AddTransient<ISpotifyListenRepository, SpotifyListenRepository>();
 
                 services.AddTransient<IListenRepository, MetaListenRepository>();
                 //services.AddTransient<IListenRepository, SpotifyListenRepository>();
@@ -152,5 +148,5 @@ namespace Selector.CLI.Extensions
 
             return services;
         }
-    }    
-}
+    }
+}
\ No newline at end of file
diff --git a/Selector.CLI/ScrobbleMapper.cs b/Selector.CLI/ScrobbleMapper.cs
index 581c8c8..08672bd 100644
--- a/Selector.CLI/ScrobbleMapper.cs
+++ b/Selector.CLI/ScrobbleMapper.cs
@@ -1,13 +1,13 @@
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using Selector.Model;
-using Selector.Operations;
-using SpotifyAPI.Web;
-using System;
+using System;
 using System.Collections.Generic;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Selector.Mapping;
+using Selector.Model;
+using Selector.Operations;
+using SpotifyAPI.Web;
 
 namespace Selector
 {
@@ -30,7 +30,9 @@ namespace Selector
         private readonly IScrobbleRepository scrobbleRepo;
         private readonly IScrobbleMappingRepository mappingRepo;
 
-        public ScrobbleMapper(ISearchClient _searchClient, ScrobbleMapperConfig _config, IScrobbleRepository _scrobbleRepository, IScrobbleMappingRepository _scrobbleMappingRepository, ILogger<ScrobbleMapper> _logger, ILoggerFactory _loggerFactory = null)
+        public ScrobbleMapper(ISearchClient _searchClient, ScrobbleMapperConfig _config,
+            IScrobbleRepository _scrobbleRepository, IScrobbleMappingRepository _scrobbleMappingRepository,
+            ILogger<ScrobbleMapper> _logger, ILoggerFactory _loggerFactory = null)
         {
             searchClient = _searchClient;
             config = _config;
@@ -68,9 +70,9 @@ namespace Selector
                 .ExceptBy(currentTracks.Select(a => (a.LastfmArtistName, a.LastfmTrackName)), a => a);
 
             var requests = tracksToPull.Select(a => new ScrobbleTrackMapping(
-                 searchClient,
-                 loggerFactory.CreateLogger<ScrobbleTrackMapping>(),
-                 a.TrackName, a.ArtistName)
+                searchClient,
+                loggerFactory.CreateLogger<ScrobbleTrackMapping>(),
+                a.TrackName, a.ArtistName)
             ).ToArray();
 
             logger.LogInformation("Found {} tracks to map, starting", requests.Length);
@@ -95,11 +97,12 @@ namespace Selector
                 if (existingTrackUris.Contains(track.Uri))
                 {
                     var artistName = track.Artists.FirstOrDefault()?.Name;
-                    var duplicates = currentTracks.Where(a => a.LastfmArtistName.Equals(artistName, StringComparison.OrdinalIgnoreCase)
-                                                                && a.LastfmTrackName.Equals(track.Name, StringComparison.OrdinalIgnoreCase));
-                    logger.LogWarning("Found duplicate Spotify uri ({}), [{}, {}] {}", 
-                        track.Uri, 
-                        track.Name, 
+                    var duplicates = currentTracks.Where(a =>
+                        a.LastfmArtistName.Equals(artistName, StringComparison.OrdinalIgnoreCase)
+                        && a.LastfmTrackName.Equals(track.Name, StringComparison.OrdinalIgnoreCase));
+                    logger.LogWarning("Found duplicate Spotify uri ({}), [{}, {}] {}",
+                        track.Uri,
+                        track.Name,
                         artistName,
                         string.Join(", ", duplicates.Select(d => $"{d.LastfmTrackName} {d.LastfmArtistName}"))
                     );
@@ -114,7 +117,7 @@ namespace Selector
                     });
                 }
 
-                if(!existingAlbumUris.Contains(track.Album.Uri))
+                if (!existingAlbumUris.Contains(track.Album.Uri))
                 {
                     mappingRepo.Add(new AlbumLastfmSpotifyMapping()
                     {
@@ -124,7 +127,7 @@ namespace Selector
                     });
                 }
 
-                foreach(var artist in track.Artists.UnionBy(track.Album.Artists, a => a.Name))
+                foreach (var artist in track.Artists.UnionBy(track.Album.Artists, a => a.Name))
                 {
                     if (!existingArtistUris.Contains(artist.Uri))
                     {
@@ -138,7 +141,7 @@ namespace Selector
             }
         }
 
-        private BatchingOperation<T> GetOperation<T>(IEnumerable<T> requests) where T: IOperation 
-            => new (config.InterRequestDelay, config.Timeout, config.SimultaneousConnections, requests);
+        private BatchingOperation<T> GetOperation<T>(IEnumerable<T> requests) where T : IOperation
+            => new(config.InterRequestDelay, config.Timeout, config.SimultaneousConnections, requests);
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.CLI/Services/DbWatcherService.cs b/Selector.CLI/Services/DbWatcherService.cs
index 29e7f9e..ca084c1 100644
--- a/Selector.CLI/Services/DbWatcherService.cs
+++ b/Selector.CLI/Services/DbWatcherService.cs
@@ -16,6 +16,11 @@ using Selector.CLI.Consumer;
 using Selector.Events;
 using Selector.Model;
 using Selector.Model.Extensions;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
+using Selector.Spotify.Consumer.Factory;
+using Selector.Spotify.FactoryProvider;
+using Selector.Spotify.Watcher;
 
 namespace Selector.CLI
 {
@@ -106,8 +111,17 @@ namespace Selector.CLI
 
             foreach (var dbWatcher in db.Watcher
                          .Include(w => w.User)
-                         .Where(w => !string.IsNullOrWhiteSpace(w.User.SpotifyRefreshToken)))
+                         .Where(w =>
+                             ((w.Type == WatcherType.SpotifyPlayer || w.Type == WatcherType.SpotifyPlaylist) &&
+                              !string.IsNullOrWhiteSpace(w.User.SpotifyRefreshToken)) ||
+                             (w.Type == WatcherType.AppleMusicPlayer && w.User.AppleMusicLinked)
+                         ))
             {
+                using var logScope = Logger.BeginScope(new Dictionary<string, string>
+                {
+                    { "username", dbWatcher.User.UserName }
+                });
+
                 var watcherCollectionIdx = dbWatcher.UserId;
                 indices.Add(watcherCollectionIdx);
 
@@ -128,20 +142,20 @@ namespace Selector.CLI
 
             var watcherCollection = Watchers[watcherCollectionIdx];
 
-            Logger.LogDebug("Getting Spotify factory");
-            var spotifyFactory = await SpotifyFactory.GetFactory(dbWatcher.User.SpotifyRefreshToken);
-
             IWatcher watcher = null;
             List<IConsumer> consumers = new();
 
             switch (dbWatcher.Type)
             {
                 case WatcherType.SpotifyPlayer:
+                    Logger.LogDebug("Getting Spotify factory");
+                    var spotifyFactory = await SpotifyFactory.GetFactory(dbWatcher.User.SpotifyRefreshToken);
+
                     watcher = await _spotifyWatcherFactory.Get<SpotifyPlayerWatcher>(spotifyFactory,
                         id: dbWatcher.UserId, pollPeriod: PollPeriod);
 
                     consumers.Add(await AudioFeatureInjectorFactory.Get(spotifyFactory));
-                    if (CacheWriterFactory is not null) consumers.Add(await CacheWriterFactory.Get());
+                    if (CacheWriterFactory is not null) consumers.Add(await CacheWriterFactory.GetSpotify());
                     if (PublisherFactory is not null) consumers.Add(await PublisherFactory.GetSpotify());
 
                     if (MappingPersisterFactory is not null && !Magic.Dummy)
@@ -168,8 +182,9 @@ namespace Selector.CLI
                 case WatcherType.AppleMusicPlayer:
                     watcher = await _appleWatcherFactory.Get<AppleMusicPlayerWatcher>(_appleMusicProvider,
                         _appleMusicOptions.Value.Key, _appleMusicOptions.Value.TeamId, _appleMusicOptions.Value.KeyId,
-                        dbWatcher.User.AppleMusicKey);
+                        dbWatcher.User.AppleMusicKey, id: dbWatcher.UserId);
 
+                    if (CacheWriterFactory is not null) consumers.Add(await CacheWriterFactory.GetApple());
                     if (PublisherFactory is not null) consumers.Add(await PublisherFactory.GetApple());
 
                     break;
diff --git a/Selector.CLI/Services/LocalWatcherService.cs b/Selector.CLI/Services/LocalWatcherService.cs
index 77051d7..6a597a3 100644
--- a/Selector.CLI/Services/LocalWatcherService.cs
+++ b/Selector.CLI/Services/LocalWatcherService.cs
@@ -12,6 +12,10 @@ using Selector.AppleMusic;
 using Selector.AppleMusic.Watcher;
 using Selector.Cache;
 using Selector.CLI.Consumer;
+using Selector.Spotify;
+using Selector.Spotify.Consumer.Factory;
+using Selector.Spotify.FactoryProvider;
+using Selector.Spotify.Watcher;
 
 namespace Selector.CLI
 {
@@ -115,7 +119,8 @@ namespace Selector.CLI
                     case WatcherType.AppleMusicPlayer:
                         var appleMusicWatcher = await _appleWatcherFactory.Get<AppleMusicPlayerWatcher>(
                             _appleMusicApiProvider, _appleMusicOptions.Value.Key, _appleMusicOptions.Value.TeamId,
-                            _appleMusicOptions.Value.KeyId, watcherOption.AppleUserToken);
+                            _appleMusicOptions.Value.KeyId, watcherOption.AppleUserToken,
+                            id: watcherOption.Name);
 
                         watcher = appleMusicWatcher;
                         break;
@@ -140,11 +145,27 @@ namespace Selector.CLI
                                 break;
 
                             case Consumers.CacheWriter:
-                                consumers.Add(await ServiceProvider.GetService<CacheWriterFactory>().Get());
+                                if (watcher is ISpotifyPlayerWatcher or IPlaylistWatcher)
+                                {
+                                    consumers.Add(await ServiceProvider.GetService<CacheWriterFactory>().GetSpotify());
+                                }
+                                else
+                                {
+                                    consumers.Add(await ServiceProvider.GetService<CacheWriterFactory>().GetApple());
+                                }
+
                                 break;
 
                             case Consumers.Publisher:
-                                consumers.Add(await ServiceProvider.GetService<PublisherFactory>().GetSpotify());
+                                if (watcher is ISpotifyPlayerWatcher or IPlaylistWatcher)
+                                {
+                                    consumers.Add(await ServiceProvider.GetService<PublisherFactory>().GetSpotify());
+                                }
+                                else
+                                {
+                                    consumers.Add(await ServiceProvider.GetService<PublisherFactory>().GetApple());
+                                }
+
                                 break;
 
                             case Consumers.PlayCounter:
diff --git a/Selector.Cache/Consumer/AppleMusic/CacheWriterConsumer.cs b/Selector.Cache/Consumer/AppleMusic/CacheWriterConsumer.cs
new file mode 100644
index 0000000..d54275f
--- /dev/null
+++ b/Selector.Cache/Consumer/AppleMusic/CacheWriterConsumer.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Selector.AppleMusic;
+using Selector.AppleMusic.Consumer;
+using StackExchange.Redis;
+
+namespace Selector.Cache
+{
+    public class AppleCacheWriter : IApplePlayerConsumer
+    {
+        private readonly IAppleMusicPlayerWatcher Watcher;
+        private readonly IDatabaseAsync Db;
+        private readonly ILogger<AppleCacheWriter> Logger;
+        public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromMinutes(20);
+
+        public CancellationToken CancelToken { get; set; }
+
+        public AppleCacheWriter(
+            IAppleMusicPlayerWatcher watcher,
+            IDatabaseAsync db,
+            ILogger<AppleCacheWriter> logger = null,
+            CancellationToken token = default
+        )
+        {
+            Watcher = watcher;
+            Db = db;
+            Logger = logger ?? NullLogger<AppleCacheWriter>.Instance;
+            CancelToken = token;
+        }
+
+        public void Callback(object sender, AppleListeningChangeEventArgs e)
+        {
+            if (e.Current is null) return;
+
+            Task.Run(async () =>
+            {
+                try
+                {
+                    await AsyncCallback(e);
+                }
+                catch (Exception e)
+                {
+                    Logger.LogError(e, "Error occured during callback");
+                }
+            }, CancelToken);
+        }
+
+        public async Task AsyncCallback(AppleListeningChangeEventArgs e)
+        {
+            // using var scope = Logger.GetListeningEventArgsScope(e);
+
+            var payload = JsonSerializer.Serialize(e, AppleJsonContext.Default.AppleListeningChangeEventArgs);
+
+            Logger.LogTrace("Caching current");
+
+            var resp = await Db.StringSetAsync(Key.CurrentlyPlayingAppleMusic(e.Id), payload, expiry: CacheExpiry);
+
+            Logger.LogDebug("Cached current, {state}", (resp ? "value set" : "value NOT set"));
+        }
+
+        public void Subscribe(IWatcher watch = null)
+        {
+            var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
+
+            if (watcher is IAppleMusicPlayerWatcher watcherCastApple)
+            {
+                watcherCastApple.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 IAppleMusicPlayerWatcher watcherCastApple)
+            {
+                watcherCastApple.ItemChange -= Callback;
+            }
+            else
+            {
+                throw new ArgumentException("Provided watcher is not a PlayerWatcher");
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/Selector.Cache/Consumer/AppleMusic/PublisherConsumer.cs b/Selector.Cache/Consumer/AppleMusic/PublisherConsumer.cs
index 77e4b38..3b63d85 100644
--- a/Selector.Cache/Consumer/AppleMusic/PublisherConsumer.cs
+++ b/Selector.Cache/Consumer/AppleMusic/PublisherConsumer.cs
@@ -5,7 +5,7 @@ using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
 using Selector.AppleMusic;
-using Selector.AppleMusic.Watcher.Consumer;
+using Selector.AppleMusic.Consumer;
 using StackExchange.Redis;
 
 namespace Selector.Cache.Consumer.AppleMusic
@@ -57,7 +57,8 @@ namespace Selector.Cache.Consumer.AppleMusic
             Logger.LogTrace("Publishing current");
 
             // TODO: currently using spotify username for cache key, use db username
-            var receivers = await Subscriber.PublishAsync(Key.CurrentlyPlayingAppleMusic(e.Id), payload);
+            var receivers =
+                await Subscriber.PublishAsync(RedisChannel.Literal(Key.CurrentlyPlayingAppleMusic(e.Id)), payload);
 
             Logger.LogDebug("Published current, {receivers} receivers", receivers);
         }
diff --git a/Selector.Cache/Consumer/Factory/AudioInjectorCaching.cs b/Selector.Cache/Consumer/Factory/AudioInjectorCaching.cs
index 2896c3a..176e7c9 100644
--- a/Selector.Cache/Consumer/Factory/AudioInjectorCaching.cs
+++ b/Selector.Cache/Consumer/Factory/AudioInjectorCaching.cs
@@ -1,5 +1,9 @@
 using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
+using Selector.Spotify;
+using Selector.Spotify.ConfigFactory;
+using Selector.Spotify.Consumer;
+using Selector.Spotify.Consumer.Factory;
 using SpotifyAPI.Web;
 using StackExchange.Redis;
 
diff --git a/Selector.Cache/Consumer/Factory/CacheWriterFactory.cs b/Selector.Cache/Consumer/Factory/CacheWriterFactory.cs
index 2ddfa01..6f73cc7 100644
--- a/Selector.Cache/Consumer/Factory/CacheWriterFactory.cs
+++ b/Selector.Cache/Consumer/Factory/CacheWriterFactory.cs
@@ -1,12 +1,16 @@
 using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
+using Selector.AppleMusic.Consumer;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using StackExchange.Redis;
 
 namespace Selector.Cache
 {
     public interface ICacheWriterFactory
     {
-        public Task<ISpotifyPlayerConsumer> Get(ISpotifyPlayerWatcher watcher = null);
+        public Task<ISpotifyPlayerConsumer> GetSpotify(ISpotifyPlayerWatcher watcher = null);
+        public Task<IApplePlayerConsumer> GetApple(IAppleMusicPlayerWatcher watcher = null);
     }
 
     public class CacheWriterFactory : ICacheWriterFactory
@@ -23,12 +27,21 @@ namespace Selector.Cache
             LoggerFactory = loggerFactory;
         }
 
-        public Task<ISpotifyPlayerConsumer> Get(ISpotifyPlayerWatcher watcher = null)
+        public Task<ISpotifyPlayerConsumer> GetSpotify(ISpotifyPlayerWatcher watcher = null)
         {
-            return Task.FromResult<ISpotifyPlayerConsumer>(new CacheWriter(
+            return Task.FromResult<ISpotifyPlayerConsumer>(new SpotifyCacheWriter(
                 watcher,
                 Cache,
-                LoggerFactory.CreateLogger<CacheWriter>()
+                LoggerFactory.CreateLogger<SpotifyCacheWriter>()
+            ));
+        }
+
+        public Task<IApplePlayerConsumer> GetApple(IAppleMusicPlayerWatcher watcher = null)
+        {
+            return Task.FromResult<IApplePlayerConsumer>(new AppleCacheWriter(
+                watcher,
+                Cache,
+                LoggerFactory.CreateLogger<AppleCacheWriter>()
             ));
         }
     }
diff --git a/Selector.Cache/Consumer/Factory/PlayCounterCachingFactory.cs b/Selector.Cache/Consumer/Factory/PlayCounterCachingFactory.cs
index d6c0f8a..21eae71 100644
--- a/Selector.Cache/Consumer/Factory/PlayCounterCachingFactory.cs
+++ b/Selector.Cache/Consumer/Factory/PlayCounterCachingFactory.cs
@@ -2,6 +2,9 @@
 using System.Threading.Tasks;
 using IF.Lastfm.Core.Api;
 using Microsoft.Extensions.Logging;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
+using Selector.Spotify.Consumer.Factory;
 using StackExchange.Redis;
 
 namespace Selector.Cache
diff --git a/Selector.Cache/Consumer/Factory/PublisherFactory.cs b/Selector.Cache/Consumer/Factory/PublisherFactory.cs
index 2f0c98d..75970e8 100644
--- a/Selector.Cache/Consumer/Factory/PublisherFactory.cs
+++ b/Selector.Cache/Consumer/Factory/PublisherFactory.cs
@@ -1,7 +1,9 @@
 using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
-using Selector.AppleMusic.Watcher.Consumer;
+using Selector.AppleMusic.Consumer;
 using Selector.Cache.Consumer.AppleMusic;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using StackExchange.Redis;
 
 namespace Selector.Cache
diff --git a/Selector.Cache/Consumer/Spotify/AudioInjectorCaching.cs b/Selector.Cache/Consumer/Spotify/AudioInjectorCaching.cs
index 2284c48..32c1de3 100644
--- a/Selector.Cache/Consumer/Spotify/AudioInjectorCaching.cs
+++ b/Selector.Cache/Consumer/Spotify/AudioInjectorCaching.cs
@@ -3,6 +3,9 @@ using System.Text.Json;
 using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
+using Selector.Extensions;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using SpotifyAPI.Web;
 using StackExchange.Redis;
 
@@ -43,7 +46,7 @@ namespace Selector.Cache
 
         public async Task AsyncCacheCallback(AnalysedTrack e)
         {
-            var payload = JsonSerializer.Serialize(e.Features, JsonContext.Default.TrackAudioFeatures);
+            var payload = JsonSerializer.Serialize(e.Features, SpotifyJsonContext.Default.TrackAudioFeatures);
 
             Logger.LogTrace("Caching current for [{track}]", e.Track.DisplayString());
 
diff --git a/Selector.Cache/Consumer/Spotify/CacheWriterConsumer.cs b/Selector.Cache/Consumer/Spotify/CacheWriterConsumer.cs
index a3ec7a0..6fedcda 100644
--- a/Selector.Cache/Consumer/Spotify/CacheWriterConsumer.cs
+++ b/Selector.Cache/Consumer/Spotify/CacheWriterConsumer.cs
@@ -4,33 +4,36 @@ using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
+using Selector.Extensions;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using StackExchange.Redis;
 
 namespace Selector.Cache
 {
-    public class CacheWriter : ISpotifyPlayerConsumer
+    public class SpotifyCacheWriter : ISpotifyPlayerConsumer
     {
         private readonly ISpotifyPlayerWatcher Watcher;
         private readonly IDatabaseAsync Db;
-        private readonly ILogger<CacheWriter> Logger;
+        private readonly ILogger<SpotifyCacheWriter> Logger;
         public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromMinutes(20);
 
         public CancellationToken CancelToken { get; set; }
 
-        public CacheWriter(
+        public SpotifyCacheWriter(
             ISpotifyPlayerWatcher watcher,
             IDatabaseAsync db,
-            ILogger<CacheWriter> logger = null,
+            ILogger<SpotifyCacheWriter> logger = null,
             CancellationToken token = default
         )
         {
             Watcher = watcher;
             Db = db;
-            Logger = logger ?? NullLogger<CacheWriter>.Instance;
+            Logger = logger ?? NullLogger<SpotifyCacheWriter>.Instance;
             CancelToken = token;
         }
 
-        public void Callback(object sender, ListeningChangeEventArgs e)
+        public void Callback(object sender, SpotifyListeningChangeEventArgs e)
         {
             if (e.Current is null) return;
 
@@ -47,11 +50,12 @@ namespace Selector.Cache
             }, CancelToken);
         }
 
-        public async Task AsyncCallback(ListeningChangeEventArgs e)
+        public async Task AsyncCallback(SpotifyListeningChangeEventArgs e)
         {
             using var scope = Logger.GetListeningEventArgsScope(e);
 
-            var payload = JsonSerializer.Serialize((CurrentlyPlayingDTO)e, JsonContext.Default.CurrentlyPlayingDTO);
+            var payload =
+                JsonSerializer.Serialize((CurrentlyPlayingDTO)e, SpotifyJsonContext.Default.CurrentlyPlayingDTO);
 
             Logger.LogTrace("Caching current");
 
diff --git a/Selector.Cache/Consumer/PlayCounterCaching.cs b/Selector.Cache/Consumer/Spotify/PlayCounterCaching.cs
similarity index 93%
rename from Selector.Cache/Consumer/PlayCounterCaching.cs
rename to Selector.Cache/Consumer/Spotify/PlayCounterCaching.cs
index 84dbf7e..6145c09 100644
--- a/Selector.Cache/Consumer/PlayCounterCaching.cs
+++ b/Selector.Cache/Consumer/Spotify/PlayCounterCaching.cs
@@ -3,6 +3,9 @@ using System.Threading;
 using System.Threading.Tasks;
 using IF.Lastfm.Core.Api;
 using Microsoft.Extensions.Logging;
+using Selector.Extensions;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using SpotifyAPI.Web;
 using StackExchange.Redis;
 
@@ -47,7 +50,7 @@ namespace Selector.Cache
 
         public async Task AsyncCacheCallback(PlayCount e)
         {
-            var track = e.ListeningEvent.Current.Item as FullTrack;
+            var track = e.SpotifyListeningEvent.Current.Item as FullTrack;
             Logger.LogTrace("Caching play count for [{track}]", track.DisplayString());
 
             var tasks = new Task[]
diff --git a/Selector.Cache/Consumer/Spotify/PublisherConsumer.cs b/Selector.Cache/Consumer/Spotify/PublisherConsumer.cs
index 55a640d..90f5ef5 100644
--- a/Selector.Cache/Consumer/Spotify/PublisherConsumer.cs
+++ b/Selector.Cache/Consumer/Spotify/PublisherConsumer.cs
@@ -4,6 +4,9 @@ using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
+using Selector.Extensions;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using StackExchange.Redis;
 
 namespace Selector.Cache
@@ -29,7 +32,7 @@ namespace Selector.Cache
             CancelToken = token;
         }
 
-        public void Callback(object sender, ListeningChangeEventArgs e)
+        public void Callback(object sender, SpotifyListeningChangeEventArgs e)
         {
             if (e.Current is null) return;
 
@@ -46,16 +49,18 @@ namespace Selector.Cache
             }, CancelToken);
         }
 
-        public async Task AsyncCallback(ListeningChangeEventArgs e)
+        public async Task AsyncCallback(SpotifyListeningChangeEventArgs e)
         {
             using var scope = Logger.GetListeningEventArgsScope(e);
 
-            var payload = JsonSerializer.Serialize((CurrentlyPlayingDTO)e, JsonContext.Default.CurrentlyPlayingDTO);
+            var payload =
+                JsonSerializer.Serialize((CurrentlyPlayingDTO)e, SpotifyJsonContext.Default.CurrentlyPlayingDTO);
 
             Logger.LogTrace("Publishing current");
 
             // TODO: currently using spotify username for cache key, use db username
-            var receivers = await Subscriber.PublishAsync(Key.CurrentlyPlayingSpotify(e.Id), payload);
+            var receivers =
+                await Subscriber.PublishAsync(RedisChannel.Literal(Key.CurrentlyPlayingSpotify(e.Id)), payload);
 
             Logger.LogDebug("Published current, {receivers} receivers", receivers);
         }
diff --git a/Selector.Cache/Extensions/ServiceExtensions.cs b/Selector.Cache/Extensions/ServiceExtensions.cs
index 8005d61..45cb0bd 100644
--- a/Selector.Cache/Extensions/ServiceExtensions.cs
+++ b/Selector.Cache/Extensions/ServiceExtensions.cs
@@ -1,8 +1,6 @@
 using System;
-using System.Collections.Generic;
-using System.Text;
 using Microsoft.Extensions.DependencyInjection;
-
+using Selector.Spotify.Consumer.Factory;
 using StackExchange.Redis;
 
 namespace Selector.Cache.Extensions
@@ -21,8 +19,10 @@ namespace Selector.Cache.Extensions
 
             var connMulti = ConnectionMultiplexer.Connect(connectionStr);
             services.AddSingleton(connMulti);
-            services.AddTransient<IDatabaseAsync>(services => services.GetService<ConnectionMultiplexer>().GetDatabase());
-            services.AddTransient<ISubscriber>(services => services.GetService<ConnectionMultiplexer>().GetSubscriber());
+            services.AddTransient<IDatabaseAsync>(
+                services => services.GetService<ConnectionMultiplexer>().GetDatabase());
+            services.AddTransient<ISubscriber>(services =>
+                services.GetService<ConnectionMultiplexer>().GetSubscriber());
 
             return services;
         }
@@ -56,4 +56,4 @@ namespace Selector.Cache.Extensions
             return services;
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Cache/Selector.Cache.csproj b/Selector.Cache/Selector.Cache.csproj
index 3a03083..186249e 100644
--- a/Selector.Cache/Selector.Cache.csproj
+++ b/Selector.Cache/Selector.Cache.csproj
@@ -14,6 +14,7 @@
 
   <ItemGroup>
     <ProjectReference Include="..\Selector.AppleMusic\Selector.AppleMusic.csproj"/>
+    <ProjectReference Include="..\Selector.Spotify\Selector.Spotify.csproj"/>
     <ProjectReference Include="..\Selector\Selector.csproj" />
   </ItemGroup>
 
diff --git a/Selector.Cache/Services/AudioFeaturePuller.cs b/Selector.Cache/Services/AudioFeaturePuller.cs
index 7d63f38..7ea75e1 100644
--- a/Selector.Cache/Services/AudioFeaturePuller.cs
+++ b/Selector.Cache/Services/AudioFeaturePuller.cs
@@ -1,6 +1,8 @@
 using System;
 using System.Text.Json;
 using System.Threading.Tasks;
+using Selector.Spotify;
+using Selector.Spotify.FactoryProvider;
 using SpotifyAPI.Web;
 using StackExchange.Redis;
 
@@ -22,12 +24,12 @@ namespace Selector.Cache
 
         public async Task<TrackAudioFeatures> Get(string refreshToken, string trackId)
         {
-            if(string.IsNullOrWhiteSpace(trackId)) throw new ArgumentNullException("No track Id provided");
+            if (string.IsNullOrWhiteSpace(trackId)) throw new ArgumentNullException("No track Id provided");
 
             var track = await Cache?.StringGetAsync(Key.AudioFeature(trackId));
             if (Cache is null || track == RedisValue.Null)
             {
-                if(!string.IsNullOrWhiteSpace(refreshToken) && !Magic.Dummy)
+                if (!string.IsNullOrWhiteSpace(refreshToken) && !Magic.Dummy)
                 {
                     var factory = await SpotifyFactory.GetFactory(refreshToken);
                     var spotifyClient = new SpotifyClient(await factory.GetConfig());
@@ -35,17 +37,16 @@ namespace Selector.Cache
                     // TODO: Error checking
                     return await spotifyClient.Tracks.GetAudioFeatures(trackId);
                 }
-                else 
+                else
                 {
                     return null;
                 }
             }
             else
             {
-                var deserialised = JsonSerializer.Deserialize(track, JsonContext.Default.TrackAudioFeatures);
+                var deserialised = JsonSerializer.Deserialize(track, SpotifyJsonContext.Default.TrackAudioFeatures);
                 return deserialised;
             }
         }
-
     }
 }
\ No newline at end of file
diff --git a/Selector.Cache/Services/DurationPuller.cs b/Selector.Cache/Services/DurationPuller.cs
index f670e7b..fd890ce 100644
--- a/Selector.Cache/Services/DurationPuller.cs
+++ b/Selector.Cache/Services/DurationPuller.cs
@@ -3,7 +3,6 @@ using System.Collections.Generic;
 using System.Linq;
 using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
-using Newtonsoft.Json.Linq;
 using SpotifyAPI.Web;
 using StackExchange.Redis;
 
@@ -21,7 +20,6 @@ namespace Selector.Cache
 
         public DurationPuller(
             ILogger<DurationPuller> logger,
-
             ITracksClient spotifyClient,
             IDatabaseAsync cache = null
         )
@@ -41,7 +39,8 @@ namespace Selector.Cache
             var cachedVal = await Cache?.HashGetAsync(Key.Track(trackId), Key.Duration);
             if (Cache is null || cachedVal == RedisValue.Null || cachedVal.IsNullOrEmpty)
             {
-                try {
+                try
+                {
                     Logger.LogDebug("Missed cache, pulling");
 
                     var info = await SpotifyClient.Get(trackId);
@@ -55,13 +54,14 @@ namespace Selector.Cache
                 catch (APIUnauthorizedException e)
                 {
                     Logger.LogError("Unauthorised error: [{message}] (should be refreshed and retried?)", e.Message);
-                    throw e;
+                    throw;
                 }
                 catch (APITooManyRequestsException e)
                 {
-                    if(_retries <= 3)
+                    if (_retries <= 3)
                     {
-                        Logger.LogWarning("Too many requests error, retrying ({}): [{message}]", e.RetryAfter, e.Message);
+                        Logger.LogWarning("Too many requests error, retrying ({}): [{message}]", e.RetryAfter,
+                            e.Message);
                         _retries++;
                         await Task.Delay(e.RetryAfter);
                         return await Get(uri);
@@ -69,7 +69,7 @@ namespace Selector.Cache
                     else
                     {
                         Logger.LogError("Too many requests error, done retrying: [{message}]", e.Message);
-                        throw e;
+                        throw;
                     }
                 }
                 catch (APIException e)
@@ -84,13 +84,13 @@ namespace Selector.Cache
                     else
                     {
                         Logger.LogError("API error, done retrying: [{message}]", e.Message);
-                        throw e;
+                        throw;
                     }
                 }
             }
             else
             {
-                return (int?) cachedVal;
+                return (int?)cachedVal;
             }
         }
 
@@ -111,17 +111,17 @@ namespace Selector.Cache
                 }
                 else
                 {
-                    ret[input] = (int) cachedVal;
+                    ret[input] = (int)cachedVal;
                 }
             }
 
             var retries = new List<string>();
 
-            foreach(var chunk in toPullFromSpotify.Chunk(50))
+            foreach (var chunk in toPullFromSpotify.Chunk(50))
             {
                 await PullChunk(chunk, ret);
                 await Task.Delay(TimeSpan.FromMilliseconds(500));
-            }            
+            }
 
             return ret;
         }
@@ -144,7 +144,7 @@ namespace Selector.Cache
             catch (APIUnauthorizedException e)
             {
                 Logger.LogError("Unauthorised error: [{message}] (should be refreshed and retried?)", e.Message);
-                throw e;
+                throw;
             }
             catch (APITooManyRequestsException e)
             {
@@ -159,7 +159,7 @@ namespace Selector.Cache
                 else
                 {
                     Logger.LogError("Too many requests error, done retrying: [{message}]", e.Message);
-                    throw e;
+                    throw;
                 }
             }
             catch (APIException e)
@@ -175,9 +175,9 @@ namespace Selector.Cache
                 else
                 {
                     Logger.LogError("API error, done retrying: [{message}]", e.Message);
-                    throw e;
+                    throw;
                 }
             }
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Cache/Services/PlayCountPuller.cs b/Selector.Cache/Services/PlayCountPuller.cs
index 7ab6873..83c1c79 100644
--- a/Selector.Cache/Services/PlayCountPuller.cs
+++ b/Selector.Cache/Services/PlayCountPuller.cs
@@ -1,13 +1,11 @@
 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 Microsoft.Extensions.Logging;
+using Selector.Spotify.Consumer;
 using StackExchange.Redis;
 
 namespace Selector.Cache
@@ -24,7 +22,6 @@ namespace Selector.Cache
 
         public PlayCountPuller(
             ILogger<PlayCountPuller> logger,
-
             ITrackApi trackClient,
             IAlbumApi albumClient,
             IArtistApi artistClient,
@@ -47,7 +44,7 @@ namespace Selector.Cache
 
             var trackCache = Cache?.StringGetAsync(Key.TrackPlayCount(username, track, artist));
             var albumCache = Cache?.StringGetAsync(Key.AlbumPlayCount(username, album, albumArtist));
-            var artistCache = Cache?.StringGetAsync(Key.ArtistPlayCount(username,  artist));
+            var artistCache = Cache?.StringGetAsync(Key.ArtistPlayCount(username, artist));
             var userCache = Cache?.StringGetAsync(Key.UserPlayCount(username));
 
             var cacheTasks = new Task[] { trackCache, albumCache, artistCache, userCache };
@@ -66,7 +63,7 @@ namespace Selector.Cache
 
             if (trackCache is not null && trackCache.IsCompletedSuccessfully && trackCache.Result != RedisValue.Null)
             {
-                playCount.Track = (int) trackCache.Result;
+                playCount.Track = (int)trackCache.Result;
             }
             else
             {
@@ -75,7 +72,7 @@ namespace Selector.Cache
 
             if (albumCache is not null && albumCache.IsCompletedSuccessfully && albumCache.Result != RedisValue.Null)
             {
-                playCount.Album = (int) albumCache.Result;
+                playCount.Album = (int)albumCache.Result;
             }
             else
             {
@@ -84,7 +81,7 @@ namespace Selector.Cache
 
             if (artistCache is not null && artistCache.IsCompletedSuccessfully && artistCache.Result != RedisValue.Null)
             {
-                playCount.Artist = (int) artistCache.Result;
+                playCount.Artist = (int)artistCache.Result;
             }
             else
             {
@@ -93,14 +90,14 @@ namespace Selector.Cache
 
             if (userCache is not null && userCache.IsCompletedSuccessfully && userCache.Result != RedisValue.Null)
             {
-                playCount.User = (int) userCache.Result;
+                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));
+            await Task.WhenAll(new Task[] { trackHttp, albumHttp, artistHttp, userHttp }.Where(t => t is not null));
 
             if (trackHttp is not null && trackHttp.IsCompletedSuccessfully)
             {
@@ -136,11 +133,12 @@ namespace Selector.Cache
                 }
                 else
                 {
-                    Logger.LogDebug("User info error [{username}] [{userHttp.Result.Status}]", username, userHttp.Result.Status);
+                    Logger.LogDebug("User info error [{username}] [{userHttp.Result.Status}]", username,
+                        userHttp.Result.Status);
                 }
             }
 
             return playCount;
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Event/CacheJsonContext.cs b/Selector.Event/CacheJsonContext.cs
index a254b71..aee6637 100644
--- a/Selector.Event/CacheJsonContext.cs
+++ b/Selector.Event/CacheJsonContext.cs
@@ -1,4 +1,5 @@
 using System.Text.Json.Serialization;
+using Selector.Spotify;
 
 namespace Selector.Events
 {
@@ -6,7 +7,7 @@ namespace Selector.Events
     [JsonSerializable(typeof(SpotifyLinkChange))]
     [JsonSerializable(typeof(AppleMusicLinkChange))]
     [JsonSerializable(typeof((string, CurrentlyPlayingDTO)))]
-    public partial class CacheJsonContext: JsonSerializerContext
+    public partial class CacheJsonContext : JsonSerializerContext
     {
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Event/CacheMappings/AppleMusicMapping.cs b/Selector.Event/CacheMappings/AppleMusicMapping.cs
index 337ed68..40ac743 100644
--- a/Selector.Event/CacheMappings/AppleMusicMapping.cs
+++ b/Selector.Event/CacheMappings/AppleMusicMapping.cs
@@ -1,9 +1,7 @@
 using System.Text.Json;
 using Microsoft.Extensions.Logging;
-
-using StackExchange.Redis;
-
 using Selector.Cache;
+using StackExchange.Redis;
 
 namespace Selector.Events
 {
@@ -35,18 +33,20 @@ namespace Selector.Events
             {
                 Logger.LogDebug("Forming Apple Music link event mapping FROM cache TO event bus");
 
-                (await Subscriber.SubscribeAsync(Key.AllUserAppleMusic)).OnMessage(message => {
-
+                (await Subscriber.SubscribeAsync(RedisChannel.Pattern(Key.AllUserAppleMusic))).OnMessage(message =>
+                {
                     try
                     {
                         var userId = Key.Param(message.Channel);
 
-                        var deserialised = JsonSerializer.Deserialize(message.Message, CacheJsonContext.Default.AppleMusicLinkChange);
+                        var deserialised = JsonSerializer.Deserialize(message.Message,
+                            CacheJsonContext.Default.AppleMusicLinkChange);
                         Logger.LogDebug("Received new Apple Music link event for [{userId}]", deserialised.UserId);
 
                         if (!userId.Equals(deserialised.UserId))
                         {
-                            Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId, deserialised.UserId);
+                            Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId,
+                                deserialised.UserId);
                         }
 
                         UserEvent.OnAppleMusicLinkChange(this, deserialised);
@@ -88,7 +88,7 @@ namespace Selector.Events
                 UserEvent.AppleLinkChange += async (o, e) =>
                 {
                     var payload = JsonSerializer.Serialize(e, CacheJsonContext.Default.AppleMusicLinkChange);
-                    await Subscriber.PublishAsync(Key.UserAppleMusic(e.UserId), payload);
+                    await Subscriber.PublishAsync(RedisChannel.Literal(Key.UserAppleMusic(e.UserId)), payload);
                 };
 
                 return Task.CompletedTask;
diff --git a/Selector.Event/CacheMappings/LastfmMapping.cs b/Selector.Event/CacheMappings/LastfmMapping.cs
index ff91b68..5ec8dcb 100644
--- a/Selector.Event/CacheMappings/LastfmMapping.cs
+++ b/Selector.Event/CacheMappings/LastfmMapping.cs
@@ -1,9 +1,7 @@
 using System.Text.Json;
 using Microsoft.Extensions.Logging;
-
-using StackExchange.Redis;
-
 using Selector.Cache;
+using StackExchange.Redis;
 
 namespace Selector.Events
 {
@@ -35,18 +33,20 @@ namespace Selector.Events
             {
                 Logger.LogDebug("Forming Last.fm username event mapping FROM cache TO event bus");
 
-                (await Subscriber.SubscribeAsync(Key.AllUserLastfm)).OnMessage(message => {
-
+                (await Subscriber.SubscribeAsync(RedisChannel.Pattern(Key.AllUserLastfm))).OnMessage(message =>
+                {
                     try
                     {
                         var userId = Key.Param(message.Channel);
 
-                        var deserialised = JsonSerializer.Deserialize(message.Message, CacheJsonContext.Default.LastfmChange);
+                        var deserialised =
+                            JsonSerializer.Deserialize(message.Message, CacheJsonContext.Default.LastfmChange);
                         Logger.LogDebug("Received new Last.fm username event for [{userId}]", deserialised.UserId);
 
                         if (!userId.Equals(deserialised.UserId))
                         {
-                            Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId, deserialised.UserId);
+                            Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId,
+                                deserialised.UserId);
                         }
 
                         UserEvent.OnLastfmCredChange(this, deserialised);
@@ -84,7 +84,7 @@ namespace Selector.Events
                 UserEvent.LastfmCredChange += async (o, e) =>
                 {
                     var payload = JsonSerializer.Serialize(e, CacheJsonContext.Default.LastfmChange);
-                    await Subscriber.PublishAsync(Key.UserLastfm(e.UserId), payload);
+                    await Subscriber.PublishAsync(RedisChannel.Literal(Key.UserLastfm(e.UserId)), payload);
                 };
 
                 return Task.CompletedTask;
diff --git a/Selector.Event/CacheMappings/NowPlayingMapping.cs b/Selector.Event/CacheMappings/NowPlayingMapping.cs
index 64271e6..0c9bb2a 100644
--- a/Selector.Event/CacheMappings/NowPlayingMapping.cs
+++ b/Selector.Event/CacheMappings/NowPlayingMapping.cs
@@ -2,6 +2,7 @@ using System.Text.Json;
 using Microsoft.Extensions.Logging;
 using Selector.AppleMusic;
 using Selector.Cache;
+using Selector.Spotify;
 using StackExchange.Redis;
 
 namespace Selector.Events
@@ -27,41 +28,45 @@ namespace Selector.Events
             {
                 Logger.LogDebug("Forming now playing event mapping between cache and event bus");
 
-                (await Subscriber.SubscribeAsync(Key.AllCurrentlyPlayingSpotify)).OnMessage(message =>
-                {
-                    try
+                (await Subscriber.SubscribeAsync(RedisChannel.Pattern(Key.AllCurrentlyPlayingSpotify))).OnMessage(
+                    message =>
                     {
-                        var userId = Key.Param(message.Channel);
+                        try
+                        {
+                            var userId = Key.Param(message.Channel);
 
-                        var deserialised =
-                            JsonSerializer.Deserialize(message.Message, JsonContext.Default.CurrentlyPlayingDTO);
-                        Logger.LogDebug("Received new Spotify currently playing [{username}]", deserialised.Username);
+                            var deserialised =
+                                JsonSerializer.Deserialize(message.Message,
+                                    SpotifyJsonContext.Default.CurrentlyPlayingDTO);
+                            Logger.LogDebug("Received new Spotify currently playing [{username}]",
+                                deserialised.Username);
 
-                        UserEvent.OnCurrentlyPlayingChangeSpotify(this, deserialised);
-                    }
-                    catch (Exception e)
+                            UserEvent.OnCurrentlyPlayingChangeSpotify(this, deserialised);
+                        }
+                        catch (Exception e)
+                        {
+                            Logger.LogError(e, "Error parsing new Spotify currently playing [{message}]", message);
+                        }
+                    });
+
+                (await Subscriber.SubscribeAsync(RedisChannel.Pattern(Key.AllCurrentlyPlayingApple))).OnMessage(
+                    message =>
                     {
-                        Logger.LogError(e, "Error parsing new Spotify currently playing [{message}]", message);
-                    }
-                });
+                        try
+                        {
+                            var userId = Key.Param(message.Channel);
 
-                (await Subscriber.SubscribeAsync(Key.AllCurrentlyPlayingApple)).OnMessage(message =>
-                {
-                    try
-                    {
-                        var userId = Key.Param(message.Channel);
+                            var deserialised = JsonSerializer.Deserialize(message.Message,
+                                AppleJsonContext.Default.AppleListeningChangeEventArgs);
+                            Logger.LogDebug("Received new Apple Music currently playing");
 
-                        var deserialised = JsonSerializer.Deserialize(message.Message,
-                            AppleJsonContext.Default.AppleListeningChangeEventArgs);
-                        Logger.LogDebug("Received new Apple Music currently playing");
-
-                        UserEvent.OnCurrentlyPlayingChangeApple(this, deserialised);
-                    }
-                    catch (Exception e)
-                    {
-                        Logger.LogError(e, "Error parsing new Apple Music currently playing [{message}]", message);
-                    }
-                });
+                            UserEvent.OnCurrentlyPlayingChangeApple(this, deserialised);
+                        }
+                        catch (Exception e)
+                        {
+                            Logger.LogError(e, "Error parsing new Apple Music currently playing [{message}]", message);
+                        }
+                    });
             }
         }
     }
@@ -89,14 +94,14 @@ namespace Selector.Events
 
                 UserEvent.CurrentlyPlayingSpotify += async (o, e) =>
                 {
-                    var payload = JsonSerializer.Serialize(e, JsonContext.Default.CurrentlyPlayingDTO);
-                    await Subscriber.PublishAsync(Key.CurrentlyPlayingSpotify(e.UserId), payload);
+                    var payload = JsonSerializer.Serialize(e, SpotifyJsonContext.Default.CurrentlyPlayingDTO);
+                    await Subscriber.PublishAsync(RedisChannel.Literal(Key.CurrentlyPlayingSpotify(e.UserId)), payload);
                 };
 
                 UserEvent.CurrentlyPlayingApple += async (o, e) =>
                 {
                     var payload = JsonSerializer.Serialize(e, AppleJsonContext.Default.AppleListeningChangeEventArgs);
-                    await Subscriber.PublishAsync(Key.CurrentlyPlayingAppleMusic(e.Id), payload);
+                    await Subscriber.PublishAsync(RedisChannel.Literal(Key.CurrentlyPlayingAppleMusic(e.Id)), payload);
                 };
 
                 return Task.CompletedTask;
diff --git a/Selector.Event/CacheMappings/SpotifyMapping.cs b/Selector.Event/CacheMappings/SpotifyMapping.cs
index 88f067e..0b08c33 100644
--- a/Selector.Event/CacheMappings/SpotifyMapping.cs
+++ b/Selector.Event/CacheMappings/SpotifyMapping.cs
@@ -1,9 +1,7 @@
 using System.Text.Json;
 using Microsoft.Extensions.Logging;
-
-using StackExchange.Redis;
-
 using Selector.Cache;
+using StackExchange.Redis;
 
 namespace Selector.Events
 {
@@ -35,18 +33,20 @@ namespace Selector.Events
             {
                 Logger.LogDebug("Forming Spotify link event mapping FROM cache TO event bus");
 
-                (await Subscriber.SubscribeAsync(Key.AllUserSpotify)).OnMessage(message => {
-
+                (await Subscriber.SubscribeAsync(RedisChannel.Pattern(Key.AllUserSpotify))).OnMessage(message =>
+                {
                     try
                     {
                         var userId = Key.Param(message.Channel);
 
-                        var deserialised = JsonSerializer.Deserialize(message.Message, CacheJsonContext.Default.SpotifyLinkChange);
+                        var deserialised = JsonSerializer.Deserialize(message.Message,
+                            CacheJsonContext.Default.SpotifyLinkChange);
                         Logger.LogDebug("Received new Spotify link event for [{userId}]", deserialised.UserId);
 
                         if (!userId.Equals(deserialised.UserId))
                         {
-                            Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId, deserialised.UserId);
+                            Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId,
+                                deserialised.UserId);
                         }
 
                         UserEvent.OnSpotifyLinkChange(this, deserialised);
@@ -88,7 +88,7 @@ namespace Selector.Events
                 UserEvent.SpotifyLinkChange += async (o, e) =>
                 {
                     var payload = JsonSerializer.Serialize(e, CacheJsonContext.Default.SpotifyLinkChange);
-                    await Subscriber.PublishAsync(Key.UserSpotify(e.UserId), payload);
+                    await Subscriber.PublishAsync(RedisChannel.Literal(Key.UserSpotify(e.UserId)), payload);
                 };
 
                 return Task.CompletedTask;
diff --git a/Selector.Event/Consumers/AppleUserEventFirer.cs b/Selector.Event/Consumers/AppleUserEventFirer.cs
index 4c861a0..470a33f 100644
--- a/Selector.Event/Consumers/AppleUserEventFirer.cs
+++ b/Selector.Event/Consumers/AppleUserEventFirer.cs
@@ -1,7 +1,7 @@
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
 using Selector.AppleMusic;
-using Selector.AppleMusic.Watcher.Consumer;
+using Selector.AppleMusic.Consumer;
 
 namespace Selector.Events
 {
diff --git a/Selector.Event/Consumers/SpotifyUserEventFirer.cs b/Selector.Event/Consumers/SpotifyUserEventFirer.cs
index 3cbf858..06e3755 100644
--- a/Selector.Event/Consumers/SpotifyUserEventFirer.cs
+++ b/Selector.Event/Consumers/SpotifyUserEventFirer.cs
@@ -1,5 +1,7 @@
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 
 namespace Selector.Events
 {
@@ -25,7 +27,7 @@ namespace Selector.Events
             CancelToken = token;
         }
 
-        public void Callback(object sender, ListeningChangeEventArgs e)
+        public void Callback(object sender, SpotifyListeningChangeEventArgs e)
         {
             if (e.Current is null) return;
 
@@ -42,7 +44,7 @@ namespace Selector.Events
             }, CancelToken);
         }
 
-        public Task AsyncCallback(ListeningChangeEventArgs e)
+        public Task AsyncCallback(SpotifyListeningChangeEventArgs e)
         {
             Logger.LogDebug("Firing Spotify now playing event on user bus [{username}/{userId}]", e.SpotifyUsername,
                 e.Id);
diff --git a/Selector.Event/Consumers/UserEventFirerFactory.cs b/Selector.Event/Consumers/UserEventFirerFactory.cs
index d2d6b5f..6c8fc91 100644
--- a/Selector.Event/Consumers/UserEventFirerFactory.cs
+++ b/Selector.Event/Consumers/UserEventFirerFactory.cs
@@ -1,4 +1,5 @@
 using Microsoft.Extensions.Logging;
+using Selector.Spotify;
 
 namespace Selector.Events
 {
diff --git a/Selector.Event/UserEventBus.cs b/Selector.Event/UserEventBus.cs
index 711168f..af47554 100644
--- a/Selector.Event/UserEventBus.cs
+++ b/Selector.Event/UserEventBus.cs
@@ -1,6 +1,7 @@
 using Microsoft.Extensions.Logging;
 using Selector.AppleMusic;
 using Selector.Model;
+using Selector.Spotify;
 
 namespace Selector.Events
 {
diff --git a/Selector.LastFm/Extensions/ServiceExtensions.cs b/Selector.LastFm/Extensions/ServiceExtensions.cs
new file mode 100644
index 0000000..0358b4b
--- /dev/null
+++ b/Selector.LastFm/Extensions/ServiceExtensions.cs
@@ -0,0 +1,26 @@
+using IF.Lastfm.Core.Api;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Selector.Extensions;
+
+public static class ServiceExtensions
+{
+    public static IServiceCollection AddLastFm(this IServiceCollection services, string client, string secret)
+    {
+        var lastAuth = new LastAuth(client, secret);
+        services.AddSingleton(lastAuth);
+        services.AddTransient(sp => new LastfmClient(sp.GetService<LastAuth>()));
+
+        services.AddTransient<ITrackApi>(sp => sp.GetService<LastfmClient>().Track);
+        services.AddTransient<IAlbumApi>(sp => sp.GetService<LastfmClient>().Album);
+        services.AddTransient<IArtistApi>(sp => sp.GetService<LastfmClient>().Artist);
+
+        services.AddTransient<IUserApi>(sp => sp.GetService<LastfmClient>().User);
+
+        services.AddTransient<IChartApi>(sp => sp.GetService<LastfmClient>().Chart);
+        services.AddTransient<ILibraryApi>(sp => sp.GetService<LastfmClient>().Library);
+        services.AddTransient<ITagApi>(sp => sp.GetService<LastfmClient>().Tag);
+
+        return services;
+    }
+}
\ No newline at end of file
diff --git a/Selector/Scrobble/Mapping/ScrobbleAlbumMapping.cs b/Selector.LastFm/Mapping/ScrobbleAlbumMapping.cs
similarity index 85%
rename from Selector/Scrobble/Mapping/ScrobbleAlbumMapping.cs
rename to Selector.LastFm/Mapping/ScrobbleAlbumMapping.cs
index 232cf94..a1578a2 100644
--- a/Selector/Scrobble/Mapping/ScrobbleAlbumMapping.cs
+++ b/Selector.LastFm/Mapping/ScrobbleAlbumMapping.cs
@@ -1,10 +1,7 @@
 using Microsoft.Extensions.Logging;
 using SpotifyAPI.Web;
-using System;
-using System.Linq;
-using System.Threading.Tasks;
 
-namespace Selector
+namespace Selector.Mapping
 {
     /// <inheritdoc/>
     public class ScrobbleAlbumMapping : ScrobbleMapping
@@ -12,7 +9,8 @@ namespace Selector
         public string AlbumName { get; set; }
         public string ArtistName { get; set; }
 
-        public ScrobbleAlbumMapping(ISearchClient _searchClient, ILogger<ScrobbleAlbumMapping> _logger, string albumName, string artistName) : base(_searchClient, _logger)
+        public ScrobbleAlbumMapping(ISearchClient _searchClient, ILogger<ScrobbleAlbumMapping> _logger,
+            string albumName, string artistName) : base(_searchClient, _logger)
         {
             AlbumName = albumName;
             ArtistName = artistName;
@@ -38,4 +36,4 @@ namespace Selector
             }
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Scrobble/Mapping/ScrobbleArtistMapping.cs b/Selector.LastFm/Mapping/ScrobbleArtistMapping.cs
similarity index 80%
rename from Selector/Scrobble/Mapping/ScrobbleArtistMapping.cs
rename to Selector.LastFm/Mapping/ScrobbleArtistMapping.cs
index 4b8c7d5..54f62b6 100644
--- a/Selector/Scrobble/Mapping/ScrobbleArtistMapping.cs
+++ b/Selector.LastFm/Mapping/ScrobbleArtistMapping.cs
@@ -1,19 +1,15 @@
 using Microsoft.Extensions.Logging;
 using SpotifyAPI.Web;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
 
-namespace Selector
+namespace Selector.Mapping
 {
     /// <inheritdoc/>
     public class ScrobbleArtistMapping : ScrobbleMapping
     {
         public string ArtistName { get; set; }
 
-        public ScrobbleArtistMapping(ISearchClient _searchClient, ILogger<ScrobbleArtistMapping> _logger, string artistName) : base(_searchClient, _logger)
+        public ScrobbleArtistMapping(ISearchClient _searchClient, ILogger<ScrobbleArtistMapping> _logger,
+            string artistName) : base(_searchClient, _logger)
         {
             ArtistName = artistName;
         }
@@ -37,4 +33,4 @@ namespace Selector
             }
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Scrobble/Mapping/ScrobbleMapping.cs b/Selector.LastFm/Mapping/ScrobbleMapping.cs
similarity index 90%
rename from Selector/Scrobble/Mapping/ScrobbleMapping.cs
rename to Selector.LastFm/Mapping/ScrobbleMapping.cs
index c6c4981..9f452cf 100644
--- a/Selector/Scrobble/Mapping/ScrobbleMapping.cs
+++ b/Selector.LastFm/Mapping/ScrobbleMapping.cs
@@ -1,14 +1,15 @@
-using Microsoft.Extensions.Logging;
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
 using Selector.Operations;
 using SpotifyAPI.Web;
-using System;
-using System.Diagnostics;
-using System.Threading.Tasks;
 
-namespace Selector
+namespace Selector.Mapping
 {
-    public enum LastfmObject{
-        Track, Album, Artist
+    public enum LastfmObject
+    {
+        Track,
+        Album,
+        Artist
     }
 
     /// <summary>
@@ -45,7 +46,7 @@ namespace Selector
             logger.LogInformation("Mapping Last.fm {} ({}) to Spotify", Query, QueryType);
 
             var netTime = Stopwatch.StartNew();
-            currentTask = searchClient.Item(new (QueryType, Query));
+            currentTask = searchClient.Item(new(QueryType, Query));
             currentTask.ContinueWith(async t =>
             {
                 try
@@ -76,7 +77,8 @@ namespace Selector
                 }
                 catch (Exception e)
                 {
-                    logger.LogError(e, "Error while mapping Last.fm {} ({}) to Spotify on attempt {}", Query, QueryType, Attempts);
+                    logger.LogError(e, "Error while mapping Last.fm {} ({}) to Spotify on attempt {}", Query, QueryType,
+                        Attempts);
                     Succeeded = false;
                 }
             });
@@ -93,4 +95,4 @@ namespace Selector
             Success?.Invoke(this, new EventArgs());
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Scrobble/Mapping/ScrobbleTrackMapping.cs b/Selector.LastFm/Mapping/ScrobbleTrackMapping.cs
similarity index 77%
rename from Selector/Scrobble/Mapping/ScrobbleTrackMapping.cs
rename to Selector.LastFm/Mapping/ScrobbleTrackMapping.cs
index ce2f0be..6ad5d4e 100644
--- a/Selector/Scrobble/Mapping/ScrobbleTrackMapping.cs
+++ b/Selector.LastFm/Mapping/ScrobbleTrackMapping.cs
@@ -1,12 +1,7 @@
 using Microsoft.Extensions.Logging;
 using SpotifyAPI.Web;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
 
-namespace Selector
+namespace Selector.Mapping
 {
     /// <inheritdoc/>
     public class ScrobbleTrackMapping : ScrobbleMapping
@@ -14,7 +9,8 @@ namespace Selector
         public string TrackName { get; set; }
         public string ArtistName { get; set; }
 
-        public ScrobbleTrackMapping(ISearchClient _searchClient, ILogger<ScrobbleTrackMapping> _logger, string trackName, string artistName) : base(_searchClient, _logger)
+        public ScrobbleTrackMapping(ISearchClient _searchClient, ILogger<ScrobbleTrackMapping> _logger,
+            string trackName, string artistName) : base(_searchClient, _logger)
         {
             TrackName = trackName;
             ArtistName = artistName;
@@ -32,12 +28,12 @@ namespace Selector
         {
             var topResult = response.Result.Tracks.Items.FirstOrDefault();
 
-            if(topResult is not null 
-                && topResult.Name.Equals(TrackName, StringComparison.InvariantCultureIgnoreCase) 
+            if (topResult is not null
+                && topResult.Name.Equals(TrackName, StringComparison.InvariantCultureIgnoreCase)
                 && topResult.Artists.First().Name.Equals(ArtistName, StringComparison.InvariantCultureIgnoreCase))
             {
                 result = topResult;
             }
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Scrobble/Scrobble.cs b/Selector.LastFm/Scrobble.cs
similarity index 62%
rename from Selector/Scrobble/Scrobble.cs
rename to Selector.LastFm/Scrobble.cs
index fa4542b..1ef1c58 100644
--- a/Selector/Scrobble/Scrobble.cs
+++ b/Selector.LastFm/Scrobble.cs
@@ -1,19 +1,21 @@
-using System;
+using IF.Lastfm.Core.Objects;
 
 namespace Selector
 {
-    public class Scrobble: IListen
+    public class Scrobble : IListen
     {
         public DateTime Timestamp { get; set; }
-        public string TrackName { get; set; }
-        public string AlbumName { get; set; }
+        public string? TrackName { get; set; }
+        public string? AlbumName { get; set; }
+
         /// <summary>
         /// Not populated by default from the service, where not the same as <see cref="ArtistName"/> these have been manually entered
         /// </summary>
-        public string AlbumArtistName { get; set; }
-        public string ArtistName { get; set; }
-        
-        public static explicit operator Scrobble(IF.Lastfm.Core.Objects.LastTrack track) => new()
+        public string? AlbumArtistName { get; set; }
+
+        public string? ArtistName { get; set; }
+
+        public static explicit operator Scrobble(LastTrack track) => new()
         {
             Timestamp = track.TimePlayed?.UtcDateTime ?? DateTime.MinValue,
 
@@ -24,4 +26,4 @@ namespace Selector
 
         public override string ToString() => $"({Timestamp}) {TrackName}, {AlbumName}, {ArtistName}";
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Scrobble/ScrobbleMatcher.cs b/Selector.LastFm/ScrobbleMatcher.cs
similarity index 92%
rename from Selector/Scrobble/ScrobbleMatcher.cs
rename to Selector.LastFm/ScrobbleMatcher.cs
index 05afc4a..aa858b4 100644
--- a/Selector/Scrobble/ScrobbleMatcher.cs
+++ b/Selector.LastFm/ScrobbleMatcher.cs
@@ -1,7 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
+using System.Diagnostics.CodeAnalysis;
 
 namespace Selector
 {
@@ -13,7 +10,8 @@ namespace Selector
         public static bool MatchTime(IListen nativeScrobble, IListen serviceScrobble)
             => serviceScrobble.Timestamp.Equals(nativeScrobble.Timestamp);
 
-        public static (IEnumerable<IListen>, IEnumerable<IListen>) IdentifyDiffs(IEnumerable<IListen> existing, IEnumerable<IListen> toApply, bool matchContents = true)
+        public static (IEnumerable<IListen>, IEnumerable<IListen>) IdentifyDiffs(IEnumerable<IListen> existing,
+            IEnumerable<IListen> toApply, bool matchContents = true)
         {
             existing = existing.OrderBy(s => s.Timestamp);
             toApply = toApply.OrderBy(s => s.Timestamp);
@@ -96,7 +94,8 @@ namespace Selector
             }
         }
 
-        public static (IEnumerable<IListen>, IEnumerable<IListen>) IdentifyDiffsContains(IEnumerable<IListen> existing, IEnumerable<IListen> toApply)
+        public static (IEnumerable<IListen>, IEnumerable<IListen>) IdentifyDiffsContains(IEnumerable<IListen> existing,
+            IEnumerable<IListen> toApply)
         {
             var toAdd = toApply.Where(s => !existing.Contains(s, new ListenComp()));
             var toRemove = existing.Where(s => !toApply.Contains(s, new ListenComp()));
@@ -111,4 +110,4 @@ namespace Selector
             public int GetHashCode([DisallowNull] IListen obj) => obj.Timestamp.GetHashCode();
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Scrobble/ScrobbleRequest.cs b/Selector.LastFm/ScrobbleRequest.cs
similarity index 78%
rename from Selector/Scrobble/ScrobbleRequest.cs
rename to Selector.LastFm/ScrobbleRequest.cs
index 0884df1..90aec08 100644
--- a/Selector/Scrobble/ScrobbleRequest.cs
+++ b/Selector.LastFm/ScrobbleRequest.cs
@@ -1,13 +1,9 @@
-using IF.Lastfm.Core.Api;
+using System.Diagnostics;
+using IF.Lastfm.Core.Api;
 using IF.Lastfm.Core.Api.Helpers;
 using IF.Lastfm.Core.Objects;
 using Microsoft.Extensions.Logging;
 using Selector.Operations;
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Linq;
-using System.Threading.Tasks;
 
 namespace Selector
 {
@@ -34,7 +30,8 @@ namespace Selector
         private TaskCompletionSource AggregateTaskSource { get; set; } = new();
         public Task Task => AggregateTaskSource.Task;
 
-        public ScrobbleRequest(IUserApi _userClient, ILogger<ScrobbleRequest> _logger, string _username, int _pageNumber, int _pageSize, DateTime? _from, DateTime? _to, int maxRetries = 5)
+        public ScrobbleRequest(IUserApi _userClient, ILogger<ScrobbleRequest> _logger, string _username,
+            int _pageNumber, int _pageSize, DateTime? _from, DateTime? _to, int maxRetries = 5)
         {
             userClient = _userClient;
             logger = _logger;
@@ -50,15 +47,24 @@ namespace Selector
 
         public Task Execute()
         {
-            using var scope = logger.BeginScope(new Dictionary<string, object>() { { "username", username }, { "page_number", pageNumber }, { "page_size", pageSize }, { "from", from }, { "to", to } });
+            using var scope = logger.BeginScope(new Dictionary<string, object>()
+            {
+                { "username", username }, { "page_number", pageNumber }, { "page_size", pageSize }, { "from", from },
+                { "to", to }
+            });
 
             logger.LogInformation("Starting request");
 
             var netTime = Stopwatch.StartNew();
-            currentTask = userClient.GetRecentScrobbles(username, pagenumber: pageNumber, count: pageSize, from: from, to: to);
+            currentTask =
+                userClient.GetRecentScrobbles(username, pagenumber: pageNumber, count: pageSize, from: from, to: to);
             currentTask.ContinueWith(async t =>
             {
-                using var scope = logger.BeginScope(new Dictionary<string, object>() { { "username", username }, { "page_number", pageNumber }, { "page_size", pageSize }, { "from", from }, { "to", to } });
+                using var scope = logger.BeginScope(new Dictionary<string, object>()
+                {
+                    { "username", username }, { "page_number", pageNumber }, { "page_size", pageSize },
+                    { "from", from }, { "to", to }
+                });
 
                 try
                 {
@@ -81,12 +87,14 @@ namespace Selector
                         {
                             if (Attempts < MaxAttempts)
                             {
-                                logger.LogDebug("Request failed: {}, retrying ({} of {})", result.Status, Attempts + 1, MaxAttempts);
+                                logger.LogDebug("Request failed: {}, retrying ({} of {})", result.Status, Attempts + 1,
+                                    MaxAttempts);
                                 await Execute();
                             }
                             else
                             {
-                                logger.LogDebug("Request failed: {}, max retries exceeded {}, not retrying", result.Status, MaxAttempts);
+                                logger.LogDebug("Request failed: {}, max retries exceeded {}, not retrying",
+                                    result.Status, MaxAttempts);
                                 AggregateTaskSource.SetCanceled();
                             }
                         }
@@ -97,7 +105,7 @@ namespace Selector
                         AggregateTaskSource.SetException(t.Exception);
                     }
                 }
-                catch(Exception e)
+                catch (Exception e)
                 {
                     logger.LogError(e, "Error while making scrobble request on attempt {}", Attempts);
                     Succeeded = false;
@@ -113,4 +121,4 @@ namespace Selector
             Success?.Invoke(this, new EventArgs());
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.LastFm/Selector.LastFm.csproj b/Selector.LastFm/Selector.LastFm.csproj
new file mode 100644
index 0000000..9b2f3de
--- /dev/null
+++ b/Selector.LastFm/Selector.LastFm.csproj
@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net9.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0"/>
+        <PackageReference Include="SpotifyAPI.Web" Version="7.2.1"/>
+        <PackageReference Include="Inflatable.Lastfm" Version="1.2.0"/>
+        <PackageReference Include="System.Linq.Async" Version="6.0.1"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <ProjectReference Include="..\Selector\Selector.csproj"/>
+    </ItemGroup>
+
+</Project>
diff --git a/Selector.MAUI/App.xaml.cs b/Selector.MAUI/App.xaml.cs
index ff7334e..6265009 100644
--- a/Selector.MAUI/App.xaml.cs
+++ b/Selector.MAUI/App.xaml.cs
@@ -10,17 +10,16 @@ public partial class App : Application
     private readonly ILogger<App> logger;
 
     public App(NowHubClient nowClient, ILogger<App> logger)
-	{
-		InitializeComponent();
+    {
+        InitializeComponent();
 
-		MainPage = new MainPage();
         this.nowClient = nowClient;
         this.logger = logger;
     }
 
     protected override Window CreateWindow(IActivationState activationState)
     {
-        Window window = base.CreateWindow(activationState);
+        Window window = new Window(new MainPage());
 
         window.Resumed += async (s, e) =>
         {
@@ -36,16 +35,13 @@ public partial class App : Application
                 await nowClient.OnConnected();
 
                 logger.LogInformation("Hubs reconnected");
-
             }
-            catch(Exception ex)
+            catch (Exception ex)
             {
                 logger.LogError(ex, "Error while reconnecting hubs");
             }
-            
         };
 
         return window;
     }
-}
-
+}
\ No newline at end of file
diff --git a/Selector.MAUI/Selector.MAUI.csproj b/Selector.MAUI/Selector.MAUI.csproj
index fee0cc9..19d36fe 100644
--- a/Selector.MAUI/Selector.MAUI.csproj
+++ b/Selector.MAUI/Selector.MAUI.csproj
@@ -85,6 +85,8 @@
     </ItemGroup>
 
     <ItemGroup>
+        <PackageReference Include="Microsoft.Maui.Controls" Version="9.0.14"/>
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="9.0.14"/>
         <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.0" />
         <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
         <PackageReference Include="System.Net.Http.Json" Version="9.0.0" />
diff --git a/Selector.MAUI/Shared/PlayCountCard.razor b/Selector.MAUI/Shared/PlayCountCard.razor
index 162c867..6d8fffc 100644
--- a/Selector.MAUI/Shared/PlayCountCard.razor
+++ b/Selector.MAUI/Shared/PlayCountCard.razor
@@ -1,5 +1,5 @@
-@using SpotifyAPI.Web;
-
+@using Selector.Spotify.Consumer
+@using SpotifyAPI.Web
 @if (Count is not null) {
 
     <div class="card info-card">
diff --git a/Selector.Model/ApplicationDbContext.cs b/Selector.Model/ApplicationDbContext.cs
index 94b2f21..ae7b162 100644
--- a/Selector.Model/ApplicationDbContext.cs
+++ b/Selector.Model/ApplicationDbContext.cs
@@ -20,6 +20,7 @@ namespace Selector.Model
         public DbSet<ArtistLastfmSpotifyMapping> ArtistMapping { get; set; }
 
         public DbSet<SpotifyListen> SpotifyListen { get; set; }
+        public DbSet<AppleMusicListen> AppleMusicListen { get; set; }
 
         public ApplicationDbContext(
             DbContextOptions<ApplicationDbContext> options,
@@ -102,6 +103,17 @@ namespace Selector.Model
             //modelBuilder.Entity<SpotifyListen>()
             //    .HasIndex(x => new { x.UserId, x.ArtistName, x.TrackName });
 
+            modelBuilder.Entity<AppleMusicListen>().HasKey(s => s.Id);
+            modelBuilder.Entity<AppleMusicListen>()
+                .Property(s => s.TrackName)
+                .UseCollation("case_insensitive");
+            modelBuilder.Entity<AppleMusicListen>()
+                .Property(s => s.AlbumName)
+                .UseCollation("case_insensitive");
+            modelBuilder.Entity<AppleMusicListen>()
+                .Property(s => s.ArtistName)
+                .UseCollation("case_insensitive");
+
             SeedData.Seed(modelBuilder);
         }
 
diff --git a/Selector.Model/Extensions/ServiceExtensions.cs b/Selector.Model/Extensions/ServiceExtensions.cs
index e564136..1ab8212 100644
--- a/Selector.Model/Extensions/ServiceExtensions.cs
+++ b/Selector.Model/Extensions/ServiceExtensions.cs
@@ -2,11 +2,20 @@
 using Microsoft.Extensions.DependencyInjection;
 using Selector.Cache;
 using Selector.Model.Authorisation;
+using Selector.Spotify;
+using Selector.Spotify.Watcher;
 
 namespace Selector.Model.Extensions
 {
     public static class ServiceExtensions
     {
+        public static IServiceCollection AddSpotifyWatcher(this IServiceCollection services)
+        {
+            services.AddSingleton<ISpotifyWatcherFactory, SpotifyWatcherFactory>();
+
+            return services;
+        }
+
         public static void AddAuthorisationHandlers(this IServiceCollection services)
         {
             services.AddScoped<IAuthorizationHandler, WatcherIsOwnerAuthHandler>();
@@ -23,4 +32,4 @@ namespace Selector.Model.Extensions
             return services;
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Model/Listen/AppleMusicListen.cs b/Selector.Model/Listen/AppleMusicListen.cs
new file mode 100644
index 0000000..41f9b7c
--- /dev/null
+++ b/Selector.Model/Listen/AppleMusicListen.cs
@@ -0,0 +1,26 @@
+using Selector.AppleMusic.Watcher;
+
+namespace Selector.Model;
+
+public class AppleMusicListen : Listen, IUserListen
+{
+    public int Id { get; set; }
+
+    public string TrackId { get; set; }
+    public string Isrc { get; set; }
+
+    public string UserId { get; set; }
+    public ApplicationUser User { get; set; }
+
+    public static explicit operator AppleMusicListen(AppleMusicCurrentlyPlayingContext track) => new()
+    {
+        Timestamp = track.FirstSeen,
+
+        TrackId = track.Track.Id,
+        Isrc = track.Track.Attributes.Isrc,
+
+        TrackName = track.Track.Attributes.Name,
+        AlbumName = track.Track.Attributes.AlbumName,
+        ArtistName = track.Track.Attributes.ArtistName,
+    };
+}
\ No newline at end of file
diff --git a/Selector.Model/Migrations/20250331203003_add_apple_listen.Designer.cs b/Selector.Model/Migrations/20250331203003_add_apple_listen.Designer.cs
new file mode 100644
index 0000000..c100829
--- /dev/null
+++ b/Selector.Model/Migrations/20250331203003_add_apple_listen.Designer.cs
@@ -0,0 +1,548 @@
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Selector.Model;
+
+#nullable disable
+
+namespace Selector.Model.Migrations
+{
+    [DbContext(typeof(ApplicationDbContext))]
+    [Migration("20250331203003_add_apple_listen")]
+    partial class add_apple_listen
+    {
+        /// <inheritdoc />
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder
+                .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "en-u-ks-primary,en-u-ks-primary,icu,False")
+                .HasAnnotation("ProductVersion", "9.0.0")
+                .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+            modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+                {
+                    b.Property<string>("Id")
+                        .HasColumnType("text");
+
+                    b.Property<string>("ConcurrencyStamp")
+                        .IsConcurrencyToken()
+                        .HasColumnType("text");
+
+                    b.Property<string>("Name")
+                        .HasMaxLength(256)
+                        .HasColumnType("character varying(256)");
+
+                    b.Property<string>("NormalizedName")
+                        .HasMaxLength(256)
+                        .HasColumnType("character varying(256)");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("NormalizedName")
+                        .IsUnique()
+                        .HasDatabaseName("RoleNameIndex");
+
+                    b.ToTable("AspNetRoles", (string)null);
+
+                    b.HasData(
+                        new
+                        {
+                            Id = "00c64c0a-3387-4933-9575-83443fa9092b",
+                            Name = "Admin",
+                            NormalizedName = "ADMIN"
+                        });
+                });
+
+            modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("integer");
+
+                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+                    b.Property<string>("ClaimType")
+                        .HasColumnType("text");
+
+                    b.Property<string>("ClaimValue")
+                        .HasColumnType("text");
+
+                    b.Property<string>("RoleId")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("RoleId");
+
+                    b.ToTable("AspNetRoleClaims", (string)null);
+                });
+
+            modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("integer");
+
+                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+                    b.Property<string>("ClaimType")
+                        .HasColumnType("text");
+
+                    b.Property<string>("ClaimValue")
+                        .HasColumnType("text");
+
+                    b.Property<string>("UserId")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("AspNetUserClaims", (string)null);
+                });
+
+            modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
+                {
+                    b.Property<string>("LoginProvider")
+                        .HasColumnType("text");
+
+                    b.Property<string>("ProviderKey")
+                        .HasColumnType("text");
+
+                    b.Property<string>("ProviderDisplayName")
+                        .HasColumnType("text");
+
+                    b.Property<string>("UserId")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.HasKey("LoginProvider", "ProviderKey");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("AspNetUserLogins", (string)null);
+                });
+
+            modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
+                {
+                    b.Property<string>("UserId")
+                        .HasColumnType("text");
+
+                    b.Property<string>("RoleId")
+                        .HasColumnType("text");
+
+                    b.HasKey("UserId", "RoleId");
+
+                    b.HasIndex("RoleId");
+
+                    b.ToTable("AspNetUserRoles", (string)null);
+                });
+
+            modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
+                {
+                    b.Property<string>("UserId")
+                        .HasColumnType("text");
+
+                    b.Property<string>("LoginProvider")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Name")
+                        .HasColumnType("text");
+
+                    b.Property<string>("Value")
+                        .HasColumnType("text");
+
+                    b.HasKey("UserId", "LoginProvider", "Name");
+
+                    b.ToTable("AspNetUserTokens", (string)null);
+                });
+
+            modelBuilder.Entity("Selector.Model.AlbumLastfmSpotifyMapping", b =>
+                {
+                    b.Property<string>("SpotifyUri")
+                        .HasColumnType("text");
+
+                    b.Property<string>("LastfmAlbumName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("LastfmArtistName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.HasKey("SpotifyUri");
+
+                    b.ToTable("AlbumMapping");
+                });
+
+            modelBuilder.Entity("Selector.Model.AppleMusicListen", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("integer");
+
+                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+                    b.Property<string>("AlbumName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("ArtistName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("Isrc")
+                        .HasColumnType("text");
+
+                    b.Property<DateTime>("Timestamp")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("TrackId")
+                        .HasColumnType("text");
+
+                    b.Property<string>("TrackName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("UserId")
+                        .HasColumnType("text");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("AppleMusicListen");
+                });
+
+            modelBuilder.Entity("Selector.Model.ApplicationUser", b =>
+                {
+                    b.Property<string>("Id")
+                        .HasColumnType("text");
+
+                    b.Property<int>("AccessFailedCount")
+                        .HasColumnType("integer");
+
+                    b.Property<string>("AppleMusicKey")
+                        .HasColumnType("text");
+
+                    b.Property<DateTime>("AppleMusicLastRefresh")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<bool>("AppleMusicLinked")
+                        .HasColumnType("boolean");
+
+                    b.Property<string>("ConcurrencyStamp")
+                        .IsConcurrencyToken()
+                        .HasColumnType("text");
+
+                    b.Property<string>("Email")
+                        .HasMaxLength(256)
+                        .HasColumnType("character varying(256)");
+
+                    b.Property<bool>("EmailConfirmed")
+                        .HasColumnType("boolean");
+
+                    b.Property<string>("LastFmUsername")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<bool>("LockoutEnabled")
+                        .HasColumnType("boolean");
+
+                    b.Property<DateTimeOffset?>("LockoutEnd")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("NormalizedEmail")
+                        .HasMaxLength(256)
+                        .HasColumnType("character varying(256)");
+
+                    b.Property<string>("NormalizedUserName")
+                        .HasMaxLength(256)
+                        .HasColumnType("character varying(256)");
+
+                    b.Property<string>("PasswordHash")
+                        .HasColumnType("text");
+
+                    b.Property<string>("PhoneNumber")
+                        .HasColumnType("text");
+
+                    b.Property<bool>("PhoneNumberConfirmed")
+                        .HasColumnType("boolean");
+
+                    b.Property<bool>("SaveScrobbles")
+                        .HasColumnType("boolean");
+
+                    b.Property<string>("SecurityStamp")
+                        .HasColumnType("text");
+
+                    b.Property<string>("SpotifyAccessToken")
+                        .HasColumnType("text");
+
+                    b.Property<bool>("SpotifyIsLinked")
+                        .HasColumnType("boolean");
+
+                    b.Property<DateTime>("SpotifyLastRefresh")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("SpotifyRefreshToken")
+                        .HasColumnType("text");
+
+                    b.Property<int>("SpotifyTokenExpiry")
+                        .HasColumnType("integer");
+
+                    b.Property<bool>("TwoFactorEnabled")
+                        .HasColumnType("boolean");
+
+                    b.Property<string>("UserName")
+                        .HasMaxLength(256)
+                        .HasColumnType("character varying(256)");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("NormalizedEmail")
+                        .HasDatabaseName("EmailIndex");
+
+                    b.HasIndex("NormalizedUserName")
+                        .IsUnique()
+                        .HasDatabaseName("UserNameIndex");
+
+                    b.ToTable("AspNetUsers", (string)null);
+                });
+
+            modelBuilder.Entity("Selector.Model.ArtistLastfmSpotifyMapping", b =>
+                {
+                    b.Property<string>("SpotifyUri")
+                        .HasColumnType("text");
+
+                    b.Property<string>("LastfmArtistName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.HasKey("SpotifyUri");
+
+                    b.ToTable("ArtistMapping");
+                });
+
+            modelBuilder.Entity("Selector.Model.SpotifyListen", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("integer");
+
+                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+                    b.Property<string>("AlbumName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("ArtistName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<int?>("PlayedDuration")
+                        .HasColumnType("integer");
+
+                    b.Property<DateTime>("Timestamp")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("TrackName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("TrackUri")
+                        .HasColumnType("text");
+
+                    b.Property<string>("UserId")
+                        .HasColumnType("text");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("SpotifyListen");
+                });
+
+            modelBuilder.Entity("Selector.Model.TrackLastfmSpotifyMapping", b =>
+                {
+                    b.Property<string>("SpotifyUri")
+                        .HasColumnType("text");
+
+                    b.Property<string>("LastfmArtistName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("LastfmTrackName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.HasKey("SpotifyUri");
+
+                    b.ToTable("TrackMapping");
+                });
+
+            modelBuilder.Entity("Selector.Model.UserScrobble", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("integer");
+
+                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+                    b.Property<string>("AlbumArtistName")
+                        .HasColumnType("text");
+
+                    b.Property<string>("AlbumName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("ArtistName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<DateTime>("Timestamp")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("TrackName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("UserId")
+                        .HasColumnType("text");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("Scrobble");
+                });
+
+            modelBuilder.Entity("Selector.Model.Watcher", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("integer");
+
+                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+                    b.Property<int>("Type")
+                        .HasColumnType("integer");
+
+                    b.Property<string>("UserId")
+                        .IsRequired()
+                        .HasColumnType("text");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("Watcher");
+                });
+
+            modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
+                {
+                    b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+                        .WithMany()
+                        .HasForeignKey("RoleId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
+                {
+                    b.HasOne("Selector.Model.ApplicationUser", null)
+                        .WithMany()
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
+                {
+                    b.HasOne("Selector.Model.ApplicationUser", null)
+                        .WithMany()
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
+                {
+                    b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+                        .WithMany()
+                        .HasForeignKey("RoleId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.HasOne("Selector.Model.ApplicationUser", null)
+                        .WithMany()
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
+                {
+                    b.HasOne("Selector.Model.ApplicationUser", null)
+                        .WithMany()
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Selector.Model.AppleMusicListen", b =>
+                {
+                    b.HasOne("Selector.Model.ApplicationUser", "User")
+                        .WithMany()
+                        .HasForeignKey("UserId");
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("Selector.Model.SpotifyListen", b =>
+                {
+                    b.HasOne("Selector.Model.ApplicationUser", "User")
+                        .WithMany()
+                        .HasForeignKey("UserId");
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("Selector.Model.UserScrobble", b =>
+                {
+                    b.HasOne("Selector.Model.ApplicationUser", "User")
+                        .WithMany("Scrobbles")
+                        .HasForeignKey("UserId");
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("Selector.Model.Watcher", b =>
+                {
+                    b.HasOne("Selector.Model.ApplicationUser", "User")
+                        .WithMany("Watchers")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("Selector.Model.ApplicationUser", b =>
+                {
+                    b.Navigation("Scrobbles");
+
+                    b.Navigation("Watchers");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}
diff --git a/Selector.Model/Migrations/20250331203003_add_apple_listen.cs b/Selector.Model/Migrations/20250331203003_add_apple_listen.cs
new file mode 100644
index 0000000..f90b892
--- /dev/null
+++ b/Selector.Model/Migrations/20250331203003_add_apple_listen.cs
@@ -0,0 +1,52 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Selector.Model.Migrations
+{
+    /// <inheritdoc />
+    public partial class add_apple_listen : Migration
+    {
+        /// <inheritdoc />
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.CreateTable(
+                name: "AppleMusicListen",
+                columns: table => new
+                {
+                    Id = table.Column<int>(type: "integer", nullable: false)
+                        .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+                    TrackId = table.Column<string>(type: "text", nullable: true),
+                    Isrc = table.Column<string>(type: "text", nullable: true),
+                    UserId = table.Column<string>(type: "text", nullable: true),
+                    TrackName = table.Column<string>(type: "text", nullable: true, collation: "case_insensitive"),
+                    AlbumName = table.Column<string>(type: "text", nullable: true, collation: "case_insensitive"),
+                    ArtistName = table.Column<string>(type: "text", nullable: true, collation: "case_insensitive"),
+                    Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_AppleMusicListen", x => x.Id);
+                    table.ForeignKey(
+                        name: "FK_AppleMusicListen_AspNetUsers_UserId",
+                        column: x => x.UserId,
+                        principalTable: "AspNetUsers",
+                        principalColumn: "Id");
+                });
+
+            migrationBuilder.CreateIndex(
+                name: "IX_AppleMusicListen_UserId",
+                table: "AppleMusicListen",
+                column: "UserId");
+        }
+
+        /// <inheritdoc />
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropTable(
+                name: "AppleMusicListen");
+        }
+    }
+}
diff --git a/Selector.Model/Migrations/ApplicationDbContextModelSnapshot.cs b/Selector.Model/Migrations/ApplicationDbContextModelSnapshot.cs
index 464de93..7dbaa78 100644
--- a/Selector.Model/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/Selector.Model/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -181,6 +181,45 @@ namespace Selector.Model.Migrations
                     b.ToTable("AlbumMapping");
                 });
 
+            modelBuilder.Entity("Selector.Model.AppleMusicListen", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("integer");
+
+                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+                    b.Property<string>("AlbumName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("ArtistName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("Isrc")
+                        .HasColumnType("text");
+
+                    b.Property<DateTime>("Timestamp")
+                        .HasColumnType("timestamp with time zone");
+
+                    b.Property<string>("TrackId")
+                        .HasColumnType("text");
+
+                    b.Property<string>("TrackName")
+                        .HasColumnType("text")
+                        .UseCollation("case_insensitive");
+
+                    b.Property<string>("UserId")
+                        .HasColumnType("text");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("AppleMusicListen");
+                });
+
             modelBuilder.Entity("Selector.Model.ApplicationUser", b =>
                 {
                     b.Property<string>("Id")
@@ -456,6 +495,15 @@ namespace Selector.Model.Migrations
                         .IsRequired();
                 });
 
+            modelBuilder.Entity("Selector.Model.AppleMusicListen", b =>
+                {
+                    b.HasOne("Selector.Model.ApplicationUser", "User")
+                        .WithMany()
+                        .HasForeignKey("UserId");
+
+                    b.Navigation("User");
+                });
+
             modelBuilder.Entity("Selector.Model.SpotifyListen", b =>
                 {
                     b.HasOne("Selector.Model.ApplicationUser", "User")
diff --git a/Selector.Model/Scrobble/DBPlayCountPuller.cs b/Selector.Model/Scrobble/DBPlayCountPuller.cs
index 07a0ed3..24a3994 100644
--- a/Selector.Model/Scrobble/DBPlayCountPuller.cs
+++ b/Selector.Model/Scrobble/DBPlayCountPuller.cs
@@ -4,6 +4,7 @@ using System.Linq;
 using System.Threading.Tasks;
 using Microsoft.Extensions.Options;
 using Selector.Model;
+using Selector.Spotify.Consumer;
 
 namespace Selector.Cache
 {
@@ -27,7 +28,8 @@ namespace Selector.Cache
 
             var userScrobbleCount = ScrobbleRepository.Count(username: username);
 
-            var artistScrobbles = ScrobbleRepository.GetAll(username: username, artistName: artist, tracking: false, orderTime: true).ToArray();
+            var artistScrobbles = ScrobbleRepository
+                .GetAll(username: username, artistName: artist, tracking: false, orderTime: true).ToArray();
             var albumScrobbles = artistScrobbles.Where(
                 s => s.AlbumName.Equals(album, StringComparison.CurrentCultureIgnoreCase)).ToArray();
             var trackScrobbles = artistScrobbles.Where(
@@ -67,4 +69,4 @@ namespace Selector.Cache
             return Task.FromResult(playCount);
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Model/Selector.Model.csproj b/Selector.Model/Selector.Model.csproj
index 4a99c9e..66c6498 100644
--- a/Selector.Model/Selector.Model.csproj
+++ b/Selector.Model/Selector.Model.csproj
@@ -7,6 +7,9 @@
   </PropertyGroup>
 
   <ItemGroup>
+    <ProjectReference Include="..\Selector.AppleMusic\Selector.AppleMusic.csproj"/>
+    <ProjectReference Include="..\Selector.LastFm\Selector.LastFm.csproj"/>
+    <ProjectReference Include="..\Selector.Spotify\Selector.Spotify.csproj"/>
     <ProjectReference Include="..\Selector\Selector.csproj" />
   </ItemGroup>
 
@@ -30,7 +33,6 @@
 
   <ItemGroup>
     <Folder Include="Migrations\" />
-    <Folder Include="Listen\" />
   </ItemGroup>
 
   <ItemGroup>
diff --git a/Selector.SignalR/INow.cs b/Selector.SignalR/INow.cs
index 6c44343..b5b4aaf 100644
--- a/Selector.SignalR/INow.cs
+++ b/Selector.SignalR/INow.cs
@@ -1,6 +1,6 @@
-using System;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using SpotifyAPI.Web;
-using System.Threading.Tasks;
 
 namespace Selector.SignalR;
 
@@ -20,5 +20,4 @@ public interface INowPlayingHub
     Task SendFacts(string track, string artist, string album, string albumArtist);
     Task SendNewPlaying();
     Task SendPlayCount(string track, string artist, string album, string albumArtist);
-}
-
+}
\ No newline at end of file
diff --git a/Selector.SignalR/NowHubCache.cs b/Selector.SignalR/NowHubCache.cs
index 37d9533..de6b92c 100644
--- a/Selector.SignalR/NowHubCache.cs
+++ b/Selector.SignalR/NowHubCache.cs
@@ -1,41 +1,42 @@
-using System;
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using SpotifyAPI.Web;
 
 namespace Selector.SignalR;
 
 public class NowHubCache
 {
-	private readonly NowHubClient _connection;
+    private readonly NowHubClient _connection;
     private readonly ILogger<NowHubCache> logger;
 
     public TrackAudioFeatures LastFeature { get; private set; }
-	public List<Card> LastCards { get; private set; } = new();
-	private readonly object updateLock = new();
+    public List<Card> LastCards { get; private set; } = new();
+    private readonly object updateLock = new();
 
-	private readonly object bindingLock = new();
-	private bool isBound = false;
+    private readonly object bindingLock = new();
+    private bool isBound = false;
 
-    public PlayCount LastPlayCount { get; private set; } 
-	public CurrentlyPlayingDTO LastPlaying { get; private set; }
+    public PlayCount LastPlayCount { get; private set; }
+    public CurrentlyPlayingDTO LastPlaying { get; private set; }
 
-	public event EventHandler NewAudioFeature;
-	public event EventHandler NewCard;
-	public event EventHandler NewPlayCount;
-	public event EventHandler NewNowPlaying;
+    public event EventHandler NewAudioFeature;
+    public event EventHandler NewCard;
+    public event EventHandler NewPlayCount;
+    public event EventHandler NewNowPlaying;
 
     public NowHubCache(NowHubClient connection, ILogger<NowHubCache> logger)
-	{
-		_connection = connection;
+    {
+        _connection = connection;
         this.logger = logger;
     }
 
-	public void BindClient()
-	{
-		lock(bindingLock)
-		{
-			if(!isBound)
-			{
+    public void BindClient()
+    {
+        lock (bindingLock)
+        {
+            if (!isBound)
+            {
                 _connection.OnNewAudioFeature(af =>
                 {
                     lock (updateLock)
@@ -108,7 +109,6 @@ public class NowHubCache
 
                 isBound = true;
             }
-		}
-	}
-}
-
+        }
+    }
+}
\ No newline at end of file
diff --git a/Selector.SignalR/NowHubClient.cs b/Selector.SignalR/NowHubClient.cs
index 31abdad..b98796e 100644
--- a/Selector.SignalR/NowHubClient.cs
+++ b/Selector.SignalR/NowHubClient.cs
@@ -1,11 +1,11 @@
-using System;
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.SignalR.Client;
+using Microsoft.AspNetCore.SignalR.Client;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using SpotifyAPI.Web;
 
 namespace Selector.SignalR;
 
-public class NowHubClient: BaseSignalRClient, INowPlayingHub, IDisposable
+public class NowHubClient : BaseSignalRClient, INowPlayingHub, IDisposable
 {
     private List<IDisposable> NewPlayingCallbacks = new();
     private List<IDisposable> NewAudioFeatureCallbacks = new();
@@ -13,9 +13,9 @@ public class NowHubClient: BaseSignalRClient, INowPlayingHub, IDisposable
     private List<IDisposable> NewCardCallbacks = new();
     private bool disposedValue;
 
-    public NowHubClient(string token = null): base("nowhub", token)
-	{
-	}
+    public NowHubClient(string token = null) : base("nowhub", token)
+    {
+    }
 
     public void OnNewPlaying(Action<CurrentlyPlayingDTO> action)
     {
@@ -93,10 +93,10 @@ public class NowHubClient: BaseSignalRClient, INowPlayingHub, IDisposable
         {
             if (disposing)
             {
-                foreach(var callback in NewPlayingCallbacks
-                    .Concat(NewAudioFeatureCallbacks)
-                    .Concat(NewPlayCountCallbacks)
-                    .Concat(NewCardCallbacks))
+                foreach (var callback in NewPlayingCallbacks
+                             .Concat(NewAudioFeatureCallbacks)
+                             .Concat(NewPlayCountCallbacks)
+                             .Concat(NewCardCallbacks))
                 {
                     callback.Dispose();
                 }
@@ -114,5 +114,4 @@ public class NowHubClient: BaseSignalRClient, INowPlayingHub, IDisposable
         Dispose(disposing: true);
         GC.SuppressFinalize(this);
     }
-}
-
+}
\ No newline at end of file
diff --git a/Selector.SignalR/Selector.SignalR.csproj b/Selector.SignalR/Selector.SignalR.csproj
index 9234cf8..97dcaa6 100644
--- a/Selector.SignalR/Selector.SignalR.csproj
+++ b/Selector.SignalR/Selector.SignalR.csproj
@@ -8,6 +8,7 @@
   </PropertyGroup>
 
   <ItemGroup>
+    <ProjectReference Include="..\Selector.Spotify\Selector.Spotify.csproj"/>
     <ProjectReference Include="..\Selector\Selector.csproj" />
     <!-- <ProjectReference Include="..\Selector.Model\Selector.Model.csproj" /> -->
   </ItemGroup>
diff --git a/Selector/Spotify/ConfigFactory/ISpotifyConfigFactory.cs b/Selector.Spotify/ConfigFactory/ISpotifyConfigFactory.cs
similarity index 59%
rename from Selector/Spotify/ConfigFactory/ISpotifyConfigFactory.cs
rename to Selector.Spotify/ConfigFactory/ISpotifyConfigFactory.cs
index 6bb792d..2448242 100644
--- a/Selector/Spotify/ConfigFactory/ISpotifyConfigFactory.cs
+++ b/Selector.Spotify/ConfigFactory/ISpotifyConfigFactory.cs
@@ -1,10 +1,9 @@
-using System.Threading.Tasks;
-using SpotifyAPI.Web;
+using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.ConfigFactory
 {
     public interface ISpotifyConfigFactory
     {
         public Task<SpotifyClientConfig> GetConfig();
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Spotify/ConfigFactory/RefreshTokenFactory.cs b/Selector.Spotify/ConfigFactory/RefreshTokenFactory.cs
similarity index 88%
rename from Selector/Spotify/ConfigFactory/RefreshTokenFactory.cs
rename to Selector.Spotify/ConfigFactory/RefreshTokenFactory.cs
index 3321ed3..b354e73 100644
--- a/Selector/Spotify/ConfigFactory/RefreshTokenFactory.cs
+++ b/Selector.Spotify/ConfigFactory/RefreshTokenFactory.cs
@@ -1,9 +1,6 @@
-using System.Threading.Tasks;
+using SpotifyAPI.Web;
 
-using SpotifyAPI.Web;
-
-
-namespace Selector
+namespace Selector.Spotify.ConfigFactory
 {
     /// <summary>
     /// Get config from a refresh token
@@ -14,7 +11,8 @@ namespace Selector
         private string ClientSecret { get; set; }
         private string RefreshToken { get; set; }
 
-        public RefreshTokenFactory(string clientId, string clientSecret, string refreshToken) {
+        public RefreshTokenFactory(string clientId, string clientSecret, string refreshToken)
+        {
             ClientId = clientId;
             ClientSecret = clientSecret;
             RefreshToken = refreshToken;
@@ -27,7 +25,8 @@ namespace Selector
 
             var config = SpotifyClientConfig
                 .CreateDefault()
-                .WithAuthenticator(new AuthorizationCodeAuthenticator(ClientId, ClientSecret, new(){
+                .WithAuthenticator(new AuthorizationCodeAuthenticator(ClientId, ClientSecret, new()
+                {
                     AccessToken = refreshed.AccessToken,
                     TokenType = refreshed.TokenType,
                     ExpiresIn = refreshed.ExpiresIn,
@@ -39,4 +38,4 @@ namespace Selector
             return config;
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Consumers/AudioFeatureInjector.cs b/Selector.Spotify/Consumer/AudioFeatureInjector.cs
similarity index 94%
rename from Selector/Consumers/AudioFeatureInjector.cs
rename to Selector.Spotify/Consumer/AudioFeatureInjector.cs
index d7ddc01..9201168 100644
--- a/Selector/Consumers/AudioFeatureInjector.cs
+++ b/Selector.Spotify/Consumer/AudioFeatureInjector.cs
@@ -1,11 +1,10 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
+using Selector.Extensions;
+using Selector.Spotify.Timeline;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Consumer
 {
     public class AudioFeatureInjector : ISpotifyPlayerConsumer
     {
@@ -32,7 +31,7 @@ namespace Selector
             CancelToken = token;
         }
 
-        public void Callback(object sender, ListeningChangeEventArgs e)
+        public void Callback(object sender, SpotifyListeningChangeEventArgs e)
         {
             if (e.Current is null) return;
 
@@ -49,7 +48,7 @@ namespace Selector
             }, CancelToken);
         }
 
-        public virtual async Task AsyncCallback(ListeningChangeEventArgs e)
+        public virtual async Task AsyncCallback(SpotifyListeningChangeEventArgs e)
         {
             using var scope = Logger.GetListeningEventArgsScope(e);
 
diff --git a/Selector/Consumers/DummyAudioFeatureInjector.cs b/Selector.Spotify/Consumer/DummyAudioFeatureInjector.cs
similarity index 93%
rename from Selector/Consumers/DummyAudioFeatureInjector.cs
rename to Selector.Spotify/Consumer/DummyAudioFeatureInjector.cs
index c0ae960..e38ffa8 100644
--- a/Selector/Consumers/DummyAudioFeatureInjector.cs
+++ b/Selector.Spotify/Consumer/DummyAudioFeatureInjector.cs
@@ -1,10 +1,8 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
+using Selector.Extensions;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Consumer
 {
     public class DummyAudioFeatureInjector : AudioFeatureInjector
     {
@@ -62,7 +60,7 @@ namespace Selector
             return _features[_contextIdx];
         }
 
-        public override Task AsyncCallback(ListeningChangeEventArgs e)
+        public override Task AsyncCallback(SpotifyListeningChangeEventArgs e)
         {
             using var scope = Logger.GetListeningEventArgsScope(e);
 
diff --git a/Selector/Consumers/Factory/AudioFeatureInjectorFactory.cs b/Selector.Spotify/Consumer/Factory/AudioFeatureInjectorFactory.cs
similarity index 91%
rename from Selector/Consumers/Factory/AudioFeatureInjectorFactory.cs
rename to Selector.Spotify/Consumer/Factory/AudioFeatureInjectorFactory.cs
index 0f0cac5..3ea3701 100644
--- a/Selector/Consumers/Factory/AudioFeatureInjectorFactory.cs
+++ b/Selector.Spotify/Consumer/Factory/AudioFeatureInjectorFactory.cs
@@ -1,8 +1,8 @@
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
+using Selector.Spotify.ConfigFactory;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Consumer.Factory
 {
     public interface IAudioFeatureInjectorFactory
     {
diff --git a/Selector/Consumers/Factory/PlayCounterFactory.cs b/Selector.Spotify/Consumer/Factory/PlayCounterFactory.cs
similarity index 93%
rename from Selector/Consumers/Factory/PlayCounterFactory.cs
rename to Selector.Spotify/Consumer/Factory/PlayCounterFactory.cs
index 9b60f10..6b25847 100644
--- a/Selector/Consumers/Factory/PlayCounterFactory.cs
+++ b/Selector.Spotify/Consumer/Factory/PlayCounterFactory.cs
@@ -1,9 +1,7 @@
-using System;
-using System.Threading.Tasks;
-using IF.Lastfm.Core.Api;
+using IF.Lastfm.Core.Api;
 using Microsoft.Extensions.Logging;
 
-namespace Selector
+namespace Selector.Spotify.Consumer.Factory
 {
     public interface IPlayCounterFactory
     {
diff --git a/Selector/Consumers/Factory/WebHookFactory.cs b/Selector.Spotify/Consumer/Factory/WebHookFactory.cs
similarity index 87%
rename from Selector/Consumers/Factory/WebHookFactory.cs
rename to Selector.Spotify/Consumer/Factory/WebHookFactory.cs
index 080d6f7..85b3671 100644
--- a/Selector/Consumers/Factory/WebHookFactory.cs
+++ b/Selector.Spotify/Consumer/Factory/WebHookFactory.cs
@@ -1,8 +1,6 @@
-using System.Net.Http;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
 
-namespace Selector
+namespace Selector.Spotify.Consumer.Factory
 {
     public interface IWebHookFactory
     {
diff --git a/Selector.Spotify/Consumer/IConsumer.cs b/Selector.Spotify/Consumer/IConsumer.cs
new file mode 100644
index 0000000..8c58eb6
--- /dev/null
+++ b/Selector.Spotify/Consumer/IConsumer.cs
@@ -0,0 +1,9 @@
+namespace Selector.Spotify.Consumer;
+
+public interface ISpotifyPlayerConsumer : IConsumer<SpotifyListeningChangeEventArgs>
+{
+}
+
+public interface IPlaylistConsumer : IConsumer<PlaylistChangeEventArgs>
+{
+}
\ No newline at end of file
diff --git a/Selector/Consumers/PlayCounter.cs b/Selector.Spotify/Consumer/PlayCounter.cs
similarity index 95%
rename from Selector/Consumers/PlayCounter.cs
rename to Selector.Spotify/Consumer/PlayCounter.cs
index 0447a3a..2211e4a 100644
--- a/Selector/Consumers/PlayCounter.cs
+++ b/Selector.Spotify/Consumer/PlayCounter.cs
@@ -1,13 +1,11 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
 using IF.Lastfm.Core.Api;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
+using Selector.Extensions;
+using Selector.Spotify.Timeline;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Consumer
 {
     public class PlayCounter : ISpotifyPlayerConsumer
     {
@@ -46,7 +44,7 @@ namespace Selector
             CancelToken = token;
         }
 
-        public void Callback(object sender, ListeningChangeEventArgs e)
+        public void Callback(object sender, SpotifyListeningChangeEventArgs e)
         {
             if (e.Current is null) return;
 
@@ -63,7 +61,7 @@ namespace Selector
             }, CancelToken);
         }
 
-        public async Task AsyncCallback(ListeningChangeEventArgs e)
+        public async Task AsyncCallback(SpotifyListeningChangeEventArgs e)
         {
             using var scope = Logger.BeginScope(new Dictionary<string, object>()
                 { { "spotify_username", e.SpotifyUsername }, { "id", e.Id }, { "username", Credentials.Username } });
@@ -155,7 +153,7 @@ namespace Selector
                     Album = albumCount,
                     Artist = artistCount,
                     User = userCount,
-                    ListeningEvent = e
+                    SpotifyListeningEvent = e
                 };
 
                 if (!string.IsNullOrWhiteSpace(Credentials.Username))
@@ -221,7 +219,7 @@ namespace Selector
         public IEnumerable<CountSample> TrackCountData { get; set; }
         public IEnumerable<CountSample> AlbumCountData { get; set; }
         public IEnumerable<CountSample> ArtistCountData { get; set; }
-        public ListeningChangeEventArgs ListeningEvent { get; set; }
+        public SpotifyListeningChangeEventArgs SpotifyListeningEvent { get; set; }
     }
 
     public class LastFmCredentials
diff --git a/Selector/Consumers/WebHook.cs b/Selector.Spotify/Consumer/WebHook.cs
similarity index 91%
rename from Selector/Consumers/WebHook.cs
rename to Selector.Spotify/Consumer/WebHook.cs
index 24abac9..4680997 100644
--- a/Selector/Consumers/WebHook.cs
+++ b/Selector.Spotify/Consumer/WebHook.cs
@@ -1,22 +1,17 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
+using Selector.Spotify.Timeline;
 
-namespace Selector
+namespace Selector.Spotify.Consumer
 {
     public class WebHookConfig
     {
         public string Name { get; set; }
-        public IEnumerable<Predicate<ListeningChangeEventArgs>> Predicates { get; set; }
+        public IEnumerable<Predicate<SpotifyListeningChangeEventArgs>> Predicates { get; set; }
         public string Url { get; set; }
         public HttpContent Content { get; set; }
 
-        public bool ShouldRequest(ListeningChangeEventArgs e)
+        public bool ShouldRequest(SpotifyListeningChangeEventArgs e)
         {
             if (Predicates is not null)
             {
@@ -60,7 +55,7 @@ namespace Selector
             CancelToken = token;
         }
 
-        public void Callback(object sender, ListeningChangeEventArgs e)
+        public void Callback(object sender, SpotifyListeningChangeEventArgs e)
         {
             if (e.Current is null) return;
 
@@ -77,7 +72,7 @@ namespace Selector
             }, CancelToken);
         }
 
-        public async Task AsyncCallback(ListeningChangeEventArgs e)
+        public async Task AsyncCallback(SpotifyListeningChangeEventArgs e)
         {
             using var scope = Logger.BeginScope(new Dictionary<string, object>()
             {
diff --git a/Selector.Spotify/Credentials.cs b/Selector.Spotify/Credentials.cs
new file mode 100644
index 0000000..f52d95f
--- /dev/null
+++ b/Selector.Spotify/Credentials.cs
@@ -0,0 +1,17 @@
+namespace Selector.Spotify;
+
+public class SpotifyAppCredentials
+{
+    public string ClientId { get; set; }
+    public string ClientSecret { get; set; }
+
+    public SpotifyAppCredentials()
+    {
+    }
+
+    public SpotifyAppCredentials(string clientId, string clientSecret)
+    {
+        ClientId = clientId;
+        ClientSecret = clientSecret;
+    }
+}
\ No newline at end of file
diff --git a/Selector/Spotify/CurrentlyPlayingDTO.cs b/Selector.Spotify/CurrentlyPlayingDTO.cs
similarity index 87%
rename from Selector/Spotify/CurrentlyPlayingDTO.cs
rename to Selector.Spotify/CurrentlyPlayingDTO.cs
index d8b25c8..0af0b99 100644
--- a/Selector/Spotify/CurrentlyPlayingDTO.cs
+++ b/Selector.Spotify/CurrentlyPlayingDTO.cs
@@ -1,10 +1,10 @@
-using System;
-
+using Selector.Extensions;
 using SpotifyAPI.Web;
 
-namespace Selector {
-
-    public class CurrentlyPlayingDTO {
+namespace Selector.Spotify
+{
+    public class CurrentlyPlayingDTO
+    {
         public CurrentlyPlayingContextDTO Context { get; set; }
         public string Username { get; set; }
         public string UserId { get; set; }
@@ -12,9 +12,9 @@ namespace Selector {
         public FullTrack Track { get; set; }
         public FullEpisode Episode { get; set; }
 
-        public static explicit operator CurrentlyPlayingDTO(ListeningChangeEventArgs e)
+        public static explicit operator CurrentlyPlayingDTO(SpotifyListeningChangeEventArgs e)
         {
-            if(e.Current.Item is FullTrack track)
+            if (e.Current.Item is FullTrack track)
             {
                 return new()
                 {
@@ -52,13 +52,14 @@ namespace Selector {
         public long Timestamp { get; set; }
         public int ProgressMs { get; set; }
         public bool IsPlaying { get; set; }
-        
+
         public string CurrentlyPlayingType { get; set; }
         public Actions Actions { get; set; }
 
         public static implicit operator CurrentlyPlayingContextDTO(CurrentlyPlayingContext context)
         {
-            return new CurrentlyPlayingContextDTO {
+            return new CurrentlyPlayingContextDTO
+            {
                 Device = context.Device,
                 RepeatState = context.RepeatState,
                 ShuffleState = context.ShuffleState,
diff --git a/Selector/Equality/PlayableItemEqualityComparer.cs b/Selector.Spotify/Equality/PlayableItemEqualityComparer.cs
similarity index 61%
rename from Selector/Equality/PlayableItemEqualityComparer.cs
rename to Selector.Spotify/Equality/PlayableItemEqualityComparer.cs
index d4d36ac..2220eaf 100644
--- a/Selector/Equality/PlayableItemEqualityComparer.cs
+++ b/Selector.Spotify/Equality/PlayableItemEqualityComparer.cs
@@ -1,11 +1,10 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.CodeAnalysis;
+using Selector.Extensions;
 using SpotifyAPI.Web;
 
-namespace Selector.Equality
+namespace Selector.Spotify.Equality
 {
-    public class PlayableItemEqualityComparer: IEqualityComparer<PlaylistTrack<IPlayableItem>>
+    public class PlayableItemEqualityComparer : IEqualityComparer<PlaylistTrack<IPlayableItem>>
     {
         public bool Equals(PlaylistTrack<IPlayableItem> x, PlaylistTrack<IPlayableItem> y)
         {
@@ -17,5 +16,4 @@ namespace Selector.Equality
             return obj.GetUri().GetHashCode();
         }
     }
-}
-
+}
\ No newline at end of file
diff --git a/Selector/Equality/StringComparers.cs b/Selector.Spotify/Equality/StringComparers.cs
similarity index 51%
rename from Selector/Equality/StringComparers.cs
rename to Selector.Spotify/Equality/StringComparers.cs
index 8430346..119b969 100644
--- a/Selector/Equality/StringComparers.cs
+++ b/Selector.Spotify/Equality/StringComparers.cs
@@ -1,12 +1,8 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Equality
 {
-
-    public abstract class NoHashCode<T>: EqualityComparer<T> 
+    public abstract class NoHashCode<T> : EqualityComparer<T>
     {
         public override int GetHashCode(T obj)
         {
@@ -16,79 +12,89 @@ namespace Selector
 
     public class FullTrackStringComparer : NoHashCode<FullTrack>
     {
-        public override bool Equals(FullTrack track1, FullTrack track2) => FullTrackStringComparer.IsEqual(track1, track2);
+        public override bool Equals(FullTrack track1, FullTrack track2) => IsEqual(track1, track2);
 
         public static bool IsEqual(FullTrack track1, FullTrack track2) => track1.Name == track2.Name
-                && Enumerable.SequenceEqual(track1.Artists.Select(a => a.Name), track2.Artists.Select(a => a.Name))
-                && SimpleAlbumStringComparer.IsEqual(track1.Album, track2.Album);
+                                                                          && Enumerable.SequenceEqual(
+                                                                              track1.Artists.Select(a => a.Name),
+                                                                              track2.Artists.Select(a => a.Name))
+                                                                          && SimpleAlbumStringComparer.IsEqual(
+                                                                              track1.Album, track2.Album);
     }
 
     public class FullEpisodeStringComparer : NoHashCode<FullEpisode>
     {
-        public override bool Equals(FullEpisode ep1, FullEpisode ep2) => FullEpisodeStringComparer.IsEqual(ep1, ep2);
+        public override bool Equals(FullEpisode ep1, FullEpisode ep2) => IsEqual(ep1, ep2);
 
-        public static bool IsEqual(FullEpisode ep1, FullEpisode ep2) => ep1.Name == ep2.Name 
-            && SimpleShowStringComparer.IsEqual(ep1.Show, ep2.Show);
+        public static bool IsEqual(FullEpisode ep1, FullEpisode ep2) => ep1.Name == ep2.Name
+                                                                        && SimpleShowStringComparer.IsEqual(ep1.Show,
+                                                                            ep2.Show);
     }
 
     public class FullAlbumStringComparer : NoHashCode<FullAlbum>
     {
-        public override bool Equals(FullAlbum album1, FullAlbum album2) => FullAlbumStringComparer.IsEqual(album1, album2);
+        public override bool Equals(FullAlbum album1, FullAlbum album2) => IsEqual(album1, album2);
 
         public static bool IsEqual(FullAlbum album1, FullAlbum album2) => album1.Name == album2.Name
-            && Enumerable.SequenceEqual(album1.Artists.Select(a => a.Name), album2.Artists.Select(a => a.Name));
+                                                                          && Enumerable.SequenceEqual(
+                                                                              album1.Artists.Select(a => a.Name),
+                                                                              album2.Artists.Select(a => a.Name));
     }
 
     public class FullShowStringComparer : NoHashCode<FullShow>
     {
-        public override bool Equals(FullShow show1, FullShow show2) => FullShowStringComparer.IsEqual(show1, show2);
+        public override bool Equals(FullShow show1, FullShow show2) => IsEqual(show1, show2);
 
         public static bool IsEqual(FullShow show1, FullShow show2) => show1.Name == show2.Name
-            && show1.Publisher == show2.Publisher;
+                                                                      && show1.Publisher == show2.Publisher;
     }
 
     public class FullArtistStringComparer : NoHashCode<FullArtist>
     {
-        public override bool Equals(FullArtist artist1, FullArtist artist2) => FullArtistStringComparer.IsEqual(artist1, artist2);
+        public override bool Equals(FullArtist artist1, FullArtist artist2) => IsEqual(artist1, artist2);
 
         public static bool IsEqual(FullArtist artist1, FullArtist artist2) => artist1.Name == artist2.Name;
     }
 
     public class SimpleTrackStringComparer : NoHashCode<SimpleTrack>
     {
-        public override bool Equals(SimpleTrack track1, SimpleTrack track2) => SimpleTrackStringComparer.IsEqual(track1, track2);
+        public override bool Equals(SimpleTrack track1, SimpleTrack track2) => IsEqual(track1, track2);
 
         public static bool IsEqual(SimpleTrack track1, SimpleTrack track2) => track1.Name == track2.Name
-            && Enumerable.SequenceEqual(track1.Artists.Select(a => a.Name), track2.Artists.Select(a => a.Name));
+                                                                              && Enumerable.SequenceEqual(
+                                                                                  track1.Artists.Select(a => a.Name),
+                                                                                  track2.Artists.Select(a => a.Name));
     }
 
     public class SimpleEpisodeStringComparer : NoHashCode<SimpleEpisode>
     {
-        public override bool Equals(SimpleEpisode ep1, SimpleEpisode ep2) => SimpleEpisodeStringComparer.IsEqual(ep1, ep2);
+        public override bool Equals(SimpleEpisode ep1, SimpleEpisode ep2) => IsEqual(ep1, ep2);
 
         public static bool IsEqual(SimpleEpisode ep1, SimpleEpisode ep2) => ep1.Name == ep2.Name;
     }
 
     public class SimpleAlbumStringComparer : NoHashCode<SimpleAlbum>
     {
-        public override bool Equals(SimpleAlbum album1, SimpleAlbum album2) => SimpleAlbumStringComparer.IsEqual(album1, album2);
+        public override bool Equals(SimpleAlbum album1, SimpleAlbum album2) => IsEqual(album1, album2);
 
         public static bool IsEqual(SimpleAlbum album1, SimpleAlbum album2) => album1.Name == album2.Name
-            && Enumerable.SequenceEqual(album1.Artists.Select(a => a.Name), album2.Artists.Select(a => a.Name));
+                                                                              && Enumerable.SequenceEqual(
+                                                                                  album1.Artists.Select(a => a.Name),
+                                                                                  album2.Artists.Select(a => a.Name));
     }
 
     public class SimpleShowStringComparer : NoHashCode<SimpleShow>
     {
-        public override bool Equals(SimpleShow show1, SimpleShow show2) => SimpleShowStringComparer.IsEqual(show1, show2);
+        public override bool Equals(SimpleShow show1, SimpleShow show2) => IsEqual(show1, show2);
 
         public static bool IsEqual(SimpleShow show1, SimpleShow show2) => show1.Name == show2.Name
-            && show1.Publisher == show2.Publisher;
+                                                                          && show1.Publisher == show2.Publisher;
     }
 
     public class SimpleArtistStringComparer : NoHashCode<SimpleArtist>
     {
-        public override bool Equals(SimpleArtist artist1, SimpleArtist artist2) => SimpleArtistStringComparer.IsEqual(artist1, artist2);
+        public override bool Equals(SimpleArtist artist1, SimpleArtist artist2) => IsEqual(artist1, artist2);
 
         public static bool IsEqual(SimpleArtist artist1, SimpleArtist artist2) => artist1.Name == artist2.Name;
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Equality/StringEqual.cs b/Selector.Spotify/Equality/StringEqual.cs
similarity index 92%
rename from Selector/Equality/StringEqual.cs
rename to Selector.Spotify/Equality/StringEqual.cs
index a3bde2d..a68fbc8 100644
--- a/Selector/Equality/StringEqual.cs
+++ b/Selector.Spotify/Equality/StringEqual.cs
@@ -1,8 +1,8 @@
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Equality
 {
-    public class StringEqual: Equal
+    public class StringEqual : Equal
     {
         public StringEqual()
         {
@@ -22,4 +22,4 @@ namespace Selector
             };
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Equality/UriEqual.cs b/Selector.Spotify/Equality/UriEqual.cs
similarity index 58%
rename from Selector/Equality/UriEqual.cs
rename to Selector.Spotify/Equality/UriEqual.cs
index 0c5cf2b..b17eb57 100644
--- a/Selector/Equality/UriEqual.cs
+++ b/Selector.Spotify/Equality/UriEqual.cs
@@ -1,6 +1,4 @@
-using System;
-
-namespace Selector
+namespace Selector.Spotify.Equality
 {
     public class UriEqual : IEqual
     {
@@ -10,18 +8,18 @@ namespace Selector
             if (item is null ^ other is null) return false;
 
             var uri = typeof(T).GetProperty("Uri");
-            
-            if(uri is not null)
-                return (string) uri.GetValue(item) == (string) uri.GetValue(other);
-            else 
+
+            if (uri is not null)
+                return (string)uri.GetValue(item) == (string)uri.GetValue(other);
+            else
             {
                 var id = typeof(T).GetProperty("Id");
 
-                if(id is not null)
-                    return (string) id.GetValue(item) == (string) id.GetValue(other);
-                else 
+                if (id is not null)
+                    return (string)id.GetValue(item) == (string)id.GetValue(other);
+                else
                     throw new ArgumentException($"{typeof(T)} does not contain a uri or id");
             }
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Watcher/Interfaces/Events.cs b/Selector.Spotify/Events.cs
similarity index 68%
rename from Selector/Watcher/Interfaces/Events.cs
rename to Selector.Spotify/Events.cs
index 0ce51f9..04ea357 100644
--- a/Selector/Watcher/Interfaces/Events.cs
+++ b/Selector.Spotify/Events.cs
@@ -1,26 +1,24 @@
-using System;
-using System.Collections.Generic;
+using Selector.Spotify.Timeline;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify
 {
-    public class ListeningChangeEventArgs: EventArgs {
+    public class SpotifyListeningChangeEventArgs : ListeningChangeEventArgs
+    {
         public CurrentlyPlayingContext Previous { get; set; }
         public CurrentlyPlayingContext Current { get; set; }
+
         /// <summary>
         /// Spotify Username
         /// </summary>
         public string SpotifyUsername { get; set; }
-        /// <summary>
-        /// String Id for watcher, used to hold user Db Id
-        /// </summary>
-        /// <value></value>
-        public string Id { get; set; }
+
         PlayerTimeline Timeline { get; set; }
 
-        public static ListeningChangeEventArgs From(CurrentlyPlayingContext previous, CurrentlyPlayingContext current, PlayerTimeline timeline, string id = null, string username = null)
+        public static SpotifyListeningChangeEventArgs From(CurrentlyPlayingContext previous,
+            CurrentlyPlayingContext current, PlayerTimeline timeline, string id = null, string username = null)
         {
-            return new ListeningChangeEventArgs()
+            return new SpotifyListeningChangeEventArgs()
             {
                 Previous = previous,
                 Current = current,
@@ -35,21 +33,27 @@ namespace Selector
     {
         public FullPlaylist Previous { get; set; }
         public FullPlaylist Current { get; set; }
+
         /// <summary>
         /// Spotify Username
         /// </summary>
         public string SpotifyUsername { get; set; }
+
         /// <summary>
         /// String Id for watcher, used to hold user Db Id
         /// </summary>
         /// <value></value>
         public string Id { get; set; }
+
         Timeline<FullPlaylist> Timeline { get; set; }
         ICollection<PlaylistTrack<IPlayableItem>> CurrentTracks { get; set; }
         ICollection<PlaylistTrack<IPlayableItem>> AddedTracks { get; set; }
         ICollection<PlaylistTrack<IPlayableItem>> RemovedTracks { get; set; }
 
-        public static PlaylistChangeEventArgs From(FullPlaylist previous, FullPlaylist current, Timeline<FullPlaylist> timeline, ICollection<PlaylistTrack<IPlayableItem>> tracks = null, ICollection<PlaylistTrack<IPlayableItem>> addedTracks = null, ICollection<PlaylistTrack<IPlayableItem>> removedTracks = null, string id = null, string username = null)
+        public static PlaylistChangeEventArgs From(FullPlaylist previous, FullPlaylist current,
+            Timeline<FullPlaylist> timeline, ICollection<PlaylistTrack<IPlayableItem>> tracks = null,
+            ICollection<PlaylistTrack<IPlayableItem>> addedTracks = null,
+            ICollection<PlaylistTrack<IPlayableItem>> removedTracks = null, string id = null, string username = null)
         {
             return new PlaylistChangeEventArgs()
             {
@@ -64,4 +68,4 @@ namespace Selector
             };
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Spotify/Extensions/LoggingExtensions.cs b/Selector.Spotify/Extensions/LoggingExtensions.cs
new file mode 100644
index 0000000..457a800
--- /dev/null
+++ b/Selector.Spotify/Extensions/LoggingExtensions.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.Logging;
+using Selector.Spotify;
+
+namespace Selector.Extensions
+{
+    public static class LoggingExtensions
+    {
+        public static IDisposable GetListeningEventArgsScope(this ILogger logger, SpotifyListeningChangeEventArgs e) =>
+            logger.BeginScope(new Dictionary<string, object>()
+                { { "spotify_username", e.SpotifyUsername }, { "id", e.Id } });
+    }
+}
\ No newline at end of file
diff --git a/Selector.Spotify/Extensions/ServiceExtensions.cs b/Selector.Spotify/Extensions/ServiceExtensions.cs
new file mode 100644
index 0000000..e4c7a37
--- /dev/null
+++ b/Selector.Spotify/Extensions/ServiceExtensions.cs
@@ -0,0 +1,30 @@
+using Microsoft.Extensions.DependencyInjection;
+using Selector.Spotify.Consumer.Factory;
+using Selector.Spotify.FactoryProvider;
+
+namespace Selector.Extensions;
+
+public static class ServiceExtensions
+{
+    public static IServiceCollection AddConsumerFactories(this IServiceCollection services)
+    {
+        services.AddTransient<IAudioFeatureInjectorFactory, AudioFeatureInjectorFactory>();
+        services.AddTransient<AudioFeatureInjectorFactory>();
+
+        services.AddTransient<IPlayCounterFactory, PlayCounterFactory>();
+        services.AddTransient<PlayCounterFactory>();
+
+        services.AddTransient<IWebHookFactory, WebHookFactory>();
+        services.AddTransient<WebHookFactory>();
+
+        return services;
+    }
+
+    public static IServiceCollection AddSpotify(this IServiceCollection services)
+    {
+        services.AddSingleton<IRefreshTokenFactoryProvider, RefreshTokenFactoryProvider>();
+        services.AddSingleton<IRefreshTokenFactoryProvider, CachingRefreshTokenFactoryProvider>();
+
+        return services;
+    }
+}
\ No newline at end of file
diff --git a/Selector/Extensions/SpotifyExtensions.cs b/Selector.Spotify/Extensions/SpotifyExtensions.cs
similarity index 68%
rename from Selector/Extensions/SpotifyExtensions.cs
rename to Selector.Spotify/Extensions/SpotifyExtensions.cs
index e332c81..8dcc64d 100644
--- a/Selector/Extensions/SpotifyExtensions.cs
+++ b/Selector.Spotify/Extensions/SpotifyExtensions.cs
@@ -1,33 +1,34 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-using System.Linq;
+using SpotifyAPI.Web;
 
-using SpotifyAPI.Web;
-
-namespace Selector
+namespace Selector.Extensions
 {
     public static class SpotifyExtensions
     {
         public static string DisplayString(this FullPlaylist playlist) => $"{playlist.Name}";
 
-        public static string DisplayString(this FullTrack track) => $"{track.Name} / {track.Album?.Name} / {track.Artists?.DisplayString()}";
-        public static string DisplayString(this SimpleAlbum album) => $"{album.Name} / {album.Artists?.DisplayString()}";
+        public static string DisplayString(this FullTrack track) =>
+            $"{track.Name} / {track.Album?.Name} / {track.Artists?.DisplayString()}";
+
+        public static string DisplayString(this SimpleAlbum album) =>
+            $"{album.Name} / {album.Artists?.DisplayString()}";
+
         public static string DisplayString(this SimpleArtist artist) => artist.Name;
 
         public static string DisplayString(this FullEpisode ep) => $"{ep.Name} / {ep.Show?.DisplayString()}";
         public static string DisplayString(this SimpleShow show) => $"{show.Name} / {show.Publisher}";
 
 
-        public static string DisplayString(this CurrentlyPlayingContext currentPlaying) {
-            
+        public static string DisplayString(this CurrentlyPlayingContext currentPlaying)
+        {
             if (currentPlaying.Item is FullTrack track)
             {
-                return $"{currentPlaying.IsPlaying}, {track.DisplayString()}, {currentPlaying.Device?.DisplayString() ?? "no device"}, {currentPlaying?.Context?.DisplayString() ?? "no context"}";
+                return
+                    $"{currentPlaying.IsPlaying}, {track.DisplayString()}, {currentPlaying.Device?.DisplayString() ?? "no device"}, {currentPlaying?.Context?.DisplayString() ?? "no context"}";
             }
             else if (currentPlaying.Item is FullEpisode episode)
             {
-                return $"{currentPlaying.IsPlaying}, {episode.DisplayString()}, {currentPlaying.Device?.DisplayString() ?? "no device"}";
+                return
+                    $"{currentPlaying.IsPlaying}, {episode.DisplayString()}, {currentPlaying.Device?.DisplayString() ?? "no device"}";
             }
             else if (currentPlaying.Item is null)
             {
@@ -40,15 +41,23 @@ 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 IEnumerable<SimpleArtist> artists) => string.Join(", ", artists.Select(a => a.DisplayString()));
+        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 IEnumerable<SimpleArtist> artists) =>
+            string.Join(", ", artists.Select(a => a.DisplayString()));
 
         public static bool IsInstrumental(this TrackAudioFeatures feature) => feature.Instrumentalness > 0.5;
         public static bool IsLive(this TrackAudioFeatures feature) => feature.Liveness > 0.8f;
         public static bool IsSpokenWord(this TrackAudioFeatures feature) => feature.Speechiness > 0.66f;
-        public static bool IsSpeechAndMusic(this TrackAudioFeatures feature) => feature.Speechiness is >= 0.33f and <= 0.66f;
+
+        public static bool IsSpeechAndMusic(this TrackAudioFeatures feature) =>
+            feature.Speechiness is >= 0.33f and <= 0.66f;
+
         public static bool IsNotSpeech(this TrackAudioFeatures feature) => feature.Speechiness < 0.33f;
 
         public static string GetUri(this IPlayableItem y)
@@ -83,4 +92,4 @@ namespace Selector
             }
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Spotify/FactoryProvider/CachingRefreshTokenFactoryProvider.cs b/Selector.Spotify/FactoryProvider/CachingRefreshTokenFactoryProvider.cs
similarity index 82%
rename from Selector/Spotify/FactoryProvider/CachingRefreshTokenFactoryProvider.cs
rename to Selector.Spotify/FactoryProvider/CachingRefreshTokenFactoryProvider.cs
index 4c8875d..fcf1ba9 100644
--- a/Selector/Spotify/FactoryProvider/CachingRefreshTokenFactoryProvider.cs
+++ b/Selector.Spotify/FactoryProvider/CachingRefreshTokenFactoryProvider.cs
@@ -1,18 +1,16 @@
-using System;
-using System.Collections.Generic;
-using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Options;
+using Selector.Spotify.ConfigFactory;
 using SpotifyAPI.Web;
 
-
-namespace Selector
+namespace Selector.Spotify.FactoryProvider
 {
     public class CachingRefreshTokenFactoryProvider : RefreshTokenFactoryProvider
     {
         protected readonly ILogger<CachingRefreshTokenFactoryProvider> Logger;
 
-        public CachingRefreshTokenFactoryProvider(IOptions<SpotifyAppCredentials> credentials, ILogger<CachingRefreshTokenFactoryProvider> logger) : base(credentials)
+        public CachingRefreshTokenFactoryProvider(IOptions<SpotifyAppCredentials> credentials,
+            ILogger<CachingRefreshTokenFactoryProvider> logger) : base(credentials)
         {
             Logger = logger;
         }
@@ -29,11 +27,11 @@ namespace Selector
             var client = new SpotifyClient(newConfig);
             var userDetails = await client.UserProfile.Current();
 
-            if(Configs.ContainsKey(userDetails.Id))
+            if (Configs.ContainsKey(userDetails.Id))
             {
                 return Configs[userDetails.Id];
             }
-            else 
+            else
             {
                 Logger.LogDebug("New user token factory added [{spotify_name}]", userDetails.DisplayName);
                 Configs[userDetails.Id] = configProvider;
@@ -41,4 +39,4 @@ namespace Selector
             }
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Spotify/FactoryProvider/IRefreshTokenFactoryProvider.cs b/Selector.Spotify/FactoryProvider/IRefreshTokenFactoryProvider.cs
similarity index 62%
rename from Selector/Spotify/FactoryProvider/IRefreshTokenFactoryProvider.cs
rename to Selector.Spotify/FactoryProvider/IRefreshTokenFactoryProvider.cs
index c4b9b5d..991a9a6 100644
--- a/Selector/Spotify/FactoryProvider/IRefreshTokenFactoryProvider.cs
+++ b/Selector.Spotify/FactoryProvider/IRefreshTokenFactoryProvider.cs
@@ -1,9 +1,9 @@
-using System.Threading.Tasks;
+using Selector.Spotify.ConfigFactory;
 
-namespace Selector
+namespace Selector.Spotify.FactoryProvider
 {
     public interface IRefreshTokenFactoryProvider
     {
         public Task<RefreshTokenFactory> GetFactory(string refreshToken);
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Spotify/FactoryProvider/RefreshTokenFactoryProvider.cs b/Selector.Spotify/FactoryProvider/RefreshTokenFactoryProvider.cs
similarity index 56%
rename from Selector/Spotify/FactoryProvider/RefreshTokenFactoryProvider.cs
rename to Selector.Spotify/FactoryProvider/RefreshTokenFactoryProvider.cs
index b8d5c06..23825fb 100644
--- a/Selector/Spotify/FactoryProvider/RefreshTokenFactoryProvider.cs
+++ b/Selector.Spotify/FactoryProvider/RefreshTokenFactoryProvider.cs
@@ -1,12 +1,7 @@
-using System;
-using System.Collections.Generic;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Options;
-using SpotifyAPI.Web;
+using Selector.Spotify.ConfigFactory;
 
-
-namespace Selector
+namespace Selector.Spotify.FactoryProvider
 {
     /// <summary>
     /// Get config from a refresh token
@@ -22,9 +17,10 @@ namespace Selector
 
         public virtual Task<RefreshTokenFactory> GetFactory(string refreshToken)
         {
-            if(string.IsNullOrEmpty(refreshToken)) throw new ArgumentException("Null or empty refresh key provided");
+            if (string.IsNullOrEmpty(refreshToken)) throw new ArgumentException("Null or empty refresh key provided");
 
-            return Task.FromResult(new RefreshTokenFactory(Credentials.ClientId, Credentials.ClientSecret, refreshToken));
+            return Task.FromResult(
+                new RefreshTokenFactory(Credentials.ClientId, Credentials.ClientSecret, refreshToken));
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/JsonContext.cs b/Selector.Spotify/JsonContext.cs
similarity index 53%
rename from Selector/JsonContext.cs
rename to Selector.Spotify/JsonContext.cs
index 5002800..3f774b7 100644
--- a/Selector/JsonContext.cs
+++ b/Selector.Spotify/JsonContext.cs
@@ -1,12 +1,12 @@
 using System.Text.Json.Serialization;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify
 {
     [JsonSerializable(typeof(CurrentlyPlayingDTO))]
     [JsonSerializable(typeof(TrackAudioFeatures))]
-    [JsonSerializable(typeof(ListeningChangeEventArgs))]
-    public partial class JsonContext: JsonSerializerContext
+    [JsonSerializable(typeof(SpotifyListeningChangeEventArgs))]
+    public partial class SpotifyJsonContext : JsonSerializerContext
     {
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Spotify/Selector.Spotify.csproj b/Selector.Spotify/Selector.Spotify.csproj
new file mode 100644
index 0000000..9b2f3de
--- /dev/null
+++ b/Selector.Spotify/Selector.Spotify.csproj
@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net9.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0"/>
+        <PackageReference Include="SpotifyAPI.Web" Version="7.2.1"/>
+        <PackageReference Include="Inflatable.Lastfm" Version="1.2.0"/>
+        <PackageReference Include="System.Linq.Async" Version="6.0.1"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <ProjectReference Include="..\Selector\Selector.csproj"/>
+    </ItemGroup>
+
+</Project>
diff --git a/Selector/Timeline/AnalysedTrackTimeline.cs b/Selector.Spotify/Timeline/AnalysedTrackTimeline.cs
similarity index 87%
rename from Selector/Timeline/AnalysedTrackTimeline.cs
rename to Selector.Spotify/Timeline/AnalysedTrackTimeline.cs
index 89ffc77..3dd90a5 100644
--- a/Selector/Timeline/AnalysedTrackTimeline.cs
+++ b/Selector.Spotify/Timeline/AnalysedTrackTimeline.cs
@@ -1,17 +1,14 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using System.Linq;
+using Selector.Spotify.Consumer;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Timeline
 {
-    public class AnalysedTrackTimeline 
+    public class AnalysedTrackTimeline
         : Timeline<AnalysedTrack>, ITrackTimeline<AnalysedTrack>
     {
         public IEqual EqualityChecker { get; set; }
 
-        public AnalysedTrack Get(FullTrack track) 
+        public AnalysedTrack Get(FullTrack track)
             => GetAll(track)
                 .LastOrDefault();
 
@@ -47,4 +44,4 @@ namespace Selector
             => Recent
                 .Where(i => EqualityChecker.IsEqual(i.Item.Track.Artists[0], artist));
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Timeline/Interfaces/ITrackTimeline.cs b/Selector.Spotify/Timeline/ITrackTimeline.cs
similarity index 79%
rename from Selector/Timeline/Interfaces/ITrackTimeline.cs
rename to Selector.Spotify/Timeline/ITrackTimeline.cs
index 54eeb9e..fc1b30b 100644
--- a/Selector/Timeline/Interfaces/ITrackTimeline.cs
+++ b/Selector.Spotify/Timeline/ITrackTimeline.cs
@@ -1,10 +1,8 @@
-using System;
-using System.Collections.Generic;
-using SpotifyAPI.Web;
+using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Timeline
 {
-    public interface ITrackTimeline<T>: ITimeline<T>
+    public interface ITrackTimeline<T> : ITimeline<T>
     {
         public T Get(FullTrack track);
         public IEnumerable<T> GetAll(FullTrack track);
@@ -18,4 +16,4 @@ namespace Selector
         public IEnumerable<T> GetAll(SimpleArtist artist);
         public IEnumerable<TimelineItem<T>> GetAllTimelineItems(SimpleArtist artist);
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Timeline/PlayerTimeline.cs b/Selector.Spotify/Timeline/PlayerTimeline.cs
similarity index 84%
rename from Selector/Timeline/PlayerTimeline.cs
rename to Selector.Spotify/Timeline/PlayerTimeline.cs
index 8610b47..a362bff 100644
--- a/Selector/Timeline/PlayerTimeline.cs
+++ b/Selector.Spotify/Timeline/PlayerTimeline.cs
@@ -1,19 +1,15 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using System.Linq;
-using SpotifyAPI.Web;
+using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Timeline
 {
-    public class PlayerTimeline 
+    public class PlayerTimeline
         : Timeline<CurrentlyPlayingContext>, ITrackTimeline<CurrentlyPlayingContext>
     {
         public IEqual EqualityChecker { get; set; }
 
         public override void Add(CurrentlyPlayingContext item) => Add(item, DateHelper.FromUnixMilli(item.Timestamp));
 
-        public CurrentlyPlayingContext Get(FullTrack track) 
+        public CurrentlyPlayingContext Get(FullTrack track)
             => GetAll(track)
                 .LastOrDefault();
 
@@ -24,7 +20,7 @@ namespace Selector
         public IEnumerable<TimelineItem<CurrentlyPlayingContext>> GetAllTimelineItems(FullTrack track)
             => Recent
                 .Where(i => i.Item.Item is FullTrack iterTrack
-                        && EqualityChecker.IsEqual(iterTrack, track));
+                            && EqualityChecker.IsEqual(iterTrack, track));
 
         public CurrentlyPlayingContext Get(FullEpisode ep)
             => GetAll(ep)
@@ -37,7 +33,7 @@ namespace Selector
         public IEnumerable<TimelineItem<CurrentlyPlayingContext>> GetAllTimelineItems(FullEpisode ep)
             => Recent
                 .Where(i => i.Item.Item is FullEpisode iterEp
-                        && EqualityChecker.IsEqual(iterEp, ep));
+                            && EqualityChecker.IsEqual(iterEp, ep));
 
         public CurrentlyPlayingContext Get(SimpleAlbum album)
             => GetAll(album)
@@ -50,7 +46,7 @@ namespace Selector
         public IEnumerable<TimelineItem<CurrentlyPlayingContext>> GetAllTimelineItems(SimpleAlbum album)
             => Recent
                 .Where(i => i.Item.Item is FullTrack iterTrack
-                        && EqualityChecker.IsEqual(iterTrack.Album, album));
+                            && EqualityChecker.IsEqual(iterTrack.Album, album));
 
         public CurrentlyPlayingContext Get(SimpleArtist artist)
             => GetAll(artist)
@@ -63,7 +59,7 @@ namespace Selector
         public IEnumerable<TimelineItem<CurrentlyPlayingContext>> GetAllTimelineItems(SimpleArtist artist)
             => Recent
                 .Where(i => i.Item.Item is FullTrack iterTrack
-                        && EqualityChecker.IsEqual(iterTrack.Artists[0], artist));
+                            && EqualityChecker.IsEqual(iterTrack.Artists[0], artist));
 
         public CurrentlyPlayingContext Get(Device device)
             => GetAll(device)
@@ -73,7 +69,7 @@ namespace Selector
             => GetAllTimelineItems(device)
                 .Select(t => t.Item);
 
-        public IEnumerable<TimelineItem<CurrentlyPlayingContext>> GetAllTimelineItems(Device device) 
+        public IEnumerable<TimelineItem<CurrentlyPlayingContext>> GetAllTimelineItems(Device device)
             => Recent
                 .Where(i => EqualityChecker.IsEqual(i.Item.Device, device));
 
@@ -89,4 +85,4 @@ namespace Selector
             => Recent
                 .Where(i => EqualityChecker.IsEqual(i.Item.Context, context));
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Watcher/BaseSpotifyWatcher.cs b/Selector.Spotify/Watcher/BaseSpotifyWatcher.cs
similarity index 87%
rename from Selector/Watcher/BaseSpotifyWatcher.cs
rename to Selector.Spotify/Watcher/BaseSpotifyWatcher.cs
index 76f195f..352c828 100644
--- a/Selector/Watcher/BaseSpotifyWatcher.cs
+++ b/Selector.Spotify/Watcher/BaseSpotifyWatcher.cs
@@ -1,8 +1,6 @@
-using System.Collections.Generic;
-using System.Linq;
 using Microsoft.Extensions.Logging;
 
-namespace Selector;
+namespace Selector.Spotify.Watcher;
 
 public abstract class BaseSpotifyWatcher(ILogger<BaseWatcher> logger = null) : BaseWatcher(logger)
 {
diff --git a/Selector/Watcher/DummySpotifyPlayerWatcher.cs b/Selector.Spotify/Watcher/DummySpotifyPlayerWatcher.cs
similarity index 96%
rename from Selector/Watcher/DummySpotifyPlayerWatcher.cs
rename to Selector.Spotify/Watcher/DummySpotifyPlayerWatcher.cs
index 3520b62..55af0a7 100644
--- a/Selector/Watcher/DummySpotifyPlayerWatcher.cs
+++ b/Selector.Spotify/Watcher/DummySpotifyPlayerWatcher.cs
@@ -1,11 +1,8 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
+using Selector.Extensions;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Watcher
 {
     public class DummySpotifyPlayerWatcher : SpotifyPlayerWatcher
     {
diff --git a/Selector/Watcher/Interfaces/IPlaylistWatcher.cs b/Selector.Spotify/Watcher/Interfaces/IPlaylistWatcher.cs
similarity index 87%
rename from Selector/Watcher/Interfaces/IPlaylistWatcher.cs
rename to Selector.Spotify/Watcher/Interfaces/IPlaylistWatcher.cs
index 8c1d6d6..7a8a621 100644
--- a/Selector/Watcher/Interfaces/IPlaylistWatcher.cs
+++ b/Selector.Spotify/Watcher/Interfaces/IPlaylistWatcher.cs
@@ -1,9 +1,8 @@
-using System;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify
 {
-    public interface IPlaylistWatcher: IWatcher
+    public interface IPlaylistWatcher : IWatcher
     {
         public event EventHandler<PlaylistChangeEventArgs> NetworkPoll;
         public event EventHandler<PlaylistChangeEventArgs> SnapshotChange;
@@ -17,4 +16,4 @@ namespace Selector
         public FullPlaylist Live { get; }
         public Timeline<FullPlaylist> Past { get; }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Spotify/Watcher/Interfaces/ISpotifyPlayerWatcher.cs b/Selector.Spotify/Watcher/Interfaces/ISpotifyPlayerWatcher.cs
new file mode 100644
index 0000000..e571c8d
--- /dev/null
+++ b/Selector.Spotify/Watcher/Interfaces/ISpotifyPlayerWatcher.cs
@@ -0,0 +1,30 @@
+using Selector.Spotify.Timeline;
+using SpotifyAPI.Web;
+
+namespace Selector.Spotify
+{
+    public interface ISpotifyPlayerWatcher : IWatcher
+    {
+        /// <summary>
+        /// Track or episode changes
+        /// </summary>
+        public event EventHandler<SpotifyListeningChangeEventArgs> NetworkPoll;
+
+        public event EventHandler<SpotifyListeningChangeEventArgs> ItemChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> AlbumChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> ArtistChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> ContextChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> ContentChange;
+
+        public event EventHandler<SpotifyListeningChangeEventArgs> VolumeChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> DeviceChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> PlayingChange;
+
+        /// <summary>
+        /// Last retrieved currently playing
+        /// </summary>
+        public CurrentlyPlayingContext Live { get; }
+
+        public PlayerTimeline Past { get; }
+    }
+}
\ No newline at end of file
diff --git a/Selector/Watcher/Interfaces/ISpotifyWatcherFactory.cs b/Selector.Spotify/Watcher/Interfaces/ISpotifyWatcherFactory.cs
similarity index 74%
rename from Selector/Watcher/Interfaces/ISpotifyWatcherFactory.cs
rename to Selector.Spotify/Watcher/Interfaces/ISpotifyWatcherFactory.cs
index e17bf38..0f391cf 100644
--- a/Selector/Watcher/Interfaces/ISpotifyWatcherFactory.cs
+++ b/Selector.Spotify/Watcher/Interfaces/ISpotifyWatcherFactory.cs
@@ -1,6 +1,6 @@
-using System.Threading.Tasks;
+using Selector.Spotify.ConfigFactory;
 
-namespace Selector
+namespace Selector.Spotify
 {
     public interface ISpotifyWatcherFactory
     {
diff --git a/Selector/Watcher/PlaylistWatcher.cs b/Selector.Spotify/Watcher/PlaylistWatcher.cs
similarity index 97%
rename from Selector/Watcher/PlaylistWatcher.cs
rename to Selector.Spotify/Watcher/PlaylistWatcher.cs
index f23505a..fb206e9 100644
--- a/Selector/Watcher/PlaylistWatcher.cs
+++ b/Selector.Spotify/Watcher/PlaylistWatcher.cs
@@ -1,14 +1,10 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
-using Selector.Equality;
+using Selector.Extensions;
+using Selector.Spotify.Equality;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Watcher
 {
     public class PlaylistWatcherConfig
     {
diff --git a/Selector/Watcher/SpotifyPlayerWatcher.cs b/Selector.Spotify/Watcher/SpotifyPlayerWatcher.cs
similarity index 84%
rename from Selector/Watcher/SpotifyPlayerWatcher.cs
rename to Selector.Spotify/Watcher/SpotifyPlayerWatcher.cs
index 7bb910e..0096628 100644
--- a/Selector/Watcher/SpotifyPlayerWatcher.cs
+++ b/Selector.Spotify/Watcher/SpotifyPlayerWatcher.cs
@@ -1,12 +1,10 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
+using Selector.Extensions;
+using Selector.Spotify.Timeline;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Watcher
 {
     public class SpotifyPlayerWatcher : BaseSpotifyWatcher, ISpotifyPlayerWatcher
     {
@@ -14,16 +12,16 @@ namespace Selector
         private readonly IPlayerClient spotifyClient;
         private readonly IEqual eq;
 
-        public event EventHandler<ListeningChangeEventArgs> NetworkPoll;
-        public event EventHandler<ListeningChangeEventArgs> ItemChange;
-        public event EventHandler<ListeningChangeEventArgs> AlbumChange;
-        public event EventHandler<ListeningChangeEventArgs> ArtistChange;
-        public event EventHandler<ListeningChangeEventArgs> ContextChange;
-        public event EventHandler<ListeningChangeEventArgs> ContentChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> NetworkPoll;
+        public event EventHandler<SpotifyListeningChangeEventArgs> ItemChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> AlbumChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> ArtistChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> ContextChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> ContentChange;
 
-        public event EventHandler<ListeningChangeEventArgs> VolumeChange;
-        public event EventHandler<ListeningChangeEventArgs> DeviceChange;
-        public event EventHandler<ListeningChangeEventArgs> PlayingChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> VolumeChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> DeviceChange;
+        public event EventHandler<SpotifyListeningChangeEventArgs> PlayingChange;
 
         public CurrentlyPlayingContext Live { get; protected set; }
         protected CurrentlyPlayingContext Previous { get; set; }
@@ -229,8 +227,8 @@ namespace Selector
             }
         }
 
-        protected ListeningChangeEventArgs GetEvent() =>
-            ListeningChangeEventArgs.From(Previous, Live, Past, id: Id, username: SpotifyUsername);
+        protected SpotifyListeningChangeEventArgs GetEvent() =>
+            SpotifyListeningChangeEventArgs.From(Previous, Live, Past, id: Id, username: SpotifyUsername);
 
         /// <summary>
         /// Store currently playing in last plays. Determine whether new list or appending required
@@ -243,47 +241,47 @@ namespace Selector
 
         #region Event Firers
 
-        protected virtual void OnNetworkPoll(ListeningChangeEventArgs args)
+        protected virtual void OnNetworkPoll(SpotifyListeningChangeEventArgs args)
         {
             NetworkPoll?.Invoke(this, args);
         }
 
-        protected virtual void OnItemChange(ListeningChangeEventArgs args)
+        protected virtual void OnItemChange(SpotifyListeningChangeEventArgs args)
         {
             ItemChange?.Invoke(this, args);
         }
 
-        protected virtual void OnAlbumChange(ListeningChangeEventArgs args)
+        protected virtual void OnAlbumChange(SpotifyListeningChangeEventArgs args)
         {
             AlbumChange?.Invoke(this, args);
         }
 
-        protected virtual void OnArtistChange(ListeningChangeEventArgs args)
+        protected virtual void OnArtistChange(SpotifyListeningChangeEventArgs args)
         {
             ArtistChange?.Invoke(this, args);
         }
 
-        protected virtual void OnContextChange(ListeningChangeEventArgs args)
+        protected virtual void OnContextChange(SpotifyListeningChangeEventArgs args)
         {
             ContextChange?.Invoke(this, args);
         }
 
-        protected virtual void OnContentChange(ListeningChangeEventArgs args)
+        protected virtual void OnContentChange(SpotifyListeningChangeEventArgs args)
         {
             ContentChange?.Invoke(this, args);
         }
 
-        protected virtual void OnVolumeChange(ListeningChangeEventArgs args)
+        protected virtual void OnVolumeChange(SpotifyListeningChangeEventArgs args)
         {
             VolumeChange?.Invoke(this, args);
         }
 
-        protected virtual void OnDeviceChange(ListeningChangeEventArgs args)
+        protected virtual void OnDeviceChange(SpotifyListeningChangeEventArgs args)
         {
             DeviceChange?.Invoke(this, args);
         }
 
-        protected virtual void OnPlayingChange(ListeningChangeEventArgs args)
+        protected virtual void OnPlayingChange(SpotifyListeningChangeEventArgs args)
         {
             PlayingChange?.Invoke(this, args);
         }
diff --git a/Selector/Watcher/SpotifyWatcherFactory.cs b/Selector.Spotify/Watcher/SpotifyWatcherFactory.cs
similarity index 96%
rename from Selector/Watcher/SpotifyWatcherFactory.cs
rename to Selector.Spotify/Watcher/SpotifyWatcherFactory.cs
index dc95539..9f41e57 100644
--- a/Selector/Watcher/SpotifyWatcherFactory.cs
+++ b/Selector.Spotify/Watcher/SpotifyWatcherFactory.cs
@@ -1,10 +1,9 @@
-using System;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
+using Selector.Spotify.ConfigFactory;
 using SpotifyAPI.Web;
 
-namespace Selector
+namespace Selector.Spotify.Watcher
 {
     public class SpotifyWatcherFactory : ISpotifyWatcherFactory
     {
diff --git a/Selector.Tests/Consumer/AudioInjector.cs b/Selector.Tests/Consumer/AudioInjector.cs
index 8b7367a..768e4fc 100644
--- a/Selector.Tests/Consumer/AudioInjector.cs
+++ b/Selector.Tests/Consumer/AudioInjector.cs
@@ -1,6 +1,10 @@
 using System;
 using System.Threading;
+using System.Threading.Tasks;
 using Moq;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
+using Selector.Spotify.Timeline;
 using SpotifyAPI.Web;
 using Xunit;
 
@@ -18,7 +22,7 @@ namespace Selector.Tests
 
             featureInjector.Subscribe();
 
-            watcherMock.VerifyAdd(m => m.ItemChange += It.IsAny<EventHandler<ListeningChangeEventArgs>>());
+            watcherMock.VerifyAdd(m => m.ItemChange += It.IsAny<EventHandler<SpotifyListeningChangeEventArgs>>());
         }
 
         [Fact]
@@ -31,7 +35,7 @@ namespace Selector.Tests
 
             featureInjector.Unsubscribe();
 
-            watcherMock.VerifyRemove(m => m.ItemChange -= It.IsAny<EventHandler<ListeningChangeEventArgs>>());
+            watcherMock.VerifyRemove(m => m.ItemChange -= It.IsAny<EventHandler<SpotifyListeningChangeEventArgs>>());
         }
 
         [Fact]
@@ -45,7 +49,8 @@ namespace Selector.Tests
 
             featureInjector.Subscribe(watcherFuncArgMock.Object);
 
-            watcherFuncArgMock.VerifyAdd(m => m.ItemChange += It.IsAny<EventHandler<ListeningChangeEventArgs>>());
+            watcherFuncArgMock.VerifyAdd(m =>
+                m.ItemChange += It.IsAny<EventHandler<SpotifyListeningChangeEventArgs>>());
             watcherMock.VerifyNoOtherCalls();
         }
 
@@ -60,17 +65,18 @@ namespace Selector.Tests
 
             featureInjector.Unsubscribe(watcherFuncArgMock.Object);
 
-            watcherFuncArgMock.VerifyRemove(m => m.ItemChange -= It.IsAny<EventHandler<ListeningChangeEventArgs>>());
+            watcherFuncArgMock.VerifyRemove(m =>
+                m.ItemChange -= It.IsAny<EventHandler<SpotifyListeningChangeEventArgs>>());
             watcherMock.VerifyNoOtherCalls();
         }
 
         [Fact]
-        public async void CallbackNoId()
+        public async Task CallbackNoId()
         {
             var watcherMock = new Mock<ISpotifyPlayerWatcher>();
             var spotifyMock = new Mock<ITracksClient>();
             var timelineMock = new Mock<AnalysedTrackTimeline>();
-            var eventArgsMock = new Mock<ListeningChangeEventArgs>();
+            var eventArgsMock = new Mock<SpotifyListeningChangeEventArgs>();
             var playingMock = new Mock<CurrentlyPlayingContext>();
             var trackMock = new Mock<FullTrack>();
             var featureMock = new Mock<TrackAudioFeatures>();
@@ -93,12 +99,12 @@ namespace Selector.Tests
         }
 
         [Fact]
-        public async void CallbackWithId()
+        public async Task CallbackWithId()
         {
             var watcherMock = new Mock<ISpotifyPlayerWatcher>();
             var spotifyMock = new Mock<ITracksClient>();
             var timelineMock = new Mock<AnalysedTrackTimeline>();
-            var eventArgsMock = new Mock<ListeningChangeEventArgs>();
+            var eventArgsMock = new Mock<SpotifyListeningChangeEventArgs>();
             var playingMock = new Mock<CurrentlyPlayingContext>();
             var trackMock = new Mock<FullTrack>();
             var featureMock = new Mock<TrackAudioFeatures>();
diff --git a/Selector.Tests/Consumer/AudioInjectorFactory.cs b/Selector.Tests/Consumer/AudioInjectorFactory.cs
index 60085cc..3a6b75e 100644
--- a/Selector.Tests/Consumer/AudioInjectorFactory.cs
+++ b/Selector.Tests/Consumer/AudioInjectorFactory.cs
@@ -1,14 +1,9 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
-using Xunit;
-using Moq;
 using FluentAssertions;
-using SpotifyAPI.Web;
-
-using Selector;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Selector.Spotify.ConfigFactory;
+using Selector.Spotify.Consumer.Factory;
+using Xunit;
 
 namespace Selector.Tests
 {
@@ -28,4 +23,4 @@ namespace Selector.Tests
             consumer.Should().NotBeNull();
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Tests/Consumer/WebHook.cs b/Selector.Tests/Consumer/WebHook.cs
index e736714..14b9030 100644
--- a/Selector.Tests/Consumer/WebHook.cs
+++ b/Selector.Tests/Consumer/WebHook.cs
@@ -6,6 +6,8 @@ using System.Threading.Tasks;
 using FluentAssertions;
 using Moq;
 using Moq.Protected;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using Xunit;
 
 namespace Selector.Tests
@@ -24,8 +26,8 @@ namespace Selector.Tests
                 .ReturnsAsync(msg);
 
             var watcherMock = new Mock<ISpotifyPlayerWatcher>();
-            watcherMock.SetupAdd(w => w.ItemChange += It.IsAny<EventHandler<ListeningChangeEventArgs>>());
-            watcherMock.SetupRemove(w => w.ItemChange -= It.IsAny<EventHandler<ListeningChangeEventArgs>>());
+            watcherMock.SetupAdd(w => w.ItemChange += It.IsAny<EventHandler<SpotifyListeningChangeEventArgs>>());
+            watcherMock.SetupRemove(w => w.ItemChange -= It.IsAny<EventHandler<SpotifyListeningChangeEventArgs>>());
 
             var link = "https://link";
             var content = new StringContent("");
@@ -40,7 +42,7 @@ namespace Selector.Tests
             var webHook = new WebHook(watcherMock.Object, http, config);
 
             webHook.Subscribe();
-            watcherMock.Raise(w => w.ItemChange += null, this, new ListeningChangeEventArgs());
+            watcherMock.Raise(w => w.ItemChange += null, this, new SpotifyListeningChangeEventArgs());
 
             await Task.Delay(100);
 
@@ -84,7 +86,7 @@ namespace Selector.Tests
 
             webHook.FailedRequest += (o, e) => { failedEvent = !successful; };
 
-            await webHook.AsyncCallback(ListeningChangeEventArgs.From(new(), new(), new()));
+            await webHook.AsyncCallback(SpotifyListeningChangeEventArgs.From(new(), new(), new()));
 
             predicateEvent.Should().Be(predicate);
             successfulEvent.Should().Be(successful);
diff --git a/Selector.Tests/Equality/Equal.cs b/Selector.Tests/Equality/Equal.cs
index 38f8835..c7ee3ce 100644
--- a/Selector.Tests/Equality/Equal.cs
+++ b/Selector.Tests/Equality/Equal.cs
@@ -1,62 +1,66 @@
-using System;
 using System.Collections.Generic;
-using Xunit;
-using Moq;
 using FluentAssertions;
+using Selector.Spotify.Equality;
 using SpotifyAPI.Web;
-
-using Selector;
+using Xunit;
 
 namespace Selector.Tests
 {
     public class EqualTests
     {
-        public static IEnumerable<object[]> TrackData => 
-        new List<object[]>
-        {
-            // SAME
-            new object[] {
-                Helper.FullTrack("1", "1", "1"),
-                Helper.FullTrack("1", "1", "1"),
-                true
-            },
-            // WRONG ALBUM
-            new object[] {
-                Helper.FullTrack("1", "1", "1"),
-                Helper.FullTrack("1", "2", "1"),
-                false
-            },
-            // WRONG TRACK
-            new object[] {
-                Helper.FullTrack("1", "1", "1"),
-                Helper.FullTrack("2", "1", "1"),
-                false
-            },
-            // WRONG ARTIST
-            new object[] {
-                Helper.FullTrack("1", "1", "1"),
-                Helper.FullTrack("1", "1", "2"),
-                false
-            },
-            // WRONG TRACK/ARTIST
-            new object[] {
-                Helper.FullTrack("1", "1", "1"),
-                Helper.FullTrack("2", "1", "2"),
-                false
-            },
-            // RIGHT MULTIPLE ARTISTS
-            new object[] {
-                Helper.FullTrack("1", "1", new List<string>() { "1", "2" }),
-                Helper.FullTrack("1", "1", new List<string>() { "1", "2" }),
-                true
-            },
-            // WRONG ARTISTS
-            new object[] {
-                Helper.FullTrack("1", "1", new List<string>() { "1", "2" }),
-                Helper.FullTrack("1", "1", new List<string>() { "1" }),
-                false
-            }
-        };
+        public static IEnumerable<object[]> TrackData =>
+            new List<object[]>
+            {
+                // SAME
+                new object[]
+                {
+                    Helper.FullTrack("1", "1", "1"),
+                    Helper.FullTrack("1", "1", "1"),
+                    true
+                },
+                // WRONG ALBUM
+                new object[]
+                {
+                    Helper.FullTrack("1", "1", "1"),
+                    Helper.FullTrack("1", "2", "1"),
+                    false
+                },
+                // WRONG TRACK
+                new object[]
+                {
+                    Helper.FullTrack("1", "1", "1"),
+                    Helper.FullTrack("2", "1", "1"),
+                    false
+                },
+                // WRONG ARTIST
+                new object[]
+                {
+                    Helper.FullTrack("1", "1", "1"),
+                    Helper.FullTrack("1", "1", "2"),
+                    false
+                },
+                // WRONG TRACK/ARTIST
+                new object[]
+                {
+                    Helper.FullTrack("1", "1", "1"),
+                    Helper.FullTrack("2", "1", "2"),
+                    false
+                },
+                // RIGHT MULTIPLE ARTISTS
+                new object[]
+                {
+                    Helper.FullTrack("1", "1", new List<string>() { "1", "2" }),
+                    Helper.FullTrack("1", "1", new List<string>() { "1", "2" }),
+                    true
+                },
+                // WRONG ARTISTS
+                new object[]
+                {
+                    Helper.FullTrack("1", "1", new List<string>() { "1", "2" }),
+                    Helper.FullTrack("1", "1", new List<string>() { "1" }),
+                    false
+                }
+            };
 
         [Theory]
         [MemberData(nameof(TrackData))]
@@ -67,39 +71,44 @@ namespace Selector.Tests
         }
 
         public static IEnumerable<object[]> AlbumData =>
-        new List<object[]>
-        {
-            // SAME
-            new object[] {
-                Helper.SimpleAlbum("1", "1"),
-                Helper.SimpleAlbum("1", "1"),
-                true
-            },
-            // DIFFERENT NAME
-            new object[] {
-                Helper.SimpleAlbum("1", "1"),
-                Helper.SimpleAlbum("2", "1"),
-                false
-            },
-            // DIFFERENT ARTIST
-            new object[] {
-                Helper.SimpleAlbum("1", "1"),
-                Helper.SimpleAlbum("1", "2"),
-                false
-            },
-            // SAME ARTISTS
-            new object[] {
-                Helper.SimpleAlbum("1", new List<string>() { "1", "2" }),
-                Helper.SimpleAlbum("1", new List<string>() { "1", "2" }),
-                true
-            },
-            // DIFFERENT ARTISTS
-            new object[] {
-                Helper.SimpleAlbum("1", new List<string>() { "1", "2" }),
-                Helper.SimpleAlbum("1", new List<string>() { "1" }),
-                false
-            },
-        };
+            new List<object[]>
+            {
+                // SAME
+                new object[]
+                {
+                    Helper.SimpleAlbum("1", "1"),
+                    Helper.SimpleAlbum("1", "1"),
+                    true
+                },
+                // DIFFERENT NAME
+                new object[]
+                {
+                    Helper.SimpleAlbum("1", "1"),
+                    Helper.SimpleAlbum("2", "1"),
+                    false
+                },
+                // DIFFERENT ARTIST
+                new object[]
+                {
+                    Helper.SimpleAlbum("1", "1"),
+                    Helper.SimpleAlbum("1", "2"),
+                    false
+                },
+                // SAME ARTISTS
+                new object[]
+                {
+                    Helper.SimpleAlbum("1", new List<string>() { "1", "2" }),
+                    Helper.SimpleAlbum("1", new List<string>() { "1", "2" }),
+                    true
+                },
+                // DIFFERENT ARTISTS
+                new object[]
+                {
+                    Helper.SimpleAlbum("1", new List<string>() { "1", "2" }),
+                    Helper.SimpleAlbum("1", new List<string>() { "1" }),
+                    false
+                },
+            };
 
         [Theory]
         [MemberData(nameof(AlbumData))]
@@ -110,21 +119,23 @@ namespace Selector.Tests
         }
 
         public static IEnumerable<object[]> ArtistData =>
-        new List<object[]>
-        {
-            // SAME
-            new object[] {
-                Helper.SimpleArtist("1"),
-                Helper.SimpleArtist("1"),
-                true
-            },
-            // DIFFERENT
-            new object[] {
-                Helper.SimpleArtist("1"),
-                Helper.SimpleArtist("2"),
-                false
-            }
-        };
+            new List<object[]>
+            {
+                // SAME
+                new object[]
+                {
+                    Helper.SimpleArtist("1"),
+                    Helper.SimpleArtist("1"),
+                    true
+                },
+                // DIFFERENT
+                new object[]
+                {
+                    Helper.SimpleArtist("1"),
+                    Helper.SimpleArtist("2"),
+                    false
+                }
+            };
 
         [Theory]
         [MemberData(nameof(ArtistData))]
@@ -135,21 +146,23 @@ namespace Selector.Tests
         }
 
         public static IEnumerable<object[]> EpisodeData =>
-        new List<object[]>
-        {
-            // SAME
-            new object[] {
-                Helper.FullEpisode("1"),
-                Helper.FullEpisode("1"),
-                true
-            },
-            // DIFFERENT
-            new object[] {
-                Helper.FullEpisode("1"),
-                Helper.FullEpisode("2"),
-                false
-            }
-        };
+            new List<object[]>
+            {
+                // SAME
+                new object[]
+                {
+                    Helper.FullEpisode("1"),
+                    Helper.FullEpisode("1"),
+                    true
+                },
+                // DIFFERENT
+                new object[]
+                {
+                    Helper.FullEpisode("1"),
+                    Helper.FullEpisode("2"),
+                    false
+                }
+            };
 
         [Theory]
         [MemberData(nameof(EpisodeData))]
@@ -159,4 +172,4 @@ namespace Selector.Tests
             eq.IsEqual(episode1, episode2).Should().Be(shouldEqual);
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Tests/Equality/UriEqual.cs b/Selector.Tests/Equality/UriEqual.cs
index b852999..9a92dbe 100644
--- a/Selector.Tests/Equality/UriEqual.cs
+++ b/Selector.Tests/Equality/UriEqual.cs
@@ -1,32 +1,31 @@
-using System;
 using System.Collections.Generic;
-using Xunit;
-using Moq;
 using FluentAssertions;
+using Selector.Spotify.Equality;
 using SpotifyAPI.Web;
-
-using Selector;
+using Xunit;
 
 namespace Selector.Tests
 {
     public class UriEqualTests
     {
-        public static IEnumerable<object[]> TrackData => 
-        new List<object[]>
-        {
-            // SAME
-            new object[] {
-                Helper.FullTrack("1"),
-                Helper.FullTrack("1"),
-                true
-            },
-            // WRONG
-            new object[] {
-                Helper.FullTrack("1"),
-                Helper.FullTrack("2"),
-                false
-            }
-        };
+        public static IEnumerable<object[]> TrackData =>
+            new List<object[]>
+            {
+                // SAME
+                new object[]
+                {
+                    Helper.FullTrack("1"),
+                    Helper.FullTrack("1"),
+                    true
+                },
+                // WRONG
+                new object[]
+                {
+                    Helper.FullTrack("1"),
+                    Helper.FullTrack("2"),
+                    false
+                }
+            };
 
         [Theory]
         [MemberData(nameof(TrackData))]
@@ -38,22 +37,24 @@ namespace Selector.Tests
                 .Be(shouldEqual);
         }
 
-        public static IEnumerable<object[]> AlbumData => 
-        new List<object[]>
-        {
-            // SAME
-            new object[] {
-                Helper.SimpleAlbum("1"),
-                Helper.SimpleAlbum("1"),
-                true
-            },
-            // WRONG
-            new object[] {
-                Helper.SimpleAlbum("1"),
-                Helper.SimpleAlbum("2"),
-                false
-            }
-        };
+        public static IEnumerable<object[]> AlbumData =>
+            new List<object[]>
+            {
+                // SAME
+                new object[]
+                {
+                    Helper.SimpleAlbum("1"),
+                    Helper.SimpleAlbum("1"),
+                    true
+                },
+                // WRONG
+                new object[]
+                {
+                    Helper.SimpleAlbum("1"),
+                    Helper.SimpleAlbum("2"),
+                    false
+                }
+            };
 
         [Theory]
         [MemberData(nameof(AlbumData))]
@@ -65,22 +66,24 @@ namespace Selector.Tests
                 .Be(shouldEqual);
         }
 
-        public static IEnumerable<object[]> ArtistData => 
-        new List<object[]>
-        {
-            // SAME
-            new object[] {
-                Helper.SimpleArtist("1"),
-                Helper.SimpleArtist("1"),
-                true
-            },
-            // WRONG
-            new object[] {
-                Helper.SimpleArtist("1"),
-                Helper.SimpleArtist("2"),
-                false
-            }
-        };
+        public static IEnumerable<object[]> ArtistData =>
+            new List<object[]>
+            {
+                // SAME
+                new object[]
+                {
+                    Helper.SimpleArtist("1"),
+                    Helper.SimpleArtist("1"),
+                    true
+                },
+                // WRONG
+                new object[]
+                {
+                    Helper.SimpleArtist("1"),
+                    Helper.SimpleArtist("2"),
+                    false
+                }
+            };
 
         [Theory]
         [MemberData(nameof(ArtistData))]
@@ -92,22 +95,24 @@ namespace Selector.Tests
                 .Be(shouldEqual);
         }
 
-        public static IEnumerable<object[]> EpisodeData => 
-        new List<object[]>
-        {
-            // SAME
-            new object[] {
-                Helper.FullEpisode("1"),
-                Helper.FullEpisode("1"),
-                true
-            },
-            // WRONG
-            new object[] {
-                Helper.FullEpisode("1"),
-                Helper.FullEpisode("2"),
-                false
-            }
-        };
+        public static IEnumerable<object[]> EpisodeData =>
+            new List<object[]>
+            {
+                // SAME
+                new object[]
+                {
+                    Helper.FullEpisode("1"),
+                    Helper.FullEpisode("1"),
+                    true
+                },
+                // WRONG
+                new object[]
+                {
+                    Helper.FullEpisode("1"),
+                    Helper.FullEpisode("2"),
+                    false
+                }
+            };
 
         [Theory]
         [MemberData(nameof(EpisodeData))]
@@ -119,22 +124,24 @@ namespace Selector.Tests
                 .Be(shouldEqual);
         }
 
-        public static IEnumerable<object[]> ContextData => 
-        new List<object[]>
-        {
-            // SAME
-            new object[] {
-                Helper.Context("1"),
-                Helper.Context("1"),
-                true
-            },
-            // WRONG
-            new object[] {
-                Helper.Context("1"),
-                Helper.Context("2"),
-                false
-            }
-        };
+        public static IEnumerable<object[]> ContextData =>
+            new List<object[]>
+            {
+                // SAME
+                new object[]
+                {
+                    Helper.Context("1"),
+                    Helper.Context("1"),
+                    true
+                },
+                // WRONG
+                new object[]
+                {
+                    Helper.Context("1"),
+                    Helper.Context("2"),
+                    false
+                }
+            };
 
         [Theory]
         [MemberData(nameof(ContextData))]
@@ -146,22 +153,24 @@ namespace Selector.Tests
                 .Be(shouldEqual);
         }
 
-        public static IEnumerable<object[]> DeviceData => 
-        new List<object[]>
-        {
-            // SAME
-            new object[] {
-                Helper.Device("1"),
-                Helper.Device("1"),
-                true
-            },
-            // WRONG
-            new object[] {
-                Helper.Device("1"),
-                Helper.Device("2"),
-                false
-            }
-        };
+        public static IEnumerable<object[]> DeviceData =>
+            new List<object[]>
+            {
+                // SAME
+                new object[]
+                {
+                    Helper.Device("1"),
+                    Helper.Device("1"),
+                    true
+                },
+                // WRONG
+                new object[]
+                {
+                    Helper.Device("1"),
+                    Helper.Device("2"),
+                    false
+                }
+            };
 
         [Theory]
         [MemberData(nameof(DeviceData))]
@@ -173,4 +182,4 @@ namespace Selector.Tests
                 .Be(shouldEqual);
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Tests/Selector.Tests.csproj b/Selector.Tests/Selector.Tests.csproj
index eea3ce7..c7d478f 100644
--- a/Selector.Tests/Selector.Tests.csproj
+++ b/Selector.Tests/Selector.Tests.csproj
@@ -24,6 +24,8 @@
 
   <ItemGroup>
     <ProjectReference Include="..\Selector.AppleMusic\Selector.AppleMusic.csproj"/>
+    <ProjectReference Include="..\Selector.LastFm\Selector.LastFm.csproj"/>
+    <ProjectReference Include="..\Selector.Spotify\Selector.Spotify.csproj"/>
     <ProjectReference Include="..\Selector\Selector.csproj" />
   </ItemGroup>
 
diff --git a/Selector.Tests/Timeline/PlayerTimeline.cs b/Selector.Tests/Timeline/PlayerTimeline.cs
index 9b327e5..98b6bec 100644
--- a/Selector.Tests/Timeline/PlayerTimeline.cs
+++ b/Selector.Tests/Timeline/PlayerTimeline.cs
@@ -1,35 +1,35 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
-using Xunit;
-using Moq;
 using FluentAssertions;
+using Selector.Spotify.Timeline;
 using SpotifyAPI.Web;
-
-using Selector;
+using Xunit;
 
 namespace Selector.Tests
 {
     public class PlayerTimelineTests
     {
-        public static IEnumerable<object[]> CountData => 
-        new List<object[]>
-        {
-            new object[] {
-                new CurrentlyPlayingContext[]
+        public static IEnumerable<object[]> CountData =>
+            new List<object[]>
+            {
+                new object[]
                 {
-                    Helper.CurrentPlayback(Helper.FullTrack("uri1"))
-                }
-            },
-            new object[] {
-                new CurrentlyPlayingContext[]
+                    new CurrentlyPlayingContext[]
+                    {
+                        Helper.CurrentPlayback(Helper.FullTrack("uri1"))
+                    }
+                },
+                new object[]
                 {
-                    Helper.CurrentPlayback(Helper.FullTrack("uri1")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri2")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri3")),
-                }
-            },
-        };
+                    new CurrentlyPlayingContext[]
+                    {
+                        Helper.CurrentPlayback(Helper.FullTrack("uri1")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri2")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri3")),
+                    }
+                },
+            };
 
         [Theory]
         [MemberData(nameof(CountData))]
@@ -37,7 +37,7 @@ namespace Selector.Tests
         {
             var timeline = new PlayerTimeline();
 
-            foreach(var i in currentlyPlaying)
+            foreach (var i in currentlyPlaying)
             {
                 timeline.Add(i);
             }
@@ -45,52 +45,60 @@ namespace Selector.Tests
             timeline.Count.Should().Be(currentlyPlaying.Length);
         }
 
-        public static IEnumerable<object[]> MaxSizeData => 
-        new List<object[]>
-        {
-            new object[] {
-                new CurrentlyPlayingContext[]
+        public static IEnumerable<object[]> MaxSizeData =>
+            new List<object[]>
+            {
+                new object[]
                 {
-                    Helper.CurrentPlayback(Helper.FullTrack("uri1"))
-                }, 5, 1
-            },
-            new object[] {
-                new CurrentlyPlayingContext[]
+                    new CurrentlyPlayingContext[]
+                    {
+                        Helper.CurrentPlayback(Helper.FullTrack("uri1"))
+                    },
+                    5, 1
+                },
+                new object[]
                 {
-                    Helper.CurrentPlayback(Helper.FullTrack("uri1")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri2")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri3"))
-                }, 1, 1
-            },
-            new object[] {
-                new CurrentlyPlayingContext[]
+                    new CurrentlyPlayingContext[]
+                    {
+                        Helper.CurrentPlayback(Helper.FullTrack("uri1")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri2")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri3"))
+                    },
+                    1, 1
+                },
+                new object[]
                 {
-                    Helper.CurrentPlayback(Helper.FullTrack("uri1")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri2")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri3")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri4")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri5")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri6")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri7")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri8")),
-                }, 5, 5
-            },
-            new object[] {
-                new CurrentlyPlayingContext[]
+                    new CurrentlyPlayingContext[]
+                    {
+                        Helper.CurrentPlayback(Helper.FullTrack("uri1")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri2")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri3")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri4")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri5")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri6")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri7")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri8")),
+                    },
+                    5, 5
+                },
+                new object[]
                 {
-                    Helper.CurrentPlayback(Helper.FullTrack("uri1")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri2")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri3")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri4")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri5")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri6")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri7")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri8")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri9")),
-                    Helper.CurrentPlayback(Helper.FullTrack("uri10"))
-                }, null, 10
-            }            
-        };
+                    new CurrentlyPlayingContext[]
+                    {
+                        Helper.CurrentPlayback(Helper.FullTrack("uri1")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri2")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri3")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri4")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri5")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri6")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri7")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri8")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri9")),
+                        Helper.CurrentPlayback(Helper.FullTrack("uri10"))
+                    },
+                    null, 10
+                }
+            };
 
         [Theory]
         [MemberData(nameof(MaxSizeData))]
@@ -133,7 +141,8 @@ namespace Selector.Tests
         [Fact]
         public void Sort()
         {
-            var timeline = new PlayerTimeline(){
+            var timeline = new PlayerTimeline()
+            {
                 SortOnBackDate = false
             };
 
@@ -164,7 +173,7 @@ namespace Selector.Tests
             var earlier = Helper.CurrentPlayback(Helper.FullTrack("uri1"));
             var earlierDate = DateTime.Now;
 
-            var middle= Helper.CurrentPlayback(Helper.FullTrack("uri3"));
+            var middle = Helper.CurrentPlayback(Helper.FullTrack("uri3"));
             var middleDate = DateTime.Now.AddDays(1);
 
             var later = Helper.CurrentPlayback(Helper.FullTrack("uri2"));
@@ -181,4 +190,4 @@ namespace Selector.Tests
             timeline.Select(i => i.Item).Should().Equal(earlier, middle, later);
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Tests/Watcher/PlayerWatcher.cs b/Selector.Tests/Watcher/PlayerWatcher.cs
index cd6d2f1..b62a2bf 100644
--- a/Selector.Tests/Watcher/PlayerWatcher.cs
+++ b/Selector.Tests/Watcher/PlayerWatcher.cs
@@ -3,6 +3,8 @@ using System.Threading;
 using System.Threading.Tasks;
 using FluentAssertions;
 using Moq;
+using Selector.Spotify.Equality;
+using Selector.Spotify.Watcher;
 using SpotifyAPI.Web;
 using Xunit;
 
@@ -26,7 +28,7 @@ namespace Selector.Tests
 
         [Theory]
         [MemberData(nameof(NowPlayingData))]
-        public async void NowPlaying(List<CurrentlyPlayingContext> playing)
+        public async Task NowPlaying(List<CurrentlyPlayingContext> playing)
         {
             var playingQueue = new Queue<CurrentlyPlayingContext>(playing);
 
@@ -252,7 +254,7 @@ namespace Selector.Tests
 
         [Theory]
         [MemberData(nameof(EventsData))]
-        public async void Events(List<CurrentlyPlayingContext> playing, List<string> toRaise, List<string> toNotRaise)
+        public async Task Events(List<CurrentlyPlayingContext> playing, List<string> toRaise, List<string> toNotRaise)
         {
             var playingQueue = new Queue<CurrentlyPlayingContext>(playing);
 
@@ -279,7 +281,7 @@ namespace Selector.Tests
         [InlineData(1000, 3500, 4)]
         [InlineData(500, 3800, 8)]
         [InlineData(100, 250, 3)]
-        public async void Watch(int pollPeriod, int execTime, int numberOfCalls)
+        public async Task Watch(int pollPeriod, int execTime, int numberOfCalls)
         {
             var spotMock = new Mock<IPlayerClient>();
             var eq = new UriEqual();
diff --git a/Selector.Tests/Watcher/PlaylistWatcher.cs b/Selector.Tests/Watcher/PlaylistWatcher.cs
index b17bd85..d69e9aa 100644
--- a/Selector.Tests/Watcher/PlaylistWatcher.cs
+++ b/Selector.Tests/Watcher/PlaylistWatcher.cs
@@ -1,39 +1,41 @@
-using System;
 using System.Collections.Generic;
-using Xunit;
-using Moq;
-using FluentAssertions;
-using SpotifyAPI.Web;
-
 using System.Threading;
 using System.Threading.Tasks;
-using Xunit.Sdk;
+using FluentAssertions;
+using Moq;
+using Selector.Spotify.Watcher;
+using SpotifyAPI.Web;
+using Xunit;
 
 namespace Selector.Tests
 {
     public class PlaylistWatcherTests
     {
         public static IEnumerable<object[]> CurrentPlaylistData =>
-        new List<object[]>
-        {
-            new object[] { new List<FullPlaylist>(){
-                    Helper.FullPlaylist("playlist1"),
-                    Helper.FullPlaylist("playlist1"),
-                    Helper.FullPlaylist("playlist1"),
-                    Helper.FullPlaylist("playlist1")
+            new List<object[]>
+            {
+                new object[]
+                {
+                    new List<FullPlaylist>()
+                    {
+                        Helper.FullPlaylist("playlist1"),
+                        Helper.FullPlaylist("playlist1"),
+                        Helper.FullPlaylist("playlist1"),
+                        Helper.FullPlaylist("playlist1")
+                    }
                 }
-            }
-        };
+            };
 
         [Theory]
         [MemberData(nameof(CurrentPlaylistData))]
-        public async void CurrentPlaylist(List<FullPlaylist> playing)
+        public async Task CurrentPlaylist(List<FullPlaylist> playing)
         {
             var playlistDequeue = new Queue<FullPlaylist>(playing);
 
             var spotMock = new Mock<ISpotifyClient>();
 
-            spotMock.Setup(s => s.Playlists.Get(It.IsAny<string>(), It.IsAny<CancellationToken>()).Result).Returns(playlistDequeue.Dequeue);
+            spotMock.Setup(s => s.Playlists.Get(It.IsAny<string>(), It.IsAny<CancellationToken>()).Result)
+                .Returns(playlistDequeue.Dequeue);
 
             var config = new PlaylistWatcherConfig() { PlaylistId = "spotify:playlist:test" };
             var watcher = new PlaylistWatcher(config, spotMock.Object);
@@ -46,41 +48,48 @@ namespace Selector.Tests
         }
 
         public static IEnumerable<object[]> EventsData =>
-        new List<object[]>
-        {
-            // NO CHANGING
-            new object[] { new List<FullPlaylist>(){
-                    Helper.FullPlaylist("Playlist", snapshotId: "snapshot1"),
-                    Helper.FullPlaylist("Playlist", snapshotId: "snapshot1"),
-                    Helper.FullPlaylist("Playlist", snapshotId: "snapshot1"),
+            new List<object[]>
+            {
+                // NO CHANGING
+                new object[]
+                {
+                    new List<FullPlaylist>()
+                    {
+                        Helper.FullPlaylist("Playlist", snapshotId: "snapshot1"),
+                        Helper.FullPlaylist("Playlist", snapshotId: "snapshot1"),
+                        Helper.FullPlaylist("Playlist", snapshotId: "snapshot1"),
+                    },
+                    // to raise
+                    new List<string>() { },
+                    // to not raise
+                    new List<string>() { "SnapshotChange" }
                 },
-                // to raise
-                new List<string>(){  },
-                // to not raise
-                new List<string>(){ "SnapshotChange" }
-            },
-            // CHANGING SNAPSHOT
-            new object[] { new List<FullPlaylist>(){
-                    Helper.FullPlaylist("Playlist", snapshotId: "snapshot1"),
-                    Helper.FullPlaylist("Playlist", snapshotId: "snapshot2"),
-                    Helper.FullPlaylist("Playlist", snapshotId: "snapshot2"),
-                },
-                // to raise
-                new List<string>(){ "SnapshotChange" },
-                // to not raise
-                new List<string>(){  }
-            }
-        };
+                // CHANGING SNAPSHOT
+                new object[]
+                {
+                    new List<FullPlaylist>()
+                    {
+                        Helper.FullPlaylist("Playlist", snapshotId: "snapshot1"),
+                        Helper.FullPlaylist("Playlist", snapshotId: "snapshot2"),
+                        Helper.FullPlaylist("Playlist", snapshotId: "snapshot2"),
+                    },
+                    // to raise
+                    new List<string>() { "SnapshotChange" },
+                    // to not raise
+                    new List<string>() { }
+                }
+            };
 
         [Theory]
         [MemberData(nameof(EventsData))]
-        public async void Events(List<FullPlaylist> playing, List<string> toRaise, List<string> toNotRaise)
+        public async Task Events(List<FullPlaylist> playing, List<string> toRaise, List<string> toNotRaise)
         {
             var playlistDequeue = new Queue<FullPlaylist>(playing);
 
             var spotMock = new Mock<ISpotifyClient>();
 
-            spotMock.Setup(s => s.Playlists.Get(It.IsAny<string>(), It.IsAny<CancellationToken>()).Result).Returns(playlistDequeue.Dequeue);
+            spotMock.Setup(s => s.Playlists.Get(It.IsAny<string>(), It.IsAny<CancellationToken>()).Result)
+                .Returns(playlistDequeue.Dequeue);
 
             var config = new PlaylistWatcherConfig() { PlaylistId = "spotify:playlist:test" };
             var watcher = new PlaylistWatcher(config, spotMock.Object);
@@ -96,4 +105,4 @@ namespace Selector.Tests
             toNotRaise.ForEach(r => monitoredWatcher.Should().NotRaise(r));
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/_Layout.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/_Layout.cshtml
index c477298..983b985 100644
--- a/Selector.Web/Areas/Identity/Pages/Account/Manage/_Layout.cshtml
+++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/_Layout.cshtml
@@ -5,7 +5,8 @@
     }
     else
     {
-        Layout = "/Areas/Identity/Pages/_Layout.cshtml";
+        throw new NotImplementedException();
+        // Layout = "/Areas/Identity/Pages/_Layout.cshtml";
     }
 }
 
diff --git a/Selector.Web/Hubs/NowPlayingHub.cs b/Selector.Web/Hubs/NowPlayingHub.cs
index 52a830f..563e635 100644
--- a/Selector.Web/Hubs/NowPlayingHub.cs
+++ b/Selector.Web/Hubs/NowPlayingHub.cs
@@ -11,6 +11,8 @@ using Selector.Cache;
 using Selector.Model;
 using Selector.Model.Extensions;
 using Selector.SignalR;
+using Selector.Spotify;
+using Selector.Spotify.Consumer;
 using StackExchange.Redis;
 
 namespace Selector.Web.Hubs
@@ -55,7 +57,8 @@ namespace Selector.Web.Hubs
             var nowPlaying = await Cache.StringGetAsync(Key.CurrentlyPlayingSpotify(Context.UserIdentifier));
             if (nowPlaying != RedisValue.Null)
             {
-                var deserialised = JsonSerializer.Deserialize(nowPlaying, JsonContext.Default.CurrentlyPlayingDTO);
+                var deserialised =
+                    JsonSerializer.Deserialize(nowPlaying, SpotifyJsonContext.Default.CurrentlyPlayingDTO);
                 await Clients.Caller.OnNewPlaying(deserialised);
             }
         }
diff --git a/Selector.Web/Startup.cs b/Selector.Web/Startup.cs
index 7044b20..8ebfadd 100644
--- a/Selector.Web/Startup.cs
+++ b/Selector.Web/Startup.cs
@@ -15,6 +15,7 @@ using Selector.Events;
 using Selector.Extensions;
 using Selector.Model;
 using Selector.Model.Extensions;
+using Selector.Spotify;
 using Selector.Web.Auth;
 using Selector.Web.Extensions;
 using Selector.Web.Hubs;
@@ -33,10 +34,7 @@ namespace Selector.Web
         // This method gets called by the runtime. Use this method to add services to the container.
         public void ConfigureServices(IServiceCollection services)
         {
-            services.Configure<RootOptions>(options =>
-            {
-                OptionsHelper.ConfigureOptions(options, Configuration);
-            });
+            services.Configure<RootOptions>(options => { OptionsHelper.ConfigureOptions(options, Configuration); });
             services.Configure<RedisOptions>(options =>
             {
                 Configuration.GetSection(string.Join(':', RootOptions.Key, RedisOptions.Key)).Bind(options);
@@ -45,10 +43,7 @@ namespace Selector.Web
             {
                 Configuration.GetSection(string.Join(':', RootOptions.Key, NowPlayingOptions.Key)).Bind(options);
             });
-            services.Configure<JwtOptions>(options =>
-            {
-                Configuration.GetSection(JwtOptions._Key).Bind(options);
-            });
+            services.Configure<JwtOptions>(options => { Configuration.GetSection(JwtOptions._Key).Bind(options); });
             services.Configure<AppleMusicOptions>(options =>
             {
                 Configuration.GetSection(AppleMusicOptions._Key).Bind(options);
@@ -60,23 +55,23 @@ namespace Selector.Web
             {
                 options.ClientId = config.ClientId;
                 options.ClientSecret = config.ClientSecret;
-            }); 
+            });
 
             services.AddRazorPages(o =>
-            {
-                o.Conventions.AllowAnonymousToPage("/");
-                o.Conventions.AuthorizePage("/Now", AuthConstants.CookieAuthentication);
-                o.Conventions.AuthorizePage("/Past", AuthConstants.CookieAuthentication);
-                o.Conventions.AllowAnonymousToPage("/Privacy");
-                o.Conventions.AllowAnonymousToPage("/Error");
-                o.Conventions.AllowAnonymousToAreaPage("Identity", "/Login");
-                o.Conventions.AllowAnonymousToAreaPage("Identity", "/Logout");
-                o.Conventions.AllowAnonymousToAreaPage("Identity", "/Register");
-                o.Conventions.AllowAnonymousToAreaPage("Identity", "/AccessDenied");
-                o.Conventions.AllowAnonymousToAreaPage("Identity", "/Lockout");
-                o.Conventions.AuthorizeAreaPage("Identity", "/Manage", AuthConstants.CookieAuthentication);
-            })
-                    .AddRazorRuntimeCompilation();
+                {
+                    o.Conventions.AllowAnonymousToPage("/");
+                    o.Conventions.AuthorizePage("/Now", AuthConstants.CookieAuthentication);
+                    o.Conventions.AuthorizePage("/Past", AuthConstants.CookieAuthentication);
+                    o.Conventions.AllowAnonymousToPage("/Privacy");
+                    o.Conventions.AllowAnonymousToPage("/Error");
+                    o.Conventions.AllowAnonymousToAreaPage("Identity", "/Login");
+                    o.Conventions.AllowAnonymousToAreaPage("Identity", "/Logout");
+                    o.Conventions.AllowAnonymousToAreaPage("Identity", "/Register");
+                    o.Conventions.AllowAnonymousToAreaPage("Identity", "/AccessDenied");
+                    o.Conventions.AllowAnonymousToAreaPage("Identity", "/Lockout");
+                    o.Conventions.AuthorizeAreaPage("Identity", "/Manage", AuthConstants.CookieAuthentication);
+                })
+                .AddRazorRuntimeCompilation();
             services.AddControllers();
             services.AddSignalR(o => o.EnableDetailedErrors = true);
             services.AddHttpClient();
@@ -95,12 +90,12 @@ namespace Selector.Web
 
         public void ConfigureDB(IServiceCollection services, RootOptions config)
         {
-            services.AddDbContext<ApplicationDbContext>(options => 
+            services.AddDbContext<ApplicationDbContext>(options =>
                 options.UseNpgsql(Configuration.GetConnectionString("Default"))
             );
             services.AddDBPlayCountPuller();
             services.AddTransient<IScrobbleRepository, ScrobbleRepository>()
-                    .AddTransient<ISpotifyListenRepository, SpotifyListenRepository>();
+                .AddTransient<ISpotifyListenRepository, SpotifyListenRepository>();
 
             services.AddTransient<IListenRepository, MetaListenRepository>();
             //services.AddTransient<IListenRepository, SpotifyListenRepository>();
@@ -130,7 +125,7 @@ namespace Selector.Web
 
                 // User settings.
                 options.User.AllowedUserNameCharacters =
-                "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
+                    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
                 options.User.RequireUniqueEmail = false;
                 options.SignIn.RequireConfirmedEmail = false;
             });
@@ -173,7 +168,8 @@ namespace Selector.Web
             {
                 options.FallbackPolicy = new AuthorizationPolicyBuilder()
                     .RequireAuthenticatedUser()
-                    .AddAuthenticationSchemes(IdentityConstants.ApplicationScheme, JwtBearerDefaults.AuthenticationScheme)
+                    .AddAuthenticationSchemes(IdentityConstants.ApplicationScheme,
+                        JwtBearerDefaults.AuthenticationScheme)
                     .Build();
 
                 options.AddPolicy(AuthConstants.CookieAuthentication, new AuthorizationPolicyBuilder()
@@ -259,4 +255,4 @@ namespace Selector.Web
             }
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector.sln b/Selector.sln
index 1f7647c..e32cb68 100644
--- a/Selector.sln
+++ b/Selector.sln
@@ -25,6 +25,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.SignalR", "Selecto
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.AppleMusic", "Selector.AppleMusic\Selector.AppleMusic.csproj", "{5049A2F6-9604-49B1-B826-1CAC1B009D5D}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.Spotify", "Selector.Spotify\Selector.Spotify.csproj", "{21E8CF70-8590-4CD3-BAA5-FDD2CD174682}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.LastFm", "Selector.LastFm\Selector.LastFm.csproj", "{9EBEC247-C0C3-4201-928F-94558659E191}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -75,6 +79,14 @@ Global
 		{5049A2F6-9604-49B1-B826-1CAC1B009D5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{5049A2F6-9604-49B1-B826-1CAC1B009D5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{5049A2F6-9604-49B1-B826-1CAC1B009D5D}.Release|Any CPU.Build.0 = Release|Any CPU
+		{21E8CF70-8590-4CD3-BAA5-FDD2CD174682}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{21E8CF70-8590-4CD3-BAA5-FDD2CD174682}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{21E8CF70-8590-4CD3-BAA5-FDD2CD174682}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{21E8CF70-8590-4CD3-BAA5-FDD2CD174682}.Release|Any CPU.Build.0 = Release|Any CPU
+		{9EBEC247-C0C3-4201-928F-94558659E191}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{9EBEC247-C0C3-4201-928F-94558659E191}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{9EBEC247-C0C3-4201-928F-94558659E191}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{9EBEC247-C0C3-4201-928F-94558659E191}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
diff --git a/Selector/Consumers/IConsumer.cs b/Selector/Consumers/IConsumer.cs
index 48180fa..29c9613 100644
--- a/Selector/Consumers/IConsumer.cs
+++ b/Selector/Consumers/IConsumer.cs
@@ -10,12 +10,4 @@
     {
         public void Callback(object sender, T e);
     }
-
-    public interface ISpotifyPlayerConsumer : IConsumer<ListeningChangeEventArgs>
-    {
-    }
-
-    public interface IPlaylistConsumer : IConsumer<PlaylistChangeEventArgs>
-    {
-    }
 }
\ No newline at end of file
diff --git a/Selector/Equality/Equal.cs b/Selector/Equality/Equal.cs
index 0678d94..4f173dc 100644
--- a/Selector/Equality/Equal.cs
+++ b/Selector/Equality/Equal.cs
@@ -1,7 +1,5 @@
 using System;
 using System.Collections.Generic;
-using System.Text;
-using SpotifyAPI.Web;
 
 namespace Selector
 {
@@ -11,12 +9,12 @@ namespace Selector
 
         public bool IsEqual<T>(T item, T other)
         {
-            if (item is null && other is null) return true; 
+            if (item is null && other is null) return true;
             if (item is null ^ other is null) return false;
 
             if (comps.ContainsKey(typeof(T)))
             {
-                var comp = (IEqualityComparer<T>) comps[typeof(T)];
+                var comp = (IEqualityComparer<T>)comps[typeof(T)];
                 return comp.Equals(item, other);
             }
             else
@@ -25,4 +23,4 @@ namespace Selector
             }
         }
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Events.cs b/Selector/Events.cs
new file mode 100644
index 0000000..c98d1f9
--- /dev/null
+++ b/Selector/Events.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace Selector;
+
+public class ListeningChangeEventArgs : EventArgs
+{
+    /// <summary>
+    /// String Id for watcher, used to hold user Db Id
+    /// </summary>
+    /// <value></value>
+    public string Id { get; set; }
+}
\ No newline at end of file
diff --git a/Selector/Extensions/LoggingExtensions.cs b/Selector/Extensions/LoggingExtensions.cs
deleted file mode 100644
index dc03ee9..0000000
--- a/Selector/Extensions/LoggingExtensions.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Microsoft.Extensions.Logging;
-
-namespace Selector
-{
-    public static class LoggingExtensions
-    {
-        public static IDisposable GetListeningEventArgsScope(this ILogger logger, ListeningChangeEventArgs e) => logger.BeginScope(new Dictionary<string, object>() { { "spotify_username", e.SpotifyUsername }, { "id", e.Id } });
-    }
-}
-
diff --git a/Selector/Extensions/ServiceExtensions.cs b/Selector/Extensions/ServiceExtensions.cs
index 3fd3564..bf8a242 100644
--- a/Selector/Extensions/ServiceExtensions.cs
+++ b/Selector/Extensions/ServiceExtensions.cs
@@ -1,54 +1,11 @@
-using IF.Lastfm.Core.Api;
-using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection;
 
 namespace Selector.Extensions
 {
     public static class ServiceExtensions
     {
-        public static IServiceCollection AddConsumerFactories(this IServiceCollection services)
-        {
-            services.AddTransient<IAudioFeatureInjectorFactory, AudioFeatureInjectorFactory>();
-            services.AddTransient<AudioFeatureInjectorFactory>();
-
-            services.AddTransient<IPlayCounterFactory, PlayCounterFactory>();
-            services.AddTransient<PlayCounterFactory>();
-
-            services.AddTransient<IWebHookFactory, WebHookFactory>();
-            services.AddTransient<WebHookFactory>();
-
-            return services;
-        }
-
-        public static IServiceCollection AddSpotify(this IServiceCollection services)
-        {
-            services.AddSingleton<IRefreshTokenFactoryProvider, RefreshTokenFactoryProvider>();
-            services.AddSingleton<IRefreshTokenFactoryProvider, CachingRefreshTokenFactoryProvider>();
-
-            return services;
-        }
-
-        public static IServiceCollection AddLastFm(this IServiceCollection services, string client, string secret)
-        {
-            var lastAuth = new LastAuth(client, secret);
-            services.AddSingleton(lastAuth);
-            services.AddTransient(sp => new LastfmClient(sp.GetService<LastAuth>()));
-
-            services.AddTransient<ITrackApi>(sp => sp.GetService<LastfmClient>().Track);
-            services.AddTransient<IAlbumApi>(sp => sp.GetService<LastfmClient>().Album);
-            services.AddTransient<IArtistApi>(sp => sp.GetService<LastfmClient>().Artist);
-
-            services.AddTransient<IUserApi>(sp => sp.GetService<LastfmClient>().User);
-
-            services.AddTransient<IChartApi>(sp => sp.GetService<LastfmClient>().Chart);
-            services.AddTransient<ILibraryApi>(sp => sp.GetService<LastfmClient>().Library);
-            services.AddTransient<ITagApi>(sp => sp.GetService<LastfmClient>().Tag);
-
-            return services;
-        }
-
         public static IServiceCollection AddWatcher(this IServiceCollection services)
         {
-            services.AddSingleton<ISpotifyWatcherFactory, SpotifyWatcherFactory>();
             services.AddSingleton<IWatcherCollectionFactory, WatcherCollectionFactory>();
 
             return services;
diff --git a/Selector/Listen/PlayDensity.cs b/Selector/Listen/PlayDensity.cs
new file mode 100644
index 0000000..60515c2
--- /dev/null
+++ b/Selector/Listen/PlayDensity.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Selector
+{
+    public static class PlayDensity
+    {
+        public static decimal Density(this IEnumerable<IListen> scrobbles, TimeSpan window) =>
+            scrobbles.Density(DateTime.UtcNow - window, DateTime.UtcNow);
+
+        public static decimal Density(this IEnumerable<IListen> scrobbles, DateTime from, DateTime to)
+        {
+            var filteredScrobbles = scrobbles.Where(s => s.Timestamp > from && s.Timestamp < to);
+
+            var dayDelta = (decimal)(to - from).Days;
+
+            return filteredScrobbles.Count() / dayDelta;
+        }
+
+        public static decimal Density(this IEnumerable<IListen> scrobbles)
+        {
+            var minDate = scrobbles.Select(s => s.Timestamp).Min();
+            var maxDate = scrobbles.Select(s => s.Timestamp).Max();
+
+            var dayDelta = (decimal)(maxDate - minDate).Days;
+
+            return scrobbles.Count() / dayDelta;
+        }
+    }
+}
\ No newline at end of file
diff --git a/Selector/Scrobble/PlayDensity.cs b/Selector/Scrobble/PlayDensity.cs
deleted file mode 100644
index 445af28..0000000
--- a/Selector/Scrobble/PlayDensity.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-namespace Selector
-{
-	public static class PlayDensity
-	{
-		public static decimal Density(this IEnumerable<IListen> scrobbles, TimeSpan window) => scrobbles.Density(DateTime.UtcNow - window, DateTime.UtcNow);
-
-		public static decimal Density(this IEnumerable<IListen> scrobbles, DateTime from, DateTime to)
-        {
-			var filteredScrobbles = scrobbles.Where(s => s.Timestamp > from && s.Timestamp < to);
-
-			var dayDelta = (decimal) (to - from).Days;
-
-			return filteredScrobbles.Count() / dayDelta;
-        }
-
-		public static decimal Density(this IEnumerable<IListen> scrobbles)
-		{
-			var minDate = scrobbles.Select(s => s.Timestamp).Min();
-			var maxDate = scrobbles.Select(s => s.Timestamp).Max();
-
-			var dayDelta = (decimal) (maxDate - minDate).Days;
-
-			return scrobbles.Count() / dayDelta;
-		}
-	}
-}
-
diff --git a/Selector/Selector.csproj b/Selector/Selector.csproj
index b3f51d9..28311e0 100644
--- a/Selector/Selector.csproj
+++ b/Selector/Selector.csproj
@@ -9,8 +9,6 @@
 
   <ItemGroup>
     <PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
-    <PackageReference Include="SpotifyAPI.Web" Version="7.2.1" />
-    <PackageReference Include="Inflatable.Lastfm" Version="1.2.0" />
     <PackageReference Include="System.Linq.Async" Version="6.0.1" />
   </ItemGroup>
 
diff --git a/Selector/Spotify/Credentials.cs b/Selector/Spotify/Credentials.cs
deleted file mode 100644
index 1be4ed6..0000000
--- a/Selector/Spotify/Credentials.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace Selector {
-    public class SpotifyAppCredentials {
-        public string ClientId { get; set; }
-        public string ClientSecret { get; set; }
-
-        public SpotifyAppCredentials() { }
-
-        public SpotifyAppCredentials(string clientId, string clientSecret)
-        {
-            ClientId = clientId;
-            ClientSecret = clientSecret;
-        }
-    }
-}
\ No newline at end of file
diff --git a/Selector/Watcher/Collection/WatcherCollection.cs b/Selector/Watcher/Collection/WatcherCollection.cs
index bbf2317..c6931f8 100644
--- a/Selector/Watcher/Collection/WatcherCollection.cs
+++ b/Selector/Watcher/Collection/WatcherCollection.cs
@@ -1,16 +1,15 @@
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
 using System;
 using System.Collections;
 using System.Collections.Generic;
 using System.Linq;
-using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
 
 namespace Selector
 {
-    public class WatcherCollection: IWatcherCollection, IDisposable, IEnumerable<WatcherContext>
+    public class WatcherCollection : IWatcherCollection, IDisposable, IEnumerable<WatcherContext>
     {
         private readonly ILogger<WatcherCollection> Logger;
         public bool IsRunning { get; private set; } = false;
@@ -28,12 +27,12 @@ namespace Selector
                 .SelectMany(w => w.Consumers)
                 .Where(t => t is not null);
 
-        public IEnumerable<Task> Tasks 
+        public IEnumerable<Task> Tasks
             => Watchers
                 .Select(w => w.Task)
                 .Where(t => t is not null);
 
-        public IEnumerable<CancellationTokenSource> TokenSources 
+        public IEnumerable<CancellationTokenSource> TokenSources
             => Watchers
                 .Select(w => w.TokenSource)
                 .Where(t => t is not null);
@@ -60,13 +59,14 @@ namespace Selector
             if (IsRunning) return;
 
             Logger.LogDebug("Starting {} watcher(s)", Count);
-            foreach(var watcher in Watchers)
+            foreach (var watcher in Watchers)
             {
                 watcher.Start();
             }
+
             IsRunning = true;
         }
-        
+
         public void Stop()
         {
             if (!IsRunning) return;
@@ -78,6 +78,7 @@ namespace Selector
                 {
                     watcher.Stop();
                 }
+
                 Task.WaitAll(Tasks.ToArray());
                 IsRunning = false;
             }
@@ -87,20 +88,21 @@ namespace Selector
             }
             catch (AggregateException ex)
             {
-                if(ex.InnerException is TaskCanceledException || ex.InnerExceptions.Any(e => e is TaskCanceledException))
+                if (ex.InnerException is TaskCanceledException ||
+                    ex.InnerExceptions.Any(e => e is TaskCanceledException))
                 {
                     Logger.LogTrace("Caught task cancelled exception");
                 }
                 else
                 {
-                    throw ex;
+                    throw;
                 }
             }
         }
 
         public void Dispose()
         {
-            foreach(var watcher in Watchers)
+            foreach (var watcher in Watchers)
             {
                 watcher.Dispose();
             }
@@ -109,4 +111,4 @@ namespace Selector
         public IEnumerator<WatcherContext> GetEnumerator() => Watchers.GetEnumerator();
         IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
     }
-}
+}
\ No newline at end of file
diff --git a/Selector/Watcher/Interfaces/ISpotifyPlayerWatcher.cs b/Selector/Watcher/Interfaces/ISpotifyPlayerWatcher.cs
deleted file mode 100644
index 8614b5b..0000000
--- a/Selector/Watcher/Interfaces/ISpotifyPlayerWatcher.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using System;
-using SpotifyAPI.Web;
-
-namespace Selector
-{
-    public interface ISpotifyPlayerWatcher : IWatcher
-    {
-        /// <summary>
-        /// Track or episode changes
-        /// </summary>
-        public event EventHandler<ListeningChangeEventArgs> NetworkPoll;
-
-        public event EventHandler<ListeningChangeEventArgs> ItemChange;
-        public event EventHandler<ListeningChangeEventArgs> AlbumChange;
-        public event EventHandler<ListeningChangeEventArgs> ArtistChange;
-        public event EventHandler<ListeningChangeEventArgs> ContextChange;
-        public event EventHandler<ListeningChangeEventArgs> ContentChange;
-
-        public event EventHandler<ListeningChangeEventArgs> VolumeChange;
-        public event EventHandler<ListeningChangeEventArgs> DeviceChange;
-        public event EventHandler<ListeningChangeEventArgs> PlayingChange;
-
-        /// <summary>
-        /// Last retrieved currently playing
-        /// </summary>
-        public CurrentlyPlayingContext Live { get; }
-
-        public PlayerTimeline Past { get; }
-    }
-}
\ No newline at end of file