apple watcher and timeline working

This commit is contained in:
Andy Pack 2025-03-30 21:50:28 +01:00
parent 6814ebd72c
commit 17b1f464dd
Signed by: sarsoo
GPG Key ID: A55BA3536A5E0ED7
71 changed files with 1877 additions and 719 deletions
Dockerfile.CLIDockerfile.Web
Selector.AppleMusic
Selector.CLI
Selector.Cache
Selector.Event
Selector.MAUI
Selector.Model
Selector.Tests
Selector.Web
Hubs
Services/EventMappings
Selector

@ -2,6 +2,7 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS base
COPY *.sln .
COPY Selector/*.csproj ./Selector/
COPY Selector.AppleMusic/*.csproj ./Selector.AppleMusic/
COPY Selector.Cache/*.csproj ./Selector.Cache/
COPY Selector.Data/*.csproj ./Selector.Data/
COPY Selector.Event/*.csproj ./Selector.Event/

@ -10,6 +10,7 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS base
COPY *.sln .
COPY Selector/*.csproj ./Selector/
COPY Selector.AppleMusic/*.csproj ./Selector.AppleMusic/
COPY Selector.Cache/*.csproj ./Selector.Cache/
COPY Selector.Data/*.csproj ./Selector.Data/
COPY Selector.Event/*.csproj ./Selector.Event/

@ -1,8 +1,14 @@
namespace Selector.AppleMusic;
using System.Net;
using System.Net.Http.Json;
using Selector.AppleMusic.Exceptions;
using Selector.AppleMusic.Model;
namespace Selector.AppleMusic;
public class AppleMusicApi(HttpClient client, string developerToken, string userToken)
{
private static readonly string _apiBaseUrl = "https://api.music.apple.com/v1";
private readonly AppleJsonContext _appleJsonContext = AppleJsonContext.Default;
private async Task<HttpResponseMessage> MakeRequest(HttpMethod httpMethod, string requestUri)
{
@ -14,8 +20,32 @@ public class AppleMusicApi(HttpClient client, string developerToken, string user
return response;
}
public async Task GetRecentlyPlayedTracks()
private void CheckResponse(HttpResponseMessage response)
{
var response = await MakeRequest(HttpMethod.Get, "/me/recent/played/tracks");
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new UnauthorisedException();
}
else if (response.StatusCode == HttpStatusCode.Forbidden)
{
throw new ForbiddenException();
}
else if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
throw new RateLimitException();
}
}
}
public async Task<RecentlyPlayedTracksResponse> GetRecentlyPlayedTracks()
{
var response = await MakeRequest(HttpMethod.Get, "/me/recent/played/tracks?types=songs");
CheckResponse(response);
var parsed = await response.Content.ReadFromJsonAsync(_appleJsonContext.RecentlyPlayedTracksResponse);
return parsed;
}
}

@ -0,0 +1,94 @@
using Selector.AppleMusic.Model;
using Selector.AppleMusic.Watcher;
namespace Selector.AppleMusic;
public class AppleTimeline : Timeline<AppleMusicCurrentlyPlayingContext>
{
public List<AppleMusicCurrentlyPlayingContext> Add(IEnumerable<Track> tracks)
=> Add(tracks
.Select(x => new AppleMusicCurrentlyPlayingContext()
{
Track = x,
FirstSeen = DateTime.UtcNow,
}).ToList());
public List<AppleMusicCurrentlyPlayingContext> Add(List<AppleMusicCurrentlyPlayingContext> items)
{
var newItems = new List<AppleMusicCurrentlyPlayingContext>();
if (items == null || !items.Any())
{
return newItems;
}
if (!Recent.Any())
{
Recent.AddRange(items.Select(x =>
TimelineItem<AppleMusicCurrentlyPlayingContext>.From(x, DateTime.UtcNow)));
return newItems;
}
if (Recent
.TakeLast(items.Count)
.Select(x => x.Item)
.SequenceEqual(items, new AppleMusicCurrentlyPlayingContextComparer()))
{
return newItems;
}
var stop = false;
var found = 0;
var startIdx = 0;
while (!stop)
{
for (var i = 0; i < items.Count; i++)
{
var storedIdx = (Recent.Count - 1) - i;
// start from the end, minus this loops index, minus the offset
var pulledIdx = (items.Count - 1) - i - startIdx;
if (pulledIdx < 0)
{
// ran to the end of new items and none matched the end, add all the new ones
stop = true;
break;
}
if (storedIdx < 0)
{
// all the new stuff matches, we're done and there's nothing new to add
stop = true;
break;
}
if (Recent[storedIdx].Item.Track.Id == items[pulledIdx].Track.Id)
{
// good, keep going
found++;
if (found >= 3)
{
stop = true;
break;
}
}
else
{
// bad, doesn't match, break and bump stored
found = 0;
break;
}
}
if (!stop) startIdx += 1;
}
foreach (var item in items.TakeLast(startIdx))
{
newItems.Add(item);
Recent.Add(TimelineItem<AppleMusicCurrentlyPlayingContext>.From(item, DateTime.UtcNow));
}
return newItems;
}
}

@ -0,0 +1,28 @@
using Selector.AppleMusic.Watcher;
namespace Selector.AppleMusic;
public class AppleListeningChangeEventArgs : EventArgs
{
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,
AppleMusicCurrentlyPlayingContext current, AppleTimeline timeline, string id = null, string username = null)
{
return new AppleListeningChangeEventArgs()
{
Previous = previous,
Current = current,
// Timeline = timeline,
Id = id
};
}
}

@ -0,0 +1,5 @@
namespace Selector.AppleMusic.Exceptions;
public class AppleMusicException : Exception
{
}

@ -0,0 +1,5 @@
namespace Selector.AppleMusic.Exceptions;
public class ForbiddenException : AppleMusicException
{
}

@ -0,0 +1,5 @@
namespace Selector.AppleMusic.Exceptions;
public class RateLimitException : AppleMusicException
{
}

@ -0,0 +1,5 @@
namespace Selector.AppleMusic.Exceptions;
public class ServiceException : AppleMusicException
{
}

@ -0,0 +1,5 @@
namespace Selector.AppleMusic.Exceptions;
public class UnauthorisedException : AppleMusicException
{
}

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Selector.AppleMusic.Watcher;
namespace Selector.AppleMusic.Extensions;
@ -6,7 +7,8 @@ public static class ServiceExtensions
{
public static IServiceCollection AddAppleMusic(this IServiceCollection services)
{
services.AddSingleton<AppleMusicApiProvider>();
services.AddSingleton<AppleMusicApiProvider>()
.AddTransient<IAppleMusicWatcherFactory, AppleMusicWatcherFactory>();
return services;
}

@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
using Selector.AppleMusic.Model;
namespace Selector.AppleMusic;
[JsonSerializable(typeof(RecentlyPlayedTracksResponse))]
[JsonSerializable(typeof(TrackAttributes))]
[JsonSerializable(typeof(PlayParams))]
[JsonSerializable(typeof(Track))]
[JsonSerializable(typeof(AppleListeningChangeEventArgs))]
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
public partial class AppleJsonContext : JsonSerializerContext
{
}

@ -0,0 +1,6 @@
namespace Selector.AppleMusic.Model;
public class RecentlyPlayedTracksResponse
{
public List<Track> Data { get; set; }
}

@ -0,0 +1,44 @@
namespace Selector.AppleMusic.Model;
public class TrackAttributes
{
public string AlbumName { get; set; }
public List<string> GenreNames { get; set; }
public int TrackNumber { get; set; }
public int DurationInMillis { get; set; }
public DateTime ReleaseDate { get; set; }
public string Isrc { get; set; }
//TODO: Artwork
public string ComposerName { get; set; }
public string Url { get; set; }
public PlayParams PlayParams { get; set; }
public int DiscNumber { get; set; }
public bool HasLyrics { get; set; }
public bool IsAppleDigitalMaster { get; set; }
public string Name { get; set; }
//TODO: previews
public string ArtistName { get; set; }
}
public class PlayParams
{
public string Id { get; set; }
public string Kind { get; set; }
}
public class Track
{
public string Id { get; set; }
public string Type { get; set; }
public string Href { get; set; }
public TrackAttributes Attributes { get; set; }
public override string ToString()
{
return $"{Attributes?.Name} / {Attributes?.AlbumName} / {Attributes?.ArtistName}";
}
}

@ -11,4 +11,8 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Selector\Selector.csproj"/>
</ItemGroup>
</Project>

@ -0,0 +1,5 @@
namespace Selector.AppleMusic.Watcher.Consumer;
public interface IApplePlayerConsumer : IConsumer<AppleListeningChangeEventArgs>
{
}

@ -0,0 +1,26 @@
using Selector.AppleMusic.Model;
namespace Selector.AppleMusic.Watcher;
public class AppleMusicCurrentlyPlayingContext
{
public DateTime FirstSeen { get; set; }
public Track Track { get; set; }
}
public class AppleMusicCurrentlyPlayingContextComparer : IEqualityComparer<AppleMusicCurrentlyPlayingContext>
{
public bool Equals(AppleMusicCurrentlyPlayingContext? x, AppleMusicCurrentlyPlayingContext? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null) return false;
if (y is null) return false;
if (x.GetType() != y.GetType()) return false;
return x.Track.Id.Equals(y.Track.Id);
}
public int GetHashCode(AppleMusicCurrentlyPlayingContext obj)
{
return obj.Track.GetHashCode();
}
}

@ -0,0 +1,20 @@
using Selector.AppleMusic;
using Selector.AppleMusic.Watcher;
namespace Selector
{
public interface IAppleMusicPlayerWatcher : IWatcher
{
public event EventHandler<AppleListeningChangeEventArgs> NetworkPoll;
public event EventHandler<AppleListeningChangeEventArgs> ItemChange;
public event EventHandler<AppleListeningChangeEventArgs> AlbumChange;
public event EventHandler<AppleListeningChangeEventArgs> ArtistChange;
/// <summary>
/// Last retrieved currently playing
/// </summary>
public AppleMusicCurrentlyPlayingContext Live { get; }
public AppleTimeline Past { get; }
}
}

@ -0,0 +1,145 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Selector.AppleMusic.Exceptions;
using Selector.AppleMusic.Model;
namespace Selector.AppleMusic.Watcher;
public class AppleMusicPlayerWatcher : BaseWatcher, IAppleMusicPlayerWatcher
{
new protected readonly ILogger<AppleMusicPlayerWatcher> Logger;
private readonly AppleMusicApi _appleMusicApi;
public event EventHandler<AppleListeningChangeEventArgs> NetworkPoll;
public event EventHandler<AppleListeningChangeEventArgs> ItemChange;
public event EventHandler<AppleListeningChangeEventArgs> AlbumChange;
public event EventHandler<AppleListeningChangeEventArgs> ArtistChange;
public AppleMusicCurrentlyPlayingContext Live { get; protected set; }
protected AppleMusicCurrentlyPlayingContext Previous { get; set; }
public AppleTimeline Past { get; set; } = new();
public AppleMusicPlayerWatcher(AppleMusicApi appleMusicClient,
ILogger<AppleMusicPlayerWatcher> logger = null,
int pollPeriod = 3000
) : base(logger)
{
_appleMusicApi = appleMusicClient;
Logger = logger ?? NullLogger<AppleMusicPlayerWatcher>.Instance;
PollPeriod = pollPeriod;
}
public override async Task WatchOne(CancellationToken token)
{
token.ThrowIfCancellationRequested();
try
{
Logger.LogTrace("Making Apple Music call");
var polledCurrent = await _appleMusicApi.GetRecentlyPlayedTracks();
// using var polledLogScope = Logger.BeginScope(new Dictionary<string, object>() { { "context", polledCurrent?.DisplayString() } });
Logger.LogTrace("Received Apple Music call");
var currentPrevious = Previous;
var reversedItems = polledCurrent.Data.ToList();
reversedItems.Reverse();
var addedItems = Past.Add(reversedItems);
// swap new item into live and bump existing down to previous
Previous = Live;
SetLive(polledCurrent);
OnNetworkPoll(GetEvent());
if (currentPrevious != null && addedItems.Any())
{
addedItems.Insert(0, currentPrevious);
foreach (var (first, second) in addedItems.Zip(addedItems.Skip(1)))
{
Logger.LogDebug("Track changed: {prevTrack} -> {currentTrack}", first.Track, second.Track);
OnItemChange(AppleListeningChangeEventArgs.From(first, second, Past, id: Id));
}
}
}
catch (RateLimitException e)
{
Logger.LogDebug("Rate Limit exception: [{message}]", e.Message);
// throw e;
}
catch (ForbiddenException e)
{
Logger.LogDebug("Forbidden exception: [{message}]", e.Message);
throw;
}
catch (ServiceException e)
{
Logger.LogDebug("Apple Music internal error: [{message}]", e.Message);
// throw e;
}
catch (UnauthorisedException e)
{
Logger.LogDebug("Unauthorised exception: [{message}]", e.Message);
// throw e;
}
}
private void SetLive(RecentlyPlayedTracksResponse recentlyPlayedTracks)
{
var lastTrack = recentlyPlayedTracks.Data?.FirstOrDefault();
if (Live != null && Live.Track != null && Live.Track.Id == lastTrack?.Id)
{
Live = new()
{
Track = Live.Track,
FirstSeen = Live.FirstSeen,
};
}
else
{
Live = new()
{
Track = recentlyPlayedTracks.Data?.FirstOrDefault(),
FirstSeen = DateTime.UtcNow,
};
}
}
public override Task Reset()
{
Previous = null;
Live = null;
Past = new();
return Task.CompletedTask;
}
protected AppleListeningChangeEventArgs GetEvent() =>
AppleListeningChangeEventArgs.From(Previous, Live, Past, id: Id);
#region Event Firers
protected virtual void OnNetworkPoll(AppleListeningChangeEventArgs args)
{
NetworkPoll?.Invoke(this, args);
}
protected virtual void OnItemChange(AppleListeningChangeEventArgs args)
{
ItemChange?.Invoke(this, args);
}
protected virtual void OnAlbumChange(AppleListeningChangeEventArgs args)
{
AlbumChange?.Invoke(this, args);
}
protected virtual void OnArtistChange(AppleListeningChangeEventArgs args)
{
ArtistChange?.Invoke(this, args);
}
#endregion
}

@ -0,0 +1,59 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Selector.AppleMusic.Watcher
{
public interface IAppleMusicWatcherFactory
{
Task<IWatcher> Get<T>(AppleMusicApiProvider appleMusicProvider, string developerToken, string teamId,
string keyId, string userToken, int pollPeriod = 3000)
where T : class, IWatcher;
}
public class AppleMusicWatcherFactory : IAppleMusicWatcherFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly IEqual Equal;
public AppleMusicWatcherFactory(ILoggerFactory loggerFactory, IEqual equal)
{
LoggerFactory = loggerFactory;
Equal = equal;
}
public async Task<IWatcher> Get<T>(AppleMusicApiProvider appleMusicProvider, string developerToken,
string teamId, string keyId, string userToken, int pollPeriod = 3000)
where T : class, IWatcher
{
if (typeof(T).IsAssignableFrom(typeof(AppleMusicPlayerWatcher)))
{
if (!Magic.Dummy)
{
var api = appleMusicProvider.GetApi(developerToken, teamId, keyId, userToken);
return new AppleMusicPlayerWatcher(
api,
LoggerFactory?.CreateLogger<AppleMusicPlayerWatcher>() ??
NullLogger<AppleMusicPlayerWatcher>.Instance,
pollPeriod: pollPeriod
);
}
else
{
return new DummySpotifyPlayerWatcher(
Equal,
LoggerFactory?.CreateLogger<DummySpotifyPlayerWatcher>() ??
NullLogger<DummySpotifyPlayerWatcher>.Instance,
pollPeriod: pollPeriod
)
{
};
}
}
else
{
throw new ArgumentException("Type unsupported");
}
}
}
}

@ -1,33 +1,33 @@
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Selector.Model;
namespace Selector.CLI.Consumer
{
public interface IMappingPersisterFactory
{
public Task<IPlayerConsumer> Get(IPlayerWatcher watcher = null);
public Task<ISpotifyPlayerConsumer> Get(ISpotifyPlayerWatcher watcher = null);
}
public class MappingPersisterFactory : IMappingPersisterFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly IServiceScopeFactory ScopeFactory;
public MappingPersisterFactory(ILoggerFactory loggerFactory, IServiceScopeFactory scopeFactory = null, LastFmCredentials creds = null)
public MappingPersisterFactory(ILoggerFactory loggerFactory, IServiceScopeFactory scopeFactory = null,
LastFmCredentials creds = null)
{
LoggerFactory = loggerFactory;
ScopeFactory = scopeFactory;
}
public Task<IPlayerConsumer> Get(IPlayerWatcher watcher = null)
public Task<ISpotifyPlayerConsumer> Get(ISpotifyPlayerWatcher watcher = null)
{
return Task.FromResult<IPlayerConsumer>(new MappingPersister(
return Task.FromResult<ISpotifyPlayerConsumer>(new MappingPersister(
watcher,
ScopeFactory,
LoggerFactory.CreateLogger<MappingPersister>()
));
}
}
}
}

@ -15,16 +15,16 @@ namespace Selector.CLI.Consumer
/// <summary>
/// Save name -> Spotify URI mappings as new objects come through the watcher without making extra queries of the Spotify API
/// </summary>
public class MappingPersister: IPlayerConsumer
public class MappingPersister : ISpotifyPlayerConsumer
{
protected readonly IPlayerWatcher Watcher;
protected readonly ISpotifyPlayerWatcher Watcher;
protected readonly IServiceScopeFactory ScopeFactory;
protected readonly ILogger<MappingPersister> Logger;
public CancellationToken CancelToken { get; set; }
public MappingPersister(
IPlayerWatcher watcher,
ISpotifyPlayerWatcher watcher,
IServiceScopeFactory scopeFactory,
ILogger<MappingPersister> logger = null,
CancellationToken token = default
@ -40,7 +40,8 @@ namespace Selector.CLI.Consumer
{
if (e.Current is null) return;
Task.Run(async () => {
Task.Run(async () =>
{
try
{
await AsyncCallback(e);
@ -59,13 +60,14 @@ namespace Selector.CLI.Consumer
public async Task AsyncCallback(ListeningChangeEventArgs e)
{
using var serviceScope = ScopeFactory.CreateScope();
using var scope = Logger.BeginScope(new Dictionary<string, object>() { { "spotify_username", e.SpotifyUsername }, { "id", e.Id } });
using var scope = Logger.BeginScope(new Dictionary<string, object>()
{ { "spotify_username", e.SpotifyUsername }, { "id", e.Id } });
if (e.Current.Item is FullTrack track)
{
{
var mappingRepo = serviceScope.ServiceProvider.GetRequiredService<IScrobbleMappingRepository>();
if(!mappingRepo.GetTracks().Select(t => t.SpotifyUri).Contains(track.Uri))
if (!mappingRepo.GetTracks().Select(t => t.SpotifyUri).Contains(track.Uri))
{
mappingRepo.Add(new TrackLastfmSpotifyMapping()
{
@ -120,7 +122,7 @@ namespace Selector.CLI.Consumer
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException(nameof(watch));
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
@ -134,7 +136,7 @@ namespace Selector.CLI.Consumer
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException(nameof(watch));
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
@ -144,5 +146,4 @@ namespace Selector.CLI.Consumer
}
}
}
}
}

@ -5,16 +5,18 @@ using Microsoft.Extensions.DependencyInjection;
namespace Selector.CLI
{
static class OptionsHelper {
static class OptionsHelper
{
public static void ConfigureOptions(RootOptions options, IConfiguration config)
{
config.GetSection(RootOptions.Key).Bind(options);
config.GetSection(FormatKeys( new[] { RootOptions.Key, WatcherOptions.Key})).Bind(options.WatcherOptions);
config.GetSection(FormatKeys( new[] { RootOptions.Key, DatabaseOptions.Key})).Bind(options.DatabaseOptions);
config.GetSection(FormatKeys( new[] { RootOptions.Key, RedisOptions.Key})).Bind(options.RedisOptions);
config.GetSection(FormatKeys( new[] { RootOptions.Key, JobsOptions.Key})).Bind(options.JobOptions);
config.GetSection(FormatKeys( new[] { RootOptions.Key, JobsOptions.Key, ScrobbleWatcherJobOptions.Key })).Bind(options.JobOptions.Scrobble);
}
config.GetSection(FormatKeys(new[] { RootOptions.Key, WatcherOptions.Key })).Bind(options.WatcherOptions);
config.GetSection(FormatKeys(new[] { RootOptions.Key, DatabaseOptions.Key })).Bind(options.DatabaseOptions);
config.GetSection(FormatKeys(new[] { RootOptions.Key, RedisOptions.Key })).Bind(options.RedisOptions);
config.GetSection(FormatKeys(new[] { RootOptions.Key, JobsOptions.Key })).Bind(options.JobOptions);
config.GetSection(FormatKeys(new[] { RootOptions.Key, JobsOptions.Key, ScrobbleWatcherJobOptions.Key }))
.Bind(options.JobOptions.Scrobble);
}
public static RootOptions ConfigureOptions(this IConfiguration config)
{
@ -29,12 +31,16 @@ namespace Selector.CLI
{
var options = config.GetSection(RootOptions.Key).Get<RootOptions>();
services.Configure<DatabaseOptions>(config.GetSection(FormatKeys(new[] { RootOptions.Key, DatabaseOptions.Key })));
services.Configure<RedisOptions>(config.GetSection(FormatKeys(new[] { RootOptions.Key, RedisOptions.Key })));
services.Configure<WatcherOptions>(config.GetSection(FormatKeys(new[] { RootOptions.Key, WatcherOptions.Key })));
services.Configure<DatabaseOptions>(config.GetSection(FormatKeys(new[]
{ RootOptions.Key, DatabaseOptions.Key })));
services.Configure<RedisOptions>(config.GetSection(FormatKeys(new[]
{ RootOptions.Key, RedisOptions.Key })));
services.Configure<WatcherOptions>(config.GetSection(FormatKeys(new[]
{ RootOptions.Key, WatcherOptions.Key })));
services.Configure<JobsOptions>(config.GetSection(FormatKeys(new[] { RootOptions.Key, JobsOptions.Key })));
services.Configure<ScrobbleWatcherJobOptions>(config.GetSection(FormatKeys(new[] { RootOptions.Key, JobsOptions.Key, ScrobbleWatcherJobOptions.Key })));
services.Configure<ScrobbleWatcherJobOptions>(config.GetSection(FormatKeys(new[]
{ RootOptions.Key, JobsOptions.Key, ScrobbleWatcherJobOptions.Key })));
services.Configure<AppleMusicOptions>(config.GetSection(AppleMusicOptions._Key));
return options;
@ -51,14 +57,17 @@ namespace Selector.CLI
/// Spotify client ID
/// </summary>
public string ClientId { get; set; }
/// <summary>
/// Spotify app secret
/// </summary>
public string ClientSecret { get; set; }
/// <summary>
/// Service account refresh token for tool spotify usage
/// </summary>
public string RefreshToken { get; set; }
public string LastfmClient { get; set; }
public string LastfmSecret { get; set; }
public WatcherOptions WatcherOptions { get; set; } = new();
@ -70,7 +79,8 @@ namespace Selector.CLI
public enum EqualityChecker
{
Uri, String
Uri,
String
}
public class WatcherOptions
@ -89,9 +99,10 @@ namespace Selector.CLI
public string Name { get; set; }
public string AccessKey { get; set; }
public string RefreshKey { get; set; }
public string AppleUserToken { get; set; }
public string LastFmUsername { get; set; }
public int PollPeriod { get; set; } = 5000;
public WatcherType Type { get; set; } = WatcherType.Player;
public WatcherType Type { get; set; } = WatcherType.SpotifyPlayer;
public List<Consumers> Consumers { get; set; } = default;
#nullable enable
public string? PlaylistUri { get; set; }
@ -101,7 +112,12 @@ namespace Selector.CLI
public enum Consumers
{
AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher, PlayCounter, MappingPersister
AudioFeatures,
AudioFeaturesCache,
CacheWriter,
Publisher,
PlayCounter,
MappingPersister
}
public class RedisOptions
@ -143,4 +159,4 @@ namespace Selector.CLI
public string KeyId { get; set; }
public TimeSpan? Expiry { get; set; } = null;
}
}
}

@ -59,4 +59,12 @@
<Folder Include="Consumer\" />
<Folder Include="Consumer\Factory\" />
</ItemGroup>
<ItemGroup>
<Content Update="appsettings.Development.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@ -7,13 +8,14 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Selector.AppleMusic;
using Selector.AppleMusic.Watcher;
using Selector.Cache;
using Selector.CLI.Consumer;
using Selector.Events;
using Selector.Model;
using Selector.Model.Extensions;
using Selector.Events;
using System.Collections.Concurrent;
using Selector.CLI.Consumer;
namespace Selector.CLI
{
@ -22,13 +24,16 @@ namespace Selector.CLI
private const int PollPeriod = 1000;
private readonly ILogger<DbWatcherService> Logger;
private readonly IOptions<AppleMusicOptions> _appleMusicOptions;
private readonly IServiceProvider ServiceProvider;
private readonly UserEventBus UserEventBus;
private readonly IWatcherFactory WatcherFactory;
private readonly ISpotifyWatcherFactory _spotifyWatcherFactory;
private readonly IAppleMusicWatcherFactory _appleWatcherFactory;
private readonly IWatcherCollectionFactory WatcherCollectionFactory;
private readonly IRefreshTokenFactoryProvider SpotifyFactory;
private readonly AppleMusicApiProvider _appleMusicProvider;
private readonly IAudioFeatureInjectorFactory AudioFeatureInjectorFactory;
private readonly IPlayCounterFactory PlayCounterFactory;
@ -42,23 +47,20 @@ namespace Selector.CLI
private ConcurrentDictionary<string, IWatcherCollection> Watchers { get; set; } = new();
public DbWatcherService(
IWatcherFactory watcherFactory,
ISpotifyWatcherFactory spotifyWatcherFactory,
IAppleMusicWatcherFactory appleWatcherFactory,
IWatcherCollectionFactory watcherCollectionFactory,
IRefreshTokenFactoryProvider spotifyFactory,
AppleMusicApiProvider appleMusicProvider,
IAudioFeatureInjectorFactory audioFeatureInjectorFactory,
IPlayCounterFactory playCounterFactory,
UserEventBus userEventBus,
ILogger<DbWatcherService> logger,
IOptions<AppleMusicOptions> appleMusicOptions,
IServiceProvider serviceProvider,
IPublisherFactory publisherFactory = null,
ICacheWriterFactory cacheWriterFactory = null,
IMappingPersisterFactory mappingPersisterFactory = null,
IUserEventFirerFactory userEventFirerFactory = null
)
{
@ -66,10 +68,13 @@ namespace Selector.CLI
ServiceProvider = serviceProvider;
UserEventBus = userEventBus;
WatcherFactory = watcherFactory;
_spotifyWatcherFactory = spotifyWatcherFactory;
_appleWatcherFactory = appleWatcherFactory;
_appleMusicOptions = appleMusicOptions;
WatcherCollectionFactory = watcherCollectionFactory;
SpotifyFactory = spotifyFactory;
_appleMusicProvider = appleMusicProvider;
AudioFeatureInjectorFactory = audioFeatureInjectorFactory;
PlayCounterFactory = playCounterFactory;
@ -100,8 +105,8 @@ namespace Selector.CLI
var indices = new HashSet<string>();
foreach (var dbWatcher in db.Watcher
.Include(w => w.User)
.Where(w => !string.IsNullOrWhiteSpace(w.User.SpotifyRefreshToken)))
.Include(w => w.User)
.Where(w => !string.IsNullOrWhiteSpace(w.User.SpotifyRefreshToken)))
{
var watcherCollectionIdx = dbWatcher.UserId;
indices.Add(watcherCollectionIdx);
@ -131,31 +136,43 @@ namespace Selector.CLI
switch (dbWatcher.Type)
{
case WatcherType.Player:
watcher = await WatcherFactory.Get<PlayerWatcher>(spotifyFactory, id: dbWatcher.UserId, pollPeriod: PollPeriod);
case WatcherType.SpotifyPlayer:
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 (PublisherFactory is not null) consumers.Add(await PublisherFactory.Get());
if (PublisherFactory is not null) consumers.Add(await PublisherFactory.GetSpotify());
if (MappingPersisterFactory is not null && !Magic.Dummy) consumers.Add(await MappingPersisterFactory.Get());
if (MappingPersisterFactory is not null && !Magic.Dummy)
consumers.Add(await MappingPersisterFactory.Get());
if (UserEventFirerFactory is not null) consumers.Add(await UserEventFirerFactory.Get());
if (dbWatcher.User.LastFmConnected())
{
consumers.Add(await PlayCounterFactory.Get(creds: new() { Username = dbWatcher.User.LastFmUsername }));
consumers.Add(await PlayCounterFactory.Get(creds: new()
{ Username = dbWatcher.User.LastFmUsername }));
}
else
{
Logger.LogDebug("[{username}] No Last.fm username, skipping play counter", dbWatcher.User.UserName);
Logger.LogDebug("[{username}] No Last.fm username, skipping play counter",
dbWatcher.User.UserName);
}
break;
case WatcherType.Playlist:
case WatcherType.SpotifyPlaylist:
throw new NotImplementedException("Playlist watchers not implemented");
// break;
break;
case WatcherType.AppleMusicPlayer:
watcher = await _appleWatcherFactory.Get<AppleMusicPlayerWatcher>(_appleMusicProvider,
_appleMusicOptions.Value.Key, _appleMusicOptions.Value.TeamId, _appleMusicOptions.Value.KeyId,
dbWatcher.User.AppleMusicKey);
if (PublisherFactory is not null) consumers.Add(await PublisherFactory.GetApple());
break;
}
return watcherCollection.Add(watcher, consumers);
@ -181,7 +198,7 @@ namespace Selector.CLI
{
Logger.LogInformation("Shutting down");
foreach((var key, var watcher) in Watchers)
foreach ((var key, var watcher) in Watchers)
{
Logger.LogInformation("Stopping watcher collection [{key}]", key);
watcher.Stop();
@ -195,24 +212,27 @@ namespace Selector.CLI
private void AttachEventBus()
{
UserEventBus.SpotifyLinkChange += SpotifyChangeCallback;
UserEventBus.AppleLinkChange += AppleMusicChangeCallback;
UserEventBus.LastfmCredChange += LastfmChangeCallback;
}
private void DetachEventBus()
{
UserEventBus.SpotifyLinkChange -= SpotifyChangeCallback;
UserEventBus.AppleLinkChange -= AppleMusicChangeCallback;
UserEventBus.LastfmCredChange -= LastfmChangeCallback;
}
public async void SpotifyChangeCallback(object sender, SpotifyLinkChange change)
{
if(Watchers.ContainsKey(change.UserId))
if (Watchers.ContainsKey(change.UserId))
{
Logger.LogDebug("Setting new Spotify link state for [{username}], [{}]", change.UserId, change.NewLinkState);
Logger.LogDebug("Setting new Spotify link state for [{username}], [{}]", change.UserId,
change.NewLinkState);
var watcherCollection = Watchers[change.UserId];
if(change.NewLinkState)
if (change.NewLinkState)
{
watcherCollection.Start();
}
@ -227,8 +247,46 @@ namespace Selector.CLI
var db = scope.ServiceProvider.GetService<ApplicationDbContext>();
var watcherEnum = db.Watcher
.Include(w => w.User)
.Where(w => w.UserId == change.UserId);
.Include(w => w.User)
.Where(w => w.UserId == change.UserId);
foreach (var dbWatcher in watcherEnum)
{
var context = await InitInstance(dbWatcher);
}
Watchers[change.UserId].Start();
Logger.LogDebug("Started {} watchers for [{username}]", watcherEnum.Count(), change.UserId);
}
}
public async void AppleMusicChangeCallback(object sender, AppleMusicLinkChange change)
{
if (Watchers.ContainsKey(change.UserId))
{
Logger.LogDebug("Setting new Apple Music link state for [{username}], [{}]", change.UserId,
change.NewLinkState);
var watcherCollection = Watchers[change.UserId];
if (change.NewLinkState)
{
watcherCollection.Start();
}
else
{
watcherCollection.Stop();
}
}
else
{
using var scope = ServiceProvider.CreateScope();
var db = scope.ServiceProvider.GetService<ApplicationDbContext>();
var watcherEnum = db.Watcher
.Include(w => w.User)
.Where(w => w.UserId == change.UserId);
foreach (var dbWatcher in watcherEnum)
{
@ -249,9 +307,9 @@ namespace Selector.CLI
var watcherCollection = Watchers[change.UserId];
foreach(var watcher in watcherCollection.Consumers)
foreach (var watcher in watcherCollection.Consumers)
{
if(watcher is PlayCounter counter)
if (watcher is PlayCounter counter)
{
counter.Credentials.Username = change.NewUsername;
}
@ -259,9 +317,8 @@ namespace Selector.CLI
}
else
{
Logger.LogDebug("No watchers running for [{username}], skipping Spotify event", change.UserId);
}
}
}
}
}

@ -4,12 +4,12 @@ using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Selector.AppleMusic;
using Selector.AppleMusic.Watcher;
using Selector.Cache;
using Selector.CLI.Consumer;
@ -21,30 +21,38 @@ namespace Selector.CLI
private readonly ILogger<LocalWatcherService> Logger;
private readonly RootOptions Config;
private readonly IWatcherFactory WatcherFactory;
private readonly ISpotifyWatcherFactory _spotifyWatcherFactory;
private readonly IAppleMusicWatcherFactory _appleWatcherFactory;
private readonly IWatcherCollectionFactory WatcherCollectionFactory;
private readonly IRefreshTokenFactoryProvider SpotifyFactory;
private readonly AppleMusicApiProvider _appleMusicApiProvider;
private readonly IOptions<AppleMusicOptions> _appleMusicOptions;
private readonly IServiceProvider ServiceProvider;
private Dictionary<string, IWatcherCollection> Watchers { get; set; } = new();
public LocalWatcherService(
IWatcherFactory watcherFactory,
ISpotifyWatcherFactory spotifyWatcherFactory,
IAppleMusicWatcherFactory appleWatcherFactory,
IWatcherCollectionFactory watcherCollectionFactory,
IRefreshTokenFactoryProvider spotifyFactory,
AppleMusicApiProvider appleMusicApiProvider,
IOptions<AppleMusicOptions> appleMusicOptions,
IServiceProvider serviceProvider,
ILogger<LocalWatcherService> logger,
IOptions<RootOptions> config
) {
)
{
Logger = logger;
Config = config.Value;
WatcherFactory = watcherFactory;
_spotifyWatcherFactory = spotifyWatcherFactory;
_appleWatcherFactory = appleWatcherFactory;
WatcherCollectionFactory = watcherCollectionFactory;
SpotifyFactory = spotifyFactory;
_appleMusicApiProvider = appleMusicApiProvider;
_appleMusicOptions = appleMusicOptions;
ServiceProvider = serviceProvider;
}
@ -75,7 +83,8 @@ namespace Selector.CLI
logMsg.Append($"Creating new [{watcherOption.Type}] watcher");
}
if (!string.IsNullOrWhiteSpace(watcherOption.PlaylistUri)) logMsg.Append($" [{ watcherOption.PlaylistUri}]");
if (!string.IsNullOrWhiteSpace(watcherOption.PlaylistUri))
logMsg.Append($" [{watcherOption.PlaylistUri}]");
Logger.LogInformation(logMsg.ToString());
var watcherCollectionIdx = watcherOption.WatcherCollection ?? ConfigInstanceKey;
@ -90,17 +99,26 @@ namespace Selector.CLI
var spotifyFactory = await SpotifyFactory.GetFactory(watcherOption.RefreshKey);
IWatcher watcher = null;
switch(watcherOption.Type)
switch (watcherOption.Type)
{
case WatcherType.Player:
watcher = await WatcherFactory.Get<PlayerWatcher>(spotifyFactory, id: watcherOption.Name, pollPeriod: watcherOption.PollPeriod);
case WatcherType.SpotifyPlayer:
watcher = await _spotifyWatcherFactory.Get<SpotifyPlayerWatcher>(spotifyFactory,
id: watcherOption.Name, pollPeriod: watcherOption.PollPeriod);
break;
case WatcherType.Playlist:
var playlistWatcher = await WatcherFactory.Get<PlaylistWatcher>(spotifyFactory, id: watcherOption.Name, pollPeriod: watcherOption.PollPeriod) as PlaylistWatcher;
case WatcherType.SpotifyPlaylist:
var playlistWatcher = await _spotifyWatcherFactory.Get<PlaylistWatcher>(spotifyFactory,
id: watcherOption.Name, pollPeriod: watcherOption.PollPeriod) as PlaylistWatcher;
playlistWatcher.config = new() { PlaylistId = watcherOption.PlaylistUri };
watcher = playlistWatcher;
break;
case WatcherType.AppleMusicPlayer:
var appleMusicWatcher = await _appleWatcherFactory.Get<AppleMusicPlayerWatcher>(
_appleMusicApiProvider, _appleMusicOptions.Value.Key, _appleMusicOptions.Value.TeamId,
_appleMusicOptions.Value.KeyId, watcherOption.AppleUserToken);
watcher = appleMusicWatcher;
break;
}
List<IConsumer> consumers = new();
@ -112,11 +130,13 @@ namespace Selector.CLI
switch (consumer)
{
case Consumers.AudioFeatures:
consumers.Add(await ServiceProvider.GetService<AudioFeatureInjectorFactory>().Get(spotifyFactory));
consumers.Add(await ServiceProvider.GetService<AudioFeatureInjectorFactory>()
.Get(spotifyFactory));
break;
case Consumers.AudioFeaturesCache:
consumers.Add(await ServiceProvider.GetService<CachingAudioFeatureInjectorFactory>().Get(spotifyFactory));
consumers.Add(await ServiceProvider.GetService<CachingAudioFeatureInjectorFactory>()
.Get(spotifyFactory));
break;
case Consumers.CacheWriter:
@ -124,18 +144,20 @@ namespace Selector.CLI
break;
case Consumers.Publisher:
consumers.Add(await ServiceProvider.GetService<PublisherFactory>().Get());
consumers.Add(await ServiceProvider.GetService<PublisherFactory>().GetSpotify());
break;
case Consumers.PlayCounter:
if (!string.IsNullOrWhiteSpace(watcherOption.LastFmUsername))
{
consumers.Add(await ServiceProvider.GetService<PlayCounterFactory>().Get(creds: new() { Username = watcherOption.LastFmUsername }));
consumers.Add(await ServiceProvider.GetService<PlayCounterFactory>()
.Get(creds: new() { Username = watcherOption.LastFmUsername }));
}
else
{
Logger.LogError("No Last.fm username provided, skipping play counter");
}
break;
case Consumers.MappingPersister:
@ -171,7 +193,7 @@ namespace Selector.CLI
{
Logger.LogInformation("Shutting down");
foreach((var key, var watcher) in Watchers)
foreach ((var key, var watcher) in Watchers)
{
Logger.LogInformation("Stopping watcher collection [{key}]", key);
watcher.Stop();
@ -180,4 +202,4 @@ namespace Selector.CLI
return Task.CompletedTask;
}
}
}
}

@ -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.Watcher.Consumer;
using StackExchange.Redis;
namespace Selector.Cache.Consumer.AppleMusic
{
public class ApplePublisher : IApplePlayerConsumer
{
private readonly IAppleMusicPlayerWatcher Watcher;
private readonly ISubscriber Subscriber;
private readonly ILogger<ApplePublisher> Logger;
public CancellationToken CancelToken { get; set; }
public ApplePublisher(
IAppleMusicPlayerWatcher watcher,
ISubscriber subscriber,
ILogger<ApplePublisher> logger = null,
CancellationToken token = default
)
{
Watcher = watcher;
Subscriber = subscriber;
Logger = logger ?? NullLogger<ApplePublisher>.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("Publishing current");
// TODO: currently using spotify username for cache key, use db username
var receivers = await Subscriber.PublishAsync(Key.CurrentlyPlayingAppleMusic(e.Id), payload);
Logger.LogDebug("Published current, {receivers} receivers", receivers);
}
public void Subscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IAppleMusicPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
public void Unsubscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IAppleMusicPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
}
}

@ -1,28 +1,26 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using SpotifyAPI.Web;
using StackExchange.Redis;
namespace Selector.Cache
{
public class CachingAudioFeatureInjectorFactory: IAudioFeatureInjectorFactory {
{
public class CachingAudioFeatureInjectorFactory : IAudioFeatureInjectorFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly IDatabaseAsync Db;
public CachingAudioFeatureInjectorFactory(
ILoggerFactory loggerFactory,
IDatabaseAsync db
) {
)
{
LoggerFactory = loggerFactory;
Db = db;
}
public async Task<IPlayerConsumer> Get(ISpotifyConfigFactory spotifyFactory, IPlayerWatcher watcher = null)
public async Task<ISpotifyPlayerConsumer> Get(ISpotifyConfigFactory spotifyFactory,
ISpotifyPlayerWatcher watcher = null)
{
if (!Magic.Dummy)
{
@ -45,4 +43,4 @@ namespace Selector.Cache
}
}
}
}
}

@ -1,37 +1,35 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Selector.Cache
{
public interface ICacheWriterFactory {
public Task<IPlayerConsumer> Get(IPlayerWatcher watcher = null);
public interface ICacheWriterFactory
{
public Task<ISpotifyPlayerConsumer> Get(ISpotifyPlayerWatcher watcher = null);
}
public class CacheWriterFactory: ICacheWriterFactory {
public class CacheWriterFactory : ICacheWriterFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly IDatabaseAsync Cache;
public CacheWriterFactory(
IDatabaseAsync cache,
IDatabaseAsync cache,
ILoggerFactory loggerFactory
) {
)
{
Cache = cache;
LoggerFactory = loggerFactory;
}
public Task<IPlayerConsumer> Get(IPlayerWatcher watcher = null)
public Task<ISpotifyPlayerConsumer> Get(ISpotifyPlayerWatcher watcher = null)
{
return Task.FromResult<IPlayerConsumer>(new CacheWriter(
return Task.FromResult<ISpotifyPlayerConsumer>(new CacheWriter(
watcher,
Cache,
LoggerFactory.CreateLogger<CacheWriter>()
));
}
}
}
}

@ -1,15 +1,12 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using IF.Lastfm.Core.Api;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Selector.Cache
{
public class PlayCounterCachingFactory: IPlayCounterFactory
public class PlayCounterCachingFactory : IPlayCounterFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly IDatabaseAsync Cache;
@ -17,9 +14,9 @@ namespace Selector.Cache
private readonly LastFmCredentials Creds;
public PlayCounterCachingFactory(
ILoggerFactory loggerFactory,
ILoggerFactory loggerFactory,
IDatabaseAsync cache,
LastfmClient client = null,
LastfmClient client = null,
LastFmCredentials creds = null)
{
LoggerFactory = loggerFactory;
@ -28,7 +25,8 @@ namespace Selector.Cache
Creds = creds;
}
public Task<IPlayerConsumer> Get(LastfmClient fmClient = null, LastFmCredentials creds = null, IPlayerWatcher watcher = null)
public Task<ISpotifyPlayerConsumer> Get(LastfmClient fmClient = null, LastFmCredentials creds = null,
ISpotifyPlayerWatcher watcher = null)
{
var client = fmClient ?? Client;
@ -37,7 +35,7 @@ namespace Selector.Cache
throw new ArgumentNullException("No Last.fm client provided");
}
return Task.FromResult<IPlayerConsumer>(new PlayCounterCaching(
return Task.FromResult<ISpotifyPlayerConsumer>(new PlayCounterCaching(
watcher,
client.Track,
client.Album,
@ -49,4 +47,4 @@ namespace Selector.Cache
));
}
}
}
}

@ -1,37 +1,47 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Selector.AppleMusic.Watcher.Consumer;
using Selector.Cache.Consumer.AppleMusic;
using StackExchange.Redis;
namespace Selector.Cache
{
public interface IPublisherFactory {
public Task<IPlayerConsumer> Get(IPlayerWatcher watcher = null);
public interface IPublisherFactory
{
public Task<ISpotifyPlayerConsumer> GetSpotify(ISpotifyPlayerWatcher watcher = null);
public Task<IApplePlayerConsumer> GetApple(IAppleMusicPlayerWatcher watcher = null);
}
public class PublisherFactory: IPublisherFactory {
public class PublisherFactory : IPublisherFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly ISubscriber Subscriber;
public PublisherFactory(
ISubscriber subscriber,
ISubscriber subscriber,
ILoggerFactory loggerFactory
) {
)
{
Subscriber = subscriber;
LoggerFactory = loggerFactory;
}
public Task<IPlayerConsumer> Get(IPlayerWatcher watcher = null)
public Task<ISpotifyPlayerConsumer> GetSpotify(ISpotifyPlayerWatcher watcher = null)
{
return Task.FromResult<IPlayerConsumer>(new Publisher(
return Task.FromResult<ISpotifyPlayerConsumer>(new SpotifyPublisher(
watcher,
Subscriber,
LoggerFactory.CreateLogger<Publisher>()
LoggerFactory.CreateLogger<SpotifyPublisher>()
));
}
public Task<IApplePlayerConsumer> GetApple(IAppleMusicPlayerWatcher watcher = null)
{
return Task.FromResult<IApplePlayerConsumer>(new ApplePublisher(
watcher,
Subscriber,
LoggerFactory.CreateLogger<ApplePublisher>()
));
}
}
}
}

@ -1,25 +1,20 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using IF.Lastfm.Core.Api;
using StackExchange.Redis;
using Microsoft.Extensions.Logging;
using SpotifyAPI.Web;
using StackExchange.Redis;
namespace Selector.Cache
{
public class PlayCounterCaching: PlayCounter
public class PlayCounterCaching : PlayCounter
{
private readonly IDatabaseAsync Db;
public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromDays(1);
public PlayCounterCaching(
IPlayerWatcher watcher,
ISpotifyPlayerWatcher watcher,
ITrackApi trackClient,
IAlbumApi albumClient,
IArtistApi artistClient,
@ -37,7 +32,8 @@ namespace Selector.Cache
public void CacheCallback(object sender, PlayCount e)
{
Task.Run(async () => {
Task.Run(async () =>
{
try
{
await AsyncCacheCallback(e);
@ -56,9 +52,12 @@ namespace Selector.Cache
var tasks = new Task[]
{
Db.StringSetAsync(Key.TrackPlayCount(e.Username, track.Name, track.Artists[0].Name), e.Track, expiry: CacheExpiry),
Db.StringSetAsync(Key.AlbumPlayCount(e.Username, track.Album.Name, track.Album.Artists[0].Name), e.Album, expiry: CacheExpiry),
Db.StringSetAsync(Key.ArtistPlayCount(e.Username, track.Artists[0].Name), e.Artist, expiry: CacheExpiry),
Db.StringSetAsync(Key.TrackPlayCount(e.Username, track.Name, track.Artists[0].Name), e.Track,
expiry: CacheExpiry),
Db.StringSetAsync(Key.AlbumPlayCount(e.Username, track.Album.Name, track.Album.Artists[0].Name),
e.Album, expiry: CacheExpiry),
Db.StringSetAsync(Key.ArtistPlayCount(e.Username, track.Artists[0].Name), e.Artist,
expiry: CacheExpiry),
Db.StringSetAsync(Key.UserPlayCount(e.Username), e.User, expiry: CacheExpiry),
};
@ -67,4 +66,4 @@ namespace Selector.Cache
Logger.LogDebug("Cached play count for [{track}]", track.DisplayString());
}
}
}
}

@ -1,10 +1,8 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using SpotifyAPI.Web;
using StackExchange.Redis;
@ -16,13 +14,13 @@ namespace Selector.Cache
public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromDays(14);
public CachingAudioFeatureInjector(
IPlayerWatcher watcher,
ISpotifyPlayerWatcher watcher,
IDatabaseAsync db,
ITracksClient trackClient,
ILogger<CachingAudioFeatureInjector> logger = null,
CancellationToken token = default
) : base(watcher, trackClient, logger, token) {
) : base(watcher, trackClient, logger, token)
{
Db = db;
NewFeature += CacheCallback;
@ -46,12 +44,13 @@ namespace Selector.Cache
public async Task AsyncCacheCallback(AnalysedTrack e)
{
var payload = JsonSerializer.Serialize(e.Features, JsonContext.Default.TrackAudioFeatures);
Logger.LogTrace("Caching current for [{track}]", e.Track.DisplayString());
var resp = await Db.StringSetAsync(Key.AudioFeature(e.Track.Id), payload, expiry: CacheExpiry);
Logger.LogDebug("Cached audio feature for [{track}], {state}", e.Track.DisplayString(), (resp ? "value set" : "value NOT set"));
Logger.LogDebug("Cached audio feature for [{track}], {state}", e.Track.DisplayString(),
(resp ? "value set" : "value NOT set"));
}
}
}
}

@ -1,18 +1,16 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
namespace Selector.Cache
{
public class CacheWriter : IPlayerConsumer
public class CacheWriter : ISpotifyPlayerConsumer
{
private readonly IPlayerWatcher Watcher;
private readonly ISpotifyPlayerWatcher Watcher;
private readonly IDatabaseAsync Db;
private readonly ILogger<CacheWriter> Logger;
public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromMinutes(20);
@ -20,11 +18,12 @@ namespace Selector.Cache
public CancellationToken CancelToken { get; set; }
public CacheWriter(
IPlayerWatcher watcher,
ISpotifyPlayerWatcher watcher,
IDatabaseAsync db,
ILogger<CacheWriter> logger = null,
CancellationToken token = default
){
)
{
Watcher = watcher;
Db = db;
Logger = logger ?? NullLogger<CacheWriter>.Instance;
@ -34,8 +33,9 @@ namespace Selector.Cache
public void Callback(object sender, ListeningChangeEventArgs e)
{
if (e.Current is null) return;
Task.Run(async () => {
Task.Run(async () =>
{
try
{
await AsyncCallback(e);
@ -44,7 +44,6 @@ namespace Selector.Cache
{
Logger.LogError(e, "Error occured during callback");
}
}, CancelToken);
}
@ -52,24 +51,23 @@ namespace Selector.Cache
{
using var scope = Logger.GetListeningEventArgsScope(e);
var payload = JsonSerializer.Serialize((CurrentlyPlayingDTO) e, JsonContext.Default.CurrentlyPlayingDTO);
var payload = JsonSerializer.Serialize((CurrentlyPlayingDTO)e, JsonContext.Default.CurrentlyPlayingDTO);
Logger.LogTrace("Caching current");
var resp = await Db.StringSetAsync(Key.CurrentlyPlaying(e.Id), payload, expiry: CacheExpiry);
var resp = await Db.StringSetAsync(Key.CurrentlyPlayingSpotify(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 IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
@ -80,7 +78,7 @@ namespace Selector.Cache
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
@ -90,4 +88,4 @@ namespace Selector.Cache
}
}
}
}
}

@ -1,32 +1,31 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
namespace Selector.Cache
{
public class Publisher : IPlayerConsumer
public class SpotifyPublisher : ISpotifyPlayerConsumer
{
private readonly IPlayerWatcher Watcher;
private readonly ISpotifyPlayerWatcher Watcher;
private readonly ISubscriber Subscriber;
private readonly ILogger<Publisher> Logger;
private readonly ILogger<SpotifyPublisher> Logger;
public CancellationToken CancelToken { get; set; }
public Publisher(
IPlayerWatcher watcher,
public SpotifyPublisher(
ISpotifyPlayerWatcher watcher,
ISubscriber subscriber,
ILogger<Publisher> logger = null,
ILogger<SpotifyPublisher> logger = null,
CancellationToken token = default
){
)
{
Watcher = watcher;
Subscriber = subscriber;
Logger = logger ?? NullLogger<Publisher>.Instance;
Logger = logger ?? NullLogger<SpotifyPublisher>.Instance;
CancelToken = token;
}
@ -34,7 +33,8 @@ namespace Selector.Cache
{
if (e.Current is null) return;
Task.Run(async () => {
Task.Run(async () =>
{
try
{
await AsyncCallback(e);
@ -50,12 +50,12 @@ namespace Selector.Cache
{
using var scope = Logger.GetListeningEventArgsScope(e);
var payload = JsonSerializer.Serialize((CurrentlyPlayingDTO) e, JsonContext.Default.CurrentlyPlayingDTO);
var payload = JsonSerializer.Serialize((CurrentlyPlayingDTO)e, JsonContext.Default.CurrentlyPlayingDTO);
Logger.LogTrace("Publishing current");
// TODO: currently using spotify username for cache key, use db username
var receivers = await Subscriber.PublishAsync(Key.CurrentlyPlaying(e.Id), payload);
var receivers = await Subscriber.PublishAsync(Key.CurrentlyPlayingSpotify(e.Id), payload);
Logger.LogDebug("Published current, {receivers} receivers", receivers);
}
@ -64,10 +64,10 @@ namespace Selector.Cache
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
@ -78,7 +78,7 @@ namespace Selector.Cache
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
@ -88,4 +88,4 @@ namespace Selector.Cache
}
}
}
}
}

@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Linq;
namespace Selector.Cache
{
@ -33,25 +30,47 @@ namespace Selector.Cache
/// </summary>
/// <param name="user">User's database Id (Guid)</param>
/// <returns></returns>
public static string CurrentlyPlaying(string user) => MajorNamespace(MinorNamespace(UserName, CurrentlyPlayingName), user);
public static readonly string AllCurrentlyPlaying = CurrentlyPlaying(All);
public static string CurrentlyPlayingSpotify(string user) =>
MajorNamespace(MinorNamespace(UserName, SpotifyName, CurrentlyPlayingName), user);
public static string CurrentlyPlayingAppleMusic(string user) =>
MajorNamespace(MinorNamespace(UserName, AppleMusicName, CurrentlyPlayingName), user);
public static readonly string AllCurrentlyPlayingSpotify = CurrentlyPlayingSpotify(All);
public static readonly string AllCurrentlyPlayingApple = CurrentlyPlayingAppleMusic(All);
public static string Track(string trackId) => MajorNamespace(TrackName, trackId);
public static readonly string AllTracks = Track(All);
public static string AudioFeature(string trackId) => MajorNamespace(MinorNamespace(TrackName, AudioFeatureName), trackId);
public static string AudioFeature(string trackId) =>
MajorNamespace(MinorNamespace(TrackName, AudioFeatureName), trackId);
public static readonly string AllAudioFeatures = AudioFeature(All);
public static string TrackPlayCount(string username, string name, string artist) => MajorNamespace(MinorNamespace(TrackName, PlayCountName), artist, name, username);
public static string AlbumPlayCount(string username, string name, string artist) => MajorNamespace(MinorNamespace(AlbumName, PlayCountName), artist, name, username);
public static string ArtistPlayCount(string username, string name) => MajorNamespace(MinorNamespace(ArtistName, PlayCountName), name, username);
public static string UserPlayCount(string username) => MajorNamespace(MinorNamespace(UserName, PlayCountName), username);
public static string TrackPlayCount(string username, string name, string artist) =>
MajorNamespace(MinorNamespace(TrackName, PlayCountName), artist, name, username);
public static string AlbumPlayCount(string username, string name, string artist) =>
MajorNamespace(MinorNamespace(AlbumName, PlayCountName), artist, name, username);
public static string ArtistPlayCount(string username, string name) =>
MajorNamespace(MinorNamespace(ArtistName, PlayCountName), name, username);
public static string UserPlayCount(string username) =>
MajorNamespace(MinorNamespace(UserName, PlayCountName), username);
public static string UserSpotify(string username) =>
MajorNamespace(MinorNamespace(UserName, SpotifyName), username);
public static string UserAppleMusic(string username) =>
MajorNamespace(MinorNamespace(UserName, AppleMusicName), username);
public static string UserSpotify(string username) => MajorNamespace(MinorNamespace(UserName, SpotifyName), username);
public static string UserAppleMusic(string username) => MajorNamespace(MinorNamespace(UserName, AppleMusicName), username);
public static readonly string AllUserSpotify = UserSpotify(All);
public static readonly string AllUserAppleMusic = UserAppleMusic(All);
public static string UserLastfm(string username) => MajorNamespace(MinorNamespace(UserName, LastfmName), username);
public static string UserLastfm(string username) =>
MajorNamespace(MinorNamespace(UserName, LastfmName), username);
public static readonly string AllUserLastfm = UserLastfm(All);
public static string Watcher(int id) => MajorNamespace(WatcherName, id.ToString());
@ -66,14 +85,17 @@ namespace Selector.Cache
public static string[] UnNamespace(string key, params char[] args) => key.Split(args);
public static string Param(string key) => UnMajorNamespace(key).Skip(1).First();
public static (string, string) ParamPair(string key) {
public static (string, string) ParamPair(string key)
{
var split = UnMajorNamespace(key);
return (split[1], split[2]);
}
public static (string, string, string) ParamTriplet(string key)
{
var split = UnMajorNamespace(key);
return (split[1], split[2], split[3]);
}
}
}
}

@ -13,6 +13,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Selector.AppleMusic\Selector.AppleMusic.csproj"/>
<ProjectReference Include="..\Selector\Selector.csproj" />
</ItemGroup>

@ -1,9 +1,8 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using Selector.AppleMusic;
using Selector.Cache;
using StackExchange.Redis;
namespace Selector.Events
{
@ -28,20 +27,39 @@ namespace Selector.Events
{
Logger.LogDebug("Forming now playing event mapping between cache and event bus");
(await Subscriber.SubscribeAsync(Key.AllCurrentlyPlaying)).OnMessage(message => {
(await Subscriber.SubscribeAsync(Key.AllCurrentlyPlayingSpotify)).OnMessage(message =>
{
try
{
var userId = Key.Param(message.Channel);
var deserialised = JsonSerializer.Deserialize(message.Message, JsonContext.Default.CurrentlyPlayingDTO);
Logger.LogDebug("Received new currently playing [{username}]", deserialised.Username);
var deserialised =
JsonSerializer.Deserialize(message.Message, JsonContext.Default.CurrentlyPlayingDTO);
Logger.LogDebug("Received new Spotify currently playing [{username}]", deserialised.Username);
UserEvent.OnCurrentlyPlayingChange(this, deserialised);
UserEvent.OnCurrentlyPlayingChangeSpotify(this, deserialised);
}
catch (Exception e)
{
Logger.LogError(e, "Error parsing new currently playing [{message}]", message);
Logger.LogError(e, "Error parsing new Spotify currently playing [{message}]", message);
}
});
(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");
UserEvent.OnCurrentlyPlayingChangeApple(this, deserialised);
}
catch (Exception e)
{
Logger.LogError(e, "Error parsing new Apple Music currently playing [{message}]", message);
}
});
}
@ -69,10 +87,16 @@ namespace Selector.Events
{
Logger.LogDebug("Forming now playing event mapping TO cache FROM event bus");
UserEvent.CurrentlyPlaying += async (o, e) =>
UserEvent.CurrentlyPlayingSpotify += async (o, e) =>
{
var payload = JsonSerializer.Serialize(e, JsonContext.Default.CurrentlyPlayingDTO);
await Subscriber.PublishAsync(Key.CurrentlyPlaying(e.UserId), payload);
await Subscriber.PublishAsync(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);
};
return Task.CompletedTask;

@ -0,0 +1,84 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Selector.AppleMusic;
using Selector.AppleMusic.Watcher.Consumer;
namespace Selector.Events
{
public class AppleUserEventFirer : IApplePlayerConsumer
{
protected readonly IAppleMusicPlayerWatcher Watcher;
protected readonly ILogger<AppleUserEventFirer> Logger;
protected readonly UserEventBus UserEvent;
public CancellationToken CancelToken { get; set; }
public AppleUserEventFirer(
IAppleMusicPlayerWatcher watcher,
UserEventBus userEvent,
ILogger<AppleUserEventFirer> logger = null,
CancellationToken token = default
)
{
Watcher = watcher;
UserEvent = userEvent;
Logger = logger ?? NullLogger<AppleUserEventFirer>.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 Task AsyncCallback(AppleListeningChangeEventArgs e)
{
Logger.LogDebug("Firing Apple now playing event on user bus [{userId}]", e.Id);
UserEvent.OnCurrentlyPlayingChangeApple(this, e);
return Task.CompletedTask;
}
public void Subscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IAppleMusicPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
public void Unsubscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IAppleMusicPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
}
}

@ -3,33 +3,34 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace Selector.Events
{
public class UserEventFirer : IPlayerConsumer
public class SpotifyUserEventFirer : ISpotifyPlayerConsumer
{
protected readonly IPlayerWatcher Watcher;
protected readonly ILogger<UserEventFirer> Logger;
protected readonly ISpotifyPlayerWatcher Watcher;
protected readonly ILogger<SpotifyUserEventFirer> Logger;
protected readonly UserEventBus UserEvent;
public CancellationToken CancelToken { get; set; }
public UserEventFirer(
IPlayerWatcher watcher,
public SpotifyUserEventFirer(
ISpotifyPlayerWatcher watcher,
UserEventBus userEvent,
ILogger<UserEventFirer> logger = null,
ILogger<SpotifyUserEventFirer> logger = null,
CancellationToken token = default
)
{
Watcher = watcher;
UserEvent = userEvent;
Logger = logger ?? NullLogger<UserEventFirer>.Instance;
Logger = logger ?? NullLogger<SpotifyUserEventFirer>.Instance;
CancelToken = token;
}
public void Callback(object sender, ListeningChangeEventArgs e)
{
if (e.Current is null) return;
Task.Run(async () => {
Task.Run(async () =>
{
try
{
await AsyncCallback(e);
@ -43,9 +44,10 @@ namespace Selector.Events
public Task AsyncCallback(ListeningChangeEventArgs e)
{
Logger.LogDebug("Firing now playing event on user bus [{username}/{userId}]", e.SpotifyUsername, e.Id);
Logger.LogDebug("Firing Spotify now playing event on user bus [{username}/{userId}]", e.SpotifyUsername,
e.Id);
UserEvent.OnCurrentlyPlayingChange(this, (CurrentlyPlayingDTO) e);
UserEvent.OnCurrentlyPlayingChangeSpotify(this, (CurrentlyPlayingDTO)e);
return Task.CompletedTask;
}
@ -54,7 +56,7 @@ namespace Selector.Events
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
@ -68,7 +70,7 @@ namespace Selector.Events
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
@ -78,4 +80,4 @@ namespace Selector.Events
}
}
}
}
}

@ -4,10 +4,10 @@ namespace Selector.Events
{
public interface IUserEventFirerFactory
{
public Task<UserEventFirer> Get(IPlayerWatcher watcher = null);
public Task<SpotifyUserEventFirer> Get(ISpotifyPlayerWatcher watcher = null);
}
public class UserEventFirerFactory: IUserEventFirerFactory
public class UserEventFirerFactory : IUserEventFirerFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly UserEventBus UserEvent;
@ -18,13 +18,13 @@ namespace Selector.Events
UserEvent = userEvent;
}
public Task<UserEventFirer> Get(IPlayerWatcher watcher = null)
public Task<SpotifyUserEventFirer> Get(ISpotifyPlayerWatcher watcher = null)
{
return Task.FromResult(new UserEventFirer(
return Task.FromResult(new SpotifyUserEventFirer(
watcher,
UserEvent,
LoggerFactory.CreateLogger<UserEventFirer>()
LoggerFactory.CreateLogger<SpotifyUserEventFirer>()
));
}
}
}
}

@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Selector.AppleMusic\Selector.AppleMusic.csproj"/>
<ProjectReference Include="..\Selector\Selector.csproj" />
<ProjectReference Include="..\Selector.Model\Selector.Model.csproj" />
<ProjectReference Include="..\Selector.Cache\Selector.Cache.csproj" />

@ -1,10 +1,10 @@
using Microsoft.Extensions.Logging;
using Selector.AppleMusic;
using Selector.Model;
namespace Selector.Events
{
public class UserEventBus: IEventBus
public class UserEventBus : IEventBus
{
private readonly ILogger<UserEventBus> Logger;
@ -13,7 +13,8 @@ namespace Selector.Events
public event EventHandler<AppleMusicLinkChange> AppleLinkChange;
public event EventHandler<LastfmChange> LastfmCredChange;
public event EventHandler<CurrentlyPlayingDTO> CurrentlyPlaying;
public event EventHandler<CurrentlyPlayingDTO> CurrentlyPlayingSpotify;
public event EventHandler<AppleListeningChangeEventArgs> CurrentlyPlayingApple;
public UserEventBus(ILogger<UserEventBus> logger)
{
@ -44,10 +45,16 @@ namespace Selector.Events
LastfmCredChange?.Invoke(sender, args);
}
public void OnCurrentlyPlayingChange(object sender, CurrentlyPlayingDTO args)
public void OnCurrentlyPlayingChangeSpotify(object sender, CurrentlyPlayingDTO args)
{
Logger.LogTrace("Firing currently playing event [{usernamne}/{userId}]", args?.Username, args.UserId);
CurrentlyPlaying?.Invoke(sender, args);
CurrentlyPlayingSpotify?.Invoke(sender, args);
}
public void OnCurrentlyPlayingChangeApple(object sender, AppleListeningChangeEventArgs args)
{
Logger.LogTrace("Firing currently playing event");
CurrentlyPlayingApple?.Invoke(sender, args);
}
}
}
}

@ -64,6 +64,8 @@
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<CodesignKey>iPhone Developer</CodesignKey>
<MtouchDebug>true</MtouchDebug>
<IOSDebugOverWiFi>true</IOSDebugOverWiFi>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<CodesignKey>iPhone Developer</CodesignKey>

@ -1,19 +1,14 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Selector.Model
{
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
private readonly ILogger<ApplicationDbContext> Logger;
@ -27,7 +22,7 @@ namespace Selector.Model
public DbSet<SpotifyListen> SpotifyListen { get; set; }
public ApplicationDbContext(
DbContextOptions<ApplicationDbContext> options,
DbContextOptions<ApplicationDbContext> options,
ILogger<ApplicationDbContext> logger
) : base(options)
{
@ -36,14 +31,14 @@ namespace Selector.Model
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasCollation("case_insensitive", locale: "en-u-ks-primary", provider: "icu", deterministic: false);
modelBuilder.HasCollation("case_insensitive", locale: "en-u-ks-primary", provider: "icu",
deterministic: false);
modelBuilder.Entity<ApplicationUser>()
.Property(u => u.SpotifyIsLinked)
@ -112,15 +107,16 @@ namespace Selector.Model
public void CreatePlayerWatcher(string userId)
{
if(Watcher.Any(w => w.UserId == userId && w.Type == WatcherType.Player))
if (Watcher.Any(w => w.UserId == userId && w.Type == WatcherType.SpotifyPlayer))
{
Logger.LogWarning("Trying to create more than one player watcher for user [{id}]", userId);
return;
}
Watcher.Add(new Watcher {
Watcher.Add(new Watcher
{
UserId = userId,
Type = WatcherType.Player
Type = WatcherType.SpotifyPlayer
});
SaveChanges();
@ -129,17 +125,18 @@ namespace Selector.Model
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
private static string GetPath(string env) => $"{@Directory.GetCurrentDirectory()}/../Selector.Web/appsettings.{env}.json";
private static string GetPath(string env) =>
$"{@Directory.GetCurrentDirectory()}/../Selector.Web/appsettings.{env}.json";
public ApplicationDbContext CreateDbContext(string[] args)
{
string configFile;
if(File.Exists(GetPath("Development")))
if (File.Exists(GetPath("Development")))
{
configFile = GetPath("Development");
}
else if(File.Exists(GetPath("Production")))
else if (File.Exists(GetPath("Production")))
{
configFile = GetPath("Production");
}
@ -155,7 +152,7 @@ namespace Selector.Model
var builder = new DbContextOptionsBuilder<ApplicationDbContext>();
builder.UseNpgsql(configuration.GetConnectionString("Default"));
return new ApplicationDbContext(builder.Options, NullLogger<ApplicationDbContext>.Instance);
}
}

@ -0,0 +1,194 @@
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using Selector.AppleMusic;
using Selector.AppleMusic.Watcher;
using Xunit;
namespace Selector.Tests.Apple;
public class AppleTimelineTests
{
public static IEnumerable<object[]> MatchingData =>
new List<object[]>
{
new object[]
{
new List<List<AppleMusicCurrentlyPlayingContext>>
{
new()
{
Helper.AppleContext("1"),
Helper.AppleContext("2"),
Helper.AppleContext("3"),
}
},
new List<string>
{
"1", "2", "3"
},
new List<List<string>>
{
new()
{
// "1", "2", "3"
}
}
},
new object[]
{
new List<List<AppleMusicCurrentlyPlayingContext>>
{
new()
{
Helper.AppleContext("1"),
Helper.AppleContext("2"),
Helper.AppleContext("3"),
},
new()
{
Helper.AppleContext("3"),
Helper.AppleContext("4"),
Helper.AppleContext("5"),
}
},
new List<string>
{
"1", "2", "3", "4", "5"
},
new List<List<string>>
{
new()
{
// "1", "2", "3"
},
new()
{
"4", "5",
}
}
},
new object[]
{
new List<List<AppleMusicCurrentlyPlayingContext>>
{
new()
{
Helper.AppleContext("1"),
Helper.AppleContext("2"),
Helper.AppleContext("3"),
},
new()
{
Helper.AppleContext("3"),
Helper.AppleContext("4"),
Helper.AppleContext("5"),
},
new()
{
Helper.AppleContext("3"),
Helper.AppleContext("4"),
Helper.AppleContext("5"),
},
new()
{
Helper.AppleContext("5"),
Helper.AppleContext("6"),
Helper.AppleContext("7"),
}
},
new List<string>
{
"1", "2", "3", "4", "5", "6", "7"
},
new List<List<string>>
{
new()
{
// "1", "2", "3"
},
new()
{
"4", "5",
},
new()
{
},
new()
{
"6", "7",
}
}
},
new object[]
{
new List<List<AppleMusicCurrentlyPlayingContext>>
{
new()
{
Helper.AppleContext("1"),
Helper.AppleContext("2"),
Helper.AppleContext("3"),
},
new()
{
Helper.AppleContext("3"),
Helper.AppleContext("4"),
Helper.AppleContext("5"),
},
new()
{
Helper.AppleContext("3"),
Helper.AppleContext("4"),
Helper.AppleContext("5"),
},
new()
{
Helper.AppleContext("1"),
Helper.AppleContext("2"),
Helper.AppleContext("3"),
}
},
new List<string>
{
"1", "2", "3", "4", "5"
},
new List<List<string>>
{
new()
{
// "1", "2", "3"
},
new()
{
"4", "5",
},
new()
{
},
new()
{
}
}
}
};
[Theory]
[MemberData(nameof(MatchingData))]
public void Matching(List<List<AppleMusicCurrentlyPlayingContext>> currentlyPlaying, List<string> expectedContent,
List<List<string>> expectedResult)
{
var timeline = new AppleTimeline();
foreach (var (batch, expectedReturn) in currentlyPlaying.Zip(expectedResult))
{
var newItems = timeline.Add(batch);
newItems.Select(x => x.Track.Id).Should().ContainInOrder(expectedReturn);
}
timeline
.Select(x => x.Item.Track.Id)
.Should()
.ContainInOrder(expectedContent);
}
}

@ -1,14 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Xunit;
using Moq;
using FluentAssertions;
using SpotifyAPI.Web;
using Selector;
using System.Threading;
using Moq;
using SpotifyAPI.Web;
using Xunit;
namespace Selector.Tests
{
@ -17,7 +11,7 @@ namespace Selector.Tests
[Fact]
public void Subscribe()
{
var watcherMock = new Mock<IPlayerWatcher>();
var watcherMock = new Mock<ISpotifyPlayerWatcher>();
var spotifyMock = new Mock<ITracksClient>();
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object);
@ -30,7 +24,7 @@ namespace Selector.Tests
[Fact]
public void Unsubscribe()
{
var watcherMock = new Mock<IPlayerWatcher>();
var watcherMock = new Mock<ISpotifyPlayerWatcher>();
var spotifyMock = new Mock<ITracksClient>();
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object);
@ -43,8 +37,8 @@ namespace Selector.Tests
[Fact]
public void SubscribeFuncArg()
{
var watcherMock = new Mock<IPlayerWatcher>();
var watcherFuncArgMock = new Mock<IPlayerWatcher>();
var watcherMock = new Mock<ISpotifyPlayerWatcher>();
var watcherFuncArgMock = new Mock<ISpotifyPlayerWatcher>();
var spotifyMock = new Mock<ITracksClient>();
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object);
@ -58,8 +52,8 @@ namespace Selector.Tests
[Fact]
public void UnsubscribeFuncArg()
{
var watcherMock = new Mock<IPlayerWatcher>();
var watcherFuncArgMock = new Mock<IPlayerWatcher>();
var watcherMock = new Mock<ISpotifyPlayerWatcher>();
var watcherFuncArgMock = new Mock<ISpotifyPlayerWatcher>();
var spotifyMock = new Mock<ITracksClient>();
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object);
@ -73,7 +67,7 @@ namespace Selector.Tests
[Fact]
public async void CallbackNoId()
{
var watcherMock = new Mock<IPlayerWatcher>();
var watcherMock = new Mock<ISpotifyPlayerWatcher>();
var spotifyMock = new Mock<ITracksClient>();
var timelineMock = new Mock<AnalysedTrackTimeline>();
var eventArgsMock = new Mock<ListeningChangeEventArgs>();
@ -84,7 +78,8 @@ namespace Selector.Tests
eventArgsMock.Object.Current = playingMock.Object;
playingMock.Object.Item = trackMock.Object;
spotifyMock.Setup(m => m.GetAudioFeatures(It.IsAny<string>(), It.IsAny<CancellationToken>()).Result).Returns(() => featureMock.Object);
spotifyMock.Setup(m => m.GetAudioFeatures(It.IsAny<string>(), It.IsAny<CancellationToken>()).Result)
.Returns(() => featureMock.Object);
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object)
{
@ -100,7 +95,7 @@ namespace Selector.Tests
[Fact]
public async void CallbackWithId()
{
var watcherMock = new Mock<IPlayerWatcher>();
var watcherMock = new Mock<ISpotifyPlayerWatcher>();
var spotifyMock = new Mock<ITracksClient>();
var timelineMock = new Mock<AnalysedTrackTimeline>();
var eventArgsMock = new Mock<ListeningChangeEventArgs>();
@ -112,7 +107,8 @@ namespace Selector.Tests
playingMock.Object.Item = trackMock.Object;
trackMock.Object.Id = "Fake-Id";
spotifyMock.Setup(m => m.GetAudioFeatures(It.IsAny<string>(), It.IsAny<CancellationToken>()).Result).Returns(() => featureMock.Object);
spotifyMock.Setup(m => m.GetAudioFeatures(It.IsAny<string>(), It.IsAny<CancellationToken>()).Result)
.Returns(() => featureMock.Object);
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object)
{
@ -128,4 +124,4 @@ namespace Selector.Tests
timelineMock.VerifyNoOtherCalls();
}
}
}
}

@ -1,18 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;
using Xunit;
using FluentAssertions;
using Moq;
using Moq.Protected;
using FluentAssertions;
using System.Net;
using SpotifyAPI.Web;
using Xunit;
namespace Selector.Tests
{
@ -25,10 +19,11 @@ namespace Selector.Tests
var httpHandlerMock = new Mock<HttpMessageHandler>();
httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(msg);
var watcherMock = new Mock<IPlayerWatcher>();
var watcherMock = new Mock<ISpotifyPlayerWatcher>();
watcherMock.SetupAdd(w => w.ItemChange += It.IsAny<EventHandler<ListeningChangeEventArgs>>());
watcherMock.SetupRemove(w => w.ItemChange -= It.IsAny<EventHandler<ListeningChangeEventArgs>>());
@ -49,7 +44,8 @@ namespace Selector.Tests
await Task.Delay(100);
httpHandlerMock.Protected().Verify<Task<HttpResponseMessage>>("SendAsync", Times.Once(), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
httpHandlerMock.Protected().Verify<Task<HttpResponseMessage>>("SendAsync", Times.Once(),
ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());
}
[Theory]
@ -62,10 +58,11 @@ namespace Selector.Tests
var httpHandlerMock = new Mock<HttpMessageHandler>();
httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(msg);
var watcherMock = new Mock<IPlayerWatcher>();
var watcherMock = new Mock<ISpotifyPlayerWatcher>();
var link = "https://link";
var content = new StringContent("");
@ -81,26 +78,17 @@ namespace Selector.Tests
var webHook = new WebHook(watcherMock.Object, http, config);
webHook.PredicatePass += (o, e) =>
{
predicateEvent = predicate;
};
webHook.PredicatePass += (o, e) => { predicateEvent = predicate; };
webHook.SuccessfulRequest += (o, e) =>
{
successfulEvent = successful;
};
webHook.SuccessfulRequest += (o, e) => { successfulEvent = successful; };
webHook.FailedRequest += (o, e) =>
{
failedEvent = !successful;
};
webHook.FailedRequest += (o, e) => { failedEvent = !successful; };
await webHook.AsyncCallback(ListeningChangeEventArgs.From(new (), new (), new()));
await webHook.AsyncCallback(ListeningChangeEventArgs.From(new(), new(), new()));
predicateEvent.Should().Be(predicate);
successfulEvent.Should().Be(successful);
failedEvent.Should().Be(!successful);
}
}
}
}

@ -1,9 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Selector.AppleMusic.Model;
using Selector.AppleMusic.Watcher;
using SpotifyAPI.Web;
namespace Selector.Tests
@ -12,8 +10,8 @@ namespace Selector.Tests
{
public static FullTrack FullTrack(string name, string album = "album name", List<string> artists = null)
{
if (artists is null) artists = new List<string>() {"artist"};
if (artists is null) artists = new List<string>() { "artist" };
return new FullTrack()
{
Name = name,
@ -40,7 +38,7 @@ namespace Selector.Tests
public static SimpleAlbum SimpleAlbum(string name, List<string> artists = null)
{
if (artists is null) artists = new List<string>() {"artist"};
if (artists is null) artists = new List<string>() { "artist" };
return new SimpleAlbum()
{
Name = name,
@ -83,7 +81,8 @@ namespace Selector.Tests
};
}
public static CurrentlyPlayingContext CurrentPlayback(FullTrack track, Device device = null, bool isPlaying = true, string context = "context")
public static CurrentlyPlayingContext CurrentPlayback(FullTrack track, Device device = null,
bool isPlaying = true, string context = "context")
{
return new CurrentlyPlayingContext()
{
@ -94,7 +93,8 @@ namespace Selector.Tests
};
}
public static CurrentlyPlaying CurrentlyPlaying(FullEpisode episode, bool isPlaying = true, string context = null)
public static CurrentlyPlaying CurrentlyPlaying(FullEpisode episode, bool isPlaying = true,
string context = null)
{
return new CurrentlyPlaying()
{
@ -104,7 +104,8 @@ namespace Selector.Tests
};
}
public static CurrentlyPlayingContext CurrentPlayback(FullEpisode episode, Device device = null, bool isPlaying = true, string context = null)
public static CurrentlyPlayingContext CurrentPlayback(FullEpisode episode, Device device = null,
bool isPlaying = true, string context = null)
{
return new CurrentlyPlayingContext()
{
@ -148,5 +149,16 @@ namespace Selector.Tests
}
};
}
public static AppleMusicCurrentlyPlayingContext AppleContext(string id)
{
return new()
{
Track = new Track()
{
Id = id
}
};
}
}
}
}

@ -23,6 +23,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Selector.AppleMusic\Selector.AppleMusic.csproj"/>
<ProjectReference Include="..\Selector\Selector.csproj" />
</ItemGroup>

@ -1,28 +1,28 @@
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 SpotifyAPI.Web;
using Xunit;
namespace Selector.Tests
{
public class PlayerWatcherTests
public class SpotifyPlayerWatcherTests
{
public static IEnumerable<object[]> NowPlayingData =>
new List<object[]>
{
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullTrack("track1", "album1", "artist1")),
Helper.CurrentPlayback(Helper.FullTrack("track2", "album2", "artist2")),
Helper.CurrentPlayback(Helper.FullTrack("track3", "album3", "artist3")),
new List<object[]>
{
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullTrack("track1", "album1", "artist1")),
Helper.CurrentPlayback(Helper.FullTrack("track2", "album2", "artist2")),
Helper.CurrentPlayback(Helper.FullTrack("track3", "album3", "artist3")),
}
}
}
};
};
[Theory]
[MemberData(nameof(NowPlayingData))]
@ -33,9 +33,10 @@ namespace Selector.Tests
var spotMock = new Mock<IPlayerClient>();
var eq = new UriEqual();
spotMock.Setup(s => s.GetCurrentPlayback(It.IsAny<CancellationToken>()).Result).Returns(playingQueue.Dequeue);
spotMock.Setup(s => s.GetCurrentPlayback(It.IsAny<CancellationToken>()).Result)
.Returns(playingQueue.Dequeue);
var watcher = new PlayerWatcher(spotMock.Object, eq);
var watcher = new SpotifyPlayerWatcher(spotMock.Object, eq);
for (var i = 0; i < playing.Count; i++)
{
@ -45,140 +46,209 @@ namespace Selector.Tests
}
public static IEnumerable<object[]> EventsData =>
new List<object[]>
{
// NO CHANGING
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullTrack("nochange", "album1", "artist1"), isPlaying: true, context: "context1"),
Helper.CurrentPlayback(Helper.FullTrack("nochange", "album1", "artist1"), isPlaying: true, context: "context1"),
Helper.CurrentPlayback(Helper.FullTrack("nochange", "album1", "artist1"), isPlaying: true, context: "context1"),
new List<object[]>
{
// NO CHANGING
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullTrack("nochange", "album1", "artist1"), isPlaying: true,
context: "context1"),
Helper.CurrentPlayback(Helper.FullTrack("nochange", "album1", "artist1"), isPlaying: true,
context: "context1"),
Helper.CurrentPlayback(Helper.FullTrack("nochange", "album1", "artist1"), isPlaying: true,
context: "context1"),
},
// to raise
new List<string>()
{ "ItemChange", "ContextChange", "PlayingChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>() { "AlbumChange", "ArtistChange" }
},
// to raise
new List<string>(){ "ItemChange", "ContextChange", "PlayingChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>(){ "AlbumChange", "ArtistChange" }
},
// TRACK CHANGE
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullTrack("trackchange1", "album1", "artist1")),
Helper.CurrentPlayback(Helper.FullTrack("trackchange2", "album1", "artist1"))
// TRACK CHANGE
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullTrack("trackchange1", "album1", "artist1")),
Helper.CurrentPlayback(Helper.FullTrack("trackchange2", "album1", "artist1"))
},
// to raise
new List<string>()
{ "ContextChange", "PlayingChange", "ItemChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>() { "AlbumChange", "ArtistChange" }
},
// to raise
new List<string>(){ "ContextChange", "PlayingChange", "ItemChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>(){ "AlbumChange", "ArtistChange" }
},
// ALBUM CHANGE
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullTrack("albumchange", "album1", "artist1")),
Helper.CurrentPlayback(Helper.FullTrack("albumchange", "album2", "artist1"))
// ALBUM CHANGE
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullTrack("albumchange", "album1", "artist1")),
Helper.CurrentPlayback(Helper.FullTrack("albumchange", "album2", "artist1"))
},
// to raise
new List<string>()
{
"ContextChange", "PlayingChange", "ItemChange", "AlbumChange", "DeviceChange", "VolumeChange"
},
// to not raise
new List<string>() { "ArtistChange" }
},
// to raise
new List<string>(){ "ContextChange", "PlayingChange", "ItemChange", "AlbumChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>(){ "ArtistChange" }
},
// ARTIST CHANGE
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullTrack("artistchange", "album1", "artist1")),
Helper.CurrentPlayback(Helper.FullTrack("artistchange", "album1", "artist2"))
// ARTIST CHANGE
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullTrack("artistchange", "album1", "artist1")),
Helper.CurrentPlayback(Helper.FullTrack("artistchange", "album1", "artist2"))
},
// to raise
new List<string>()
{
"ContextChange", "PlayingChange", "ItemChange", "ArtistChange", "DeviceChange", "VolumeChange"
},
// to not raise
new List<string>() { "AlbumChange" }
},
// to raise
new List<string>(){ "ContextChange", "PlayingChange", "ItemChange", "ArtistChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>(){ "AlbumChange" }
},
// CONTEXT CHANGE
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullTrack("contextchange", "album1", "artist1"), context: "context1"),
Helper.CurrentPlayback(Helper.FullTrack("contextchange", "album1", "artist1"), context: "context2")
// CONTEXT CHANGE
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullTrack("contextchange", "album1", "artist1"),
context: "context1"),
Helper.CurrentPlayback(Helper.FullTrack("contextchange", "album1", "artist1"),
context: "context2")
},
// to raise
new List<string>()
{ "PlayingChange", "ItemChange", "ContextChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>() { "AlbumChange", "ArtistChange" }
},
// to raise
new List<string>(){ "PlayingChange", "ItemChange", "ContextChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>(){ "AlbumChange", "ArtistChange" }
},
// PLAYING CHANGE
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullTrack("playingchange1", "album1", "artist1"), isPlaying: true, context: "context1"),
Helper.CurrentPlayback(Helper.FullTrack("playingchange1", "album1", "artist1"), isPlaying: false, context: "context1")
// PLAYING CHANGE
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullTrack("playingchange1", "album1", "artist1"), isPlaying: true,
context: "context1"),
Helper.CurrentPlayback(Helper.FullTrack("playingchange1", "album1", "artist1"),
isPlaying: false, context: "context1")
},
// to raise
new List<string>()
{ "ContextChange", "ItemChange", "PlayingChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>() { "AlbumChange", "ArtistChange" }
},
// to raise
new List<string>(){ "ContextChange", "ItemChange", "PlayingChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>(){ "AlbumChange", "ArtistChange" }
},
// PLAYING CHANGE
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullTrack("playingchange2", "album1", "artist1"), isPlaying: false, context: "context1"),
Helper.CurrentPlayback(Helper.FullTrack("playingchange2", "album1", "artist1"), isPlaying: true, context: "context1")
// PLAYING CHANGE
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullTrack("playingchange2", "album1", "artist1"),
isPlaying: false, context: "context1"),
Helper.CurrentPlayback(Helper.FullTrack("playingchange2", "album1", "artist1"), isPlaying: true,
context: "context1")
},
// to raise
new List<string>()
{ "ContextChange", "ItemChange", "PlayingChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>() { "AlbumChange", "ArtistChange" }
},
// to raise
new List<string>(){ "ContextChange", "ItemChange", "PlayingChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>(){ "AlbumChange", "ArtistChange" }
},
// CONTENT CHANGE
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullTrack("contentchange1", "album1", "artist1"), isPlaying: true, context: "context1"),
Helper.CurrentPlayback(Helper.FullEpisode("contentchange1", "show1", "pub1"), isPlaying: true, context: "context2")
// CONTENT CHANGE
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullTrack("contentchange1", "album1", "artist1"), isPlaying: true,
context: "context1"),
Helper.CurrentPlayback(Helper.FullEpisode("contentchange1", "show1", "pub1"), isPlaying: true,
context: "context2")
},
// to raise
new List<string>()
{
"PlayingChange", "ContentChange", "ContextChange", "ItemChange", "DeviceChange", "VolumeChange"
},
// to not raise
new List<string>() { "AlbumChange", "ArtistChange" }
},
// to raise
new List<string>(){ "PlayingChange", "ContentChange", "ContextChange", "ItemChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>(){ "AlbumChange", "ArtistChange" }
},
// CONTENT CHANGE
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullEpisode("contentchange1", "show1", "pub1"), isPlaying: true, context: "context2"),
Helper.CurrentPlayback(Helper.FullTrack("contentchange1", "album1", "artist1"), isPlaying: true, context: "context1")
// CONTENT CHANGE
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullEpisode("contentchange1", "show1", "pub1"), isPlaying: true,
context: "context2"),
Helper.CurrentPlayback(Helper.FullTrack("contentchange1", "album1", "artist1"), isPlaying: true,
context: "context1")
},
// to raise
new List<string>()
{
"PlayingChange", "ContentChange", "ContextChange", "ItemChange", "DeviceChange", "VolumeChange"
},
// to not raise
new List<string>() { "AlbumChange", "ArtistChange" }
},
// to raise
new List<string>(){ "PlayingChange", "ContentChange", "ContextChange", "ItemChange", "DeviceChange", "VolumeChange" },
// to not raise
new List<string>(){ "AlbumChange", "ArtistChange" }
},
// DEVICE CHANGE
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullTrack("devicechange", "album1", "artist1"), device: Helper.Device("dev1")),
Helper.CurrentPlayback(Helper.FullTrack("devicechange", "album1", "artist1"), device: Helper.Device("dev2"))
// DEVICE CHANGE
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullTrack("devicechange", "album1", "artist1"),
device: Helper.Device("dev1")),
Helper.CurrentPlayback(Helper.FullTrack("devicechange", "album1", "artist1"),
device: Helper.Device("dev2"))
},
// to raise
new List<string>()
{ "ContextChange", "PlayingChange", "ItemChange", "VolumeChange", "DeviceChange" },
// to not raise
new List<string>() { "AlbumChange", "ArtistChange", "ContentChange" }
},
// to raise
new List<string>(){ "ContextChange", "PlayingChange", "ItemChange", "VolumeChange", "DeviceChange" },
// to not raise
new List<string>(){ "AlbumChange", "ArtistChange", "ContentChange" }
},
// VOLUME CHANGE
new object[] { new List<CurrentlyPlayingContext>(){
Helper.CurrentPlayback(Helper.FullTrack("volumechange", "album1", "artist1"), device: Helper.Device("dev1", volume: 50)),
Helper.CurrentPlayback(Helper.FullTrack("volumechange", "album1", "artist1"), device: Helper.Device("dev1", volume: 60))
// VOLUME CHANGE
new object[]
{
new List<CurrentlyPlayingContext>()
{
Helper.CurrentPlayback(Helper.FullTrack("volumechange", "album1", "artist1"),
device: Helper.Device("dev1", volume: 50)),
Helper.CurrentPlayback(Helper.FullTrack("volumechange", "album1", "artist1"),
device: Helper.Device("dev1", volume: 60))
},
// to raise
new List<string>()
{ "ContextChange", "PlayingChange", "ItemChange", "VolumeChange", "DeviceChange" },
// to not raise
new List<string>() { "AlbumChange", "ArtistChange", "ContentChange" }
},
// to raise
new List<string>(){ "ContextChange", "PlayingChange", "ItemChange", "VolumeChange", "DeviceChange" },
// to not raise
new List<string>(){ "AlbumChange", "ArtistChange", "ContentChange" }
},
// // STARTED PLAYBACK
// new object[] { new List<CurrentlyPlayingContext>(){
// null,
// Helper.CurrentPlayback(Helper.FullTrack("track1", "album1", "artist1"), isPlaying: true, context: "context1")
// },
// // to raise
// new List<string>(){ "PlayingChange" },
// // to not raise
// new List<string>(){ "AlbumChange", "ArtistChange", "ContentChange", "ContextChange", "ItemChange", "DeviceChange", "VolumeChange" }
// },
// // STARTED PLAYBACK
// new object[] { new List<CurrentlyPlayingContext>(){
// Helper.CurrentPlayback(Helper.FullTrack("track1", "album1", "artist1"), isPlaying: true, context: "context1"),
// null
// },
// // to raise
// new List<string>(){ "PlayingChange" },
// // to not raise
// new List<string>(){ "AlbumChange", "ArtistChange", "ContentChange", "ContextChange", "ItemChange", "DeviceChange", "VolumeChange" }
// }
};
// // STARTED PLAYBACK
// new object[] { new List<CurrentlyPlayingContext>(){
// null,
// Helper.CurrentPlayback(Helper.FullTrack("track1", "album1", "artist1"), isPlaying: true, context: "context1")
// },
// // to raise
// new List<string>(){ "PlayingChange" },
// // to not raise
// new List<string>(){ "AlbumChange", "ArtistChange", "ContentChange", "ContextChange", "ItemChange", "DeviceChange", "VolumeChange" }
// },
// // STARTED PLAYBACK
// new object[] { new List<CurrentlyPlayingContext>(){
// Helper.CurrentPlayback(Helper.FullTrack("track1", "album1", "artist1"), isPlaying: true, context: "context1"),
// null
// },
// // to raise
// new List<string>(){ "PlayingChange" },
// // to not raise
// new List<string>(){ "AlbumChange", "ArtistChange", "ContentChange", "ContextChange", "ItemChange", "DeviceChange", "VolumeChange" }
// }
};
[Theory]
[MemberData(nameof(EventsData))]
@ -193,7 +263,7 @@ namespace Selector.Tests
s => s.GetCurrentPlayback(It.IsAny<CancellationToken>()).Result
).Returns(playingQueue.Dequeue);
var watcher = new PlayerWatcher(spotMock.Object, eq);
var watcher = new SpotifyPlayerWatcher(spotMock.Object, eq);
using var monitoredWatcher = watcher.Monitor();
for (var i = 0; i < playing.Count; i++)
@ -213,14 +283,14 @@ namespace Selector.Tests
{
var spotMock = new Mock<IPlayerClient>();
var eq = new UriEqual();
var watch = new PlayerWatcher(spotMock.Object, eq)
var watch = new SpotifyPlayerWatcher(spotMock.Object, eq)
{
PollPeriod = pollPeriod
};
var tokenSource = new CancellationTokenSource();
var task = watch.Watch(tokenSource.Token);
await Task.Delay(execTime);
tokenSource.Cancel();
@ -238,4 +308,4 @@ namespace Selector.Tests
// await watch.Watch(token.Token);
// }
}
}
}

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Data.SqlTypes;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
@ -12,7 +11,6 @@ using Selector.Cache;
using Selector.Model;
using Selector.Model.Extensions;
using Selector.SignalR;
using SpotifyAPI.Web;
using StackExchange.Redis;
namespace Selector.Web.Hubs
@ -54,7 +52,7 @@ namespace Selector.Web.Hubs
public async Task SendNewPlaying()
{
var nowPlaying = await Cache.StringGetAsync(Key.CurrentlyPlaying(Context.UserIdentifier));
var nowPlaying = await Cache.StringGetAsync(Key.CurrentlyPlayingSpotify(Context.UserIdentifier));
if (nowPlaying != RedisValue.Null)
{
var deserialised = JsonSerializer.Deserialize(nowPlaying, JsonContext.Default.CurrentlyPlayingDTO);
@ -67,16 +65,16 @@ namespace Selector.Web.Hubs
if (string.IsNullOrWhiteSpace(trackId)) return;
var user = Db.Users
.AsNoTracking()
.Where(u => u.Id == Context.UserIdentifier)
.SingleOrDefault()
?? throw new SqlNullValueException("No user returned");
.AsNoTracking()
.Where(u => u.Id == Context.UserIdentifier)
.SingleOrDefault()
?? throw new SqlNullValueException("No user returned");
var watcher = Db.Watcher
.AsNoTracking()
.Where(w => w.UserId == Context.UserIdentifier
&& w.Type == WatcherType.Player)
.SingleOrDefault()
?? throw new SqlNullValueException($"No player watcher found for [{user.UserName}]");
.AsNoTracking()
.Where(w => w.UserId == Context.UserIdentifier
&& w.Type == WatcherType.SpotifyPlayer)
.SingleOrDefault()
?? throw new SqlNullValueException($"No player watcher found for [{user.UserName}]");
var feature = await AudioFeaturePuller.Get(user.SpotifyRefreshToken, trackId);
@ -91,10 +89,10 @@ namespace Selector.Web.Hubs
if (PlayCountPuller is not null)
{
var user = Db.Users
.AsNoTracking()
.Where(u => u.Id == Context.UserIdentifier)
.SingleOrDefault()
?? throw new SqlNullValueException("No user returned");
.AsNoTracking()
.Where(u => u.Id == Context.UserIdentifier)
.SingleOrDefault()
?? throw new SqlNullValueException("No user returned");
if (user.LastFmConnected())
{
@ -125,7 +123,8 @@ namespace Selector.Web.Hubs
if (user.ScrobbleSavingEnabled())
{
var artistScrobbles = ScrobbleRepository.GetAll(userId: user.Id, artistName: artist, from: GetMaximumWindow()).ToArray();
var artistScrobbles = ScrobbleRepository
.GetAll(userId: user.Id, artistName: artist, from: GetMaximumWindow()).ToArray();
var artistDensity = artistScrobbles.Density(nowOptions.Value.ArtistDensityWindow);
var tasks = new List<Task>(3);
@ -138,7 +137,9 @@ namespace Selector.Web.Hubs
}));
}
var albumDensity = artistScrobbles.Where(s => s.AlbumName.Equals(album, StringComparison.InvariantCultureIgnoreCase)).Density(nowOptions.Value.AlbumDensityWindow);
var albumDensity = artistScrobbles
.Where(s => s.AlbumName.Equals(album, StringComparison.InvariantCultureIgnoreCase))
.Density(nowOptions.Value.AlbumDensityWindow);
if (albumDensity > nowOptions.Value.AlbumDensityThreshold)
{
@ -148,7 +149,9 @@ namespace Selector.Web.Hubs
}));
}
var trackDensity = artistScrobbles.Where(s => s.TrackName.Equals(track, StringComparison.InvariantCultureIgnoreCase)).Density(nowOptions.Value.TrackDensityWindow);
var trackDensity = artistScrobbles
.Where(s => s.TrackName.Equals(track, StringComparison.InvariantCultureIgnoreCase))
.Density(nowOptions.Value.TrackDensityWindow);
if (albumDensity > nowOptions.Value.TrackDensityThreshold)
{
@ -165,7 +168,13 @@ namespace Selector.Web.Hubs
}
}
private DateTime GetMaximumWindow() => GetMaximumWindow(new TimeSpan[] { nowOptions.Value.ArtistDensityWindow, nowOptions.Value.AlbumDensityWindow, nowOptions.Value.TrackDensityWindow });
private DateTime GetMaximumWindow(IEnumerable<TimeSpan> windows) => windows.Select(w => DateTime.UtcNow - w).Min();
private DateTime GetMaximumWindow() => GetMaximumWindow(new TimeSpan[]
{
nowOptions.Value.ArtistDensityWindow, nowOptions.Value.AlbumDensityWindow,
nowOptions.Value.TrackDensityWindow
});
private DateTime GetMaximumWindow(IEnumerable<TimeSpan> windows) =>
windows.Select(w => DateTime.UtcNow - w).Min();
}
}

@ -1,14 +1,13 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Selector.Web.Hubs;
using Selector.Events;
using Selector.SignalR;
using Selector.Web.Hubs;
namespace Selector.Web.Service
{
public class NowPlayingHubMapping: IEventHubMapping<NowPlayingHub, INowPlayingHubClient>
public class NowPlayingHubMapping : IEventHubMapping<NowPlayingHub, INowPlayingHubClient>
{
private readonly ILogger<NowPlayingHubMapping> Logger;
private readonly UserEventBus UserEvent;
@ -27,14 +26,21 @@ namespace Selector.Web.Service
{
Logger.LogDebug("Forming now playing event mapping between event bus and SignalR hub");
UserEvent.CurrentlyPlaying += async (o, args) =>
UserEvent.CurrentlyPlayingSpotify += async (o, args) =>
{
Logger.LogDebug("Passing now playing event to SignalR hub [{userId}]", args.UserId);
await Hub.Clients.User(args.UserId).OnNewPlaying(args);
};
// UserEvent.CurrentlyPlayingApple += async (o, args) =>
// {
// Logger.LogDebug("Passing now playing event to SignalR hub", args.UserId);
//
// await Hub.Clients.User(args.UserId).OnNewPlaying(args);
// };
return Task.CompletedTask;
}
}
}
}

@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@ -9,9 +7,9 @@ using SpotifyAPI.Web;
namespace Selector
{
public class AudioFeatureInjector : IPlayerConsumer
public class AudioFeatureInjector : ISpotifyPlayerConsumer
{
protected readonly IPlayerWatcher Watcher;
protected readonly ISpotifyPlayerWatcher Watcher;
protected readonly ITracksClient TrackClient;
protected readonly ILogger<AudioFeatureInjector> Logger;
@ -22,11 +20,12 @@ namespace Selector
public AnalysedTrackTimeline Timeline { get; set; } = new();
public AudioFeatureInjector(
IPlayerWatcher watcher,
ITracksClient trackClient,
ISpotifyPlayerWatcher watcher,
ITracksClient trackClient,
ILogger<AudioFeatureInjector> logger = null,
CancellationToken token = default
){
)
{
Watcher = watcher;
TrackClient = trackClient;
Logger = logger ?? NullLogger<AudioFeatureInjector>.Instance;
@ -37,7 +36,8 @@ namespace Selector
{
if (e.Current is null) return;
Task.Run(async () => {
Task.Run(async () =>
{
try
{
await AsyncCallback(e);
@ -57,10 +57,12 @@ namespace Selector
{
if (string.IsNullOrWhiteSpace(track.Id)) return;
try {
try
{
Logger.LogTrace("Making Spotify call");
var audioFeatures = await TrackClient.GetAudioFeatures(track.Id);
Logger.LogDebug("Adding audio features [{track}]: [{audio_features}]", track.DisplayString(), audioFeatures.DisplayString());
Logger.LogDebug("Adding audio features [{track}]: [{audio_features}]", track.DisplayString(),
audioFeatures.DisplayString());
var analysedTrack = AnalysedTrack.From(track, audioFeatures);
@ -103,10 +105,10 @@ namespace Selector
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
@ -117,7 +119,7 @@ namespace Selector
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
@ -129,11 +131,12 @@ namespace Selector
protected virtual void OnNewFeature(AnalysedTrack args)
{
NewFeature?.Invoke(this, args);
NewFeature?.Invoke(this, args);
}
}
public class AnalysedTrack {
public class AnalysedTrack
{
public FullTrack Track { get; set; }
public TrackAudioFeatures Features { get; set; }
@ -146,4 +149,4 @@ namespace Selector
};
}
}
}
}

@ -1,10 +1,7 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using SpotifyAPI.Web;
namespace Selector
@ -30,18 +27,18 @@ namespace Selector
Valence = 0.5f,
}
};
private int _contextIdx = 0;
private DateTime _lastNext = DateTime.UtcNow;
private TimeSpan _contextLifespan = TimeSpan.FromSeconds(30);
public DummyAudioFeatureInjector(
IPlayerWatcher watcher,
ISpotifyPlayerWatcher watcher,
ILogger<DummyAudioFeatureInjector> logger = null,
CancellationToken token = default
): base (watcher, null, logger, token)
) : base(watcher, null, logger, token)
{
}
private bool ShouldCycle() => DateTime.UtcNow - _lastNext > _contextLifespan;
@ -98,4 +95,4 @@ namespace Selector
return Task.CompletedTask;
}
}
}
}

@ -1,20 +1,17 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using SpotifyAPI.Web;
namespace Selector
{
public interface IAudioFeatureInjectorFactory
{
public Task<IPlayerConsumer> Get(ISpotifyConfigFactory spotifyFactory, IPlayerWatcher watcher = null);
public Task<ISpotifyPlayerConsumer> Get(ISpotifyConfigFactory spotifyFactory,
ISpotifyPlayerWatcher watcher = null);
}
public class AudioFeatureInjectorFactory: IAudioFeatureInjectorFactory {
public class AudioFeatureInjectorFactory : IAudioFeatureInjectorFactory
{
private readonly ILoggerFactory LoggerFactory;
public AudioFeatureInjectorFactory(ILoggerFactory loggerFactory)
@ -22,7 +19,8 @@ namespace Selector
LoggerFactory = loggerFactory;
}
public async Task<IPlayerConsumer> Get(ISpotifyConfigFactory spotifyFactory, IPlayerWatcher watcher = null)
public async Task<ISpotifyPlayerConsumer> Get(ISpotifyConfigFactory spotifyFactory,
ISpotifyPlayerWatcher watcher = null)
{
if (!Magic.Dummy)
{
@ -44,4 +42,4 @@ namespace Selector
}
}
}
}
}

@ -1,41 +1,41 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using IF.Lastfm.Core.Api;
using Microsoft.Extensions.Logging;
namespace Selector
{
public interface IPlayCounterFactory
{
public Task<IPlayerConsumer> Get(LastfmClient fmClient = null, LastFmCredentials creds = null, IPlayerWatcher watcher = null);
public Task<ISpotifyPlayerConsumer> Get(LastfmClient fmClient = null, LastFmCredentials creds = null,
ISpotifyPlayerWatcher watcher = null);
}
public class PlayCounterFactory: IPlayCounterFactory {
public class PlayCounterFactory : IPlayCounterFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly LastfmClient Client;
private readonly LastFmCredentials Creds;
public PlayCounterFactory(ILoggerFactory loggerFactory, LastfmClient client = null, LastFmCredentials creds = null)
public PlayCounterFactory(ILoggerFactory loggerFactory, LastfmClient client = null,
LastFmCredentials creds = null)
{
LoggerFactory = loggerFactory;
Client = client;
Creds = creds;
}
public Task<IPlayerConsumer> Get(LastfmClient fmClient = null, LastFmCredentials creds = null, IPlayerWatcher watcher = null)
public Task<ISpotifyPlayerConsumer> Get(LastfmClient fmClient = null, LastFmCredentials creds = null,
ISpotifyPlayerWatcher watcher = null)
{
var client = fmClient ?? Client;
if(client is null)
if (client is null)
{
throw new ArgumentNullException("No Last.fm client provided");
}
return Task.FromResult<IPlayerConsumer>(new PlayCounter(
return Task.FromResult<ISpotifyPlayerConsumer>(new PlayCounter(
watcher,
client.Track,
client.Album,
@ -46,4 +46,4 @@ namespace Selector
));
}
}
}
}

@ -1,19 +1,15 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using System.Net.Http;
namespace Selector
{
public interface IWebHookFactory
{
public Task<WebHook> Get(WebHookConfig config, IPlayerWatcher watcher = null);
public Task<WebHook> Get(WebHookConfig config, ISpotifyPlayerWatcher watcher = null);
}
public class WebHookFactory: IWebHookFactory
public class WebHookFactory : IWebHookFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly HttpClient Http;
@ -24,7 +20,7 @@ namespace Selector
Http = httpClient;
}
public Task<WebHook> Get(WebHookConfig config, IPlayerWatcher watcher = null)
public Task<WebHook> Get(WebHookConfig config, ISpotifyPlayerWatcher watcher = null)
{
return Task.FromResult(new WebHook(
watcher,
@ -34,4 +30,4 @@ namespace Selector
));
}
}
}
}

@ -1,7 +1,4 @@
using System;
using System.Threading.Tasks;
namespace Selector
namespace Selector
{
public interface IConsumer
{
@ -9,14 +6,16 @@ namespace Selector
public void Unsubscribe(IWatcher watch = null);
}
public interface IConsumer<T>: IConsumer
public interface IConsumer<T> : IConsumer
{
public void Callback(object sender, T e);
}
public interface IPlayerConsumer: IConsumer<ListeningChangeEventArgs>
{ }
public interface ISpotifyPlayerConsumer : IConsumer<ListeningChangeEventArgs>
{
}
public interface IPlaylistConsumer : IConsumer<PlaylistChangeEventArgs>
{ }
}
{
}
}

@ -1,21 +1,17 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using IF.Lastfm.Core.Api;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using SpotifyAPI.Web;
using IF.Lastfm.Core.Api;
using IF.Lastfm.Core.Objects;
using IF.Lastfm.Core.Api.Helpers;
namespace Selector
{
public class PlayCounter : IPlayerConsumer
public class PlayCounter : ISpotifyPlayerConsumer
{
protected readonly IPlayerWatcher Watcher;
protected readonly ISpotifyPlayerWatcher Watcher;
protected readonly ITrackApi TrackClient;
protected readonly IAlbumApi AlbumClient;
protected readonly IArtistApi ArtistClient;
@ -30,7 +26,7 @@ namespace Selector
public AnalysedTrackTimeline Timeline { get; set; } = new();
public PlayCounter(
IPlayerWatcher watcher,
ISpotifyPlayerWatcher watcher,
ITrackApi trackClient,
IAlbumApi albumClient,
IArtistApi artistClient,
@ -53,8 +49,9 @@ namespace Selector
public void Callback(object sender, ListeningChangeEventArgs e)
{
if (e.Current is null) return;
Task.Run(async () => {
Task.Run(async () =>
{
try
{
await AsyncCallback(e);
@ -68,7 +65,8 @@ namespace Selector
public async Task AsyncCallback(ListeningChangeEventArgs e)
{
using var scope = Logger.BeginScope(new Dictionary<string, object>() { { "spotify_username", e.SpotifyUsername }, { "id", e.Id }, { "username", Credentials.Username } });
using var scope = Logger.BeginScope(new Dictionary<string, object>()
{ { "spotify_username", e.SpotifyUsername }, { "id", e.Id }, { "username", Credentials.Username } });
if (Credentials is null || string.IsNullOrWhiteSpace(Credentials.Username))
{
@ -78,12 +76,15 @@ namespace Selector
if (e.Current.Item is FullTrack track)
{
using var trackScope = Logger.BeginScope(new Dictionary<string, object>() { { "track", track.DisplayString() } });
using var trackScope = Logger.BeginScope(new Dictionary<string, object>()
{ { "track", track.DisplayString() } });
Logger.LogTrace("Making Last.fm call");
var trackInfo = TrackClient.GetInfoAsync(track.Name, track.Artists[0].Name, username: Credentials?.Username);
var albumInfo = AlbumClient.GetInfoAsync(track.Album.Artists[0].Name, track.Album.Name, username: Credentials?.Username);
var trackInfo =
TrackClient.GetInfoAsync(track.Name, track.Artists[0].Name, username: Credentials?.Username);
var albumInfo = AlbumClient.GetInfoAsync(track.Album.Artists[0].Name, track.Album.Name,
username: Credentials?.Username);
var artistInfo = ArtistClient.GetInfoAsync(track.Artists[0].Name);
var userInfo = UserClient.GetInfoAsync(Credentials.Username);
@ -104,7 +105,8 @@ namespace Selector
}
else
{
Logger.LogError(trackInfo.Exception, "Track info task faulted, [{context}]", e.Current.DisplayString());
Logger.LogError(trackInfo.Exception, "Track info task faulted, [{context}]",
e.Current.DisplayString());
}
if (albumInfo.IsCompletedSuccessfully)
@ -120,7 +122,8 @@ namespace Selector
}
else
{
Logger.LogError(albumInfo.Exception, "Album info task faulted, [{context}]", e.Current.DisplayString());
Logger.LogError(albumInfo.Exception, "Album info task faulted, [{context}]",
e.Current.DisplayString());
}
//TODO: Add artist count
@ -138,10 +141,13 @@ namespace Selector
}
else
{
Logger.LogError(userInfo.Exception, "User info task faulted, [{context}]", e.Current.DisplayString());
Logger.LogError(userInfo.Exception, "User info task faulted, [{context}]",
e.Current.DisplayString());
}
Logger.LogDebug("Adding Last.fm data [{username}], track: {track_count}, album: {album_count}, artist: {artist_count}, user: {user_count}", Credentials.Username, trackCount, albumCount, artistCount, userCount);
Logger.LogDebug(
"Adding Last.fm data [{username}], track: {track_count}, album: {album_count}, artist: {artist_count}, user: {user_count}",
Credentials.Username, trackCount, albumCount, artistCount, userCount);
PlayCount playCount = new()
{
@ -175,7 +181,7 @@ namespace Selector
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
@ -189,7 +195,7 @@ namespace Selector
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
@ -222,4 +228,4 @@ namespace Selector
{
public string Username { get; set; }
}
}
}

@ -1,8 +1,7 @@
using System;
using System.Net.Http;
using System.Linq;
using System.Collections.Generic;
using System.Text;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@ -19,7 +18,7 @@ namespace Selector
public bool ShouldRequest(ListeningChangeEventArgs e)
{
if(Predicates is not null)
if (Predicates is not null)
{
return Predicates.Select(p => p(e)).Aggregate((a, b) => a && b);
}
@ -30,9 +29,9 @@ namespace Selector
}
}
public class WebHook : IPlayerConsumer
public class WebHook : ISpotifyPlayerConsumer
{
protected readonly IPlayerWatcher Watcher;
protected readonly ISpotifyPlayerWatcher Watcher;
protected readonly HttpClient HttpClient;
protected readonly ILogger<WebHook> Logger;
@ -47,7 +46,7 @@ namespace Selector
public AnalysedTrackTimeline Timeline { get; set; } = new();
public WebHook(
IPlayerWatcher watcher,
ISpotifyPlayerWatcher watcher,
HttpClient httpClient,
WebHookConfig config,
ILogger<WebHook> logger = null,
@ -64,8 +63,9 @@ namespace Selector
public void Callback(object sender, ListeningChangeEventArgs e)
{
if (e.Current is null) return;
Task.Run(async () => {
Task.Run(async () =>
{
try
{
await AsyncCallback(e);
@ -79,7 +79,11 @@ namespace Selector
public async Task AsyncCallback(ListeningChangeEventArgs e)
{
using var scope = Logger.BeginScope(new Dictionary<string, object>() { { "spotify_username", e.SpotifyUsername }, { "id", e.Id }, { "name", Config.Name }, { "url", Config.Url } });
using var scope = Logger.BeginScope(new Dictionary<string, object>()
{
{ "spotify_username", e.SpotifyUsername }, { "id", e.Id }, { "name", Config.Name },
{ "url", Config.Url }
});
if (Config.ShouldRequest(e))
{
@ -101,7 +105,7 @@ namespace Selector
OnFailedRequest(new EventArgs());
}
}
catch(HttpRequestException ex)
catch (HttpRequestException ex)
{
Logger.LogError(ex, "Exception occured during request");
}
@ -120,7 +124,7 @@ namespace Selector
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
@ -134,7 +138,7 @@ namespace Selector
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
@ -159,4 +163,4 @@ namespace Selector
FailedRequest?.Invoke(this, args);
}
}
}
}

@ -2,6 +2,8 @@ namespace Selector
{
public enum WatcherType
{
Player, Playlist
SpotifyPlayer,
SpotifyPlaylist,
AppleMusicPlayer
}
}

@ -1,10 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text;
using IF.Lastfm.Core.Api;
using Microsoft.Extensions.DependencyInjection;
using IF.Lastfm.Core.Api;
namespace Selector.Extensions
{
public static class ServiceExtensions
@ -52,10 +48,10 @@ namespace Selector.Extensions
public static IServiceCollection AddWatcher(this IServiceCollection services)
{
services.AddSingleton<IWatcherFactory, WatcherFactory>();
services.AddSingleton<ISpotifyWatcherFactory, SpotifyWatcherFactory>();
services.AddSingleton<IWatcherCollectionFactory, WatcherCollectionFactory>();
return services;
}
}
}
}

@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
namespace Selector;
public abstract class BaseSpotifyWatcher(ILogger<BaseWatcher> logger = null) : BaseWatcher(logger)
{
public string SpotifyUsername { get; set; }
protected override Dictionary<string, object> LogScopeContext =>
new[]
{
base.LogScopeContext,
new Dictionary<string, object>() { { "spotify_username", SpotifyUsername } }
}
.SelectMany(x => x)
.ToDictionary();
}

@ -3,17 +3,15 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Selector
{
public abstract class BaseWatcher: IWatcher
public abstract class BaseWatcher : IWatcher
{
protected readonly ILogger<BaseWatcher> Logger;
public string Id { get; set; }
public string SpotifyUsername { get; set; }
private Stopwatch ExecutionTimer { get; set; }
public BaseWatcher(ILogger<BaseWatcher> logger = null)
@ -25,13 +23,16 @@ namespace Selector
public abstract Task WatchOne(CancellationToken token);
public abstract Task Reset();
protected virtual Dictionary<string, object> LogScopeContext => new()
{ { "id", Id } };
public async Task Watch(CancellationToken cancelToken)
{
using var logScope = Logger.BeginScope(new Dictionary<string, object>() { { "spotify_username", SpotifyUsername }, { "id", Id } });
using var logScope = Logger.BeginScope(LogScopeContext);
Logger.LogDebug("Starting watcher");
while (true) {
while (true)
{
ExecutionTimer.Start();
cancelToken.ThrowIfCancellationRequested();
@ -49,16 +50,18 @@ namespace Selector
var waitTime = decimal.ToInt32(Math.Max(0, PollPeriod - ExecutionTimer.ElapsedMilliseconds));
ExecutionTimer.Reset();
Logger.LogTrace("Finished watch one, delaying \"{poll_period}\"ms ({wait_time}ms)...", PollPeriod, waitTime);
Logger.LogTrace("Finished watch one, delaying \"{poll_period}\"ms ({wait_time}ms)...", PollPeriod,
waitTime);
await Task.Delay(waitTime, cancelToken);
}
}
private int _pollPeriod;
public int PollPeriod
{
get => _pollPeriod;
set => _pollPeriod = Math.Max(0, value);
}
}
}
}

@ -1,14 +1,14 @@
using System;
using Microsoft.Extensions.Logging;
using SpotifyAPI.Web;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using SpotifyAPI.Web;
namespace Selector
{
public class DummyPlayerWatcher: PlayerWatcher
{
public class DummySpotifyPlayerWatcher : SpotifyPlayerWatcher
{
private CurrentlyPlayingContext[] _contexts = new[]
{
new CurrentlyPlayingContext
@ -26,7 +26,6 @@ namespace Selector
ShuffleState = false,
Context = new Context
{
},
IsPlaying = true,
Item = new FullTrack
@ -71,7 +70,6 @@ namespace Selector
ShuffleState = false,
Context = new Context
{
},
IsPlaying = true,
Item = new FullTrack
@ -108,11 +106,11 @@ namespace Selector
private DateTime _lastNext = DateTime.UtcNow;
private TimeSpan _contextLifespan = TimeSpan.FromSeconds(30);
public DummyPlayerWatcher(IEqual equalityChecker,
ILogger<DummyPlayerWatcher> logger = null,
int pollPeriod = 3000) : base(null, equalityChecker, logger, pollPeriod)
{
}
public DummySpotifyPlayerWatcher(IEqual equalityChecker,
ILogger<DummySpotifyPlayerWatcher> logger = null,
int pollPeriod = 3000) : base(null, equalityChecker, logger, pollPeriod)
{
}
private bool ShouldCycle() => DateTime.UtcNow - _lastNext > _contextLifespan;
@ -122,7 +120,7 @@ namespace Selector
_contextIdx++;
if(_contextIdx >= _contexts.Length)
if (_contextIdx >= _contexts.Length)
{
_contextIdx = 0;
}
@ -141,7 +139,8 @@ namespace Selector
var polledCurrent = GetContext();
using var polledLogScope = Logger.BeginScope(new Dictionary<string, object>() { { "context", polledCurrent?.DisplayString() } });
using var polledLogScope = Logger.BeginScope(new Dictionary<string, object>()
{ { "context", polledCurrent?.DisplayString() } });
if (polledCurrent != null) StoreCurrentPlaying(polledCurrent);
@ -159,5 +158,4 @@ namespace Selector
return Task.CompletedTask;
}
}
}
}

@ -3,12 +3,13 @@ using SpotifyAPI.Web;
namespace Selector
{
public interface IPlayerWatcher: IWatcher
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;
@ -23,6 +24,7 @@ namespace Selector
/// Last retrieved currently playing
/// </summary>
public CurrentlyPlayingContext Live { get; }
public PlayerTimeline Past { get; }
}
}
}

@ -1,13 +1,10 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using System.Threading.Tasks;
namespace Selector
{
public interface IWatcherFactory
public interface ISpotifyWatcherFactory
{
public Task<IWatcher> Get<T>(ISpotifyConfigFactory spotifyFactory, string id, int pollPeriod)
where T : class, IWatcher;
}
}
}

@ -1,13 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SpotifyAPI.Web;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using System.Linq;
using Selector.Equality;
using SpotifyAPI.Web;
namespace Selector
{
@ -17,7 +16,7 @@ namespace Selector
public bool PullTracks { get; set; } = true;
}
public class PlaylistWatcher: BaseWatcher, IPlaylistWatcher
public class PlaylistWatcher : BaseSpotifyWatcher, IPlaylistWatcher
{
new private readonly ILogger<PlaylistWatcher> Logger;
private readonly ISpotifyClient spotifyClient;
@ -44,11 +43,11 @@ namespace Selector
private IEqualityComparer<PlaylistTrack<IPlayableItem>> EqualityComparer = new PlayableItemEqualityComparer();
public PlaylistWatcher(PlaylistWatcherConfig config,
ISpotifyClient spotifyClient,
ILogger<PlaylistWatcher> logger = null,
int pollPeriod = 3000
) : base(logger) {
ISpotifyClient spotifyClient,
ILogger<PlaylistWatcher> logger = null,
int pollPeriod = 3000
) : base(logger)
{
this.spotifyClient = spotifyClient;
this.config = config;
Logger = logger ?? NullLogger<PlaylistWatcher>.Instance;
@ -64,16 +63,18 @@ namespace Selector
return Task.CompletedTask;
}
public override async Task WatchOne(CancellationToken token = default)
public override async Task WatchOne(CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
using var logScope = Logger.BeginScope(new Dictionary<string, object> { { "playlist_id", config.PlaylistId }, { "pull_tracks", config.PullTracks } });
try{
using var logScope = Logger.BeginScope(new Dictionary<string, object>
{ { "playlist_id", config.PlaylistId }, { "pull_tracks", config.PullTracks } });
try
{
string id;
if(config.PlaylistId.Contains(':'))
if (config.PlaylistId.Contains(':'))
{
id = config.PlaylistId.Split(':').Last();
}
@ -97,18 +98,18 @@ namespace Selector
await CheckSnapshot();
CheckStringValues();
}
catch(APIUnauthorizedException e)
catch (APIUnauthorizedException e)
{
Logger.LogDebug("Unauthorised error: [{message}] (should be refreshed and retried?)", e.Message);
//throw e;
}
catch(APITooManyRequestsException e)
catch (APITooManyRequestsException e)
{
Logger.LogDebug("Too many requests error: [{message}]", e.Message);
await Task.Delay(e.RetryAfter, token);
// throw e;
}
catch(APIException e)
catch (APIException e)
{
Logger.LogDebug("API error: [{message}]", e.Message);
// throw e;
@ -117,7 +118,7 @@ namespace Selector
private async Task CheckSnapshot()
{
switch(Previous, Live)
switch (Previous, Live)
{
case (null, not null): // gone null
await PageLiveTracks();
@ -128,13 +129,14 @@ namespace Selector
if (Live.SnapshotId != Previous.SnapshotId)
{
Logger.LogDebug("Snapshot Id changed: {previous} -> {current}", Previous.SnapshotId, Live.SnapshotId);
Logger.LogDebug("Snapshot Id changed: {previous} -> {current}", Previous.SnapshotId,
Live.SnapshotId);
await PageLiveTracks();
OnSnapshotChange(GetEvent());
}
break;
}
}
}
private async Task PageLiveTracks()
@ -171,7 +173,9 @@ namespace Selector
}
}
private PlaylistChangeEventArgs GetEvent() => PlaylistChangeEventArgs.From(Previous, Live, Past, tracks: CurrentTracks, addedTracks: LastAddedTracks, removedTracks: LastRemovedTracks, id: Id, username: SpotifyUsername);
private PlaylistChangeEventArgs GetEvent() => PlaylistChangeEventArgs.From(Previous, Live, Past,
tracks: CurrentTracks, addedTracks: LastAddedTracks, removedTracks: LastRemovedTracks, id: Id,
username: SpotifyUsername);
private void CheckStringValues()
{
@ -183,11 +187,12 @@ namespace Selector
break;
case (not null, not null): // continuing non-null
if((Previous, Live) is ({ Name: not null }, { Name: not null }))
if ((Previous, Live) is ({ Name: not null }, { Name: not null }))
{
if (!Live.Name.Equals(Previous.Name))
{
Logger.LogDebug("Name changed: {previous} -> {current}", Previous.SnapshotId, Live.SnapshotId);
Logger.LogDebug("Name changed: {previous} -> {current}", Previous.SnapshotId,
Live.SnapshotId);
OnNameChanged(GetEvent());
}
}
@ -196,7 +201,8 @@ namespace Selector
{
if (!Live.Description.Equals(Previous.Description))
{
Logger.LogDebug("Description changed: {previous} -> {current}", Previous.SnapshotId, Live.SnapshotId);
Logger.LogDebug("Description changed: {previous} -> {current}", Previous.SnapshotId,
Live.SnapshotId);
OnDescriptionChanged(GetEvent());
}
}
@ -209,12 +215,13 @@ namespace Selector
/// Store currently playing in last plays. Determine whether new list or appending required
/// </summary>
/// <param name="current">New currently playing to store</param>
private void StoreCurrentPlaying(FullPlaylist current)
private void StoreCurrentPlaying(FullPlaylist current)
{
Past?.Add(current);
}
#region Event Firers
protected virtual void OnNetworkPoll(PlaylistChangeEventArgs args)
{
Logger.LogTrace("Firing network poll event");
@ -226,7 +233,7 @@ namespace Selector
{
Logger.LogTrace("Firing snapshot change event");
SnapshotChange?.Invoke(this, args);
SnapshotChange?.Invoke(this, args);
}
protected virtual void OnTracksAdded(PlaylistChangeEventArgs args)
@ -259,4 +266,4 @@ namespace Selector
#endregion
}
}
}

@ -2,16 +2,15 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using SpotifyAPI.Web;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using SpotifyAPI.Web;
namespace Selector
{
public class PlayerWatcher: BaseWatcher, IPlayerWatcher
public class SpotifyPlayerWatcher : BaseSpotifyWatcher, ISpotifyPlayerWatcher
{
new protected readonly ILogger<PlayerWatcher> Logger;
new protected readonly ILogger<SpotifyPlayerWatcher> Logger;
private readonly IPlayerClient spotifyClient;
private readonly IEqual eq;
@ -30,15 +29,15 @@ namespace Selector
protected CurrentlyPlayingContext Previous { get; set; }
public PlayerTimeline Past { get; set; } = new();
public PlayerWatcher(IPlayerClient spotifyClient,
IEqual equalityChecker,
ILogger<PlayerWatcher> logger = null,
int pollPeriod = 3000
) : base(logger) {
public SpotifyPlayerWatcher(IPlayerClient spotifyClient,
IEqual equalityChecker,
ILogger<SpotifyPlayerWatcher> logger = null,
int pollPeriod = 3000
) : base(logger)
{
this.spotifyClient = spotifyClient;
eq = equalityChecker;
Logger = logger ?? NullLogger<PlayerWatcher>.Instance;
Logger = logger ?? NullLogger<SpotifyPlayerWatcher>.Instance;
PollPeriod = pollPeriod;
}
@ -51,15 +50,17 @@ namespace Selector
return Task.CompletedTask;
}
public override async Task WatchOne(CancellationToken token = default)
public override async Task WatchOne(CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
try{
try
{
Logger.LogTrace("Making Spotify call");
var polledCurrent = await spotifyClient.GetCurrentPlayback();
using var polledLogScope = Logger.BeginScope(new Dictionary<string, object>() { { "context", polledCurrent?.DisplayString() } });
using var polledLogScope = Logger.BeginScope(new Dictionary<string, object>()
{ { "context", polledCurrent?.DisplayString() } });
Logger.LogTrace("Received Spotify call");
@ -75,20 +76,19 @@ namespace Selector
CheckContext();
CheckItem();
CheckDevice();
}
catch(APIUnauthorizedException e)
catch (APIUnauthorizedException e)
{
Logger.LogDebug("Unauthorised error: [{message}] (should be refreshed and retried?)", e.Message);
//throw e;
}
catch(APITooManyRequestsException e)
catch (APITooManyRequestsException e)
{
Logger.LogDebug("Too many requests error: [{message}]", e.Message);
await Task.Delay(e.RetryAfter, token);
// throw e;
}
catch(APIException e)
catch (APIException e)
{
Logger.LogDebug("API error: [{message}]", e.Message);
// throw e;
@ -97,7 +97,6 @@ namespace Selector
protected void CheckItem()
{
switch (Previous, Live)
{
case (null or { Item: null }, { Item: FullTrack track }):
@ -127,38 +126,46 @@ namespace Selector
case ({ Item: FullTrack previousTrack }, { Item: FullTrack currentTrack }):
if (!eq.IsEqual(previousTrack, currentTrack))
{
Logger.LogDebug("Track changed: {prevTrack} -> {currentTrack}", previousTrack.DisplayString(), currentTrack.DisplayString());
Logger.LogDebug("Track changed: {prevTrack} -> {currentTrack}", previousTrack.DisplayString(),
currentTrack.DisplayString());
OnItemChange(GetEvent());
}
if (!eq.IsEqual(previousTrack.Album, currentTrack.Album))
{
Logger.LogDebug("Album changed: {previous} -> {current}", previousTrack.Album.DisplayString(), currentTrack.Album.DisplayString());
Logger.LogDebug("Album changed: {previous} -> {current}", previousTrack.Album.DisplayString(),
currentTrack.Album.DisplayString());
OnAlbumChange(GetEvent());
}
if (!eq.IsEqual(previousTrack.Artists[0], currentTrack.Artists[0]))
{
Logger.LogDebug("Artist changed: {previous} -> {current}", previousTrack.Artists.DisplayString(), currentTrack.Artists.DisplayString());
Logger.LogDebug("Artist changed: {previous} -> {current}",
previousTrack.Artists.DisplayString(), currentTrack.Artists.DisplayString());
OnArtistChange(GetEvent());
}
break;
case ({ Item: FullTrack previousTrack }, { Item: FullEpisode currentEp }):
Logger.LogDebug("Media type changed: {previous}, {current}", previousTrack.DisplayString(), currentEp.DisplayString());
Logger.LogDebug("Media type changed: {previous}, {current}", previousTrack.DisplayString(),
currentEp.DisplayString());
OnContentChange(GetEvent());
OnItemChange(GetEvent());
break;
case ({ Item: FullEpisode previousEpisode }, { Item: FullTrack currentTrack }):
Logger.LogDebug("Media type changed: {previous}, {current}", previousEpisode.DisplayString(), currentTrack.DisplayString());
Logger.LogDebug("Media type changed: {previous}, {current}", previousEpisode.DisplayString(),
currentTrack.DisplayString());
OnContentChange(GetEvent());
OnItemChange(GetEvent());
break;
case ({ Item: FullEpisode previousEp }, { Item: FullEpisode currentEp }):
if (!eq.IsEqual(previousEp, currentEp))
{
Logger.LogDebug("Podcast changed: {previous_ep} -> {current_ep}", previousEp.DisplayString(), currentEp.DisplayString());
Logger.LogDebug("Podcast changed: {previous_ep} -> {current_ep}", previousEp.DisplayString(),
currentEp.DisplayString());
OnItemChange(GetEvent());
}
break;
}
}
@ -173,7 +180,8 @@ namespace Selector
}
else if (!eq.IsEqual(Previous?.Context, Live?.Context))
{
Logger.LogDebug("Context changed: {previous_context} -> {live_context}", Previous?.Context?.DisplayString() ?? "none", Live?.Context?.DisplayString() ?? "none");
Logger.LogDebug("Context changed: {previous_context} -> {live_context}",
Previous?.Context?.DisplayString() ?? "none", Live?.Context?.DisplayString() ?? "none");
OnContextChange(GetEvent());
}
}
@ -196,7 +204,8 @@ namespace Selector
// IS PLAYING
if (Previous?.IsPlaying != Live?.IsPlaying)
{
Logger.LogDebug("Playing state changed: {previous_playing} -> {live_playing}", Previous?.IsPlaying, Live?.IsPlaying);
Logger.LogDebug("Playing state changed: {previous_playing} -> {live_playing}", Previous?.IsPlaying,
Live?.IsPlaying);
OnPlayingChange(GetEvent());
}
}
@ -206,30 +215,34 @@ namespace Selector
// DEVICE
if (!eq.IsEqual(Previous?.Device, Live?.Device))
{
Logger.LogDebug("Device changed: {previous_device} -> {live_device}", Previous?.Device?.DisplayString() ?? "none", Live?.Device?.DisplayString() ?? "none");
Logger.LogDebug("Device changed: {previous_device} -> {live_device}",
Previous?.Device?.DisplayString() ?? "none", Live?.Device?.DisplayString() ?? "none");
OnDeviceChange(GetEvent());
}
// VOLUME
if (Previous?.Device?.VolumePercent != Live?.Device?.VolumePercent)
{
Logger.LogDebug("Volume changed: {previous_volume}% -> {live_volume}%", Previous?.Device?.VolumePercent, Live?.Device?.VolumePercent);
Logger.LogDebug("Volume changed: {previous_volume}% -> {live_volume}%", Previous?.Device?.VolumePercent,
Live?.Device?.VolumePercent);
OnVolumeChange(GetEvent());
}
}
protected ListeningChangeEventArgs GetEvent() => ListeningChangeEventArgs.From(Previous, Live, Past, id: Id, username: SpotifyUsername);
protected ListeningChangeEventArgs GetEvent() =>
ListeningChangeEventArgs.From(Previous, Live, Past, id: Id, username: SpotifyUsername);
/// <summary>
/// Store currently playing in last plays. Determine whether new list or appending required
/// </summary>
/// <param name="current">New currently playing to store</param>
protected void StoreCurrentPlaying(CurrentlyPlayingContext current)
protected void StoreCurrentPlaying(CurrentlyPlayingContext current)
{
Past?.Add(current);
}
#region Event Firers
protected virtual void OnNetworkPoll(ListeningChangeEventArgs args)
{
NetworkPoll?.Invoke(this, args);
@ -237,27 +250,27 @@ namespace Selector
protected virtual void OnItemChange(ListeningChangeEventArgs args)
{
ItemChange?.Invoke(this, args);
ItemChange?.Invoke(this, args);
}
protected virtual void OnAlbumChange(ListeningChangeEventArgs args)
{
AlbumChange?.Invoke(this, args);
AlbumChange?.Invoke(this, args);
}
protected virtual void OnArtistChange(ListeningChangeEventArgs args)
{
ArtistChange?.Invoke(this, args);
ArtistChange?.Invoke(this, args);
}
protected virtual void OnContextChange(ListeningChangeEventArgs args)
{
ContextChange?.Invoke(this, args);
ContextChange?.Invoke(this, args);
}
protected virtual void OnContentChange(ListeningChangeEventArgs args)
{
ContentChange?.Invoke(this, args);
ContentChange?.Invoke(this, args);
}
protected virtual void OnVolumeChange(ListeningChangeEventArgs args)
@ -267,14 +280,14 @@ namespace Selector
protected virtual void OnDeviceChange(ListeningChangeEventArgs args)
{
DeviceChange?.Invoke(this, args);
DeviceChange?.Invoke(this, args);
}
protected virtual void OnPlayingChange(ListeningChangeEventArgs args)
{
PlayingChange?.Invoke(this, args);
PlayingChange?.Invoke(this, args);
}
#endregion
}
}
}

@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@ -8,23 +6,24 @@ using SpotifyAPI.Web;
namespace Selector
{
public class WatcherFactory : IWatcherFactory {
public class SpotifyWatcherFactory : ISpotifyWatcherFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly IEqual Equal;
public WatcherFactory(ILoggerFactory loggerFactory, IEqual equal)
public SpotifyWatcherFactory(ILoggerFactory loggerFactory, IEqual equal)
{
LoggerFactory = loggerFactory;
Equal = equal;
}
public async Task<IWatcher> Get<T>(ISpotifyConfigFactory spotifyFactory, string id = null, int pollPeriod = 3000)
public async Task<IWatcher> Get<T>(ISpotifyConfigFactory spotifyFactory, string id = null,
int pollPeriod = 3000)
where T : class, IWatcher
{
if(typeof(T).IsAssignableFrom(typeof(PlayerWatcher)))
if (typeof(T).IsAssignableFrom(typeof(SpotifyPlayerWatcher)))
{
if(!Magic.Dummy)
if (!Magic.Dummy)
{
var config = await spotifyFactory.GetConfig();
var client = new SpotifyClient(config);
@ -32,10 +31,11 @@ namespace Selector
// TODO: catch spotify exceptions
var user = await client.UserProfile.Current();
return new PlayerWatcher(
return new SpotifyPlayerWatcher(
client.Player,
Equal,
LoggerFactory?.CreateLogger<PlayerWatcher>() ?? NullLogger<PlayerWatcher>.Instance,
LoggerFactory?.CreateLogger<SpotifyPlayerWatcher>() ??
NullLogger<SpotifyPlayerWatcher>.Instance,
pollPeriod: pollPeriod
)
{
@ -45,9 +45,10 @@ namespace Selector
}
else
{
return new DummyPlayerWatcher(
return new DummySpotifyPlayerWatcher(
Equal,
LoggerFactory?.CreateLogger<DummyPlayerWatcher>() ?? NullLogger<DummyPlayerWatcher>.Instance,
LoggerFactory?.CreateLogger<DummySpotifyPlayerWatcher>() ??
NullLogger<DummySpotifyPlayerWatcher>.Instance,
pollPeriod: pollPeriod
)
{
@ -81,4 +82,4 @@ namespace Selector
}
}
}
}
}