adding apple music scrobbling, warning fixing

This commit is contained in:
Andy Pack 2025-04-08 21:13:03 +01:00
parent 7c6f82fd7b
commit 0d7b23d9ea
Signed by: sarsoo
GPG Key ID: A55BA3536A5E0ED7
94 changed files with 1066 additions and 775 deletions
Dockerfile.CLIDockerfile.Web
Selector.AppleMusic
Selector.CLI
Selector.Cache
Selector.Data
Selector.Event
Selector.LastFm
Selector.MAUI
Selector.Model
Selector.SignalR
Selector.Spotify
Selector.Tests
Selector.Web
Selector

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

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

@ -43,7 +43,7 @@ public class AppleMusicApi(HttpClient client, string developerToken, string user
}
}
public async Task<RecentlyPlayedTracksResponse> GetRecentlyPlayedTracks()
public async Task<RecentlyPlayedTracksResponse?> GetRecentlyPlayedTracks()
{
var response = await MakeRequest(HttpMethod.Get, "/me/recent/played/tracks");

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using Selector.AppleMusic.Model;
using Selector.AppleMusic.Watcher;
@ -26,6 +27,7 @@ public class AppleTimeline : Timeline<AppleMusicCurrentlyPlayingContext>
{
Recent.AddRange(items.Select(x =>
TimelineItem<AppleMusicCurrentlyPlayingContext>.From(x, DateTime.UtcNow)));
Recent.ForEach(x => x.Item.Scrobbled = true);
return newItems;
}
@ -37,34 +39,44 @@ public class AppleTimeline : Timeline<AppleMusicCurrentlyPlayingContext>
return newItems;
}
var (found, startIdx) = Loop(items, 0);
TimelineItem<AppleMusicCurrentlyPlayingContext>? popped = null;
if (found == 0)
var dict = new ConcurrentDictionary<int, (int, int)>();
Parallel.ForEach(Enumerable.Range(0, 3), idx =>
{
var (foundOffseted, startIdxOffseted) = Loop(items, 1);
var (found, startIdx) = Loop(items, idx);
dict.TryAdd(idx, (found, startIdx));
});
if (foundOffseted > found)
int maxFound = dict[0].Item1;
int storedIdx = 0;
int startIdx = dict[0].Item2;
foreach (var item in dict)
{
if (item.Value.Item1 > maxFound)
{
popped = Recent[^1];
Recent.RemoveAt(Recent.Count - 1);
startIdx = startIdxOffseted;
storedIdx = item.Key;
maxFound = item.Value.Item1;
startIdx = item.Value.Item2;
}
}
var popped = new List<AppleMusicCurrentlyPlayingContext>();
popped.AddRange(Recent.TakeLast(storedIdx).Select(x => x.Item));
foreach (var item in items.TakeLast(startIdx))
{
newItems.Add(item);
Recent.Add(TimelineItem<AppleMusicCurrentlyPlayingContext>.From(item, DateTime.UtcNow));
}
if (popped is not null)
if (popped.Any())
{
var idx = newItems.FindIndex(x => x.Track.Id == popped.Item.Track.Id);
if (idx >= 0)
foreach (var item in popped)
{
newItems.RemoveAt(idx);
var idx = newItems.FindIndex(x => x.Track.Id == item.Track.Id);
if (idx >= 0)
{
newItems.RemoveAt(idx);
}
}
}
@ -116,7 +128,7 @@ public class AppleTimeline : Timeline<AppleMusicCurrentlyPlayingContext>
{
// good, keep going
found++;
if (found >= 3)
if (found >= 4)
{
stop = true;
break;

@ -0,0 +1,119 @@
using IF.Lastfm.Core.Api;
using IF.Lastfm.Core.Objects;
using IF.Lastfm.Core.Scrobblers;
using Microsoft.Extensions.Logging;
namespace Selector.AppleMusic.Consumer;
public class AppleMusicScrobbler :
BaseSequentialPlayerConsumer<IAppleMusicPlayerWatcher, AppleListeningChangeEventArgs>, IApplePlayerConsumer
{
public CancellationToken CancelToken { get; set; }
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
private readonly IScrobbler _scrobbler;
private readonly LastfmClient _lastClient;
public AppleMusicScrobbler(IAppleMusicPlayerWatcher? watcher,
LastfmClient LastClient,
ILogger<AppleMusicScrobbler> logger,
CancellationToken token = default) : base(watcher, logger)
{
CancelToken = token;
_lastClient = LastClient;
_scrobbler = _lastClient.Scrobbler;
}
public async Task Auth(string lastfmUsername,
string lastfmPassword)
{
var response = await _lastClient.Auth.GetSessionTokenAsync(lastfmUsername, lastfmPassword);
if (response.Success)
{
Logger.LogInformation("[{username}] Successfully authenticated to Last.fm for Apple Music scrobbling",
lastfmUsername);
}
else
{
Logger.LogError("[{username}] Failed to authenticate to Last.fm for Apple Music scrobbling ({})",
lastfmUsername, response.Status);
}
}
protected override async Task ProcessEvent(AppleListeningChangeEventArgs e)
{
await _lock.WaitAsync(CancelToken);
try
{
var lastScrobbled = e.Timeline.LastOrDefault(t => t.Item.Scrobbled);
var toScrobble = e.Timeline.Where(e => !e.Item.Scrobbled).ToList();
Logger.LogInformation("Sending any cached Apple Music scrobbles");
var response = await _scrobbler.SendCachedScrobblesAsync();
if (response.Success)
{
Logger.LogInformation("Sent [{}] cached Apple Music scrobbles", response.AcceptedCount);
}
else
{
Logger.LogError(response.Exception, "Failed to send cached Apple Music scrobbles");
}
if (!toScrobble.Any()) return;
var scrobbleTimes = new List<DateTimeOffset>();
if (lastScrobbled != null)
{
var times = (toScrobble.Last().Time - lastScrobbled.Time).Seconds;
var intervals = times / (toScrobble.Count + 1);
foreach (var interval in Enumerable.Range(0, toScrobble.Count))
{
scrobbleTimes.Add(toScrobble.Last().Time - TimeSpan.FromSeconds(interval * int.Max(31, intervals)));
}
}
else
{
foreach (var interval in Enumerable.Range(0, toScrobble.Count))
{
scrobbleTimes.Add(toScrobble.Last().Time - TimeSpan.FromSeconds(interval * 31));
}
}
scrobbleTimes.Reverse();
Logger.LogInformation("Sending scrobbles for [{}]",
string.Join(", ", toScrobble.Select(x => x.Item.Track)));
var scrobbleResponse = await _scrobbler.ScrobbleAsync(
toScrobble.Zip(scrobbleTimes)
.Select((s) =>
new Scrobble(
s.First.Item.Track.Attributes.ArtistName,
s.First.Item.Track.Attributes.AlbumName,
s.First.Item.Track.Attributes.Name,
s.Second)));
foreach (var track in toScrobble)
{
track.Item.Scrobbled = true;
}
if (scrobbleResponse.Success)
{
Logger.LogInformation("Sent [{}] Apple Music scrobbles", response.AcceptedCount);
}
else
{
Logger.LogError(response.Exception, "Failed to send Apple Music scrobbles, ignored [{}]",
string.Join(", ", scrobbleResponse.Ignored));
}
}
finally
{
_lock.Release();
}
}
}

@ -0,0 +1,31 @@
using IF.Lastfm.Core.Api;
using Microsoft.Extensions.Logging;
namespace Selector.AppleMusic.Consumer.Factory
{
public interface IAppleMusicScrobblerFactory
{
public Task<AppleMusicScrobbler> Get(IAppleMusicPlayerWatcher? watcher = null);
}
public class AppleMusicScrobblerFactory : IAppleMusicScrobblerFactory
{
private readonly ILoggerFactory _loggerFactory;
private readonly LastfmClient _lastClient;
public AppleMusicScrobblerFactory(ILoggerFactory loggerFactory, LastfmClient lastClient)
{
_loggerFactory = loggerFactory;
_lastClient = lastClient;
}
public Task<AppleMusicScrobbler> Get(IAppleMusicPlayerWatcher? watcher = null)
{
return Task.FromResult(new AppleMusicScrobbler(
watcher,
_lastClient,
_loggerFactory.CreateLogger<AppleMusicScrobbler>()
));
}
}
}

@ -4,16 +4,16 @@ namespace Selector.AppleMusic
{
public class AppleCurrentlyPlayingDTO
{
public Track Track { get; set; }
public required Track? Track { get; set; }
// public string Username { get; set; }
public string UserId { get; set; }
public required string UserId { get; set; }
public static explicit operator AppleCurrentlyPlayingDTO(AppleListeningChangeEventArgs e)
{
return new()
{
Track = e.Current.Track,
Track = e.Current?.Track,
UserId = e.Id
};
}

@ -4,13 +4,13 @@ namespace Selector.AppleMusic;
public class AppleListeningChangeEventArgs : ListeningChangeEventArgs
{
public AppleMusicCurrentlyPlayingContext Previous { get; set; }
public AppleMusicCurrentlyPlayingContext Current { get; set; }
public required AppleMusicCurrentlyPlayingContext? Previous { get; set; }
public required AppleMusicCurrentlyPlayingContext? Current { get; set; }
AppleTimeline Timeline { get; set; }
public required AppleTimeline Timeline { get; set; }
public static AppleListeningChangeEventArgs From(AppleMusicCurrentlyPlayingContext previous,
AppleMusicCurrentlyPlayingContext current, AppleTimeline timeline, string id = null, string username = null)
public static AppleListeningChangeEventArgs From(AppleMusicCurrentlyPlayingContext? previous,
AppleMusicCurrentlyPlayingContext? current, AppleTimeline timeline, string id, string? username = null)
{
return new AppleListeningChangeEventArgs()
{

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

@ -2,5 +2,5 @@ namespace Selector.AppleMusic.Model;
public class RecentlyPlayedTracksResponse
{
public List<Track> Data { get; set; }
public List<Track>? Data { get; set; }
}

@ -2,46 +2,46 @@ namespace Selector.AppleMusic.Model;
public class TrackAttributes
{
public string AlbumName { get; set; }
public List<string> GenreNames { get; set; }
public required string AlbumName { get; set; }
public required 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; }
public required string Isrc { get; set; }
//TODO: Artwork
public Artwork Artwork { get; set; }
public string ComposerName { get; set; }
public string Url { get; set; }
public PlayParams PlayParams { get; set; }
public required Artwork Artwork { get; set; }
public required string ComposerName { get; set; }
public required string Url { get; set; }
public required PlayParams PlayParams { get; set; }
public int DiscNumber { get; set; }
public bool HasLyrics { get; set; }
public bool IsAppleDigitalMaster { get; set; }
public string Name { get; set; }
public required string Name { get; set; }
//TODO: previews
public string ArtistName { get; set; }
public required string ArtistName { get; set; }
}
public class Artwork
{
public string Url { get; set; }
public required string Url { get; set; }
}
public class PlayParams
{
public string Id { get; set; }
public string Kind { get; set; }
public required string Id { get; set; }
public required 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 required string Id { get; set; }
public required string Type { get; set; }
public required string Href { get; set; }
public required TrackAttributes Attributes { get; set; }
public override string ToString()
{

@ -4,9 +4,11 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Inflatable.Lastfm" Version="1.2.0"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.3"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0"/>
</ItemGroup>

@ -5,7 +5,8 @@ namespace Selector.AppleMusic.Watcher;
public class AppleMusicCurrentlyPlayingContext
{
public DateTime FirstSeen { get; set; }
public Track Track { get; set; }
public required Track Track { get; set; }
public bool Scrobbled { get; set; }
}
public class AppleMusicCurrentlyPlayingContextComparer : IEqualityComparer<AppleMusicCurrentlyPlayingContext>

@ -12,7 +12,7 @@ namespace Selector
/// <summary>
/// Last retrieved currently playing
/// </summary>
public AppleMusicCurrentlyPlayingContext Live { get; }
public AppleMusicCurrentlyPlayingContext? Live { get; }
public AppleTimeline Past { get; }
}

@ -10,10 +10,10 @@ public class AppleMusicPlayerWatcher : BaseWatcher, IAppleMusicPlayerWatcher
private new 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 event EventHandler<AppleListeningChangeEventArgs>? NetworkPoll;
public event EventHandler<AppleListeningChangeEventArgs>? ItemChange;
public event EventHandler<AppleListeningChangeEventArgs>? AlbumChange;
public event EventHandler<AppleListeningChangeEventArgs>? ArtistChange;
public AppleMusicCurrentlyPlayingContext? Live { get; private set; }
private AppleMusicCurrentlyPlayingContext? Previous { get; set; }
@ -70,7 +70,7 @@ public class AppleMusicPlayerWatcher : BaseWatcher, IAppleMusicPlayerWatcher
addedItems.Insert(0, currentPrevious);
foreach (var (first, second) in addedItems.Zip(addedItems.Skip(1)))
{
Logger.LogDebug("Track changed: {prevTrack} -> {currentTrack}", first.Track, second.Track);
Logger.LogInformation("Track changed: {prevTrack} -> {currentTrack}", first.Track, second.Track);
OnItemChange(AppleListeningChangeEventArgs.From(first, second, Past, id: Id));
}
}
@ -85,7 +85,7 @@ public class AppleMusicPlayerWatcher : BaseWatcher, IAppleMusicPlayerWatcher
Logger.LogError(e, "Forbidden exception");
// throw;
}
catch (ServiceException e)
catch (ServiceException)
{
Logger.LogInformation("Apple Music internal error");
// throw;
@ -112,15 +112,20 @@ public class AppleMusicPlayerWatcher : BaseWatcher, IAppleMusicPlayerWatcher
{
Track = Live.Track,
FirstSeen = Live.FirstSeen,
Scrobbled = Live.Scrobbled,
};
}
else
{
Live = new()
if (recentlyPlayedTracks.Data is not null && recentlyPlayedTracks.Data.Any())
{
Track = recentlyPlayedTracks.Data?.FirstOrDefault(),
FirstSeen = DateTime.UtcNow,
};
Live = new()
{
Track = recentlyPlayedTracks.Data.First(),
FirstSeen = DateTime.UtcNow,
Scrobbled = false,
};
}
}
}

@ -6,23 +6,14 @@ namespace Selector.AppleMusic.Watcher
public interface IAppleMusicWatcherFactory
{
Task<IWatcher> Get<T>(AppleMusicApiProvider appleMusicProvider, string developerToken, string teamId,
string keyId, string userToken, string id = null, int pollPeriod = 3000)
string keyId, string userToken, string id, int pollPeriod = 3000)
where T : class, IWatcher;
}
public class AppleMusicWatcherFactory : IAppleMusicWatcherFactory
public class AppleMusicWatcherFactory(ILoggerFactory loggerFactory) : 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, string id = null, int pollPeriod = 3000)
public Task<IWatcher> Get<T>(AppleMusicApiProvider appleMusicProvider, string developerToken,
string teamId, string keyId, string userToken, string id, int pollPeriod = 3000)
where T : class, IWatcher
{
if (typeof(T).IsAssignableFrom(typeof(AppleMusicPlayerWatcher)))
@ -31,15 +22,15 @@ namespace Selector.AppleMusic.Watcher
{
var api = appleMusicProvider.GetApi(developerToken, teamId, keyId, userToken);
return new AppleMusicPlayerWatcher(
return Task.FromResult<IWatcher>(new AppleMusicPlayerWatcher(
api,
LoggerFactory?.CreateLogger<AppleMusicPlayerWatcher>() ??
loggerFactory?.CreateLogger<AppleMusicPlayerWatcher>() ??
NullLogger<AppleMusicPlayerWatcher>.Instance,
pollPeriod: pollPeriod
)
{
Id = id
};
});
}
else
{

@ -110,7 +110,8 @@ namespace Selector.CLI.Extensions
);
services.AddTransient<IScrobbleRepository, ScrobbleRepository>()
.AddTransient<ISpotifyListenRepository, SpotifyListenRepository>();
.AddTransient<ISpotifyListenRepository, SpotifyListenRepository>()
.AddTransient<IAppleListenRepository, AppleListenRepository>();
services.AddTransient<IListenRepository, MetaListenRepository>();
//services.AddTransient<IListenRepository, SpotifyListenRepository>();

@ -5,6 +5,8 @@
<TargetFramework>net9.0</TargetFramework>
<StartupObject>Selector.CLI.Program</StartupObject>
<LangVersion>latest</LangVersion>
<IsAotCompatible>true</IsAotCompatible>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
<ItemGroup>

@ -10,6 +10,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Selector.AppleMusic;
using Selector.AppleMusic.Consumer.Factory;
using Selector.AppleMusic.Watcher;
using Selector.Cache;
using Selector.CLI.Consumer;
@ -24,87 +25,45 @@ using Selector.Spotify.Watcher;
namespace Selector.CLI
{
class DbWatcherService : IHostedService
class DbWatcherService(
ISpotifyWatcherFactory spotifyWatcherFactory,
IAppleMusicWatcherFactory appleWatcherFactory,
IWatcherCollectionFactory watcherCollectionFactory,
IRefreshTokenFactoryProvider factory,
AppleMusicApiProvider appleMusicProvider,
// IAudioFeatureInjectorFactory audioFeatureInjectorFactory,
IAppleMusicScrobblerFactory scrobblerFactory,
IPlayCounterFactory playCounterFactory,
UserEventBus userEventBus,
ILogger<DbWatcherService> logger,
IOptions<AppleMusicOptions> appleMusicOptions,
IServiceProvider serviceProvider,
IPublisherFactory publisherFactory = null,
ICacheWriterFactory cacheWriterFactory = null,
IMappingPersisterFactory mappingPersisterFactory = null,
IUserEventFirerFactory userEventFirerFactory = null)
: IHostedService
{
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 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;
private readonly IUserEventFirerFactory UserEventFirerFactory;
private readonly IPublisherFactory PublisherFactory;
private readonly ICacheWriterFactory CacheWriterFactory;
private readonly IMappingPersisterFactory MappingPersisterFactory;
// private readonly IAudioFeatureInjectorFactory AudioFeatureInjectorFactory = audioFeatureInjectorFactory;
private ConcurrentDictionary<string, IWatcherCollection> Watchers { get; set; } = new();
public DbWatcherService(
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
)
{
Logger = logger;
ServiceProvider = serviceProvider;
UserEventBus = userEventBus;
_spotifyWatcherFactory = spotifyWatcherFactory;
_appleWatcherFactory = appleWatcherFactory;
_appleMusicOptions = appleMusicOptions;
WatcherCollectionFactory = watcherCollectionFactory;
SpotifyFactory = spotifyFactory;
_appleMusicProvider = appleMusicProvider;
AudioFeatureInjectorFactory = audioFeatureInjectorFactory;
PlayCounterFactory = playCounterFactory;
UserEventFirerFactory = userEventFirerFactory;
PublisherFactory = publisherFactory;
CacheWriterFactory = cacheWriterFactory;
MappingPersisterFactory = mappingPersisterFactory;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
Logger.LogInformation("Starting database watcher service...");
logger.LogInformation("Starting database watcher service...");
var watcherIndices = await InitInstances();
AttachEventBus();
Logger.LogInformation("Starting {count} affected watcher collection(s)...", watcherIndices.Count());
logger.LogInformation("Starting {count} affected watcher collection(s)...", watcherIndices.Count());
StartWatcherCollections(watcherIndices);
}
private async Task<IEnumerable<string>> InitInstances()
{
using var scope = ServiceProvider.CreateScope();
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetService<ApplicationDbContext>();
var indices = new HashSet<string>();
@ -112,12 +71,12 @@ namespace Selector.CLI
foreach (var dbWatcher in db.Watcher
.Include(w => w.User)
.Where(w =>
((w.Type == WatcherType.SpotifyPlayer || w.Type == WatcherType.SpotifyPlaylist) &&
!string.IsNullOrWhiteSpace(w.User.SpotifyRefreshToken)) ||
// ((w.Type == WatcherType.SpotifyPlayer || w.Type == WatcherType.SpotifyPlaylist) &&
// !string.IsNullOrWhiteSpace(w.User.SpotifyRefreshToken)) ||
(w.Type == WatcherType.AppleMusicPlayer && w.User.AppleMusicLinked)
))
{
using var logScope = Logger.BeginScope(new Dictionary<string, string>
using var logScope = logger.BeginScope(new Dictionary<string, string>
{
{ "username", dbWatcher.User.UserName }
});
@ -133,12 +92,12 @@ namespace Selector.CLI
private async Task<IWatcherContext> InitInstance(Watcher dbWatcher)
{
Logger.LogInformation("Creating new [{type}] watcher", dbWatcher.Type);
logger.LogInformation("Creating new [{type}] watcher", dbWatcher.Type);
var watcherCollectionIdx = dbWatcher.UserId;
if (!Watchers.ContainsKey(watcherCollectionIdx))
Watchers[watcherCollectionIdx] = WatcherCollectionFactory.Get();
Watchers[watcherCollectionIdx] = watcherCollectionFactory.Get();
var watcherCollection = Watchers[watcherCollectionIdx];
@ -148,30 +107,30 @@ namespace Selector.CLI
switch (dbWatcher.Type)
{
case WatcherType.SpotifyPlayer:
Logger.LogDebug("Getting Spotify factory");
var spotifyFactory = await SpotifyFactory.GetFactory(dbWatcher.User.SpotifyRefreshToken);
logger.LogDebug("Getting Spotify factory");
var spotifyFactory = await factory.GetFactory(dbWatcher.User.SpotifyRefreshToken);
watcher = await _spotifyWatcherFactory.Get<SpotifyPlayerWatcher>(spotifyFactory,
watcher = await spotifyWatcherFactory.Get<SpotifyPlayerWatcher>(spotifyFactory,
id: dbWatcher.UserId, pollPeriod: PollPeriod);
// deprecated, thanks Spotify!
// consumers.Add(await AudioFeatureInjectorFactory.Get(spotifyFactory));
if (CacheWriterFactory is not null) consumers.Add(await CacheWriterFactory.GetSpotify());
if (PublisherFactory is not null) consumers.Add(await PublisherFactory.GetSpotify());
if (cacheWriterFactory is not null) consumers.Add(await cacheWriterFactory.GetSpotify());
if (publisherFactory is not null) consumers.Add(await publisherFactory.GetSpotify());
if (MappingPersisterFactory is not null && !Magic.Dummy)
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.GetSpotify());
if (userEventFirerFactory is not null) consumers.Add(await userEventFirerFactory.GetSpotify());
if (dbWatcher.User.LastFmConnected())
{
consumers.Add(await PlayCounterFactory.Get(creds: new()
consumers.Add(await playCounterFactory.Get(creds: new()
{ Username = dbWatcher.User.LastFmUsername }));
}
else
{
Logger.LogDebug("[{username}] No Last.fm username, skipping play counter",
logger.LogDebug("[{username}] No Last.fm username, skipping play counter",
dbWatcher.User.UserName);
}
@ -179,16 +138,28 @@ namespace Selector.CLI
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,
watcher = await appleWatcherFactory.Get<AppleMusicPlayerWatcher>(appleMusicProvider,
appleMusicOptions.Value.Key, appleMusicOptions.Value.TeamId, appleMusicOptions.Value.KeyId,
dbWatcher.User.AppleMusicKey, id: dbWatcher.UserId);
if (CacheWriterFactory is not null) consumers.Add(await CacheWriterFactory.GetApple());
if (PublisherFactory is not null) consumers.Add(await PublisherFactory.GetApple());
if (cacheWriterFactory is not null) consumers.Add(await cacheWriterFactory.GetApple());
if (publisherFactory is not null) consumers.Add(await publisherFactory.GetApple());
if (UserEventFirerFactory is not null) consumers.Add(await UserEventFirerFactory.GetApple());
if (userEventFirerFactory is not null) consumers.Add(await userEventFirerFactory.GetApple());
// if (dbWatcher.User.LastFmConnected() && !string.IsNullOrWhiteSpace(dbWatcher.User.LastFmPassword))
// {
// var scrobbler = await scrobblerFactory.Get();
// await scrobbler.Auth(dbWatcher.User.LastFmUsername, dbWatcher.User.LastFmPassword);
// consumers.Add(scrobbler);
// }
// else
// {
// logger.LogDebug("[{username}] No Last.fm username/password, skipping scrobbler",
// dbWatcher.User.UserName);
// }
break;
}
@ -202,23 +173,23 @@ namespace Selector.CLI
{
try
{
Logger.LogInformation("Starting watcher collection [{index}]", index);
logger.LogInformation("Starting watcher collection [{index}]", index);
Watchers[index].Start();
}
catch (KeyNotFoundException)
{
Logger.LogError("Unable to retrieve watcher collection [{index}] when starting", index);
logger.LogError("Unable to retrieve watcher collection [{index}] when starting", index);
}
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
Logger.LogInformation("Shutting down");
logger.LogInformation("Shutting down");
foreach ((var key, var watcher) in Watchers)
{
Logger.LogInformation("Stopping watcher collection [{key}]", key);
logger.LogInformation("Stopping watcher collection [{key}]", key);
watcher.Stop();
}
@ -229,23 +200,23 @@ namespace Selector.CLI
private void AttachEventBus()
{
UserEventBus.SpotifyLinkChange += SpotifyChangeCallback;
UserEventBus.AppleLinkChange += AppleMusicChangeCallback;
UserEventBus.LastfmCredChange += LastfmChangeCallback;
userEventBus.SpotifyLinkChange += SpotifyChangeCallback;
userEventBus.AppleLinkChange += AppleMusicChangeCallback;
userEventBus.LastfmCredChange += LastfmChangeCallback;
}
private void DetachEventBus()
{
UserEventBus.SpotifyLinkChange -= SpotifyChangeCallback;
UserEventBus.AppleLinkChange -= AppleMusicChangeCallback;
UserEventBus.LastfmCredChange -= LastfmChangeCallback;
userEventBus.SpotifyLinkChange -= SpotifyChangeCallback;
userEventBus.AppleLinkChange -= AppleMusicChangeCallback;
userEventBus.LastfmCredChange -= LastfmChangeCallback;
}
public async void SpotifyChangeCallback(object sender, SpotifyLinkChange change)
{
if (Watchers.ContainsKey(change.UserId))
{
Logger.LogDebug("Setting new Spotify link state for [{username}], [{}]", change.UserId,
logger.LogDebug("Setting new Spotify link state for [{username}], [{}]", change.UserId,
change.NewLinkState);
var watcherCollection = Watchers[change.UserId];
@ -261,7 +232,7 @@ namespace Selector.CLI
}
else
{
using var scope = ServiceProvider.CreateScope();
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetService<ApplicationDbContext>();
var watcherEnum = db.Watcher
@ -275,7 +246,7 @@ namespace Selector.CLI
Watchers[change.UserId].Start();
Logger.LogDebug("Started {} watchers for [{username}]", watcherEnum.Count(), change.UserId);
logger.LogDebug("Started {} watchers for [{username}]", watcherEnum.Count(), change.UserId);
}
}
@ -283,7 +254,7 @@ namespace Selector.CLI
{
if (Watchers.ContainsKey(change.UserId))
{
Logger.LogDebug("Setting new Apple Music link state for [{username}], [{}]", change.UserId,
logger.LogDebug("Setting new Apple Music link state for [{username}], [{}]", change.UserId,
change.NewLinkState);
var watcherCollection = Watchers[change.UserId];
@ -299,7 +270,7 @@ namespace Selector.CLI
}
else
{
using var scope = ServiceProvider.CreateScope();
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetService<ApplicationDbContext>();
var watcherEnum = db.Watcher
@ -313,7 +284,7 @@ namespace Selector.CLI
Watchers[change.UserId].Start();
Logger.LogDebug("Started {} watchers for [{username}]", watcherEnum.Count(), change.UserId);
logger.LogDebug("Started {} watchers for [{username}]", watcherEnum.Count(), change.UserId);
}
}
@ -321,7 +292,7 @@ namespace Selector.CLI
{
if (Watchers.ContainsKey(change.UserId))
{
Logger.LogDebug("Setting new username for [{}], [{}]", change.UserId, change.NewUsername);
logger.LogDebug("Setting new username for [{}], [{}]", change.UserId, change.NewUsername);
var watcherCollection = Watchers[change.UserId];
@ -335,7 +306,7 @@ namespace Selector.CLI
}
else
{
Logger.LogDebug("No watchers running for [{username}], skipping Spotify event", change.UserId);
logger.LogDebug("No watchers running for [{username}], skipping Spotify event", change.UserId);
}
}
}

@ -134,15 +134,15 @@ namespace Selector.CLI
{
switch (consumer)
{
case Consumers.AudioFeatures:
consumers.Add(await ServiceProvider.GetService<AudioFeatureInjectorFactory>()
.Get(spotifyFactory));
break;
// case Consumers.AudioFeatures:
// consumers.Add(await ServiceProvider.GetService<AudioFeatureInjectorFactory>()
// .Get(spotifyFactory));
// break;
case Consumers.AudioFeaturesCache:
consumers.Add(await ServiceProvider.GetService<CachingAudioFeatureInjectorFactory>()
.Get(spotifyFactory));
break;
// case Consumers.AudioFeaturesCache:
// consumers.Add(await ServiceProvider.GetService<CachingAudioFeatureInjectorFactory>()
// .Get(spotifyFactory));
// break;
case Consumers.CacheWriter:
if (watcher is ISpotifyPlayerWatcher or IPlaylistWatcher)

@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Selector.Spotify;
@ -9,18 +10,19 @@ using StackExchange.Redis;
namespace Selector.Cache
{
[Obsolete]
public class CachingAudioFeatureInjectorFactory : IAudioFeatureInjectorFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly IDatabaseAsync Db;
private readonly ILoggerFactory _loggerFactory;
private readonly IDatabaseAsync _db;
public CachingAudioFeatureInjectorFactory(
ILoggerFactory loggerFactory,
IDatabaseAsync db
)
{
LoggerFactory = loggerFactory;
Db = db;
_loggerFactory = loggerFactory;
_db = db;
}
public async Task<ISpotifyPlayerConsumer> Get(ISpotifyConfigFactory spotifyFactory,
@ -33,16 +35,16 @@ namespace Selector.Cache
return new CachingAudioFeatureInjector(
watcher,
Db,
_db,
client.Tracks,
LoggerFactory.CreateLogger<CachingAudioFeatureInjector>()
_loggerFactory.CreateLogger<CachingAudioFeatureInjector>()
);
}
else
{
return new DummyAudioFeatureInjector(
watcher,
LoggerFactory.CreateLogger<DummyAudioFeatureInjector>()
_loggerFactory.CreateLogger<DummyAudioFeatureInjector>()
);
}
}

@ -11,6 +11,7 @@ using StackExchange.Redis;
namespace Selector.Cache
{
[Obsolete]
public class CachingAudioFeatureInjector : AudioFeatureInjector
{
private readonly IDatabaseAsync Db;

@ -20,17 +20,17 @@ namespace Selector.Cache.Extensions
var connMulti = ConnectionMultiplexer.Connect(connectionStr);
services.AddSingleton(connMulti);
services.AddTransient<IDatabaseAsync>(
services => services.GetService<ConnectionMultiplexer>().GetDatabase());
services.AddTransient<ISubscriber>(services =>
services.GetService<ConnectionMultiplexer>().GetSubscriber());
s => s.GetService<ConnectionMultiplexer>().GetDatabase());
services.AddTransient<ISubscriber>(s =>
s.GetService<ConnectionMultiplexer>().GetSubscriber());
return services;
}
public static IServiceCollection AddCachingConsumerFactories(this IServiceCollection services)
{
services.AddTransient<IAudioFeatureInjectorFactory, CachingAudioFeatureInjectorFactory>();
services.AddTransient<CachingAudioFeatureInjectorFactory>();
// services.AddTransient<IAudioFeatureInjectorFactory, CachingAudioFeatureInjectorFactory>();
// services.AddTransient<CachingAudioFeatureInjectorFactory>();
services.AddTransient<IPlayCounterFactory, PlayCounterCachingFactory>();
services.AddTransient<PlayCounterCachingFactory>();

@ -4,6 +4,7 @@
<TargetFramework>net9.0</TargetFramework>
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
<LangVersion>latest</LangVersion>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>

@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>

@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>

@ -12,15 +12,15 @@ public static class ServiceExtensions
services.AddTransient<LastAuth>(sp => new LastAuth(client, secret));
services.AddTransient(sp => new LastfmClient(sp.GetService<LastAuth>()));
services.AddTransient<ITrackApi>(sp => sp.GetService<LastfmClient>().Track);
services.AddTransient<IAlbumApi>(sp => sp.GetService<LastfmClient>().Album);
services.AddTransient<IArtistApi>(sp => sp.GetService<LastfmClient>().Artist);
services.AddTransient<ITrackApi>(sp => sp.GetRequiredService<LastfmClient>().Track);
services.AddTransient<IAlbumApi>(sp => sp.GetRequiredService<LastfmClient>().Album);
services.AddTransient<IArtistApi>(sp => sp.GetRequiredService<LastfmClient>().Artist);
services.AddTransient<IUserApi>(sp => sp.GetService<LastfmClient>().User);
services.AddTransient<IUserApi>(sp => sp.GetRequiredService<LastfmClient>().User);
services.AddTransient<IChartApi>(sp => sp.GetService<LastfmClient>().Chart);
services.AddTransient<ILibraryApi>(sp => sp.GetService<LastfmClient>().Library);
services.AddTransient<ITagApi>(sp => sp.GetService<LastfmClient>().Tag);
services.AddTransient<IChartApi>(sp => sp.GetRequiredService<LastfmClient>().Chart);
services.AddTransient<ILibraryApi>(sp => sp.GetRequiredService<LastfmClient>().Library);
services.AddTransient<ITagApi>(sp => sp.GetRequiredService<LastfmClient>().Tag);
services.AddTransient<IScrobbler, MemoryScrobbler>();

@ -16,9 +16,9 @@ namespace Selector.Mapping
ArtistName = artistName;
}
private SimpleAlbum result;
public SimpleAlbum Album => result;
public override object Result => result;
private SimpleAlbum? _result;
public SimpleAlbum? Album => _result;
public override object? Result => _result;
public override string Query => $"{AlbumName} {ArtistName}";
@ -26,13 +26,13 @@ namespace Selector.Mapping
public override void HandleResponse(Task<SearchResponse> response)
{
var topResult = response.Result.Albums.Items.FirstOrDefault();
var topResult = response.Result.Albums.Items?.FirstOrDefault();
if (topResult is not null
&& topResult.Name.Equals(AlbumName, StringComparison.InvariantCultureIgnoreCase)
&& topResult.Artists.First().Name.Equals(ArtistName, StringComparison.InvariantCultureIgnoreCase))
{
result = topResult;
_result = topResult;
}
}
}

@ -8,15 +8,15 @@ namespace Selector.Mapping
{
public string ArtistName { get; set; }
public ScrobbleArtistMapping(ISearchClient _searchClient, ILogger<ScrobbleArtistMapping> _logger,
string artistName) : base(_searchClient, _logger)
public ScrobbleArtistMapping(ISearchClient searchClient, ILogger<ScrobbleArtistMapping> logger,
string artistName) : base(searchClient, logger)
{
ArtistName = artistName;
}
private FullArtist result;
public FullArtist Artist => result;
public override object Result => result;
private FullArtist? _result;
public FullArtist? Artist => _result;
public override object? Result => _result;
public override string Query => ArtistName;
@ -24,12 +24,12 @@ namespace Selector.Mapping
public override void HandleResponse(Task<SearchResponse> response)
{
var topResult = response.Result.Artists.Items.FirstOrDefault();
var topResult = response.Result.Artists.Items?.FirstOrDefault();
if (topResult is not null
&& topResult.Name.Equals(ArtistName, StringComparison.InvariantCultureIgnoreCase))
{
result = topResult;
_result = topResult;
}
}
}

@ -17,42 +17,42 @@ namespace Selector.Mapping
/// </summary>
public abstract class ScrobbleMapping : IOperation
{
private readonly ILogger<ScrobbleMapping> logger;
private readonly ISearchClient searchClient;
private readonly ILogger<ScrobbleMapping> _logger;
private readonly ISearchClient _searchClient;
public event EventHandler Success;
public event EventHandler? Success;
public int MaxAttempts { get; private set; } = 5;
public int Attempts { get; private set; }
private Task<SearchResponse> currentTask { get; set; }
private Task<SearchResponse>? CurrentTask { get; set; }
public bool Succeeded { get; private set; } = false;
public abstract object Result { get; }
public abstract object? Result { get; }
public abstract string Query { get; }
public abstract SearchRequest.Types QueryType { get; }
private TaskCompletionSource AggregateTaskSource { get; set; } = new();
public Task Task => AggregateTaskSource.Task;
public ScrobbleMapping(ISearchClient _searchClient, ILogger<ScrobbleMapping> _logger)
public ScrobbleMapping(ISearchClient searchClient, ILogger<ScrobbleMapping> logger)
{
logger = _logger;
searchClient = _searchClient;
_logger = logger;
_searchClient = searchClient;
}
public Task Execute()
{
logger.LogInformation("Mapping Last.fm {} ({}) to Spotify", Query, QueryType);
_logger.LogInformation("Mapping Last.fm {} ({}) to Spotify", Query, QueryType);
var netTime = Stopwatch.StartNew();
currentTask = searchClient.Item(new(QueryType, Query));
currentTask.ContinueWith(async t =>
CurrentTask = _searchClient.Item(new(QueryType, Query));
CurrentTask.ContinueWith(async t =>
{
try
{
netTime.Stop();
logger.LogTrace("Network request took {:n} ms", netTime.ElapsedMilliseconds);
_logger.LogTrace("Network request took {:n} ms", netTime.ElapsedMilliseconds);
if (t.IsCompletedSuccessfully)
{
@ -62,22 +62,23 @@ namespace Selector.Mapping
}
else
{
if (t.Exception.InnerException is APITooManyRequestsException ex)
if (t.Exception?.InnerException is APITooManyRequestsException ex)
{
logger.LogError("Spotify search request too many requests, waiting for {}", ex.RetryAfter);
_logger.LogError("Spotify search request too many requests, waiting for {}", ex.RetryAfter);
await Task.Delay(ex.RetryAfter.Add(TimeSpan.FromSeconds(1)));
await Execute();
}
else
{
logger.LogError("Spotify search request task faulted, {}", t.Exception);
AggregateTaskSource.SetException(t.Exception);
_logger.LogError("Spotify search request task faulted, {}", t.Exception);
if (t.Exception != null) AggregateTaskSource.SetException(t.Exception);
}
}
}
catch (Exception e)
{
logger.LogError(e, "Error while mapping Last.fm {} ({}) to Spotify on attempt {}", Query, QueryType,
_logger.LogError(e, "Error while mapping Last.fm {} ({}) to Spotify on attempt {}", Query,
QueryType,
Attempts);
Succeeded = false;
}
@ -92,7 +93,7 @@ namespace Selector.Mapping
protected virtual void OnSuccess()
{
// Raise the event in a thread-safe manner using the ?. operator.
Success?.Invoke(this, new EventArgs());
Success?.Invoke(this, EventArgs.Empty);
}
}
}

@ -9,16 +9,16 @@ namespace Selector.Mapping
public string TrackName { get; set; }
public string ArtistName { get; set; }
public ScrobbleTrackMapping(ISearchClient _searchClient, ILogger<ScrobbleTrackMapping> _logger,
string trackName, string artistName) : base(_searchClient, _logger)
public ScrobbleTrackMapping(ISearchClient searchClient, ILogger<ScrobbleTrackMapping> logger,
string trackName, string artistName) : base(searchClient, logger)
{
TrackName = trackName;
ArtistName = artistName;
}
private FullTrack result;
public FullTrack Track => result;
public override object Result => result;
private FullTrack? _result;
public FullTrack? Track => _result;
public override object? Result => _result;
public override string Query => $"{TrackName} {ArtistName}";
@ -26,13 +26,13 @@ namespace Selector.Mapping
public override void HandleResponse(Task<SearchResponse> response)
{
var topResult = response.Result.Tracks.Items.FirstOrDefault();
var topResult = response.Result.Tracks.Items?.FirstOrDefault();
if (topResult is not null
&& topResult.Name.Equals(TrackName, StringComparison.InvariantCultureIgnoreCase)
&& topResult.Artists.First().Name.Equals(ArtistName, StringComparison.InvariantCultureIgnoreCase))
{
result = topResult;
_result = topResult;
}
}
}

@ -5,15 +5,15 @@ namespace Selector
public class Scrobble : IListen
{
public DateTime Timestamp { get; set; }
public string? TrackName { get; set; }
public string? AlbumName { get; set; }
public required string TrackName { get; set; }
public required string AlbumName { get; set; }
/// <summary>
/// Not populated by default from the service, where not the same as <see cref="ArtistName"/> these have been manually entered
/// </summary>
public string? AlbumArtistName { get; set; }
public string? ArtistName { get; set; }
public required string ArtistName { get; set; }
public static explicit operator Scrobble(LastTrack track) => new()
{

@ -105,7 +105,7 @@ namespace Selector
public class ListenComp : IEqualityComparer<IListen>
{
public bool Equals(IListen x, IListen y) => x.Timestamp == y.Timestamp;
public bool Equals(IListen? x, IListen? y) => x?.Timestamp == y?.Timestamp;
public int GetHashCode([DisallowNull] IListen obj) => obj.Timestamp.GetHashCode();
}

@ -9,67 +9,68 @@ namespace Selector
{
public class ScrobbleRequest : IOperation
{
private readonly ILogger<ScrobbleRequest> logger;
private readonly IUserApi userClient;
private readonly ILogger<ScrobbleRequest> _logger;
private readonly IUserApi _userClient;
public event EventHandler Success;
public event EventHandler? Success;
public int MaxAttempts { get; private set; }
public int Attempts { get; private set; }
public IEnumerable<LastTrack> Scrobbles { get; private set; }
public IEnumerable<LastTrack> Scrobbles { get; private set; } = [];
public int TotalPages { get; private set; }
private Task<PageResponse<LastTrack>> currentTask { get; set; }
private Task<PageResponse<LastTrack>>? CurrentTask { get; set; }
public bool Succeeded { get; private set; } = false;
private string username { get; set; }
private int pageNumber { get; set; }
int pageSize { get; set; }
DateTime? from { get; set; }
DateTime? to { get; set; }
private string Username { get; set; }
private int PageNumber { get; set; }
private int PageSize { get; set; }
private DateTime? From { get; set; }
private DateTime? To { get; set; }
private TaskCompletionSource AggregateTaskSource { get; set; } = new();
public Task Task => AggregateTaskSource.Task;
public ScrobbleRequest(IUserApi _userClient, ILogger<ScrobbleRequest> _logger, string _username,
int _pageNumber, int _pageSize, DateTime? _from, DateTime? _to, int maxRetries = 5)
public ScrobbleRequest(IUserApi userClient, ILogger<ScrobbleRequest> logger, string username,
int pageNumber, int pageSize, DateTime? from, DateTime? to, int maxRetries = 5)
{
userClient = _userClient;
logger = _logger;
_userClient = userClient;
_logger = logger;
username = _username;
pageNumber = _pageNumber;
pageSize = _pageSize;
from = _from;
to = _to;
Username = username;
PageNumber = pageNumber;
PageSize = pageSize;
From = from;
To = to;
MaxAttempts = maxRetries;
}
public Task Execute()
{
using var scope = logger.BeginScope(new Dictionary<string, object>()
using var scope = _logger.BeginScope(new Dictionary<string, object>()
{
{ "username", username }, { "page_number", pageNumber }, { "page_size", pageSize }, { "from", from },
{ "to", to }
{ "username", Username }, { "page_number", PageNumber }, { "page_size", PageSize },
{ "from", From ?? DateTime.MinValue },
{ "to", To ?? DateTime.MinValue }
});
logger.LogInformation("Starting request");
_logger.LogInformation("Starting request");
var netTime = Stopwatch.StartNew();
currentTask =
userClient.GetRecentScrobbles(username, pagenumber: pageNumber, count: pageSize, from: from, to: to);
currentTask.ContinueWith(async t =>
CurrentTask =
_userClient.GetRecentScrobbles(Username, pagenumber: PageNumber, count: PageSize, from: From, to: To);
CurrentTask.ContinueWith(async t =>
{
using var scope = logger.BeginScope(new Dictionary<string, object>()
using var scope = _logger.BeginScope(new Dictionary<string, object>()
{
{ "username", username }, { "page_number", pageNumber }, { "page_size", pageSize },
{ "from", from }, { "to", to }
{ "username", Username }, { "page_number", PageNumber }, { "page_size", PageSize },
{ "from", From ?? DateTime.MinValue }, { "to", To ?? DateTime.MinValue }
});
try
{
netTime.Stop();
logger.LogTrace("Network request took {:n} ms", netTime.ElapsedMilliseconds);
_logger.LogTrace("Network request took {:n} ms", netTime.ElapsedMilliseconds);
if (t.IsCompletedSuccessfully)
{
@ -87,13 +88,13 @@ namespace Selector
{
if (Attempts < MaxAttempts)
{
logger.LogDebug("Request failed: {}, retrying ({} of {})", result.Status, Attempts + 1,
_logger.LogDebug("Request failed: {}, retrying ({} of {})", result.Status, Attempts + 1,
MaxAttempts);
await Execute();
}
else
{
logger.LogDebug("Request failed: {}, max retries exceeded {}, not retrying",
_logger.LogDebug("Request failed: {}, max retries exceeded {}, not retrying",
result.Status, MaxAttempts);
AggregateTaskSource.SetCanceled();
}
@ -101,13 +102,13 @@ namespace Selector
}
else
{
logger.LogError("Scrobble request task faulted, {}", t.Exception);
AggregateTaskSource.SetException(t.Exception);
_logger.LogError("Scrobble request task faulted, {}", t.Exception);
if (t.Exception != null) AggregateTaskSource.SetException(t.Exception);
}
}
catch (Exception e)
{
logger.LogError(e, "Error while making scrobble request on attempt {}", Attempts);
_logger.LogError(e, "Error while making scrobble request on attempt {}", Attempts);
Succeeded = false;
}
});
@ -118,7 +119,7 @@ namespace Selector
protected virtual void OnSuccess()
{
Success?.Invoke(this, new EventArgs());
Success?.Invoke(this, EventArgs.Empty);
}
}
}

@ -4,6 +4,7 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
using Selector.MAUI.Services;
namespace Selector.MAUI;
[JsonSerializable(typeof(SelectorNetClient.TokenNetworkResponse))]
public partial class MauiJsonContext : JsonSerializerContext
{
}

@ -4,13 +4,15 @@
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('OSX'))">$(TargetFrameworks);net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.19041.0</TargetFrameworks>
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
<!-- <TargetFrameworks>$(TargetFrameworks);net8.0-tizen</TargetFrameworks> -->
<!-- <TargetFrameworks>$(TargetFrameworks);net9.0-tizen</TargetFrameworks> -->
<OutputType>Exe</OutputType>
<RootNamespace>Selector.MAUI</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultCssItems>false</EnableDefaultCssItems>
<!-- <PublishAot>true</PublishAot> -->
<IsAotCompatible>true</IsAotCompatible>
<!-- Display name -->
<ApplicationTitle>Selector</ApplicationTitle>

@ -1,5 +1,4 @@
using System;
using System.Net;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Selector.SignalR;
@ -45,11 +44,12 @@ public class SelectorNetClient : ISelectorNetClient
ArgumentNullException.ThrowIfNullOrEmpty(username);
ArgumentNullException.ThrowIfNullOrEmpty(password);
var result = await _client.PostAsync(_baseUrl + "/api/auth/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "Username", username },
{ "Password", password }
}));
var result = await _client.PostAsync(_baseUrl + "/api/auth/token", new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "Username", username },
{ "Password", password }
}));
return FormTokenResponse(result);
}
@ -86,7 +86,7 @@ public class SelectorNetClient : ISelectorNetClient
break;
case HttpStatusCode.OK:
ret.Status = TokenResponseStatus.OK;
ret.Token = result.Content.ReadFromJsonAsync<TokenNetworkResponse>().Result.Token;
ret.Token = result.Content.ReadFromJsonAsync(MauiJsonContext.Default.TokenNetworkResponse).Result.Token;
_nowClient.Token = ret.Token;
_pastClient.Token = ret.Token;
break;
@ -110,7 +110,11 @@ public class SelectorNetClient : ISelectorNetClient
public enum TokenResponseStatus
{
Malformed, UserSearchFailed, BadCreds, ExpiredCreds, OK
Malformed,
UserSearchFailed,
BadCreds,
ExpiredCreds,
OK
}
private class TokenModel
@ -118,4 +122,4 @@ public class SelectorNetClient : ISelectorNetClient
public string Username { get; set; }
public string Password { get; set; }
}
}
}

@ -1,31 +1,24 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace Selector.Model
{
public class ApplicationUser : IdentityUser
{
[PersonalData]
public bool SpotifyIsLinked { get; set; }
[PersonalData]
public DateTime SpotifyLastRefresh { get; set; }
[PersonalData] public bool SpotifyIsLinked { get; set; }
[PersonalData] public DateTime SpotifyLastRefresh { get; set; }
public int SpotifyTokenExpiry { get; set; }
public string SpotifyAccessToken { get; set; }
public string SpotifyRefreshToken { get; set; }
[PersonalData]
public bool AppleMusicLinked { get; set; }
[PersonalData] public bool AppleMusicLinked { get; set; }
public string AppleMusicKey { get; set; }
[PersonalData]
public DateTime AppleMusicLastRefresh { get; set; }
[PersonalData] public DateTime AppleMusicLastRefresh { get; set; }
[PersonalData]
public string LastFmUsername { get; set; }
[PersonalData]
public bool SaveScrobbles { get; set; }
[PersonalData] public string LastFmUsername { get; set; }
[PersonalData] public string LastFmPassword { get; set; }
[PersonalData] public bool SaveScrobbles { get; set; }
public List<Watcher> Watchers { get; set; }
public List<UserScrobble> Scrobbles { get; set; }
@ -51,7 +44,8 @@ namespace Selector.Model
public string LastFmUsername { get; set; }
public static explicit operator ApplicationUserDTO(ApplicationUser user) => new() {
public static explicit operator ApplicationUserDTO(ApplicationUser user) => new()
{
Id = user.Id,
UserName = user.UserName,
Email = user.Email,

@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
namespace Selector.Model
{
public class AppleListenRepository : IAppleListenRepository
{
private readonly ApplicationDbContext db;
public AppleListenRepository(ApplicationDbContext context)
{
db = context;
}
public void Add(AppleMusicListen item)
{
db.AppleMusicListen.Add(item);
}
public void AddRange(IEnumerable<AppleMusicListen> item)
{
db.AppleMusicListen.AddRange(item);
}
public AppleMusicListen Find(DateTime key, string include = null)
{
var listens = db.AppleMusicListen.Where(s => s.Timestamp == key);
if (!string.IsNullOrWhiteSpace(include))
{
listens = listens.Include(include);
}
return listens.FirstOrDefault();
}
public IQueryable<IListen> GetAll(string include = null, string userId = null, string username = null,
string trackName = null, string albumName = null, string artistName = null, DateTime? from = null,
DateTime? to = null, bool tracking = true, bool orderTime = false)
{
var listens = db.AppleMusicListen.AsQueryable();
if (!tracking)
{
listens = listens.AsNoTracking();
}
if (!string.IsNullOrWhiteSpace(include))
{
listens = listens.Include(include);
}
if (!string.IsNullOrWhiteSpace(userId))
{
listens = listens.Where(s => s.UserId == userId);
}
if (!string.IsNullOrWhiteSpace(username))
{
var normalUsername = username.ToUpperInvariant();
var user = db.Users.AsNoTracking().Where(u => u.NormalizedUserName == normalUsername).FirstOrDefault();
if (user is not null)
{
listens = listens.Where(s => s.UserId == user.Id);
}
else
{
listens = Enumerable.Empty<AppleMusicListen>().AsQueryable();
}
}
if (!string.IsNullOrWhiteSpace(trackName))
{
listens = listens.Where(s => s.TrackName == trackName);
}
if (!string.IsNullOrWhiteSpace(albumName))
{
listens = listens.Where(s => s.AlbumName == albumName);
}
if (!string.IsNullOrWhiteSpace(artistName))
{
listens = listens.Where(s => s.ArtistName == artistName);
}
if (from is not null)
{
listens = listens.Where(u => u.Timestamp >= from.Value);
}
if (to is not null)
{
listens = listens.Where(u => u.Timestamp < to.Value);
}
if (orderTime)
{
listens = listens.OrderBy(x => x.Timestamp);
}
return listens;
}
// public IEnumerable<IListen> GetAll(string include = null, string userId = null, string username = null, string trackName = null, string albumName = null, string artistName = null, DateTime? from = null, DateTime? to = null, bool tracking = true, bool orderTime = false)
// => GetAllQueryable(include: include, userId: userId, username: username, trackName: trackName, albumName: albumName, artistName: artistName, from: from, to: to, tracking: tracking, orderTime: orderTime).AsEnumerable();
public void Remove(DateTime key)
{
Remove(Find(key));
}
public void Remove(AppleMusicListen scrobble)
{
db.AppleMusicListen.Remove(scrobble);
}
public void RemoveRange(IEnumerable<AppleMusicListen> scrobbles)
{
db.AppleMusicListen.RemoveRange(scrobbles);
}
public void Update(AppleMusicListen item)
{
db.AppleMusicListen.Update(item);
}
public Task<int> Save()
{
return db.SaveChangesAsync();
}
public int Count(string userId = null, string username = null, string trackName = null, string albumName = null,
string artistName = null, DateTime? from = null, DateTime? to = null)
=> GetAll(userId: userId, username: username, trackName: trackName, albumName: albumName,
artistName: artistName, from: from, to: to, tracking: false).Count();
}
}

@ -8,6 +8,7 @@ public class AppleMusicListen : Listen, IUserListen
public string TrackId { get; set; }
public string Isrc { get; set; }
public bool IsScrobbled { get; set; }
public string UserId { get; set; }
public ApplicationUser User { get; set; }
@ -18,6 +19,7 @@ public class AppleMusicListen : Listen, IUserListen
TrackId = track.Track.Id,
Isrc = track.Track.Attributes.Isrc,
IsScrobbled = track.Scrobbled,
TrackName = track.Track.Attributes.Name,
AlbumName = track.Track.Attributes.AlbumName,

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Selector.Model
{
public interface IAppleListenRepository : IListenRepository
{
void Add(AppleMusicListen item);
void AddRange(IEnumerable<AppleMusicListen> item);
//IEnumerable<AppleMusicListen> GetAll(string include = null, string userId = null, string username = null, string trackName = null, string albumName = null, string artistName = null, DateTime? from = null, DateTime? to = null);
AppleMusicListen Find(DateTime key, string include = null);
void Remove(DateTime key);
public void Remove(AppleMusicListen scrobble);
public void RemoveRange(IEnumerable<AppleMusicListen> scrobbles);
void Update(AppleMusicListen item);
Task<int> Save();
//int Count(string userId = null, string username = null, string trackName = null, string albumName = null, string artistName = null, DateTime? from = null, DateTime? to = null);
}
}

@ -4,6 +4,7 @@
<TargetFramework>net9.0</TargetFramework>
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
<LangVersion>latest</LangVersion>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>

@ -1,33 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Selector.Model.Services
{
public class MigratorService : IHostedService
public class MigratorService(
IServiceScopeFactory scopeProvider,
IOptions<DatabaseOptions> options,
ILogger<MigratorService> logger)
: IHostedService
{
private readonly IServiceScopeFactory scopeProvider;
private readonly DatabaseOptions options;
private readonly ILogger<MigratorService> logger;
public MigratorService(IServiceScopeFactory _scopeProvider, IOptions<DatabaseOptions> _options, ILogger<MigratorService> _logger)
{
scopeProvider = _scopeProvider;
options = _options.Value;
logger = _logger;
}
private readonly DatabaseOptions _options = options.Value;
public Task StartAsync(CancellationToken cancellationToken)
{
if(options.Migrate)
if (_options.Migrate)
{
using var scope = scopeProvider.CreateScope();
logger.LogInformation("Applying migrations");
scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.Migrate();
}
@ -40,4 +34,4 @@ namespace Selector.Model.Services
return Task.CompletedTask;
}
}
}
}

@ -1,16 +1,15 @@
using System;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.AspNetCore.SignalR.Client;
namespace Selector.SignalR;
public abstract class BaseSignalRClient: IAsyncDisposable
public abstract class BaseSignalRClient : IAsyncDisposable
{
private readonly string _baseUrl;
protected HubConnection hubConnection;
public string Token { get; set; }
public string? Token { get; set; }
public BaseSignalRClient(string path, string token)
{
public BaseSignalRClient(string path, string? token)
{
//var baseOverride = Environment.GetEnvironmentVariable("SELECTOR_BASE_URL");
//if (!string.IsNullOrWhiteSpace(baseOverride))
@ -26,13 +25,8 @@ public abstract class BaseSignalRClient: IAsyncDisposable
_baseUrl = "https://selector.sarsoo.xyz";
hubConnection = new HubConnectionBuilder()
.WithUrl(_baseUrl + "/" + path, options =>
{
options.AccessTokenProvider = () =>
{
return Task.FromResult(Token);
};
})
.WithUrl(_baseUrl + "/" + path,
options => { options.AccessTokenProvider = () => { return Task.FromResult(Token); }; })
.WithAutomaticReconnect()
.Build();
}
@ -48,5 +42,4 @@ public abstract class BaseSignalRClient: IAsyncDisposable
{
await hubConnection.StartAsync();
}
}
}

@ -1,10 +1,6 @@
using System;
using Selector.SignalR;
namespace Selector.SignalR;
namespace Selector.SignalR;
public class Card
{
public string Content { get; set; }
}
public required string Content { get; set; }
}

@ -1,15 +1,11 @@
using System;
using Selector.SignalR;
namespace Selector.SignalR;
namespace Selector.SignalR;
public class PastParams
{
public string Track { get; set; }
public string Album { get; set; }
public string Artist { get; set; }
public string From { get; set; }
public string To { get; set; }
}
public required string Track { get; set; }
public required string Album { get; set; }
public required string Artist { get; set; }
public required string From { get; set; }
public required string To { get; set; }
}

@ -9,64 +9,64 @@ namespace Selector.SignalR;
public class NowHubCache
{
private readonly NowHubClient _connection;
private readonly ILogger<NowHubCache> logger;
private readonly ILogger<NowHubCache> _logger;
public TrackAudioFeatures LastFeature { get; private set; }
public TrackAudioFeatures? LastFeature { get; private set; }
public List<Card> LastCards { get; private set; } = new();
private readonly object updateLock = new();
private readonly object _updateLock = new();
private readonly object bindingLock = new();
private bool isBound = false;
private readonly object _bindingLock = new();
private bool _isBound = false;
public PlayCount LastPlayCount { get; private set; }
public SpotifyCurrentlyPlayingDTO LastPlayingSpotify { get; private set; }
public AppleCurrentlyPlayingDTO LastPlayingApple { get; private set; }
public PlayCount? LastPlayCount { get; private set; }
public SpotifyCurrentlyPlayingDTO? LastPlayingSpotify { get; private set; }
public AppleCurrentlyPlayingDTO? LastPlayingApple { get; private set; }
public event EventHandler NewAudioFeature;
public event EventHandler NewCard;
public event EventHandler NewPlayCount;
public event EventHandler NewNowPlayingSpotify;
public event EventHandler NewNowPlayingApple;
public event EventHandler? NewAudioFeature;
public event EventHandler? NewCard;
public event EventHandler? NewPlayCount;
public event EventHandler? NewNowPlayingSpotify;
public event EventHandler? NewNowPlayingApple;
public NowHubCache(NowHubClient connection, ILogger<NowHubCache> logger)
{
_connection = connection;
this.logger = logger;
_logger = logger;
}
public void BindClient()
{
lock (bindingLock)
lock (_bindingLock)
{
if (!isBound)
if (!_isBound)
{
_connection.OnNewAudioFeature(af =>
{
lock (updateLock)
lock (_updateLock)
{
logger.LogInformation("New audio features received: {0}", af);
_logger.LogInformation("New audio features received: {0}", af);
LastFeature = af;
NewAudioFeature?.Invoke(this, null);
NewAudioFeature?.Invoke(this, EventArgs.Empty);
}
});
_connection.OnNewCard(c =>
{
lock (updateLock)
lock (_updateLock)
{
logger.LogInformation("New card received: {0}", c);
_logger.LogInformation("New card received: {0}", c);
LastCards.Add(c);
NewCard?.Invoke(this, null);
NewCard?.Invoke(this, EventArgs.Empty);
}
});
_connection.OnNewPlayCount(pc =>
{
lock (updateLock)
lock (_updateLock)
{
logger.LogInformation("New play count received: {0}", pc);
_logger.LogInformation("New play count received: {0}", pc);
LastPlayCount = pc;
NewPlayCount?.Invoke(this, null);
NewPlayCount?.Invoke(this, EventArgs.Empty);
}
});
@ -74,12 +74,12 @@ public class NowHubCache
{
try
{
lock (updateLock)
lock (_updateLock)
{
logger.LogInformation("New Spotify now playing recieved: {0}", np);
_logger.LogInformation("New Spotify now playing recieved: {0}", np);
LastPlayingSpotify = np;
LastCards.Clear();
NewNowPlayingSpotify?.Invoke(this, null);
NewNowPlayingSpotify?.Invoke(this, EventArgs.Empty);
}
if (LastPlayingSpotify?.Track is not null)
@ -106,20 +106,20 @@ public class NowHubCache
}
catch (Exception e)
{
logger.LogError(e, "Error while handling new Spotify now playing");
_logger.LogError(e, "Error while handling new Spotify now playing");
}
});
_connection.OnNewPlayingApple(async np =>
_connection.OnNewPlayingApple(np =>
{
try
{
lock (updateLock)
lock (_updateLock)
{
logger.LogInformation("New Apple now playing recieved: {0}", np);
_logger.LogInformation("New Apple now playing recieved: {0}", np);
LastPlayingApple = np;
LastCards.Clear();
NewNowPlayingApple?.Invoke(this, null);
NewNowPlayingApple?.Invoke(this, EventArgs.Empty);
}
if (LastPlayingApple?.Track is not null)
@ -146,11 +146,13 @@ public class NowHubCache
}
catch (Exception e)
{
logger.LogError(e, "Error while handling new Apple now playing");
_logger.LogError(e, "Error while handling new Apple now playing");
}
return Task.CompletedTask;
});
isBound = true;
_isBound = true;
}
}
}

@ -8,65 +8,65 @@ namespace Selector.SignalR;
public class NowHubClient : BaseSignalRClient, INowPlayingHub, IDisposable
{
private List<IDisposable> NewPlayingSpotifyCallbacks = new();
private List<IDisposable> NewPlayingAppleCallbacks = new();
private List<IDisposable> NewAudioFeatureCallbacks = new();
private List<IDisposable> NewPlayCountCallbacks = new();
private List<IDisposable> NewCardCallbacks = new();
private bool disposedValue;
private readonly List<IDisposable> _newPlayingSpotifyCallbacks = new();
private readonly List<IDisposable> _newPlayingAppleCallbacks = new();
private readonly List<IDisposable> _newAudioFeatureCallbacks = new();
private readonly List<IDisposable> _newPlayCountCallbacks = new();
private readonly List<IDisposable> _newCardCallbacks = new();
private bool _disposedValue;
public NowHubClient(string token = null) : base("nowhub", token)
public NowHubClient(string? token = null) : base("nowhub", token)
{
}
public void OnNewPlayingSpotify(Action<SpotifyCurrentlyPlayingDTO> action)
{
NewPlayingSpotifyCallbacks.Add(hubConnection.On(nameof(OnNewPlayingSpotify), action));
_newPlayingSpotifyCallbacks.Add(hubConnection.On(nameof(OnNewPlayingSpotify), action));
}
public void OnNewPlayingApple(Action<AppleCurrentlyPlayingDTO> action)
{
NewPlayingAppleCallbacks.Add(hubConnection.On(nameof(OnNewPlayingApple), action));
_newPlayingAppleCallbacks.Add(hubConnection.On(nameof(OnNewPlayingApple), action));
}
public void OnNewAudioFeature(Action<TrackAudioFeatures> action)
{
NewAudioFeatureCallbacks.Add(hubConnection.On(nameof(OnNewAudioFeature), action));
_newAudioFeatureCallbacks.Add(hubConnection.On(nameof(OnNewAudioFeature), action));
}
public void OnNewPlayCount(Action<PlayCount> action)
{
NewPlayCountCallbacks.Add(hubConnection.On(nameof(OnNewPlayCount), action));
_newPlayCountCallbacks.Add(hubConnection.On(nameof(OnNewPlayCount), action));
}
public void OnNewCard(Action<Card> action)
{
NewCardCallbacks.Add(hubConnection.On(nameof(OnNewCard), action));
_newCardCallbacks.Add(hubConnection.On(nameof(OnNewCard), action));
}
public void OnNewPlayingSpotify(Func<SpotifyCurrentlyPlayingDTO, Task> action)
{
NewPlayingSpotifyCallbacks.Add(hubConnection.On(nameof(OnNewPlayingSpotify), action));
_newPlayingSpotifyCallbacks.Add(hubConnection.On(nameof(OnNewPlayingSpotify), action));
}
public void OnNewPlayingApple(Func<AppleCurrentlyPlayingDTO, Task> action)
{
NewPlayingAppleCallbacks.Add(hubConnection.On(nameof(OnNewPlayingApple), action));
_newPlayingAppleCallbacks.Add(hubConnection.On(nameof(OnNewPlayingApple), action));
}
public void OnNewAudioFeature(Func<TrackAudioFeatures, Task> action)
{
NewAudioFeatureCallbacks.Add(hubConnection.On(nameof(OnNewAudioFeature), action));
_newAudioFeatureCallbacks.Add(hubConnection.On(nameof(OnNewAudioFeature), action));
}
public void OnNewPlayCount(Func<PlayCount, Task> action)
{
NewPlayCountCallbacks.Add(hubConnection.On(nameof(OnNewPlayCount), action));
_newPlayCountCallbacks.Add(hubConnection.On(nameof(OnNewPlayCount), action));
}
public void OnNewCard(Func<Card, Task> action)
{
NewCardCallbacks.Add(hubConnection.On(nameof(OnNewCard), action));
_newCardCallbacks.Add(hubConnection.On(nameof(OnNewCard), action));
}
public Task OnConnected()
@ -102,14 +102,14 @@ public class NowHubClient : BaseSignalRClient, INowPlayingHub, IDisposable
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (!_disposedValue)
{
if (disposing)
{
foreach (var callback in NewPlayingSpotifyCallbacks
.Concat(NewAudioFeatureCallbacks)
.Concat(NewPlayCountCallbacks)
.Concat(NewCardCallbacks))
foreach (var callback in _newPlayingSpotifyCallbacks
.Concat(_newAudioFeatureCallbacks)
.Concat(_newPlayCountCallbacks)
.Concat(_newCardCallbacks))
{
callback.Dispose();
}
@ -117,7 +117,7 @@ public class NowHubClient : BaseSignalRClient, INowPlayingHub, IDisposable
base.DisposeAsync();
}
disposedValue = true;
_disposedValue = true;
}
}

@ -2,18 +2,18 @@
namespace Selector.SignalR;
public class PastHubClient: BaseSignalRClient, IPastHub, IDisposable
public class PastHubClient : BaseSignalRClient, IPastHub, IDisposable
{
private List<IDisposable> SearchResultCallbacks = new();
private bool disposedValue;
private List<IDisposable> _searchResultCallbacks = new();
private bool _disposedValue;
public PastHubClient(string token = null): base("nowhub", token)
{
}
public PastHubClient(string? token = null) : base("nowhub", token)
{
}
public void OnRankResult(Action<IRankResult> action)
{
SearchResultCallbacks.Add(hubConnection.On(nameof(OnRankResult), action));
_searchResultCallbacks.Add(hubConnection.On(nameof(OnRankResult), action));
}
public Task OnConnected()
@ -28,11 +28,11 @@ public class PastHubClient: BaseSignalRClient, IPastHub, IDisposable
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (!_disposedValue)
{
if (disposing)
{
foreach(var callback in SearchResultCallbacks)
foreach (var callback in _searchResultCallbacks)
{
callback.Dispose();
}
@ -40,7 +40,7 @@ public class PastHubClient: BaseSignalRClient, IPastHub, IDisposable
base.DisposeAsync();
}
disposedValue = true;
_disposedValue = true;
}
}
@ -50,5 +50,4 @@ public class PastHubClient: BaseSignalRClient, IPastHub, IDisposable
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

@ -2,20 +2,16 @@
<PropertyGroup>
<TargetFrameworks>net9.0</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('OSX'))">$(TargetFrameworks);net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Selector.AppleMusic\Selector.AppleMusic.csproj"/>
<ProjectReference Include="..\Selector.Spotify\Selector.Spotify.csproj"/>
<ProjectReference Include="..\Selector\Selector.csproj" />
<!-- <ProjectReference Include="..\Selector.Model\Selector.Model.csproj" /> -->
</ItemGroup>
<!-- <ItemGroup>
<None Remove="Microsoft.AspNetCore.SignalR.Client" />
</ItemGroup> -->
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.3"/>
</ItemGroup>

@ -18,9 +18,9 @@ namespace Selector.Spotify.Consumer
public AnalysedTrackTimeline Timeline { get; set; } = new();
public AudioFeatureInjector(
ISpotifyPlayerWatcher watcher,
ISpotifyPlayerWatcher? watcher,
ITracksClient trackClient,
ILogger<AudioFeatureInjector> logger = null,
ILogger<AudioFeatureInjector> logger,
CancellationToken token = default
) : base(watcher, logger)
{
@ -58,12 +58,12 @@ namespace Selector.Spotify.Consumer
catch (APITooManyRequestsException ex)
{
Logger.LogDebug("Too many requests error: [{message}]", ex.Message);
throw ex;
throw;
}
catch (APIException ex)
{
Logger.LogDebug("API error: [{message}]", ex.Message);
throw ex;
throw;
}
}
else if (e.Current.Item is FullEpisode episode)

@ -4,6 +4,7 @@ using SpotifyAPI.Web;
namespace Selector.Spotify.Consumer
{
[Obsolete]
public class DummyAudioFeatureInjector : AudioFeatureInjector
{
private TrackAudioFeatures[] _features = new[]
@ -33,7 +34,7 @@ namespace Selector.Spotify.Consumer
public DummyAudioFeatureInjector(
ISpotifyPlayerWatcher watcher,
ILogger<DummyAudioFeatureInjector> logger = null,
ILogger<DummyAudioFeatureInjector> logger,
CancellationToken token = default
) : base(watcher, null, logger, token)
{

@ -8,7 +8,7 @@ namespace Selector.Spotify.Consumer.Factory
public interface IAudioFeatureInjectorFactory
{
public Task<ISpotifyPlayerConsumer> Get(ISpotifyConfigFactory spotifyFactory,
ISpotifyPlayerWatcher watcher = null);
ISpotifyPlayerWatcher? watcher = null);
}
[Obsolete]
@ -22,7 +22,7 @@ namespace Selector.Spotify.Consumer.Factory
}
public async Task<ISpotifyPlayerConsumer> Get(ISpotifyConfigFactory spotifyFactory,
ISpotifyPlayerWatcher watcher = null)
ISpotifyPlayerWatcher? watcher = null)
{
if (!Magic.Dummy)
{

@ -5,32 +5,32 @@ namespace Selector.Spotify.Consumer.Factory
{
public interface IPlayCounterFactory
{
public Task<ISpotifyPlayerConsumer> Get(LastfmClient fmClient = null, LastFmCredentials creds = null,
ISpotifyPlayerWatcher watcher = null);
public Task<ISpotifyPlayerConsumer> Get(LastfmClient? fmClient = null, LastFmCredentials? creds = null,
ISpotifyPlayerWatcher? watcher = null);
}
public class PlayCounterFactory : IPlayCounterFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly LastfmClient Client;
private readonly LastFmCredentials Creds;
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;
_loggerFactory = loggerFactory;
_client = client;
_creds = creds;
}
public Task<ISpotifyPlayerConsumer> Get(LastfmClient fmClient = null, LastFmCredentials creds = null,
ISpotifyPlayerWatcher watcher = null)
public Task<ISpotifyPlayerConsumer> Get(LastfmClient? fmClient = null, LastFmCredentials? creds = null,
ISpotifyPlayerWatcher? watcher = null)
{
var client = fmClient ?? Client;
var client = fmClient ?? _client;
if (client is null)
{
throw new ArgumentNullException("No Last.fm client provided");
throw new ArgumentNullException(nameof(client));
}
return Task.FromResult<ISpotifyPlayerConsumer>(new PlayCounter(
@ -39,8 +39,8 @@ namespace Selector.Spotify.Consumer.Factory
client.Album,
client.Artist,
client.User,
credentials: creds ?? Creds,
LoggerFactory.CreateLogger<PlayCounter>()
credentials: creds ?? _creds,
_loggerFactory.CreateLogger<PlayCounter>()
));
}
}

@ -4,27 +4,27 @@ namespace Selector.Spotify.Consumer.Factory
{
public interface IWebHookFactory
{
public Task<WebHook> Get(WebHookConfig config, ISpotifyPlayerWatcher watcher = null);
public Task<WebHook> Get(WebHookConfig config, ISpotifyPlayerWatcher? watcher = null);
}
public class WebHookFactory : IWebHookFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly HttpClient Http;
private readonly ILoggerFactory _loggerFactory;
private readonly HttpClient _http;
public WebHookFactory(ILoggerFactory loggerFactory, HttpClient httpClient)
{
LoggerFactory = loggerFactory;
Http = httpClient;
_loggerFactory = loggerFactory;
_http = httpClient;
}
public Task<WebHook> Get(WebHookConfig config, ISpotifyPlayerWatcher watcher = null)
public Task<WebHook> Get(WebHookConfig config, ISpotifyPlayerWatcher? watcher = null)
{
return Task.FromResult(new WebHook(
watcher,
Http,
_http,
config,
LoggerFactory.CreateLogger<WebHook>()
_loggerFactory.CreateLogger<WebHook>()
));
}
}

@ -13,8 +13,8 @@ namespace Selector.Spotify.Consumer
IAlbumApi albumClient,
IArtistApi artistClient,
IUserApi userClient,
LastFmCredentials credentials = null,
ILogger<PlayCounter> logger = null,
LastFmCredentials? credentials = null,
ILogger<PlayCounter>? logger = null,
CancellationToken token = default)
: BaseParallelPlayerConsumer<ISpotifyPlayerWatcher, SpotifyListeningChangeEventArgs>(watcher, logger),
ISpotifyPlayerConsumer
@ -24,10 +24,10 @@ namespace Selector.Spotify.Consumer
protected readonly IAlbumApi AlbumClient = albumClient;
protected readonly IArtistApi ArtistClient = artistClient;
protected readonly IUserApi UserClient = userClient;
public readonly LastFmCredentials Credentials = credentials;
protected readonly ILogger<PlayCounter> Logger = logger ?? NullLogger<PlayCounter>.Instance;
public readonly LastFmCredentials? Credentials = credentials;
protected new readonly ILogger<PlayCounter> Logger = logger ?? NullLogger<PlayCounter>.Instance;
protected event EventHandler<PlayCount> NewPlayCount;
protected event EventHandler<PlayCount>? NewPlayCount;
public CancellationToken CancelToken { get; set; } = token;

@ -5,14 +5,14 @@ namespace Selector.Spotify.Consumer
{
public class WebHookConfig
{
public string Name { get; set; }
public IEnumerable<Predicate<SpotifyListeningChangeEventArgs>> Predicates { get; set; }
public string Url { get; set; }
public HttpContent Content { get; set; }
public required string Name { get; set; }
public IEnumerable<Predicate<SpotifyListeningChangeEventArgs>> Predicates { get; set; } = [];
public required string Url { get; set; }
public HttpContent? Content { get; set; }
public bool ShouldRequest(SpotifyListeningChangeEventArgs e)
{
if (Predicates is not null)
if (Predicates.Any())
{
return Predicates.Select(p => p(e)).Aggregate((a, b) => a && b);
}
@ -24,10 +24,10 @@ namespace Selector.Spotify.Consumer
}
public class WebHook(
ISpotifyPlayerWatcher watcher,
ISpotifyPlayerWatcher? watcher,
HttpClient httpClient,
WebHookConfig config,
ILogger<WebHook> logger = null,
ILogger<WebHook> logger,
CancellationToken token = default)
: BaseParallelPlayerConsumer<ISpotifyPlayerWatcher, SpotifyListeningChangeEventArgs>(watcher, logger),
ISpotifyPlayerConsumer
@ -36,9 +36,9 @@ namespace Selector.Spotify.Consumer
protected readonly WebHookConfig Config = config;
public event EventHandler PredicatePass;
public event EventHandler SuccessfulRequest;
public event EventHandler FailedRequest;
public event EventHandler? PredicatePass;
public event EventHandler? SuccessfulRequest;
public event EventHandler? FailedRequest;
public CancellationToken CancelToken { get; set; } = token;
@ -59,19 +59,19 @@ namespace Selector.Spotify.Consumer
try
{
Logger.LogDebug("Predicate passed, making request");
OnPredicatePass(new EventArgs());
OnPredicatePass(EventArgs.Empty);
var response = await HttpClient.PostAsync(Config.Url, Config.Content, CancelToken);
if (response.IsSuccessStatusCode)
{
Logger.LogDebug("Request success");
OnSuccessfulRequest(new EventArgs());
OnSuccessfulRequest(EventArgs.Empty);
}
else
{
Logger.LogDebug("Request failed [{error}] [{content}]", response.StatusCode, response.Content);
OnFailedRequest(new EventArgs());
OnFailedRequest(EventArgs.Empty);
}
}
catch (HttpRequestException ex)

@ -6,9 +6,17 @@ namespace Selector.Spotify.Equality
{
public class PlayableItemEqualityComparer : IEqualityComparer<PlaylistTrack<IPlayableItem>>
{
public bool Equals(PlaylistTrack<IPlayableItem> x, PlaylistTrack<IPlayableItem> y)
public bool Equals(PlaylistTrack<IPlayableItem>? x, PlaylistTrack<IPlayableItem>? y)
{
return x.GetUri().Equals(y.GetUri());
switch (x, y)
{
case (null, null):
case (null, not null):
case (not null, null):
return false;
case var (left, right):
return left.GetUri().Equals(right.GetUri());
}
}
public int GetHashCode([DisallowNull] PlaylistTrack<IPlayableItem> obj)

@ -12,89 +12,89 @@ namespace Selector.Spotify.Equality
public class FullTrackStringComparer : NoHashCode<FullTrack>
{
public override bool Equals(FullTrack track1, FullTrack track2) => IsEqual(track1, track2);
public override bool Equals(FullTrack? track1, FullTrack? track2) => IsEqual(track1, track2);
public static bool IsEqual(FullTrack track1, FullTrack track2) => track1.Name == track2.Name
&& Enumerable.SequenceEqual(
track1.Artists.Select(a => a.Name),
track2.Artists.Select(a => a.Name))
&& SimpleAlbumStringComparer.IsEqual(
track1.Album, track2.Album);
public static bool IsEqual(FullTrack? track1, FullTrack? track2) => track1.Name == track2.Name
&& Enumerable.SequenceEqual(
track1.Artists.Select(a => a.Name),
track2.Artists.Select(a => a.Name))
&& SimpleAlbumStringComparer.IsEqual(
track1.Album, track2.Album);
}
public class FullEpisodeStringComparer : NoHashCode<FullEpisode>
{
public override bool Equals(FullEpisode ep1, FullEpisode ep2) => IsEqual(ep1, ep2);
public override bool Equals(FullEpisode? ep1, FullEpisode? ep2) => IsEqual(ep1, ep2);
public static bool IsEqual(FullEpisode ep1, FullEpisode ep2) => ep1.Name == ep2.Name
&& SimpleShowStringComparer.IsEqual(ep1.Show,
ep2.Show);
public static bool IsEqual(FullEpisode? ep1, FullEpisode? ep2) => ep1.Name == ep2.Name
&& SimpleShowStringComparer.IsEqual(ep1.Show,
ep2.Show);
}
public class FullAlbumStringComparer : NoHashCode<FullAlbum>
{
public override bool Equals(FullAlbum album1, FullAlbum album2) => IsEqual(album1, album2);
public override bool Equals(FullAlbum? album1, FullAlbum? album2) => IsEqual(album1, album2);
public static bool IsEqual(FullAlbum album1, FullAlbum album2) => album1.Name == album2.Name
&& Enumerable.SequenceEqual(
album1.Artists.Select(a => a.Name),
album2.Artists.Select(a => a.Name));
public static bool IsEqual(FullAlbum? album1, FullAlbum? album2) => album1.Name == album2.Name
&& Enumerable.SequenceEqual(
album1.Artists.Select(a => a.Name),
album2.Artists.Select(a => a.Name));
}
public class FullShowStringComparer : NoHashCode<FullShow>
{
public override bool Equals(FullShow show1, FullShow show2) => IsEqual(show1, show2);
public override bool Equals(FullShow? show1, FullShow? show2) => IsEqual(show1, show2);
public static bool IsEqual(FullShow show1, FullShow show2) => show1.Name == show2.Name
&& show1.Publisher == show2.Publisher;
public static bool IsEqual(FullShow? show1, FullShow? show2) => show1.Name == show2.Name
&& show1.Publisher == show2.Publisher;
}
public class FullArtistStringComparer : NoHashCode<FullArtist>
{
public override bool Equals(FullArtist artist1, FullArtist artist2) => IsEqual(artist1, artist2);
public override bool Equals(FullArtist? artist1, FullArtist? artist2) => IsEqual(artist1, artist2);
public static bool IsEqual(FullArtist artist1, FullArtist artist2) => artist1.Name == artist2.Name;
public static bool IsEqual(FullArtist? artist1, FullArtist? artist2) => artist1.Name == artist2.Name;
}
public class SimpleTrackStringComparer : NoHashCode<SimpleTrack>
{
public override bool Equals(SimpleTrack track1, SimpleTrack track2) => IsEqual(track1, track2);
public override bool Equals(SimpleTrack? track1, SimpleTrack? track2) => IsEqual(track1, track2);
public static bool IsEqual(SimpleTrack track1, SimpleTrack track2) => track1.Name == track2.Name
&& Enumerable.SequenceEqual(
track1.Artists.Select(a => a.Name),
track2.Artists.Select(a => a.Name));
public static bool IsEqual(SimpleTrack? track1, SimpleTrack? track2) => track1.Name == track2.Name
&& Enumerable.SequenceEqual(
track1.Artists.Select(a => a.Name),
track2.Artists.Select(a => a.Name));
}
public class SimpleEpisodeStringComparer : NoHashCode<SimpleEpisode>
{
public override bool Equals(SimpleEpisode ep1, SimpleEpisode ep2) => IsEqual(ep1, ep2);
public override bool Equals(SimpleEpisode? ep1, SimpleEpisode? ep2) => IsEqual(ep1, ep2);
public static bool IsEqual(SimpleEpisode ep1, SimpleEpisode ep2) => ep1.Name == ep2.Name;
public static bool IsEqual(SimpleEpisode? ep1, SimpleEpisode? ep2) => ep1.Name == ep2.Name;
}
public class SimpleAlbumStringComparer : NoHashCode<SimpleAlbum>
{
public override bool Equals(SimpleAlbum album1, SimpleAlbum album2) => IsEqual(album1, album2);
public override bool Equals(SimpleAlbum? album1, SimpleAlbum? album2) => IsEqual(album1, album2);
public static bool IsEqual(SimpleAlbum album1, SimpleAlbum album2) => album1.Name == album2.Name
&& Enumerable.SequenceEqual(
album1.Artists.Select(a => a.Name),
album2.Artists.Select(a => a.Name));
public static bool IsEqual(SimpleAlbum? album1, SimpleAlbum? album2) => album1?.Name == album2?.Name
&& Enumerable.SequenceEqual(
album1?.Artists.Select(a => a.Name),
album2?.Artists.Select(a => a.Name));
}
public class SimpleShowStringComparer : NoHashCode<SimpleShow>
{
public override bool Equals(SimpleShow show1, SimpleShow show2) => IsEqual(show1, show2);
public override bool Equals(SimpleShow? show1, SimpleShow? show2) => IsEqual(show1, show2);
public static bool IsEqual(SimpleShow show1, SimpleShow show2) => show1.Name == show2.Name
&& show1.Publisher == show2.Publisher;
public static bool IsEqual(SimpleShow? show1, SimpleShow? show2) => show1?.Name == show2?.Name
&& show1?.Publisher == show2?.Publisher;
}
public class SimpleArtistStringComparer : NoHashCode<SimpleArtist>
{
public override bool Equals(SimpleArtist artist1, SimpleArtist artist2) => IsEqual(artist1, artist2);
public override bool Equals(SimpleArtist? artist1, SimpleArtist? artist2) => IsEqual(artist1, artist2);
public static bool IsEqual(SimpleArtist artist1, SimpleArtist artist2) => artist1.Name == artist2.Name;
public static bool IsEqual(SimpleArtist? artist1, SimpleArtist? artist2) => artist1?.Name == artist2?.Name;
}
}

@ -5,18 +5,18 @@ namespace Selector.Spotify
{
public class SpotifyListeningChangeEventArgs : ListeningChangeEventArgs
{
public CurrentlyPlayingContext Previous { get; set; }
public CurrentlyPlayingContext Current { get; set; }
public CurrentlyPlayingContext? Previous { get; set; }
public required CurrentlyPlayingContext Current { get; set; }
/// <summary>
/// Spotify Username
/// </summary>
public string SpotifyUsername { get; set; }
public required string SpotifyUsername { get; set; }
PlayerTimeline Timeline { get; set; }
public required PlayerTimeline Timeline { get; set; }
public static SpotifyListeningChangeEventArgs From(CurrentlyPlayingContext previous,
CurrentlyPlayingContext current, PlayerTimeline timeline, string id = null, string username = null)
public static SpotifyListeningChangeEventArgs From(CurrentlyPlayingContext? previous,
CurrentlyPlayingContext current, PlayerTimeline timeline, string id, string username)
{
return new SpotifyListeningChangeEventArgs()
{
@ -31,29 +31,30 @@ namespace Selector.Spotify
public class PlaylistChangeEventArgs : EventArgs
{
public FullPlaylist Previous { get; set; }
public FullPlaylist Current { get; set; }
public FullPlaylist? Previous { get; set; }
public required FullPlaylist Current { get; set; }
/// <summary>
/// Spotify Username
/// </summary>
public string SpotifyUsername { get; set; }
public required string SpotifyUsername { get; set; }
/// <summary>
/// String Id for watcher, used to hold user Db Id
/// </summary>
/// <value></value>
public string Id { get; set; }
public required string Id { get; set; }
Timeline<FullPlaylist> Timeline { get; set; }
ICollection<PlaylistTrack<IPlayableItem>> CurrentTracks { get; set; }
ICollection<PlaylistTrack<IPlayableItem>> AddedTracks { get; set; }
ICollection<PlaylistTrack<IPlayableItem>> RemovedTracks { get; set; }
public required Timeline<FullPlaylist> Timeline { get; set; }
private ICollection<PlaylistTrack<IPlayableItem>>? CurrentTracks { get; set; }
private ICollection<PlaylistTrack<IPlayableItem>>? AddedTracks { get; set; }
private ICollection<PlaylistTrack<IPlayableItem>>? RemovedTracks { get; set; }
public static PlaylistChangeEventArgs From(FullPlaylist previous, FullPlaylist current,
Timeline<FullPlaylist> timeline, ICollection<PlaylistTrack<IPlayableItem>> tracks = null,
ICollection<PlaylistTrack<IPlayableItem>> addedTracks = null,
ICollection<PlaylistTrack<IPlayableItem>> removedTracks = null, string id = null, string username = null)
Timeline<FullPlaylist> timeline, string id, string username,
ICollection<PlaylistTrack<IPlayableItem>>? tracks = null,
ICollection<PlaylistTrack<IPlayableItem>>? addedTracks = null,
ICollection<PlaylistTrack<IPlayableItem>>? removedTracks = null)
{
return new PlaylistChangeEventArgs()
{

@ -8,8 +8,8 @@ public static class ServiceExtensions
{
public static IServiceCollection AddConsumerFactories(this IServiceCollection services)
{
services.AddTransient<IAudioFeatureInjectorFactory, AudioFeatureInjectorFactory>();
services.AddTransient<AudioFeatureInjectorFactory>();
// services.AddTransient<IAudioFeatureInjectorFactory, AudioFeatureInjectorFactory>();
// services.AddTransient<AudioFeatureInjectorFactory>();
services.AddTransient<IPlayCounterFactory, PlayCounterFactory>();
services.AddTransient<PlayCounterFactory>();

@ -5,7 +5,7 @@ namespace Selector.Spotify
{
[JsonSerializable(typeof(SpotifyCurrentlyPlayingDTO))]
[JsonSerializable(typeof(TrackAudioFeatures))]
[JsonSerializable(typeof(SpotifyListeningChangeEventArgs))]
// [JsonSerializable(typeof(SpotifyListeningChangeEventArgs))]
public partial class SpotifyJsonContext : JsonSerializerContext
{
}

@ -4,6 +4,7 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>

@ -4,15 +4,15 @@ namespace Selector.Spotify.Timeline
{
public interface ITrackTimeline<T> : ITimeline<T>
{
public T Get(FullTrack track);
public T? Get(FullTrack track);
public IEnumerable<T> GetAll(FullTrack track);
public IEnumerable<TimelineItem<T>> GetAllTimelineItems(FullTrack track);
public T Get(SimpleAlbum album);
public T? Get(SimpleAlbum album);
public IEnumerable<T> GetAll(SimpleAlbum album);
public IEnumerable<TimelineItem<T>> GetAllTimelineItems(SimpleAlbum album);
public T Get(SimpleArtist artist);
public T? Get(SimpleArtist artist);
public IEnumerable<T> GetAll(SimpleArtist artist);
public IEnumerable<TimelineItem<T>> GetAllTimelineItems(SimpleArtist artist);
}

@ -5,11 +5,11 @@ namespace Selector.Spotify.Timeline
public class PlayerTimeline
: Timeline<CurrentlyPlayingContext>, ITrackTimeline<CurrentlyPlayingContext>
{
public IEqual EqualityChecker { get; set; }
public required IEqual EqualityChecker { get; set; }
public override void Add(CurrentlyPlayingContext item) => Add(item, DateHelper.FromUnixMilli(item.Timestamp));
public CurrentlyPlayingContext Get(FullTrack track)
public CurrentlyPlayingContext? Get(FullTrack track)
=> GetAll(track)
.LastOrDefault();
@ -22,7 +22,7 @@ namespace Selector.Spotify.Timeline
.Where(i => i.Item.Item is FullTrack iterTrack
&& EqualityChecker.IsEqual(iterTrack, track));
public CurrentlyPlayingContext Get(FullEpisode ep)
public CurrentlyPlayingContext? Get(FullEpisode ep)
=> GetAll(ep)
.LastOrDefault();
@ -35,7 +35,7 @@ namespace Selector.Spotify.Timeline
.Where(i => i.Item.Item is FullEpisode iterEp
&& EqualityChecker.IsEqual(iterEp, ep));
public CurrentlyPlayingContext Get(SimpleAlbum album)
public CurrentlyPlayingContext? Get(SimpleAlbum album)
=> GetAll(album)
.LastOrDefault();
@ -48,7 +48,7 @@ namespace Selector.Spotify.Timeline
.Where(i => i.Item.Item is FullTrack iterTrack
&& EqualityChecker.IsEqual(iterTrack.Album, album));
public CurrentlyPlayingContext Get(SimpleArtist artist)
public CurrentlyPlayingContext? Get(SimpleArtist artist)
=> GetAll(artist)
.LastOrDefault();
@ -61,7 +61,7 @@ namespace Selector.Spotify.Timeline
.Where(i => i.Item.Item is FullTrack iterTrack
&& EqualityChecker.IsEqual(iterTrack.Artists[0], artist));
public CurrentlyPlayingContext Get(Device device)
public CurrentlyPlayingContext? Get(Device device)
=> GetAll(device)
.LastOrDefault();
@ -73,7 +73,7 @@ namespace Selector.Spotify.Timeline
=> Recent
.Where(i => EqualityChecker.IsEqual(i.Item.Device, device));
public CurrentlyPlayingContext Get(Context context)
public CurrentlyPlayingContext? Get(Context context)
=> GetAll(context)
.LastOrDefault();

@ -2,9 +2,9 @@ using Microsoft.Extensions.Logging;
namespace Selector.Spotify.Watcher;
public abstract class BaseSpotifyWatcher(ILogger<BaseWatcher> logger = null) : BaseWatcher(logger)
public abstract class BaseSpotifyWatcher(ILogger<BaseWatcher>? logger) : BaseWatcher(logger)
{
public string SpotifyUsername { get; set; }
public required string SpotifyUsername { get; set; }
protected override Dictionary<string, object> LogScopeContext =>
new[]

@ -9,41 +9,42 @@ namespace Selector.Spotify.Watcher
public class SpotifyPlayerWatcher : BaseSpotifyWatcher, ISpotifyPlayerWatcher
{
new protected readonly ILogger<SpotifyPlayerWatcher> Logger;
private readonly IPlayerClient spotifyClient;
private readonly IEqual eq;
private readonly IPlayerClient _spotifyClient;
private readonly IEqual _eq;
public event EventHandler<SpotifyListeningChangeEventArgs> NetworkPoll;
public event EventHandler<SpotifyListeningChangeEventArgs> ItemChange;
public event EventHandler<SpotifyListeningChangeEventArgs> AlbumChange;
public event EventHandler<SpotifyListeningChangeEventArgs> ArtistChange;
public event EventHandler<SpotifyListeningChangeEventArgs> ContextChange;
public event EventHandler<SpotifyListeningChangeEventArgs> ContentChange;
public event EventHandler<SpotifyListeningChangeEventArgs>? NetworkPoll;
public event EventHandler<SpotifyListeningChangeEventArgs>? ItemChange;
public event EventHandler<SpotifyListeningChangeEventArgs>? AlbumChange;
public event EventHandler<SpotifyListeningChangeEventArgs>? ArtistChange;
public event EventHandler<SpotifyListeningChangeEventArgs>? ContextChange;
public event EventHandler<SpotifyListeningChangeEventArgs>? ContentChange;
public event EventHandler<SpotifyListeningChangeEventArgs> VolumeChange;
public event EventHandler<SpotifyListeningChangeEventArgs> DeviceChange;
public event EventHandler<SpotifyListeningChangeEventArgs> PlayingChange;
public event EventHandler<SpotifyListeningChangeEventArgs>? VolumeChange;
public event EventHandler<SpotifyListeningChangeEventArgs>? DeviceChange;
public event EventHandler<SpotifyListeningChangeEventArgs>? PlayingChange;
public CurrentlyPlayingContext Live { get; protected set; }
protected CurrentlyPlayingContext Previous { get; set; }
public PlayerTimeline Past { get; set; } = new();
public CurrentlyPlayingContext? Live { get; protected set; }
protected CurrentlyPlayingContext? Previous { get; set; }
public PlayerTimeline Past { get; set; }
public SpotifyPlayerWatcher(IPlayerClient spotifyClient,
IEqual equalityChecker,
ILogger<SpotifyPlayerWatcher> logger = null,
ILogger<SpotifyPlayerWatcher>? logger = null,
int pollPeriod = 3000
) : base(logger)
{
this.spotifyClient = spotifyClient;
eq = equalityChecker;
_spotifyClient = spotifyClient;
_eq = equalityChecker;
Logger = logger ?? NullLogger<SpotifyPlayerWatcher>.Instance;
PollPeriod = pollPeriod;
Past = new() { EqualityChecker = equalityChecker };
}
public override Task Reset()
{
Previous = null;
Live = null;
Past = new();
Past = new() { EqualityChecker = _eq };
return Task.CompletedTask;
}
@ -55,7 +56,7 @@ namespace Selector.Spotify.Watcher
try
{
Logger.LogTrace("Making Spotify call");
var polledCurrent = await spotifyClient.GetCurrentPlayback();
var polledCurrent = await _spotifyClient.GetCurrentPlayback();
using var polledLogScope = Logger.BeginScope(new Dictionary<string, object>()
{ { "context", polledCurrent?.DisplayString() } });
@ -122,7 +123,7 @@ namespace Selector.Spotify.Watcher
switch (Previous, Live)
{
case ({ Item: FullTrack previousTrack }, { Item: FullTrack currentTrack }):
if (!eq.IsEqual(previousTrack, currentTrack))
if (!_eq.IsEqual(previousTrack, currentTrack))
{
Logger.LogInformation("Track changed: {prevTrack} -> {currentTrack}",
previousTrack.DisplayString(),
@ -130,14 +131,14 @@ namespace Selector.Spotify.Watcher
OnItemChange(GetEvent());
}
if (!eq.IsEqual(previousTrack.Album, currentTrack.Album))
if (!_eq.IsEqual(previousTrack.Album, currentTrack.Album))
{
Logger.LogDebug("Album changed: {previous} -> {current}", previousTrack.Album.DisplayString(),
currentTrack.Album.DisplayString());
OnAlbumChange(GetEvent());
}
if (!eq.IsEqual(previousTrack.Artists[0], currentTrack.Artists[0]))
if (!_eq.IsEqual(previousTrack.Artists[0], currentTrack.Artists[0]))
{
Logger.LogDebug("Artist changed: {previous} -> {current}",
previousTrack.Artists.DisplayString(), currentTrack.Artists.DisplayString());
@ -158,7 +159,7 @@ namespace Selector.Spotify.Watcher
OnItemChange(GetEvent());
break;
case ({ Item: FullEpisode previousEp }, { Item: FullEpisode currentEp }):
if (!eq.IsEqual(previousEp, currentEp))
if (!_eq.IsEqual(previousEp, currentEp))
{
Logger.LogDebug("Podcast changed: {previous_ep} -> {current_ep}", previousEp.DisplayString(),
currentEp.DisplayString());
@ -177,7 +178,7 @@ namespace Selector.Spotify.Watcher
Logger.LogDebug("Context started: {context}", Live?.Context.DisplayString());
OnContextChange(GetEvent());
}
else if (!eq.IsEqual(Previous?.Context, Live?.Context))
else if (!_eq.IsEqual(Previous?.Context, Live?.Context))
{
Logger.LogDebug("Context changed: {previous_context} -> {live_context}",
Previous?.Context?.DisplayString() ?? "none", Live?.Context?.DisplayString() ?? "none");
@ -212,7 +213,7 @@ namespace Selector.Spotify.Watcher
protected void CheckDevice()
{
// DEVICE
if (!eq.IsEqual(Previous?.Device, Live?.Device))
if (!_eq.IsEqual(Previous?.Device, Live?.Device))
{
Logger.LogDebug("Device changed: {previous_device} -> {live_device}",
Previous?.Device?.DisplayString() ?? "none", Live?.Device?.DisplayString() ?? "none");

@ -12,6 +12,7 @@ using Xunit;
namespace Selector.Tests
{
[Obsolete]
public class AudioInjectorTests
{
[Fact]
@ -20,7 +21,8 @@ namespace Selector.Tests
var watcherMock = new Mock<ISpotifyPlayerWatcher>();
var spotifyMock = new Mock<ITracksClient>();
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object);
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object,
NullLogger<AudioFeatureInjector>.Instance);
featureInjector.Subscribe();
@ -33,7 +35,8 @@ namespace Selector.Tests
var watcherMock = new Mock<ISpotifyPlayerWatcher>();
var spotifyMock = new Mock<ITracksClient>();
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object);
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object,
NullLogger<AudioFeatureInjector>.Instance);
featureInjector.Unsubscribe();
@ -47,7 +50,8 @@ namespace Selector.Tests
var watcherFuncArgMock = new Mock<ISpotifyPlayerWatcher>();
var spotifyMock = new Mock<ITracksClient>();
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object);
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object,
NullLogger<AudioFeatureInjector>.Instance);
featureInjector.Subscribe(watcherFuncArgMock.Object);
@ -63,7 +67,8 @@ namespace Selector.Tests
var watcherFuncArgMock = new Mock<ISpotifyPlayerWatcher>();
var spotifyMock = new Mock<ITracksClient>();
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object);
var featureInjector = new AudioFeatureInjector(watcherMock.Object, spotifyMock.Object,
NullLogger<AudioFeatureInjector>.Instance);
featureInjector.Unsubscribe(watcherFuncArgMock.Object);

@ -1,3 +1,4 @@
using System;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
@ -7,6 +8,7 @@ using Xunit;
namespace Selector.Tests
{
[Obsolete]
public class AudioInjectorFactoryTests
{
[Fact]

@ -37,14 +37,17 @@ namespace Selector.Tests
{
Url = link,
Content = content,
Name = "test"
};
var http = new HttpClient(httpHandlerMock.Object);
var webHook = new WebHook(watcherMock.Object, http, config);
var webHook = new WebHook(watcherMock.Object, http, config, logger: NullLogger<WebHook>.Instance);
webHook.Subscribe();
watcherMock.Raise(w => w.ItemChange += null, this, new SpotifyListeningChangeEventArgs());
watcherMock.Raise(w => w.ItemChange += null, this,
new SpotifyListeningChangeEventArgs()
{ Id = "test", SpotifyUsername = "test", Current = null, Timeline = null });
await Task.Delay(100);
@ -74,6 +77,7 @@ namespace Selector.Tests
{
Url = link,
Content = content,
Name = "test"
};
var http = new HttpClient(httpHandlerMock.Object);
@ -89,7 +93,12 @@ namespace Selector.Tests
webHook.FailedRequest += (o, e) => { failedEvent = !successful; };
await (Task)typeof(WebHook).GetMethod("ProcessEvent", BindingFlags.NonPublic | BindingFlags.Instance)
.Invoke(webHook, new object[] { SpotifyListeningChangeEventArgs.From(new(), new(), new()) });
.Invoke(webHook,
new object[]
{
SpotifyListeningChangeEventArgs.From(new(), new(), new() { EqualityChecker = null }, null,
"test")
});
predicateEvent.Should().Be(predicate);
successfulEvent.Should().Be(successful);

@ -154,9 +154,12 @@ namespace Selector.Tests
{
return new()
{
Track = new Track()
Track = new Track
{
Id = id
Id = id,
Type = "track",
Href = null,
Attributes = null,
}
};
}

@ -35,7 +35,7 @@ namespace Selector.Tests
[MemberData(nameof(CountData))]
public void Count(CurrentlyPlayingContext[] currentlyPlaying)
{
var timeline = new PlayerTimeline();
var timeline = new PlayerTimeline() { EqualityChecker = null };
foreach (var i in currentlyPlaying)
{
@ -106,7 +106,8 @@ namespace Selector.Tests
{
var timeline = new PlayerTimeline
{
MaxSize = maxSize
MaxSize = maxSize,
EqualityChecker = null
};
foreach (var i in currentlyPlaying)
@ -120,7 +121,7 @@ namespace Selector.Tests
[Fact]
public void Clear()
{
var timeline = new PlayerTimeline();
var timeline = new PlayerTimeline() { EqualityChecker = null };
var tracks = new CurrentlyPlayingContext[]
{
Helper.CurrentPlayback(Helper.FullTrack("uri1")),
@ -143,7 +144,8 @@ namespace Selector.Tests
{
var timeline = new PlayerTimeline()
{
SortOnBackDate = false
SortOnBackDate = false,
EqualityChecker = null
};
var earlier = Helper.CurrentPlayback(Helper.FullTrack("uri1"));
@ -167,7 +169,8 @@ namespace Selector.Tests
{
var timeline = new PlayerTimeline()
{
SortOnBackDate = false
SortOnBackDate = false,
EqualityChecker = null
};
var earlier = Helper.CurrentPlayback(Helper.FullTrack("uri1"));

@ -38,7 +38,7 @@ namespace Selector.Tests
spotMock.Setup(s => s.GetCurrentPlayback(It.IsAny<CancellationToken>()).Result)
.Returns(playingQueue.Dequeue);
var watcher = new SpotifyPlayerWatcher(spotMock.Object, eq);
var watcher = new SpotifyPlayerWatcher(spotMock.Object, eq) { Id = "test", SpotifyUsername = "test" };
for (var i = 0; i < playing.Count; i++)
{
@ -265,7 +265,7 @@ namespace Selector.Tests
s => s.GetCurrentPlayback(It.IsAny<CancellationToken>()).Result
).Returns(playingQueue.Dequeue);
var watcher = new SpotifyPlayerWatcher(spotMock.Object, eq);
var watcher = new SpotifyPlayerWatcher(spotMock.Object, eq) { Id = "test", SpotifyUsername = "test" };
using var monitoredWatcher = watcher.Monitor();
for (var i = 0; i < playing.Count; i++)
@ -287,7 +287,9 @@ namespace Selector.Tests
var eq = new UriEqual();
var watch = new SpotifyPlayerWatcher(spotMock.Object, eq)
{
PollPeriod = pollPeriod
PollPeriod = pollPeriod,
Id = "test",
SpotifyUsername = "test"
};
var tokenSource = new CancellationTokenSource();

@ -38,7 +38,7 @@ namespace Selector.Tests
.Returns(playlistDequeue.Dequeue);
var config = new PlaylistWatcherConfig() { PlaylistId = "spotify:playlist:test" };
var watcher = new PlaylistWatcher(config, spotMock.Object);
var watcher = new PlaylistWatcher(config, spotMock.Object) { Id = "test", SpotifyUsername = "test" };
for (var i = 0; i < playing.Count; i++)
{
@ -92,7 +92,7 @@ namespace Selector.Tests
.Returns(playlistDequeue.Dequeue);
var config = new PlaylistWatcherConfig() { PlaylistId = "spotify:playlist:test" };
var watcher = new PlaylistWatcher(config, spotMock.Object);
var watcher = new PlaylistWatcher(config, spotMock.Object) { Id = "test", SpotifyUsername = "test" };
using var monitoredWatcher = watcher.Monitor();

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using IF.Lastfm.Core.Objects;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
using Selector.Cache;
@ -42,9 +41,9 @@ namespace Selector.Web.Hubs
pastOptions = options;
}
public async Task OnConnected()
public Task OnConnected()
{
return Task.CompletedTask;
}
private static IEnumerable<string> AlbumSuffixes = new[]
@ -63,8 +62,12 @@ namespace Selector.Web.Hubs
param.Album = string.IsNullOrWhiteSpace(param.Album) ? null : param.Album;
param.Artist = string.IsNullOrWhiteSpace(param.Artist) ? null : param.Artist;
DateTime? from = param.From is string f && DateTime.TryParse(f, out var fromDate) ? fromDate.ToUniversalTime() : null;
DateTime? to = param.To is string t && DateTime.TryParse(t, out var toDate) ? toDate.ToUniversalTime() : null;
DateTime? from = param.From is string f && DateTime.TryParse(f, out var fromDate)
? fromDate.ToUniversalTime()
: null;
DateTime? to = param.To is string t && DateTime.TryParse(t, out var toDate)
? toDate.ToUniversalTime()
: null;
var listenQuery = ListenRepository.GetAll(
userId: Context.UserIdentifier,

@ -4,6 +4,8 @@
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
<IsAotCompatible>true</IsAotCompatible>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
<ItemGroup>

@ -96,7 +96,8 @@ namespace Selector.Web
);
services.AddDBPlayCountPuller();
services.AddTransient<IScrobbleRepository, ScrobbleRepository>()
.AddTransient<ISpotifyListenRepository, SpotifyListenRepository>();
.AddTransient<ISpotifyListenRepository, SpotifyListenRepository>()
.AddTransient<IAppleListenRepository, AppleListenRepository>();
services.AddTransient<IListenRepository, MetaListenRepository>();
//services.AddTransient<IListenRepository, SpotifyListenRepository>();

@ -1,12 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace Selector
namespace Selector
{
public interface IConsumer
{
public void Subscribe(IWatcher watch = null);
public void Unsubscribe(IWatcher watch = null);
public void Subscribe(IWatcher? watch = null);
public void Unsubscribe(IWatcher? watch = null);
}
public interface IProcessingConsumer

@ -1,18 +1,15 @@
using System;
using System.Collections.Generic;
namespace Selector
namespace Selector
{
public class Equal : IEqual
{
protected Dictionary<Type, object> comps;
protected Dictionary<Type, object>? comps;
public bool IsEqual<T>(T item, T other)
{
if (item is null && other is null) return true;
if (item is null ^ other is null) return false;
if (comps.ContainsKey(typeof(T)))
if (comps?.ContainsKey(typeof(T)) ?? false)
{
var comp = (IEqualityComparer<T>)comps[typeof(T)];
return comp.Equals(item, other);

@ -1,5 +1,3 @@
using System;
namespace Selector;
public class ListeningChangeEventArgs : EventArgs
@ -8,5 +6,5 @@ public class ListeningChangeEventArgs : EventArgs
/// String Id for watcher, used to hold user Db Id
/// </summary>
/// <value></value>
public string Id { get; set; }
public required string Id { get; set; }
}

@ -1,13 +1,10 @@
using System;
namespace Selector;
namespace Selector;
public class Listen: IListen
public class Listen : IListen
{
public string TrackName { get; set; }
public string AlbumName { get; set; }
public string ArtistName { get; set; }
public required string TrackName { get; set; }
public required string AlbumName { get; set; }
public required string ArtistName { get; set; }
public DateTime Timestamp { get; set; }
}
}

@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Selector
namespace Selector
{
public record struct CountSample
{
@ -98,8 +94,8 @@ namespace Selector
}
while (sortedScrobblesIter.MoveNext()
&& sortedScrobblesIter.Current.Timestamp.Year == counter.Year
&& sortedScrobblesIter.Current.Timestamp.Month == counter.Month)
&& sortedScrobblesIter.Current?.Timestamp.Year == counter.Year
&& sortedScrobblesIter.Current?.Timestamp.Month == counter.Month)
{
count++;
}

@ -1,20 +1,13 @@
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Selector.Operations
{
{
public class BatchingOperation<T> where T : IOperation
{
protected ILogger<BatchingOperation<T>> logger;
protected CancellationToken _token;
protected Task aggregateNetworkTask;
public ConcurrentQueue<T> WaitingRequests { get; private set; } = new();
public ConcurrentQueue<T> DoneRequests { get; private set; } = new();
@ -23,14 +16,15 @@ namespace Selector.Operations
private TimeSpan timeout;
private int simultaneousRequests;
public BatchingOperation(TimeSpan _interRequestDelay, TimeSpan _timeout, int _simultaneous, IEnumerable<T> requests, ILogger<BatchingOperation<T>> _logger = null)
public BatchingOperation(TimeSpan _interRequestDelay, TimeSpan _timeout, int _simultaneous,
IEnumerable<T> requests, ILogger<BatchingOperation<T>>? _logger = null)
{
interRequestDelay = _interRequestDelay;
timeout = _timeout;
simultaneousRequests = _simultaneous;
logger = _logger ?? NullLogger<BatchingOperation<T>>.Instance;
foreach(var request in requests)
foreach (var request in requests)
{
WaitingRequests.Enqueue(request);
}
@ -38,7 +32,7 @@ namespace Selector.Operations
public bool TimedOut { get; private set; } = false;
private async void HandleSuccessfulRequest(object o, EventArgs e)
private async void HandleSuccessfulRequest(object? o, EventArgs e)
{
await Task.Delay(interRequestDelay, _token);
TransitionRequest();
@ -52,7 +46,8 @@ namespace Selector.Operations
_ = request.Execute();
DoneRequests.Enqueue(request);
logger.LogInformation("Executing request {} of {}", DoneRequests.Count, WaitingRequests.Count + DoneRequests.Count);
logger.LogInformation("Executing request {} of {}", DoneRequests.Count,
WaitingRequests.Count + DoneRequests.Count);
}
}
@ -71,4 +66,4 @@ namespace Selector.Operations
TimedOut = firstToFinish == timeoutTask;
}
}
}
}

@ -2,9 +2,12 @@
<PropertyGroup>
<TargetFrameworks>net9.0</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('OSX'))">$(TargetFrameworks);net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAotCompatible>true</IsAotCompatible>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>

@ -1,13 +1,11 @@
using System;
namespace Selector
namespace Selector
{
public interface ITimeline<T>
{
public int Count { get; }
public T Get(DateTime at);
public T? Get(DateTime at);
public T Get();
public void Add(T item, DateTime timestamp);
public void Clear();
}
}
}

@ -1,26 +1,29 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Collections;
namespace Selector
{
public class Timeline<T> : ITimeline<T>, IEnumerable<TimelineItem<T>> where T : class
{
protected List<TimelineItem<T>> Recent = new();
public int Count { get => Recent.Count; }
public int Count
{
get => Recent.Count;
}
public void Clear() => Recent.Clear();
public bool SortOnBackDate { get; set; } = true;
private int? max = 1000;
public int? MaxSize
{
get => max;
set => max = value is null ? value : Math.Max(1, (int) value);
set => max = value is null ? value : Math.Max(1, (int)value);
}
public virtual void Add(T item) => Add(item, DateTime.UtcNow);
public virtual void Add(T item, DateTime timestamp)
{
Recent.Add(TimelineItem<T>.From(item, timestamp));
@ -32,7 +35,7 @@ namespace Selector
CheckSize();
}
public void Sort()
{
Recent = Recent.OrderBy(i => i.Time).ToList();
@ -49,13 +52,13 @@ namespace Selector
public T Get()
=> Recent.Last().Item;
public T Get(DateTime at)
=> GetTimelineItem(at).Item;
public TimelineItem<T> GetTimelineItem(DateTime at)
=> Recent
.Where(i => i.Time <= at).LastOrDefault();
public T? Get(DateTime at)
=> GetTimelineItem(at)?.Item;
public TimelineItem<T>? GetTimelineItem(DateTime at)
=> Recent.LastOrDefault(i => i.Time <= at);
public IEnumerator<TimelineItem<T>> GetEnumerator() => Recent.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}
}

@ -1,10 +1,8 @@
using System;
namespace Selector
namespace Selector
{
public class TimelineItem<T>: ITimelineItem<T>
public class TimelineItem<T> : ITimelineItem<T>
{
public T Item { get; set; }
public required T Item { get; set; }
public DateTime Time { get; set; }
public static TimelineItem<TT> From<TT>(TT item, DateTime time)
@ -16,4 +14,4 @@ namespace Selector
};
}
}
}
}

@ -1,17 +1,16 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Selector;
public abstract class BaseParallelPlayerConsumer<TWatcher, TArgs>(
TWatcher watcher,
ILogger<BaseParallelPlayerConsumer<TWatcher, TArgs>> logger) : IConsumer<TArgs>
TWatcher? watcher,
ILogger<BaseParallelPlayerConsumer<TWatcher, TArgs>> logger)
: BasePlayerConsumer<TWatcher, TArgs>(watcher, logger), IConsumer<TArgs>
where TWatcher : IWatcher<TArgs>
{
protected readonly ILogger<BaseParallelPlayerConsumer<TWatcher, TArgs>> Logger = logger;
protected new readonly ILogger<BaseParallelPlayerConsumer<TWatcher, TArgs>> Logger = logger;
public void Callback(object sender, TArgs e)
public override void Callback(object? sender, TArgs e)
{
Task.Run(async () =>
{
@ -25,34 +24,4 @@ public abstract class BaseParallelPlayerConsumer<TWatcher, TArgs>(
}
});
}
protected abstract Task ProcessEvent(TArgs e);
public void Subscribe(IWatcher? watch = null)
{
var watcher1 = watch ?? watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher1 is TWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
public void Unsubscribe(IWatcher? watch = null)
{
var watcher1 = watch ?? watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher1 is TWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
}

@ -0,0 +1,42 @@
using Microsoft.Extensions.Logging;
namespace Selector;
public abstract class BasePlayerConsumer<TWatcher, TArgs>(
TWatcher? watcher,
ILogger<BasePlayerConsumer<TWatcher, TArgs>> logger) : IConsumer<TArgs>
where TWatcher : IWatcher<TArgs>
{
protected readonly ILogger<BasePlayerConsumer<TWatcher, TArgs>> Logger = logger;
public abstract void Callback(object? sender, TArgs e);
protected abstract Task ProcessEvent(TArgs e);
public void Subscribe(IWatcher? watch = null)
{
var watcher1 = watch ?? watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher1 is TWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
public void Unsubscribe(IWatcher? watch = null)
{
var watcher1 = watch ?? watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher1 is TWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
}

@ -1,21 +1,19 @@
using System;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Selector;
public abstract class BaseSequentialPlayerConsumer<TWatcher, TArgs>(
TWatcher watcher,
ILogger<BaseSequentialPlayerConsumer<TWatcher, TArgs>> logger) : IProcessingConsumer<TArgs>
TWatcher? watcher,
ILogger<BaseSequentialPlayerConsumer<TWatcher, TArgs>> logger)
: BasePlayerConsumer<TWatcher, TArgs>(watcher, logger), IProcessingConsumer<TArgs>
where TWatcher : IWatcher<TArgs>
{
protected readonly ILogger<BaseSequentialPlayerConsumer<TWatcher, TArgs>> Logger = logger;
protected new readonly ILogger<BaseSequentialPlayerConsumer<TWatcher, TArgs>> Logger = logger;
private readonly Channel<TArgs> _events = Channel.CreateUnbounded<TArgs>();
public void Callback(object sender, TArgs e)
public override void Callback(object? sender, TArgs e)
{
if (!_events.Writer.TryWrite(e))
{
@ -41,34 +39,4 @@ public abstract class BaseSequentialPlayerConsumer<TWatcher, TArgs>(
}
}, cancellationToken: token);
}
protected abstract Task ProcessEvent(TArgs e);
public void Subscribe(IWatcher? watch = null)
{
var watcher1 = watch ?? watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher1 is TWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
public void Unsubscribe(IWatcher? watch = null)
{
var watcher1 = watch ?? watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher1 is TWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
}

@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@ -11,10 +7,10 @@ namespace Selector
public abstract class BaseWatcher : IWatcher
{
protected readonly ILogger<BaseWatcher> Logger;
public string Id { get; set; }
public required string Id { get; set; }
private Stopwatch ExecutionTimer { get; set; }
public BaseWatcher(ILogger<BaseWatcher> logger = null)
public BaseWatcher(ILogger<BaseWatcher>? logger = null)
{
Logger = logger ?? NullLogger<BaseWatcher>.Instance;
ExecutionTimer = new Stopwatch();

@ -1,9 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@ -15,7 +10,7 @@ namespace Selector
public bool IsRunning { get; private set; } = false;
private List<WatcherContext> Watchers { get; set; } = new();
public WatcherCollection(ILogger<WatcherCollection> logger = null)
public WatcherCollection(ILogger<WatcherCollection>? logger = null)
{
Logger = logger ?? NullLogger<WatcherCollection>.Instance;
}
@ -30,9 +25,10 @@ namespace Selector
public IEnumerable<Task> Tasks
=> Watchers
.Select(w => w.Task)
.Where(t => t is not null);
.Where(t => t is not null)
.Select(t => t!);
public IEnumerable<CancellationTokenSource> TokenSources
public IEnumerable<CancellationTokenSource?> TokenSources
=> Watchers
.Select(w => w.TokenSource)
.Where(t => t is not null);

@ -1,9 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Selector
{
public class WatcherContext : IDisposable, IWatcherContext
@ -16,9 +10,9 @@ namespace Selector
/// Reference to Watcher.Watch() task when running
/// </summary>
/// <value></value>
public Task Task { get; set; }
public Task? Task { get; private set; }
public CancellationTokenSource TokenSource { get; set; }
public CancellationTokenSource? TokenSource { get; private set; }
public WatcherContext(IWatcher watcher)
{
@ -57,9 +51,9 @@ namespace Selector
IsRunning = true;
TokenSource = new();
Consumers.ForEach(c => c.Subscribe(Watcher));
foreach (var consumer in Consumers)
{
consumer.Subscribe(Watcher);
if (consumer is IProcessingConsumer c)
{
c.ProcessQueue(TokenSource.Token);
@ -73,7 +67,7 @@ namespace Selector
{
if (Task is not null && !Task.IsCompleted)
{
TokenSource.Cancel();
TokenSource?.Cancel();
}
TokenSource = new();
@ -91,15 +85,15 @@ namespace Selector
{
Consumers.ForEach(c => c.Unsubscribe(Watcher));
TokenSource.Cancel();
TokenSource?.Cancel();
IsRunning = false;
}
private void Clear()
{
if (IsRunning
|| Task.Status == TaskStatus.Running
|| Task.Status == TaskStatus.WaitingToRun)
|| Task?.Status == TaskStatus.Running
|| Task?.Status == TaskStatus.WaitingToRun)
{
Stop();
}