Compare commits

...

6 Commits

163 changed files with 5300 additions and 1890 deletions
Dockerfile.CLIDockerfile.Web
Selector.AppleMusic
Selector.CLI
Selector.Cache
Selector.Event
Selector.LastFm
Selector.MAUI
Selector.Model
Selector.SignalR
Selector.Spotify

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

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

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

@ -0,0 +1,16 @@
using Selector.Web.Apple;
namespace Selector.AppleMusic;
public class AppleMusicApiProvider(HttpClient client)
{
public AppleMusicApi GetApi(string developerKey, string teamId, string keyId, string userKey)
{
var jwtGenerator = new TokenGenerator(developerKey, teamId, keyId);
var developerToken = jwtGenerator.Generate();
var api = new AppleMusicApi(client, developerToken, userKey);
return api;
}
}

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

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

@ -0,0 +1,23 @@
using Selector.AppleMusic.Watcher;
namespace Selector.AppleMusic;
public class AppleListeningChangeEventArgs : ListeningChangeEventArgs
{
public AppleMusicCurrentlyPlayingContext Previous { get; set; }
public AppleMusicCurrentlyPlayingContext Current { get; set; }
// AppleTimeline Timeline { get; set; }
public static AppleListeningChangeEventArgs From(AppleMusicCurrentlyPlayingContext previous,
AppleMusicCurrentlyPlayingContext current, AppleTimeline timeline, string id = null, string username = null)
{
return new AppleListeningChangeEventArgs()
{
Previous = previous,
Current = current,
// Timeline = timeline,
Id = id
};
}
}

@ -0,0 +1,8 @@
using System.Net;
namespace Selector.AppleMusic.Exceptions;
public class AppleMusicException : Exception
{
public HttpStatusCode StatusCode { get; set; }
}

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

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

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

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

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

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

119
Selector.AppleMusic/Jwt.cs Normal file

@ -0,0 +1,119 @@
// https://github.com/CurtisUpdike/AppleDeveloperToken
// MIT License
//
// Copyright (c) 2023 Curtis Updike
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
using System;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
namespace Selector.Web.Apple;
internal record AppleAccount(string TeamId, string KeyId, string PrivateKey);
public class TokenGenerator
{
private static readonly JwtSecurityTokenHandler _tokenHandler = new();
private readonly AppleAccount _account;
private int _secondsValid;
public int SecondsValid
{
get { return _secondsValid; }
set
{
ValidateTime(value);
_secondsValid = value;
}
}
public TokenGenerator(string privateKey, string teamId, string keyId, int secondsValid = 15777000)
{
ValidateTime(secondsValid);
_account = new(teamId, keyId, FormatKey(privateKey));
_secondsValid = secondsValid;
}
public string Generate()
{
return GenerateToken(_account, TimeSpan.FromSeconds(SecondsValid));
}
public string Generate(int secondsValid)
{
ValidateTime(secondsValid);
return GenerateToken(_account, TimeSpan.FromSeconds(secondsValid));
}
public string Generate(TimeSpan timeValid)
{
ValidateTime(timeValid.Seconds);
return GenerateToken(_account, timeValid);
}
private static string GenerateToken(AppleAccount account, TimeSpan timeValid)
{
var now = DateTime.UtcNow;
var algorithm = CreateAlgorithm(account.PrivateKey);
var signingCredentials = CreateSigningCredentials(account.KeyId, algorithm);
var tokenDescriptor = new SecurityTokenDescriptor
{
Issuer = account.TeamId,
IssuedAt = now,
NotBefore = now,
Expires = now.Add(timeValid),
SigningCredentials = signingCredentials
};
var token = _tokenHandler.CreateJwtSecurityToken(tokenDescriptor);
return _tokenHandler.WriteToken(token);
}
private static ECDsa CreateAlgorithm(string key)
{
var algorithm = ECDsa.Create();
algorithm.ImportPkcs8PrivateKey(Convert.FromBase64String(key), out _);
return algorithm;
}
private static SigningCredentials CreateSigningCredentials(string keyId, ECDsa algorithm)
{
var key = new ECDsaSecurityKey(algorithm) { KeyId = keyId };
return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
}
private static void ValidateTime(int seconds)
{
if (seconds > 15777000)
{
throw new ArgumentException("Must be less than 15777000 seconds (6 months).");
}
}
private static string FormatKey(string key)
{
return key.Replace("-----BEGIN PRIVATE KEY-----", "")
.Replace("-----END PRIVATE KEY-----", "")
.Replace("\n", "")
.Replace("\r", "");
}
}

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

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

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Selector\Selector.csproj"/>
</ItemGroup>
</Project>

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

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

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

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

@ -1,18 +1,21 @@
using Microsoft.Extensions.DependencyInjection; using System;
using System.CommandLine;
using System.CommandLine.Invocation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NLog.Extensions.Logging; using NLog.Extensions.Logging;
using Selector.AppleMusic.Extensions;
using Selector.Cache.Extensions; using Selector.Cache.Extensions;
using Selector.CLI.Extensions; using Selector.CLI.Extensions;
using Selector.Events; using Selector.Events;
using Selector.Extensions; using Selector.Extensions;
using System; using Selector.Model.Extensions;
using System.CommandLine; using Selector.Spotify;
using System.CommandLine.Invocation;
namespace Selector.CLI namespace Selector.CLI
{ {
public class HostRootCommand: RootCommand public class HostRootCommand : RootCommand
{ {
public HostRootCommand() public HostRootCommand()
{ {
@ -31,7 +34,7 @@ namespace Selector.CLI
{ {
try try
{ {
var host = CreateHostBuilder(Environment.GetCommandLineArgs(),ConfigureDefault, ConfigureDefaultNlog) var host = CreateHostBuilder(Environment.GetCommandLineArgs(), ConfigureDefault, ConfigureDefaultNlog)
.Build(); .Build();
var logger = host.Services.GetRequiredService<ILogger<HostCommand>>(); var logger = host.Services.GetRequiredService<ILogger<HostCommand>>();
@ -40,7 +43,7 @@ namespace Selector.CLI
host.Run(); host.Run();
} }
catch(Exception ex) catch (Exception ex)
{ {
Console.WriteLine(ex); Console.WriteLine(ex);
return 1; return 1;
@ -53,7 +56,7 @@ namespace Selector.CLI
{ {
AppDomain.CurrentDomain.UnhandledException += (_, e) => AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{ {
if(e.ExceptionObject is Exception ex) if (e.ExceptionObject is Exception ex)
{ {
logger.LogError(ex, "Unhandled exception thrown"); logger.LogError(ex, "Unhandled exception thrown");
@ -96,7 +99,7 @@ namespace Selector.CLI
Console.WriteLine("> Adding Services..."); Console.WriteLine("> Adding Services...");
// SERVICES // SERVICES
services.AddHttpClient() services.AddHttpClient()
.ConfigureDb(config); .ConfigureDb(config);
services.AddConsumerFactories(); services.AddConsumerFactories();
services.AddCLIConsumerFactories(); services.AddCLIConsumerFactories();
@ -107,12 +110,14 @@ namespace Selector.CLI
} }
services.AddWatcher() services.AddWatcher()
.AddEvents() .AddSpotifyWatcher()
.AddSpotify(); .AddEvents()
.AddSpotify()
.AddAppleMusic();
services.ConfigureLastFm(config) services.ConfigureLastFm(config)
.ConfigureEqual(config) .ConfigureEqual(config)
.ConfigureJobs(config); .ConfigureJobs(config);
if (config.RedisOptions.Enabled) if (config.RedisOptions.Enabled)
{ {
@ -121,7 +126,8 @@ namespace Selector.CLI
Console.WriteLine("> Adding cache event maps..."); Console.WriteLine("> Adding cache event maps...");
services.AddTransient<IEventMapping, FromPubSub.SpotifyLink>() services.AddTransient<IEventMapping, FromPubSub.SpotifyLink>()
.AddTransient<IEventMapping, FromPubSub.Lastfm>(); .AddTransient<IEventMapping, FromPubSub.AppleMusicLink>()
.AddTransient<IEventMapping, FromPubSub.Lastfm>();
Console.WriteLine("> Adding caching Spotify consumers..."); Console.WriteLine("> Adding caching Spotify consumers...");
services.AddCachingSpotify(); services.AddCachingSpotify();
@ -147,14 +153,16 @@ namespace Selector.CLI
public static void ConfigureDefaultNlog(HostBuilderContext context, ILoggingBuilder builder) public static void ConfigureDefaultNlog(HostBuilderContext context, ILoggingBuilder builder)
{ {
builder.ClearProviders() builder.ClearProviders()
.SetMinimumLevel(LogLevel.Trace) .SetMinimumLevel(LogLevel.Trace)
.AddNLog(context.Configuration); .AddNLog(context.Configuration);
} }
static IHostBuilder CreateHostBuilder(string[] args, Action<HostBuilderContext, IServiceCollection> buildServices, Action<HostBuilderContext, ILoggingBuilder> buildLogs) static IHostBuilder CreateHostBuilder(string[] args,
Action<HostBuilderContext, IServiceCollection> buildServices,
Action<HostBuilderContext, ILoggingBuilder> buildLogs)
=> Host.CreateDefaultBuilder(args) => Host.CreateDefaultBuilder(args)
.UseSystemd() .UseSystemd()
.ConfigureServices((context, services) => buildServices(context, services)) .ConfigureServices((context, services) => buildServices(context, services))
.ConfigureLogging((context, builder) => buildLogs(context, builder)); .ConfigureLogging((context, builder) => buildLogs(context, builder));
} }
} }

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

@ -7,7 +7,10 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Selector.Extensions;
using Selector.Model; using Selector.Model;
using Selector.Spotify;
using Selector.Spotify.Consumer;
using SpotifyAPI.Web; using SpotifyAPI.Web;
namespace Selector.CLI.Consumer namespace Selector.CLI.Consumer
@ -15,16 +18,16 @@ namespace Selector.CLI.Consumer
/// <summary> /// <summary>
/// Save name -> Spotify URI mappings as new objects come through the watcher without making extra queries of the Spotify API /// Save name -> Spotify URI mappings as new objects come through the watcher without making extra queries of the Spotify API
/// </summary> /// </summary>
public class MappingPersister: IPlayerConsumer public class MappingPersister : ISpotifyPlayerConsumer
{ {
protected readonly IPlayerWatcher Watcher; protected readonly ISpotifyPlayerWatcher Watcher;
protected readonly IServiceScopeFactory ScopeFactory; protected readonly IServiceScopeFactory ScopeFactory;
protected readonly ILogger<MappingPersister> Logger; protected readonly ILogger<MappingPersister> Logger;
public CancellationToken CancelToken { get; set; } public CancellationToken CancelToken { get; set; }
public MappingPersister( public MappingPersister(
IPlayerWatcher watcher, ISpotifyPlayerWatcher watcher,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ILogger<MappingPersister> logger = null, ILogger<MappingPersister> logger = null,
CancellationToken token = default CancellationToken token = default
@ -36,11 +39,12 @@ namespace Selector.CLI.Consumer
CancelToken = token; CancelToken = token;
} }
public void Callback(object sender, ListeningChangeEventArgs e) public void Callback(object sender, SpotifyListeningChangeEventArgs e)
{ {
if (e.Current is null) return; if (e.Current is null) return;
Task.Run(async () => { Task.Run(async () =>
{
try try
{ {
await AsyncCallback(e); await AsyncCallback(e);
@ -56,16 +60,17 @@ namespace Selector.CLI.Consumer
}, CancelToken); }, CancelToken);
} }
public async Task AsyncCallback(ListeningChangeEventArgs e) public async Task AsyncCallback(SpotifyListeningChangeEventArgs e)
{ {
using var serviceScope = ScopeFactory.CreateScope(); using var serviceScope = ScopeFactory.CreateScope();
using var scope = Logger.BeginScope(new Dictionary<string, object>() { { "spotify_username", e.SpotifyUsername }, { "id", e.Id } }); using var scope = Logger.BeginScope(new Dictionary<string, object>()
{ { "spotify_username", e.SpotifyUsername }, { "id", e.Id } });
if (e.Current.Item is FullTrack track) if (e.Current.Item is FullTrack track)
{ {
var mappingRepo = serviceScope.ServiceProvider.GetRequiredService<IScrobbleMappingRepository>(); var mappingRepo = serviceScope.ServiceProvider.GetRequiredService<IScrobbleMappingRepository>();
if(!mappingRepo.GetTracks().Select(t => t.SpotifyUri).Contains(track.Uri)) if (!mappingRepo.GetTracks().Select(t => t.SpotifyUri).Contains(track.Uri))
{ {
mappingRepo.Add(new TrackLastfmSpotifyMapping() mappingRepo.Add(new TrackLastfmSpotifyMapping()
{ {
@ -120,7 +125,7 @@ namespace Selector.CLI.Consumer
{ {
var watcher = watch ?? Watcher ?? throw new ArgumentNullException(nameof(watch)); var watcher = watch ?? Watcher ?? throw new ArgumentNullException(nameof(watch));
if (watcher is IPlayerWatcher watcherCast) if (watcher is ISpotifyPlayerWatcher watcherCast)
{ {
watcherCast.ItemChange += Callback; watcherCast.ItemChange += Callback;
} }
@ -134,7 +139,7 @@ namespace Selector.CLI.Consumer
{ {
var watcher = watch ?? Watcher ?? throw new ArgumentNullException(nameof(watch)); var watcher = watch ?? Watcher ?? throw new ArgumentNullException(nameof(watch));
if (watcher is IPlayerWatcher watcherCast) if (watcher is ISpotifyPlayerWatcher watcherCast)
{ {
watcherCast.ItemChange -= Callback; watcherCast.ItemChange -= Callback;
} }
@ -144,5 +149,4 @@ namespace Selector.CLI.Consumer
} }
} }
} }
} }

@ -1,12 +1,13 @@
using IF.Lastfm.Core.Api; using System.Linq;
using IF.Lastfm.Core.Api;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Selector.Model; using Selector.Model;
using Selector.Spotify.ConfigFactory;
using SpotifyAPI.Web; using SpotifyAPI.Web;
using StackExchange.Redis; using StackExchange.Redis;
using System.Linq;
namespace Selector.CLI.Extensions namespace Selector.CLI.Extensions
{ {
@ -16,8 +17,8 @@ namespace Selector.CLI.Extensions
{ {
var configBuild = new ConfigurationBuilder(); var configBuild = new ConfigurationBuilder();
configBuild.AddJsonFile("appsettings.json", optional: true) configBuild.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Development.json", optional: true) .AddJsonFile("appsettings.Development.json", optional: true)
.AddJsonFile("appsettings.Production.json", optional: true); .AddJsonFile("appsettings.Production.json", optional: true);
context.Config = configBuild.Build().ConfigureOptions(); context.Config = configBuild.Build().ConfigureOptions();
return context; return context;
@ -28,10 +29,7 @@ namespace Selector.CLI.Extensions
context.Logger = LoggerFactory.Create(builder => context.Logger = LoggerFactory.Create(builder =>
{ {
//builder.AddConsole(a => a.); //builder.AddConsole(a => a.);
builder.AddSimpleConsole(options => builder.AddSimpleConsole(options => { options.SingleLine = true; });
{
options.SingleLine = true;
});
builder.SetMinimumLevel(LogLevel.Trace); builder.SetMinimumLevel(LogLevel.Trace);
}); });
@ -46,7 +44,9 @@ namespace Selector.CLI.Extensions
} }
context.DatabaseConfig = new DbContextOptionsBuilder<ApplicationDbContext>(); context.DatabaseConfig = new DbContextOptionsBuilder<ApplicationDbContext>();
context.DatabaseConfig.UseNpgsql(string.IsNullOrWhiteSpace(connectionString) ? context.Config.DatabaseOptions.ConnectionString : connectionString); context.DatabaseConfig.UseNpgsql(string.IsNullOrWhiteSpace(connectionString)
? context.Config.DatabaseOptions.ConnectionString
: connectionString);
return context; return context;
} }
@ -58,7 +58,8 @@ namespace Selector.CLI.Extensions
context.WithConfig(); context.WithConfig();
} }
context.LastFmClient = new LastfmClient(new LastAuth(context.Config.LastfmClient, context.Config.LastfmSecret)); context.LastFmClient =
new LastfmClient(new LastAuth(context.Config.LastfmClient, context.Config.LastfmSecret));
return context; return context;
} }
@ -72,21 +73,23 @@ namespace Selector.CLI.Extensions
var refreshToken = context.Config.RefreshToken; var refreshToken = context.Config.RefreshToken;
if(string.IsNullOrWhiteSpace(refreshToken)) if (string.IsNullOrWhiteSpace(refreshToken))
{ {
if (context.DatabaseConfig is null) if (context.DatabaseConfig is null)
{ {
context.WithDb(); context.WithDb();
} }
using var db = new ApplicationDbContext(context.DatabaseConfig.Options, NullLogger<ApplicationDbContext>.Instance); using var db = new ApplicationDbContext(context.DatabaseConfig.Options,
NullLogger<ApplicationDbContext>.Instance);
var user = db.Users.FirstOrDefault(u => u.UserName == "sarsoo"); var user = db.Users.FirstOrDefault(u => u.UserName == "sarsoo");
refreshToken = user?.SpotifyRefreshToken; refreshToken = user?.SpotifyRefreshToken;
} }
var configFactory = new RefreshTokenFactory(context.Config.ClientId, context.Config.ClientSecret, refreshToken); var configFactory =
new RefreshTokenFactory(context.Config.ClientId, context.Config.ClientSecret, refreshToken);
context.Spotify = new SpotifyClient(configFactory.GetConfig().Result); context.Spotify = new SpotifyClient(configFactory.GetConfig().Result);
@ -109,4 +112,4 @@ namespace Selector.CLI.Extensions
return context; return context;
} }
} }
} }

@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Quartz; using Quartz;
using Selector.Cache.Extensions; using Selector.Cache.Extensions;
@ -7,7 +8,7 @@ using Selector.CLI.Jobs;
using Selector.Extensions; using Selector.Extensions;
using Selector.Model; using Selector.Model;
using Selector.Model.Services; using Selector.Model.Services;
using System; using Selector.Spotify.Equality;
namespace Selector.CLI.Extensions namespace Selector.CLI.Extensions
{ {
@ -41,16 +42,13 @@ namespace Selector.CLI.Extensions
{ {
Console.WriteLine("> Adding Jobs..."); Console.WriteLine("> Adding Jobs...");
services.AddQuartz(options => { services.AddQuartz(options =>
{
options.UseMicrosoftDependencyInjectionJobFactory(); options.UseMicrosoftDependencyInjectionJobFactory();
options.UseSimpleTypeLoader(); options.UseSimpleTypeLoader();
options.UseInMemoryStore(); options.UseInMemoryStore();
options.UseDefaultThreadPool(tp => options.UseDefaultThreadPool(tp => { tp.MaxConcurrency = 5; });
{
tp.MaxConcurrency = 5;
});
if (config.JobOptions.Scrobble.Enabled) if (config.JobOptions.Scrobble.Enabled)
{ {
@ -68,7 +66,8 @@ namespace Selector.CLI.Extensions
.WithIdentity("scrobble-watcher-agile-trigger") .WithIdentity("scrobble-watcher-agile-trigger")
.ForJob(scrobbleKey) .ForJob(scrobbleKey)
.StartNow() .StartNow()
.WithSimpleSchedule(x => x.WithInterval(config.JobOptions.Scrobble.InterJobDelay).RepeatForever()) .WithSimpleSchedule(x =>
x.WithInterval(config.JobOptions.Scrobble.InterJobDelay).RepeatForever())
.WithDescription("Periodic trigger for scrobble watcher") .WithDescription("Periodic trigger for scrobble watcher")
); );
@ -86,17 +85,14 @@ namespace Selector.CLI.Extensions
.WithCronSchedule(config.JobOptions.Scrobble.FullScrobbleCron) .WithCronSchedule(config.JobOptions.Scrobble.FullScrobbleCron)
.WithDescription("Periodic trigger for scrobble watcher") .WithDescription("Periodic trigger for scrobble watcher")
); );
} }
else else
{ {
Console.WriteLine("> Skipping Scrobble Jobs..."); Console.WriteLine("> Skipping Scrobble Jobs...");
} }
}); });
services.AddQuartzHostedService(options => { services.AddQuartzHostedService(options => { options.WaitForJobsToComplete = true; });
options.WaitForJobsToComplete = true;
});
services.AddTransient<ScrobbleWatcherJob>(); services.AddTransient<ScrobbleWatcherJob>();
services.AddTransient<IJob, ScrobbleWatcherJob>(); services.AddTransient<IJob, ScrobbleWatcherJob>();
@ -115,7 +111,7 @@ namespace Selector.CLI.Extensions
); );
services.AddTransient<IScrobbleRepository, ScrobbleRepository>() services.AddTransient<IScrobbleRepository, ScrobbleRepository>()
.AddTransient<ISpotifyListenRepository, SpotifyListenRepository>(); .AddTransient<ISpotifyListenRepository, SpotifyListenRepository>();
services.AddTransient<IListenRepository, MetaListenRepository>(); services.AddTransient<IListenRepository, MetaListenRepository>();
//services.AddTransient<IListenRepository, SpotifyListenRepository>(); //services.AddTransient<IListenRepository, SpotifyListenRepository>();
@ -152,5 +148,5 @@ namespace Selector.CLI.Extensions
return services; return services;
} }
} }
} }

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

@ -1,13 +1,13 @@
using Microsoft.Extensions.Logging; using System;
using Microsoft.Extensions.Logging.Abstractions;
using Selector.Model;
using Selector.Operations;
using SpotifyAPI.Web;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Selector.Mapping;
using Selector.Model;
using Selector.Operations;
using SpotifyAPI.Web;
namespace Selector namespace Selector
{ {
@ -30,7 +30,9 @@ namespace Selector
private readonly IScrobbleRepository scrobbleRepo; private readonly IScrobbleRepository scrobbleRepo;
private readonly IScrobbleMappingRepository mappingRepo; private readonly IScrobbleMappingRepository mappingRepo;
public ScrobbleMapper(ISearchClient _searchClient, ScrobbleMapperConfig _config, IScrobbleRepository _scrobbleRepository, IScrobbleMappingRepository _scrobbleMappingRepository, ILogger<ScrobbleMapper> _logger, ILoggerFactory _loggerFactory = null) public ScrobbleMapper(ISearchClient _searchClient, ScrobbleMapperConfig _config,
IScrobbleRepository _scrobbleRepository, IScrobbleMappingRepository _scrobbleMappingRepository,
ILogger<ScrobbleMapper> _logger, ILoggerFactory _loggerFactory = null)
{ {
searchClient = _searchClient; searchClient = _searchClient;
config = _config; config = _config;
@ -68,9 +70,9 @@ namespace Selector
.ExceptBy(currentTracks.Select(a => (a.LastfmArtistName, a.LastfmTrackName)), a => a); .ExceptBy(currentTracks.Select(a => (a.LastfmArtistName, a.LastfmTrackName)), a => a);
var requests = tracksToPull.Select(a => new ScrobbleTrackMapping( var requests = tracksToPull.Select(a => new ScrobbleTrackMapping(
searchClient, searchClient,
loggerFactory.CreateLogger<ScrobbleTrackMapping>(), loggerFactory.CreateLogger<ScrobbleTrackMapping>(),
a.TrackName, a.ArtistName) a.TrackName, a.ArtistName)
).ToArray(); ).ToArray();
logger.LogInformation("Found {} tracks to map, starting", requests.Length); logger.LogInformation("Found {} tracks to map, starting", requests.Length);
@ -95,11 +97,12 @@ namespace Selector
if (existingTrackUris.Contains(track.Uri)) if (existingTrackUris.Contains(track.Uri))
{ {
var artistName = track.Artists.FirstOrDefault()?.Name; var artistName = track.Artists.FirstOrDefault()?.Name;
var duplicates = currentTracks.Where(a => a.LastfmArtistName.Equals(artistName, StringComparison.OrdinalIgnoreCase) var duplicates = currentTracks.Where(a =>
&& a.LastfmTrackName.Equals(track.Name, StringComparison.OrdinalIgnoreCase)); a.LastfmArtistName.Equals(artistName, StringComparison.OrdinalIgnoreCase)
logger.LogWarning("Found duplicate Spotify uri ({}), [{}, {}] {}", && a.LastfmTrackName.Equals(track.Name, StringComparison.OrdinalIgnoreCase));
track.Uri, logger.LogWarning("Found duplicate Spotify uri ({}), [{}, {}] {}",
track.Name, track.Uri,
track.Name,
artistName, artistName,
string.Join(", ", duplicates.Select(d => $"{d.LastfmTrackName} {d.LastfmArtistName}")) string.Join(", ", duplicates.Select(d => $"{d.LastfmTrackName} {d.LastfmArtistName}"))
); );
@ -114,7 +117,7 @@ namespace Selector
}); });
} }
if(!existingAlbumUris.Contains(track.Album.Uri)) if (!existingAlbumUris.Contains(track.Album.Uri))
{ {
mappingRepo.Add(new AlbumLastfmSpotifyMapping() mappingRepo.Add(new AlbumLastfmSpotifyMapping()
{ {
@ -124,7 +127,7 @@ namespace Selector
}); });
} }
foreach(var artist in track.Artists.UnionBy(track.Album.Artists, a => a.Name)) foreach (var artist in track.Artists.UnionBy(track.Album.Artists, a => a.Name))
{ {
if (!existingArtistUris.Contains(artist.Uri)) if (!existingArtistUris.Contains(artist.Uri))
{ {
@ -138,7 +141,7 @@ namespace Selector
} }
} }
private BatchingOperation<T> GetOperation<T>(IEnumerable<T> requests) where T: IOperation private BatchingOperation<T> GetOperation<T>(IEnumerable<T> requests) where T : IOperation
=> new (config.InterRequestDelay, config.Timeout, config.SimultaneousConnections, requests); => new(config.InterRequestDelay, config.Timeout, config.SimultaneousConnections, requests);
} }
} }

@ -28,6 +28,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Selector.AppleMusic\Selector.AppleMusic.csproj" />
<ProjectReference Include="..\Selector\Selector.csproj" /> <ProjectReference Include="..\Selector\Selector.csproj" />
<ProjectReference Include="..\Selector.Model\Selector.Model.csproj" /> <ProjectReference Include="..\Selector.Model\Selector.Model.csproj" />
<ProjectReference Include="..\Selector.Cache\Selector.Cache.csproj" /> <ProjectReference Include="..\Selector.Cache\Selector.Cache.csproj" />
@ -58,4 +59,12 @@
<Folder Include="Consumer\" /> <Folder Include="Consumer\" />
<Folder Include="Consumer\Factory\" /> <Folder Include="Consumer\Factory\" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Update="appsettings.Development.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project> </Project>

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

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

@ -0,0 +1,93 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Selector.AppleMusic;
using Selector.AppleMusic.Consumer;
using StackExchange.Redis;
namespace Selector.Cache
{
public class AppleCacheWriter : IApplePlayerConsumer
{
private readonly IAppleMusicPlayerWatcher Watcher;
private readonly IDatabaseAsync Db;
private readonly ILogger<AppleCacheWriter> Logger;
public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromMinutes(20);
public CancellationToken CancelToken { get; set; }
public AppleCacheWriter(
IAppleMusicPlayerWatcher watcher,
IDatabaseAsync db,
ILogger<AppleCacheWriter> logger = null,
CancellationToken token = default
)
{
Watcher = watcher;
Db = db;
Logger = logger ?? NullLogger<AppleCacheWriter>.Instance;
CancelToken = token;
}
public void Callback(object sender, AppleListeningChangeEventArgs e)
{
if (e.Current is null) return;
Task.Run(async () =>
{
try
{
await AsyncCallback(e);
}
catch (Exception e)
{
Logger.LogError(e, "Error occured during callback");
}
}, CancelToken);
}
public async Task AsyncCallback(AppleListeningChangeEventArgs e)
{
// using var scope = Logger.GetListeningEventArgsScope(e);
var payload = JsonSerializer.Serialize(e, AppleJsonContext.Default.AppleListeningChangeEventArgs);
Logger.LogTrace("Caching current");
var resp = await Db.StringSetAsync(Key.CurrentlyPlayingAppleMusic(e.Id), payload, expiry: CacheExpiry);
Logger.LogDebug("Cached current, {state}", (resp ? "value set" : "value NOT set"));
}
public void Subscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IAppleMusicPlayerWatcher watcherCastApple)
{
watcherCastApple.ItemChange += Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
public void Unsubscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IAppleMusicPlayerWatcher watcherCastApple)
{
watcherCastApple.ItemChange -= Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
}
}

@ -0,0 +1,94 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Selector.AppleMusic;
using Selector.AppleMusic.Consumer;
using StackExchange.Redis;
namespace Selector.Cache.Consumer.AppleMusic
{
public class ApplePublisher : IApplePlayerConsumer
{
private readonly IAppleMusicPlayerWatcher Watcher;
private readonly ISubscriber Subscriber;
private readonly ILogger<ApplePublisher> Logger;
public CancellationToken CancelToken { get; set; }
public ApplePublisher(
IAppleMusicPlayerWatcher watcher,
ISubscriber subscriber,
ILogger<ApplePublisher> logger = null,
CancellationToken token = default
)
{
Watcher = watcher;
Subscriber = subscriber;
Logger = logger ?? NullLogger<ApplePublisher>.Instance;
CancelToken = token;
}
public void Callback(object sender, AppleListeningChangeEventArgs e)
{
if (e.Current is null) return;
Task.Run(async () =>
{
try
{
await AsyncCallback(e);
}
catch (Exception e)
{
Logger.LogError(e, "Error occured during callback");
}
}, CancelToken);
}
public async Task AsyncCallback(AppleListeningChangeEventArgs e)
{
// using var scope = Logger.GetListeningEventArgsScope(e);
var payload = JsonSerializer.Serialize(e, AppleJsonContext.Default.AppleListeningChangeEventArgs);
Logger.LogTrace("Publishing current");
// TODO: currently using spotify username for cache key, use db username
var receivers =
await Subscriber.PublishAsync(RedisChannel.Literal(Key.CurrentlyPlayingAppleMusic(e.Id)), payload);
Logger.LogDebug("Published current, {receivers} receivers", receivers);
}
public void Subscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IAppleMusicPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
public void Unsubscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IAppleMusicPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
}
}

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

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

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

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

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

@ -1,41 +1,44 @@
using System; using System;
using System.Collections.Generic;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Selector.Extensions;
using Selector.Spotify;
using Selector.Spotify.Consumer;
using StackExchange.Redis; using StackExchange.Redis;
namespace Selector.Cache namespace Selector.Cache
{ {
public class CacheWriter : IPlayerConsumer public class SpotifyCacheWriter : ISpotifyPlayerConsumer
{ {
private readonly IPlayerWatcher Watcher; private readonly ISpotifyPlayerWatcher Watcher;
private readonly IDatabaseAsync Db; private readonly IDatabaseAsync Db;
private readonly ILogger<CacheWriter> Logger; private readonly ILogger<SpotifyCacheWriter> Logger;
public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromMinutes(20); public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromMinutes(20);
public CancellationToken CancelToken { get; set; } public CancellationToken CancelToken { get; set; }
public CacheWriter( public SpotifyCacheWriter(
IPlayerWatcher watcher, ISpotifyPlayerWatcher watcher,
IDatabaseAsync db, IDatabaseAsync db,
ILogger<CacheWriter> logger = null, ILogger<SpotifyCacheWriter> logger = null,
CancellationToken token = default CancellationToken token = default
){ )
{
Watcher = watcher; Watcher = watcher;
Db = db; Db = db;
Logger = logger ?? NullLogger<CacheWriter>.Instance; Logger = logger ?? NullLogger<SpotifyCacheWriter>.Instance;
CancelToken = token; CancelToken = token;
} }
public void Callback(object sender, ListeningChangeEventArgs e) public void Callback(object sender, SpotifyListeningChangeEventArgs e)
{ {
if (e.Current is null) return; if (e.Current is null) return;
Task.Run(async () => { Task.Run(async () =>
{
try try
{ {
await AsyncCallback(e); await AsyncCallback(e);
@ -44,32 +47,31 @@ namespace Selector.Cache
{ {
Logger.LogError(e, "Error occured during callback"); Logger.LogError(e, "Error occured during callback");
} }
}, CancelToken); }, CancelToken);
} }
public async Task AsyncCallback(ListeningChangeEventArgs e) public async Task AsyncCallback(SpotifyListeningChangeEventArgs e)
{ {
using var scope = Logger.GetListeningEventArgsScope(e); using var scope = Logger.GetListeningEventArgsScope(e);
var payload = JsonSerializer.Serialize((CurrentlyPlayingDTO) e, JsonContext.Default.CurrentlyPlayingDTO); var payload =
JsonSerializer.Serialize((CurrentlyPlayingDTO)e, SpotifyJsonContext.Default.CurrentlyPlayingDTO);
Logger.LogTrace("Caching current"); Logger.LogTrace("Caching current");
var resp = await Db.StringSetAsync(Key.CurrentlyPlaying(e.Id), payload, expiry: CacheExpiry); var resp = await Db.StringSetAsync(Key.CurrentlyPlayingSpotify(e.Id), payload, expiry: CacheExpiry);
Logger.LogDebug("Cached current, {state}", (resp ? "value set" : "value NOT set")); Logger.LogDebug("Cached current, {state}", (resp ? "value set" : "value NOT set"));
} }
public void Subscribe(IWatcher watch = null) public void Subscribe(IWatcher watch = null)
{ {
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided"); var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast) if (watcher is ISpotifyPlayerWatcher watcherCast)
{ {
watcherCast.ItemChange += Callback; watcherCast.ItemChange += Callback;
} }
else else
{ {
throw new ArgumentException("Provided watcher is not a PlayerWatcher"); throw new ArgumentException("Provided watcher is not a PlayerWatcher");
@ -80,7 +82,7 @@ namespace Selector.Cache
{ {
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided"); var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast) if (watcher is ISpotifyPlayerWatcher watcherCast)
{ {
watcherCast.ItemChange -= Callback; watcherCast.ItemChange -= Callback;
} }
@ -90,4 +92,4 @@ namespace Selector.Cache
} }
} }
} }
} }

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

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

@ -1,8 +1,6 @@
using System; using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Selector.Spotify.Consumer.Factory;
using StackExchange.Redis; using StackExchange.Redis;
namespace Selector.Cache.Extensions namespace Selector.Cache.Extensions
@ -21,8 +19,10 @@ namespace Selector.Cache.Extensions
var connMulti = ConnectionMultiplexer.Connect(connectionStr); var connMulti = ConnectionMultiplexer.Connect(connectionStr);
services.AddSingleton(connMulti); services.AddSingleton(connMulti);
services.AddTransient<IDatabaseAsync>(services => services.GetService<ConnectionMultiplexer>().GetDatabase()); services.AddTransient<IDatabaseAsync>(
services.AddTransient<ISubscriber>(services => services.GetService<ConnectionMultiplexer>().GetSubscriber()); services => services.GetService<ConnectionMultiplexer>().GetDatabase());
services.AddTransient<ISubscriber>(services =>
services.GetService<ConnectionMultiplexer>().GetSubscriber());
return services; return services;
} }
@ -56,4 +56,4 @@ namespace Selector.Cache.Extensions
return services; return services;
} }
} }
} }

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

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

@ -1,6 +1,8 @@
using System; using System;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Selector.Spotify;
using Selector.Spotify.FactoryProvider;
using SpotifyAPI.Web; using SpotifyAPI.Web;
using StackExchange.Redis; using StackExchange.Redis;
@ -22,12 +24,12 @@ namespace Selector.Cache
public async Task<TrackAudioFeatures> Get(string refreshToken, string trackId) public async Task<TrackAudioFeatures> Get(string refreshToken, string trackId)
{ {
if(string.IsNullOrWhiteSpace(trackId)) throw new ArgumentNullException("No track Id provided"); if (string.IsNullOrWhiteSpace(trackId)) throw new ArgumentNullException("No track Id provided");
var track = await Cache?.StringGetAsync(Key.AudioFeature(trackId)); var track = await Cache?.StringGetAsync(Key.AudioFeature(trackId));
if (Cache is null || track == RedisValue.Null) if (Cache is null || track == RedisValue.Null)
{ {
if(!string.IsNullOrWhiteSpace(refreshToken) && !Magic.Dummy) if (!string.IsNullOrWhiteSpace(refreshToken) && !Magic.Dummy)
{ {
var factory = await SpotifyFactory.GetFactory(refreshToken); var factory = await SpotifyFactory.GetFactory(refreshToken);
var spotifyClient = new SpotifyClient(await factory.GetConfig()); var spotifyClient = new SpotifyClient(await factory.GetConfig());
@ -35,17 +37,16 @@ namespace Selector.Cache
// TODO: Error checking // TODO: Error checking
return await spotifyClient.Tracks.GetAudioFeatures(trackId); return await spotifyClient.Tracks.GetAudioFeatures(trackId);
} }
else else
{ {
return null; return null;
} }
} }
else else
{ {
var deserialised = JsonSerializer.Deserialize(track, JsonContext.Default.TrackAudioFeatures); var deserialised = JsonSerializer.Deserialize(track, SpotifyJsonContext.Default.TrackAudioFeatures);
return deserialised; return deserialised;
} }
} }
} }
} }

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using SpotifyAPI.Web; using SpotifyAPI.Web;
using StackExchange.Redis; using StackExchange.Redis;
@ -21,7 +20,6 @@ namespace Selector.Cache
public DurationPuller( public DurationPuller(
ILogger<DurationPuller> logger, ILogger<DurationPuller> logger,
ITracksClient spotifyClient, ITracksClient spotifyClient,
IDatabaseAsync cache = null IDatabaseAsync cache = null
) )
@ -41,7 +39,8 @@ namespace Selector.Cache
var cachedVal = await Cache?.HashGetAsync(Key.Track(trackId), Key.Duration); var cachedVal = await Cache?.HashGetAsync(Key.Track(trackId), Key.Duration);
if (Cache is null || cachedVal == RedisValue.Null || cachedVal.IsNullOrEmpty) if (Cache is null || cachedVal == RedisValue.Null || cachedVal.IsNullOrEmpty)
{ {
try { try
{
Logger.LogDebug("Missed cache, pulling"); Logger.LogDebug("Missed cache, pulling");
var info = await SpotifyClient.Get(trackId); var info = await SpotifyClient.Get(trackId);
@ -55,13 +54,14 @@ namespace Selector.Cache
catch (APIUnauthorizedException e) catch (APIUnauthorizedException e)
{ {
Logger.LogError("Unauthorised error: [{message}] (should be refreshed and retried?)", e.Message); Logger.LogError("Unauthorised error: [{message}] (should be refreshed and retried?)", e.Message);
throw e; throw;
} }
catch (APITooManyRequestsException e) catch (APITooManyRequestsException e)
{ {
if(_retries <= 3) if (_retries <= 3)
{ {
Logger.LogWarning("Too many requests error, retrying ({}): [{message}]", e.RetryAfter, e.Message); Logger.LogWarning("Too many requests error, retrying ({}): [{message}]", e.RetryAfter,
e.Message);
_retries++; _retries++;
await Task.Delay(e.RetryAfter); await Task.Delay(e.RetryAfter);
return await Get(uri); return await Get(uri);
@ -69,7 +69,7 @@ namespace Selector.Cache
else else
{ {
Logger.LogError("Too many requests error, done retrying: [{message}]", e.Message); Logger.LogError("Too many requests error, done retrying: [{message}]", e.Message);
throw e; throw;
} }
} }
catch (APIException e) catch (APIException e)
@ -84,13 +84,13 @@ namespace Selector.Cache
else else
{ {
Logger.LogError("API error, done retrying: [{message}]", e.Message); Logger.LogError("API error, done retrying: [{message}]", e.Message);
throw e; throw;
} }
} }
} }
else else
{ {
return (int?) cachedVal; return (int?)cachedVal;
} }
} }
@ -111,17 +111,17 @@ namespace Selector.Cache
} }
else else
{ {
ret[input] = (int) cachedVal; ret[input] = (int)cachedVal;
} }
} }
var retries = new List<string>(); var retries = new List<string>();
foreach(var chunk in toPullFromSpotify.Chunk(50)) foreach (var chunk in toPullFromSpotify.Chunk(50))
{ {
await PullChunk(chunk, ret); await PullChunk(chunk, ret);
await Task.Delay(TimeSpan.FromMilliseconds(500)); await Task.Delay(TimeSpan.FromMilliseconds(500));
} }
return ret; return ret;
} }
@ -144,7 +144,7 @@ namespace Selector.Cache
catch (APIUnauthorizedException e) catch (APIUnauthorizedException e)
{ {
Logger.LogError("Unauthorised error: [{message}] (should be refreshed and retried?)", e.Message); Logger.LogError("Unauthorised error: [{message}] (should be refreshed and retried?)", e.Message);
throw e; throw;
} }
catch (APITooManyRequestsException e) catch (APITooManyRequestsException e)
{ {
@ -159,7 +159,7 @@ namespace Selector.Cache
else else
{ {
Logger.LogError("Too many requests error, done retrying: [{message}]", e.Message); Logger.LogError("Too many requests error, done retrying: [{message}]", e.Message);
throw e; throw;
} }
} }
catch (APIException e) catch (APIException e)
@ -175,9 +175,9 @@ namespace Selector.Cache
else else
{ {
Logger.LogError("API error, done retrying: [{message}]", e.Message); Logger.LogError("API error, done retrying: [{message}]", e.Message);
throw e; throw;
} }
} }
} }
} }
} }

@ -1,13 +1,11 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using IF.Lastfm.Core.Api; using IF.Lastfm.Core.Api;
using IF.Lastfm.Core.Api.Helpers; using IF.Lastfm.Core.Api.Helpers;
using IF.Lastfm.Core.Objects; using IF.Lastfm.Core.Objects;
using Microsoft.Extensions.Logging;
using Selector.Spotify.Consumer;
using StackExchange.Redis; using StackExchange.Redis;
namespace Selector.Cache namespace Selector.Cache
@ -24,7 +22,6 @@ namespace Selector.Cache
public PlayCountPuller( public PlayCountPuller(
ILogger<PlayCountPuller> logger, ILogger<PlayCountPuller> logger,
ITrackApi trackClient, ITrackApi trackClient,
IAlbumApi albumClient, IAlbumApi albumClient,
IArtistApi artistClient, IArtistApi artistClient,
@ -47,7 +44,7 @@ namespace Selector.Cache
var trackCache = Cache?.StringGetAsync(Key.TrackPlayCount(username, track, artist)); var trackCache = Cache?.StringGetAsync(Key.TrackPlayCount(username, track, artist));
var albumCache = Cache?.StringGetAsync(Key.AlbumPlayCount(username, album, albumArtist)); var albumCache = Cache?.StringGetAsync(Key.AlbumPlayCount(username, album, albumArtist));
var artistCache = Cache?.StringGetAsync(Key.ArtistPlayCount(username, artist)); var artistCache = Cache?.StringGetAsync(Key.ArtistPlayCount(username, artist));
var userCache = Cache?.StringGetAsync(Key.UserPlayCount(username)); var userCache = Cache?.StringGetAsync(Key.UserPlayCount(username));
var cacheTasks = new Task[] { trackCache, albumCache, artistCache, userCache }; var cacheTasks = new Task[] { trackCache, albumCache, artistCache, userCache };
@ -66,7 +63,7 @@ namespace Selector.Cache
if (trackCache is not null && trackCache.IsCompletedSuccessfully && trackCache.Result != RedisValue.Null) if (trackCache is not null && trackCache.IsCompletedSuccessfully && trackCache.Result != RedisValue.Null)
{ {
playCount.Track = (int) trackCache.Result; playCount.Track = (int)trackCache.Result;
} }
else else
{ {
@ -75,7 +72,7 @@ namespace Selector.Cache
if (albumCache is not null && albumCache.IsCompletedSuccessfully && albumCache.Result != RedisValue.Null) if (albumCache is not null && albumCache.IsCompletedSuccessfully && albumCache.Result != RedisValue.Null)
{ {
playCount.Album = (int) albumCache.Result; playCount.Album = (int)albumCache.Result;
} }
else else
{ {
@ -84,7 +81,7 @@ namespace Selector.Cache
if (artistCache is not null && artistCache.IsCompletedSuccessfully && artistCache.Result != RedisValue.Null) if (artistCache is not null && artistCache.IsCompletedSuccessfully && artistCache.Result != RedisValue.Null)
{ {
playCount.Artist = (int) artistCache.Result; playCount.Artist = (int)artistCache.Result;
} }
else else
{ {
@ -93,14 +90,14 @@ namespace Selector.Cache
if (userCache is not null && userCache.IsCompletedSuccessfully && userCache.Result != RedisValue.Null) if (userCache is not null && userCache.IsCompletedSuccessfully && userCache.Result != RedisValue.Null)
{ {
playCount.User = (int) userCache.Result; playCount.User = (int)userCache.Result;
} }
else else
{ {
userHttp = UserClient.GetInfoAsync(username); userHttp = UserClient.GetInfoAsync(username);
} }
await Task.WhenAll(new Task[] {trackHttp, albumHttp, artistHttp, userHttp}.Where(t => t is not null)); await Task.WhenAll(new Task[] { trackHttp, albumHttp, artistHttp, userHttp }.Where(t => t is not null));
if (trackHttp is not null && trackHttp.IsCompletedSuccessfully) if (trackHttp is not null && trackHttp.IsCompletedSuccessfully)
{ {
@ -136,11 +133,12 @@ namespace Selector.Cache
} }
else else
{ {
Logger.LogDebug("User info error [{username}] [{userHttp.Result.Status}]", username, userHttp.Result.Status); Logger.LogDebug("User info error [{username}] [{userHttp.Result.Status}]", username,
userHttp.Result.Status);
} }
} }
return playCount; return playCount;
} }
} }
} }

@ -1,11 +1,13 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Selector.Spotify;
namespace Selector.Events namespace Selector.Events
{ {
[JsonSerializable(typeof(LastfmChange))] [JsonSerializable(typeof(LastfmChange))]
[JsonSerializable(typeof(SpotifyLinkChange))] [JsonSerializable(typeof(SpotifyLinkChange))]
[JsonSerializable(typeof(AppleMusicLinkChange))]
[JsonSerializable(typeof((string, CurrentlyPlayingDTO)))] [JsonSerializable(typeof((string, CurrentlyPlayingDTO)))]
public partial class CacheJsonContext: JsonSerializerContext public partial class CacheJsonContext : JsonSerializerContext
{ {
} }
} }

@ -0,0 +1,98 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Selector.Cache;
using StackExchange.Redis;
namespace Selector.Events
{
public class AppleMusicLinkChange
{
public string UserId { get; set; }
public bool PreviousLinkState { get; set; }
public bool NewLinkState { get; set; }
}
public partial class FromPubSub
{
public class AppleMusicLink : IEventMapping
{
private readonly ILogger<AppleMusicLink> Logger;
private readonly ISubscriber Subscriber;
private readonly UserEventBus UserEvent;
public AppleMusicLink(ILogger<AppleMusicLink> logger,
ISubscriber subscriber,
UserEventBus userEvent)
{
Logger = logger;
Subscriber = subscriber;
UserEvent = userEvent;
}
public async Task ConstructMapping()
{
Logger.LogDebug("Forming Apple Music link event mapping FROM cache TO event bus");
(await Subscriber.SubscribeAsync(RedisChannel.Pattern(Key.AllUserAppleMusic))).OnMessage(message =>
{
try
{
var userId = Key.Param(message.Channel);
var deserialised = JsonSerializer.Deserialize(message.Message,
CacheJsonContext.Default.AppleMusicLinkChange);
Logger.LogDebug("Received new Apple Music link event for [{userId}]", deserialised.UserId);
if (!userId.Equals(deserialised.UserId))
{
Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId,
deserialised.UserId);
}
UserEvent.OnAppleMusicLinkChange(this, deserialised);
}
catch (TaskCanceledException)
{
Logger.LogDebug("Task Cancelled");
}
catch (Exception e)
{
Logger.LogError(e, "Error parsing new Apple Music link event");
}
});
}
}
}
public partial class ToPubSub
{
public class AppleMusicLink : IEventMapping
{
private readonly ILogger<AppleMusicLink> Logger;
private readonly ISubscriber Subscriber;
private readonly UserEventBus UserEvent;
public AppleMusicLink(ILogger<AppleMusicLink> logger,
ISubscriber subscriber,
UserEventBus userEvent)
{
Logger = logger;
Subscriber = subscriber;
UserEvent = userEvent;
}
public Task ConstructMapping()
{
Logger.LogDebug("Forming Apple Music link event mapping TO cache FROM event bus");
UserEvent.AppleLinkChange += async (o, e) =>
{
var payload = JsonSerializer.Serialize(e, CacheJsonContext.Default.AppleMusicLinkChange);
await Subscriber.PublishAsync(RedisChannel.Literal(Key.UserAppleMusic(e.UserId)), payload);
};
return Task.CompletedTask;
}
}
}
}

@ -1,9 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using Selector.Cache; using Selector.Cache;
using StackExchange.Redis;
namespace Selector.Events namespace Selector.Events
{ {
@ -35,18 +33,20 @@ namespace Selector.Events
{ {
Logger.LogDebug("Forming Last.fm username event mapping FROM cache TO event bus"); Logger.LogDebug("Forming Last.fm username event mapping FROM cache TO event bus");
(await Subscriber.SubscribeAsync(Key.AllUserLastfm)).OnMessage(message => { (await Subscriber.SubscribeAsync(RedisChannel.Pattern(Key.AllUserLastfm))).OnMessage(message =>
{
try try
{ {
var userId = Key.Param(message.Channel); var userId = Key.Param(message.Channel);
var deserialised = JsonSerializer.Deserialize(message.Message, CacheJsonContext.Default.LastfmChange); var deserialised =
JsonSerializer.Deserialize(message.Message, CacheJsonContext.Default.LastfmChange);
Logger.LogDebug("Received new Last.fm username event for [{userId}]", deserialised.UserId); Logger.LogDebug("Received new Last.fm username event for [{userId}]", deserialised.UserId);
if (!userId.Equals(deserialised.UserId)) if (!userId.Equals(deserialised.UserId))
{ {
Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId, deserialised.UserId); Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId,
deserialised.UserId);
} }
UserEvent.OnLastfmCredChange(this, deserialised); UserEvent.OnLastfmCredChange(this, deserialised);
@ -84,7 +84,7 @@ namespace Selector.Events
UserEvent.LastfmCredChange += async (o, e) => UserEvent.LastfmCredChange += async (o, e) =>
{ {
var payload = JsonSerializer.Serialize(e, CacheJsonContext.Default.LastfmChange); var payload = JsonSerializer.Serialize(e, CacheJsonContext.Default.LastfmChange);
await Subscriber.PublishAsync(Key.UserLastfm(e.UserId), payload); await Subscriber.PublishAsync(RedisChannel.Literal(Key.UserLastfm(e.UserId)), payload);
}; };
return Task.CompletedTask; return Task.CompletedTask;

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

@ -1,9 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using Selector.Cache; using Selector.Cache;
using StackExchange.Redis;
namespace Selector.Events namespace Selector.Events
{ {
@ -35,18 +33,20 @@ namespace Selector.Events
{ {
Logger.LogDebug("Forming Spotify link event mapping FROM cache TO event bus"); Logger.LogDebug("Forming Spotify link event mapping FROM cache TO event bus");
(await Subscriber.SubscribeAsync(Key.AllUserSpotify)).OnMessage(message => { (await Subscriber.SubscribeAsync(RedisChannel.Pattern(Key.AllUserSpotify))).OnMessage(message =>
{
try try
{ {
var userId = Key.Param(message.Channel); var userId = Key.Param(message.Channel);
var deserialised = JsonSerializer.Deserialize(message.Message, CacheJsonContext.Default.SpotifyLinkChange); var deserialised = JsonSerializer.Deserialize(message.Message,
CacheJsonContext.Default.SpotifyLinkChange);
Logger.LogDebug("Received new Spotify link event for [{userId}]", deserialised.UserId); Logger.LogDebug("Received new Spotify link event for [{userId}]", deserialised.UserId);
if (!userId.Equals(deserialised.UserId)) if (!userId.Equals(deserialised.UserId))
{ {
Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId, deserialised.UserId); Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId,
deserialised.UserId);
} }
UserEvent.OnSpotifyLinkChange(this, deserialised); UserEvent.OnSpotifyLinkChange(this, deserialised);
@ -88,7 +88,7 @@ namespace Selector.Events
UserEvent.SpotifyLinkChange += async (o, e) => UserEvent.SpotifyLinkChange += async (o, e) =>
{ {
var payload = JsonSerializer.Serialize(e, CacheJsonContext.Default.SpotifyLinkChange); var payload = JsonSerializer.Serialize(e, CacheJsonContext.Default.SpotifyLinkChange);
await Subscriber.PublishAsync(Key.UserSpotify(e.UserId), payload); await Subscriber.PublishAsync(RedisChannel.Literal(Key.UserSpotify(e.UserId)), payload);
}; };
return Task.CompletedTask; return Task.CompletedTask;

@ -1,35 +1,38 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Selector.AppleMusic;
using Selector.AppleMusic.Consumer;
namespace Selector.Events namespace Selector.Events
{ {
public class UserEventFirer : IPlayerConsumer public class AppleUserEventFirer : IApplePlayerConsumer
{ {
protected readonly IPlayerWatcher Watcher; protected readonly IAppleMusicPlayerWatcher Watcher;
protected readonly ILogger<UserEventFirer> Logger; protected readonly ILogger<AppleUserEventFirer> Logger;
protected readonly UserEventBus UserEvent; protected readonly UserEventBus UserEvent;
public CancellationToken CancelToken { get; set; } public CancellationToken CancelToken { get; set; }
public UserEventFirer( public AppleUserEventFirer(
IPlayerWatcher watcher, IAppleMusicPlayerWatcher watcher,
UserEventBus userEvent, UserEventBus userEvent,
ILogger<UserEventFirer> logger = null, ILogger<AppleUserEventFirer> logger = null,
CancellationToken token = default CancellationToken token = default
) )
{ {
Watcher = watcher; Watcher = watcher;
UserEvent = userEvent; UserEvent = userEvent;
Logger = logger ?? NullLogger<UserEventFirer>.Instance; Logger = logger ?? NullLogger<AppleUserEventFirer>.Instance;
CancelToken = token; CancelToken = token;
} }
public void Callback(object sender, ListeningChangeEventArgs e) public void Callback(object sender, AppleListeningChangeEventArgs e)
{ {
if (e.Current is null) return; if (e.Current is null) return;
Task.Run(async () => { Task.Run(async () =>
{
try try
{ {
await AsyncCallback(e); await AsyncCallback(e);
@ -41,11 +44,11 @@ namespace Selector.Events
}, CancelToken); }, CancelToken);
} }
public Task AsyncCallback(ListeningChangeEventArgs e) public Task AsyncCallback(AppleListeningChangeEventArgs e)
{ {
Logger.LogDebug("Firing now playing event on user bus [{username}/{userId}]", e.SpotifyUsername, e.Id); Logger.LogDebug("Firing Apple now playing event on user bus [{userId}]", e.Id);
UserEvent.OnCurrentlyPlayingChange(this, (CurrentlyPlayingDTO) e); UserEvent.OnCurrentlyPlayingChangeApple(this, e);
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -54,7 +57,7 @@ namespace Selector.Events
{ {
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided"); var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast) if (watcher is IAppleMusicPlayerWatcher watcherCast)
{ {
watcherCast.ItemChange += Callback; watcherCast.ItemChange += Callback;
} }
@ -68,7 +71,7 @@ namespace Selector.Events
{ {
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided"); var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast) if (watcher is IAppleMusicPlayerWatcher watcherCast)
{ {
watcherCast.ItemChange -= Callback; watcherCast.ItemChange -= Callback;
} }
@ -78,4 +81,4 @@ namespace Selector.Events
} }
} }
} }
} }

@ -0,0 +1,85 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Selector.Spotify;
using Selector.Spotify.Consumer;
namespace Selector.Events
{
public class SpotifyUserEventFirer : ISpotifyPlayerConsumer
{
protected readonly ISpotifyPlayerWatcher Watcher;
protected readonly ILogger<SpotifyUserEventFirer> Logger;
protected readonly UserEventBus UserEvent;
public CancellationToken CancelToken { get; set; }
public SpotifyUserEventFirer(
ISpotifyPlayerWatcher watcher,
UserEventBus userEvent,
ILogger<SpotifyUserEventFirer> logger = null,
CancellationToken token = default
)
{
Watcher = watcher;
UserEvent = userEvent;
Logger = logger ?? NullLogger<SpotifyUserEventFirer>.Instance;
CancelToken = token;
}
public void Callback(object sender, SpotifyListeningChangeEventArgs e)
{
if (e.Current is null) return;
Task.Run(async () =>
{
try
{
await AsyncCallback(e);
}
catch (Exception e)
{
Logger.LogError(e, "Error occured during callback");
}
}, CancelToken);
}
public Task AsyncCallback(SpotifyListeningChangeEventArgs e)
{
Logger.LogDebug("Firing Spotify now playing event on user bus [{username}/{userId}]", e.SpotifyUsername,
e.Id);
UserEvent.OnCurrentlyPlayingChangeSpotify(this, (CurrentlyPlayingDTO)e);
return Task.CompletedTask;
}
public void Subscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
public void Unsubscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is ISpotifyPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
}
}

@ -1,13 +1,15 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Selector.Spotify;
namespace Selector.Events namespace Selector.Events
{ {
public interface IUserEventFirerFactory public interface IUserEventFirerFactory
{ {
public Task<UserEventFirer> Get(IPlayerWatcher watcher = null); public Task<SpotifyUserEventFirer> GetSpotify(ISpotifyPlayerWatcher watcher = null);
public Task<AppleUserEventFirer> GetApple(IAppleMusicPlayerWatcher watcher = null);
} }
public class UserEventFirerFactory: IUserEventFirerFactory public class UserEventFirerFactory : IUserEventFirerFactory
{ {
private readonly ILoggerFactory LoggerFactory; private readonly ILoggerFactory LoggerFactory;
private readonly UserEventBus UserEvent; private readonly UserEventBus UserEvent;
@ -18,13 +20,22 @@ namespace Selector.Events
UserEvent = userEvent; UserEvent = userEvent;
} }
public Task<UserEventFirer> Get(IPlayerWatcher watcher = null) public Task<SpotifyUserEventFirer> GetSpotify(ISpotifyPlayerWatcher watcher = null)
{ {
return Task.FromResult(new UserEventFirer( return Task.FromResult(new SpotifyUserEventFirer(
watcher, watcher,
UserEvent, UserEvent,
LoggerFactory.CreateLogger<UserEventFirer>() LoggerFactory.CreateLogger<SpotifyUserEventFirer>()
));
}
public Task<AppleUserEventFirer> GetApple(IAppleMusicPlayerWatcher watcher = null)
{
return Task.FromResult(new AppleUserEventFirer(
watcher,
UserEvent,
LoggerFactory.CreateLogger<AppleUserEventFirer>()
)); ));
} }
} }
} }

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

@ -1,18 +1,21 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Selector.AppleMusic;
using Selector.Model; using Selector.Model;
using Selector.Spotify;
namespace Selector.Events namespace Selector.Events
{ {
public class UserEventBus: IEventBus public class UserEventBus : IEventBus
{ {
private readonly ILogger<UserEventBus> Logger; private readonly ILogger<UserEventBus> Logger;
public event EventHandler<ApplicationUser> UserChange; public event EventHandler<ApplicationUser> UserChange;
public event EventHandler<SpotifyLinkChange> SpotifyLinkChange; public event EventHandler<SpotifyLinkChange> SpotifyLinkChange;
public event EventHandler<AppleMusicLinkChange> AppleLinkChange;
public event EventHandler<LastfmChange> LastfmCredChange; public event EventHandler<LastfmChange> LastfmCredChange;
public event EventHandler<CurrentlyPlayingDTO> CurrentlyPlaying; public event EventHandler<CurrentlyPlayingDTO> CurrentlyPlayingSpotify;
public event EventHandler<AppleListeningChangeEventArgs> CurrentlyPlayingApple;
public UserEventBus(ILogger<UserEventBus> logger) public UserEventBus(ILogger<UserEventBus> logger)
{ {
@ -31,16 +34,28 @@ namespace Selector.Events
SpotifyLinkChange?.Invoke(sender, args); SpotifyLinkChange?.Invoke(sender, args);
} }
public void OnAppleMusicLinkChange(object sender, AppleMusicLinkChange args)
{
Logger.LogTrace("Firing user Apple Music event [{usernamne}]", args?.UserId);
AppleLinkChange?.Invoke(sender, args);
}
public void OnLastfmCredChange(object sender, LastfmChange args) public void OnLastfmCredChange(object sender, LastfmChange args)
{ {
Logger.LogTrace("Firing user Last.fm event [{usernamne}]", args?.UserId); Logger.LogTrace("Firing user Last.fm event [{usernamne}]", args?.UserId);
LastfmCredChange?.Invoke(sender, args); LastfmCredChange?.Invoke(sender, args);
} }
public void OnCurrentlyPlayingChange(object sender, CurrentlyPlayingDTO args) public void OnCurrentlyPlayingChangeSpotify(object sender, CurrentlyPlayingDTO args)
{ {
Logger.LogTrace("Firing currently playing event [{usernamne}/{userId}]", args?.Username, args.UserId); Logger.LogTrace("Firing currently playing event [{usernamne}/{userId}]", args?.Username, args.UserId);
CurrentlyPlaying?.Invoke(sender, args); CurrentlyPlayingSpotify?.Invoke(sender, args);
}
public void OnCurrentlyPlayingChangeApple(object sender, AppleListeningChangeEventArgs args)
{
Logger.LogTrace("Firing currently playing event");
CurrentlyPlayingApple?.Invoke(sender, args);
} }
} }
} }

@ -0,0 +1,28 @@
using IF.Lastfm.Core.Api;
using IF.Lastfm.Core.Scrobblers;
using Microsoft.Extensions.DependencyInjection;
namespace Selector.Extensions;
public static class ServiceExtensions
{
public static IServiceCollection AddLastFm(this IServiceCollection services, string client, string secret)
{
services.AddTransient(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<IUserApi>(sp => sp.GetService<LastfmClient>().User);
services.AddTransient<IChartApi>(sp => sp.GetService<LastfmClient>().Chart);
services.AddTransient<ILibraryApi>(sp => sp.GetService<LastfmClient>().Library);
services.AddTransient<ITagApi>(sp => sp.GetService<LastfmClient>().Tag);
services.AddTransient<IScrobbler, MemoryScrobbler>();
return services;
}
}

@ -1,10 +1,7 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SpotifyAPI.Web; using SpotifyAPI.Web;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Selector namespace Selector.Mapping
{ {
/// <inheritdoc/> /// <inheritdoc/>
public class ScrobbleAlbumMapping : ScrobbleMapping public class ScrobbleAlbumMapping : ScrobbleMapping
@ -12,7 +9,8 @@ namespace Selector
public string AlbumName { get; set; } public string AlbumName { get; set; }
public string ArtistName { get; set; } public string ArtistName { get; set; }
public ScrobbleAlbumMapping(ISearchClient _searchClient, ILogger<ScrobbleAlbumMapping> _logger, string albumName, string artistName) : base(_searchClient, _logger) public ScrobbleAlbumMapping(ISearchClient _searchClient, ILogger<ScrobbleAlbumMapping> _logger,
string albumName, string artistName) : base(_searchClient, _logger)
{ {
AlbumName = albumName; AlbumName = albumName;
ArtistName = artistName; ArtistName = artistName;
@ -38,4 +36,4 @@ namespace Selector
} }
} }
} }
} }

@ -1,19 +1,15 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SpotifyAPI.Web; using SpotifyAPI.Web;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Selector namespace Selector.Mapping
{ {
/// <inheritdoc/> /// <inheritdoc/>
public class ScrobbleArtistMapping : ScrobbleMapping public class ScrobbleArtistMapping : ScrobbleMapping
{ {
public string ArtistName { get; set; } 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; ArtistName = artistName;
} }
@ -37,4 +33,4 @@ namespace Selector
} }
} }
} }
} }

@ -1,14 +1,15 @@
using Microsoft.Extensions.Logging; using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Selector.Operations; using Selector.Operations;
using SpotifyAPI.Web; using SpotifyAPI.Web;
using System;
using System.Diagnostics;
using System.Threading.Tasks;
namespace Selector namespace Selector.Mapping
{ {
public enum LastfmObject{ public enum LastfmObject
Track, Album, Artist {
Track,
Album,
Artist
} }
/// <summary> /// <summary>
@ -45,7 +46,7 @@ namespace Selector
logger.LogInformation("Mapping Last.fm {} ({}) to Spotify", Query, QueryType); logger.LogInformation("Mapping Last.fm {} ({}) to Spotify", Query, QueryType);
var netTime = Stopwatch.StartNew(); var netTime = Stopwatch.StartNew();
currentTask = searchClient.Item(new (QueryType, Query)); currentTask = searchClient.Item(new(QueryType, Query));
currentTask.ContinueWith(async t => currentTask.ContinueWith(async t =>
{ {
try try
@ -76,7 +77,8 @@ namespace Selector
} }
catch (Exception e) catch (Exception e)
{ {
logger.LogError(e, "Error while mapping Last.fm {} ({}) to Spotify on attempt {}", Query, QueryType, Attempts); logger.LogError(e, "Error while mapping Last.fm {} ({}) to Spotify on attempt {}", Query, QueryType,
Attempts);
Succeeded = false; Succeeded = false;
} }
}); });
@ -93,4 +95,4 @@ namespace Selector
Success?.Invoke(this, new EventArgs()); Success?.Invoke(this, new EventArgs());
} }
} }
} }

@ -1,12 +1,7 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SpotifyAPI.Web; using SpotifyAPI.Web;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Selector namespace Selector.Mapping
{ {
/// <inheritdoc/> /// <inheritdoc/>
public class ScrobbleTrackMapping : ScrobbleMapping public class ScrobbleTrackMapping : ScrobbleMapping
@ -14,7 +9,8 @@ namespace Selector
public string TrackName { get; set; } public string TrackName { get; set; }
public string ArtistName { 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; TrackName = trackName;
ArtistName = artistName; ArtistName = artistName;
@ -32,12 +28,12 @@ namespace Selector
{ {
var topResult = response.Result.Tracks.Items.FirstOrDefault(); var topResult = response.Result.Tracks.Items.FirstOrDefault();
if(topResult is not null if (topResult is not null
&& topResult.Name.Equals(TrackName, StringComparison.InvariantCultureIgnoreCase) && topResult.Name.Equals(TrackName, StringComparison.InvariantCultureIgnoreCase)
&& topResult.Artists.First().Name.Equals(ArtistName, StringComparison.InvariantCultureIgnoreCase)) && topResult.Artists.First().Name.Equals(ArtistName, StringComparison.InvariantCultureIgnoreCase))
{ {
result = topResult; result = topResult;
} }
} }
} }
} }

@ -1,19 +1,21 @@
using System; using IF.Lastfm.Core.Objects;
namespace Selector namespace Selector
{ {
public class Scrobble: IListen public class Scrobble : IListen
{ {
public DateTime Timestamp { get; set; } public DateTime Timestamp { get; set; }
public string TrackName { get; set; } public string? TrackName { get; set; }
public string AlbumName { get; set; } public string? AlbumName { get; set; }
/// <summary> /// <summary>
/// Not populated by default from the service, where not the same as <see cref="ArtistName"/> these have been manually entered /// Not populated by default from the service, where not the same as <see cref="ArtistName"/> these have been manually entered
/// </summary> /// </summary>
public string AlbumArtistName { get; set; } public string? AlbumArtistName { get; set; }
public string ArtistName { get; set; }
public string? ArtistName { get; set; }
public static explicit operator Scrobble(IF.Lastfm.Core.Objects.LastTrack track) => new()
public static explicit operator Scrobble(LastTrack track) => new()
{ {
Timestamp = track.TimePlayed?.UtcDateTime ?? DateTime.MinValue, Timestamp = track.TimePlayed?.UtcDateTime ?? DateTime.MinValue,
@ -24,4 +26,4 @@ namespace Selector
public override string ToString() => $"({Timestamp}) {TrackName}, {AlbumName}, {ArtistName}"; public override string ToString() => $"({Timestamp}) {TrackName}, {AlbumName}, {ArtistName}";
} }
} }

@ -1,7 +1,4 @@
using System; using System.Diagnostics.CodeAnalysis;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace Selector namespace Selector
{ {
@ -13,7 +10,8 @@ namespace Selector
public static bool MatchTime(IListen nativeScrobble, IListen serviceScrobble) public static bool MatchTime(IListen nativeScrobble, IListen serviceScrobble)
=> serviceScrobble.Timestamp.Equals(nativeScrobble.Timestamp); => serviceScrobble.Timestamp.Equals(nativeScrobble.Timestamp);
public static (IEnumerable<IListen>, IEnumerable<IListen>) IdentifyDiffs(IEnumerable<IListen> existing, IEnumerable<IListen> toApply, bool matchContents = true) public static (IEnumerable<IListen>, IEnumerable<IListen>) IdentifyDiffs(IEnumerable<IListen> existing,
IEnumerable<IListen> toApply, bool matchContents = true)
{ {
existing = existing.OrderBy(s => s.Timestamp); existing = existing.OrderBy(s => s.Timestamp);
toApply = toApply.OrderBy(s => s.Timestamp); toApply = toApply.OrderBy(s => s.Timestamp);
@ -96,7 +94,8 @@ namespace Selector
} }
} }
public static (IEnumerable<IListen>, IEnumerable<IListen>) IdentifyDiffsContains(IEnumerable<IListen> existing, IEnumerable<IListen> toApply) public static (IEnumerable<IListen>, IEnumerable<IListen>) IdentifyDiffsContains(IEnumerable<IListen> existing,
IEnumerable<IListen> toApply)
{ {
var toAdd = toApply.Where(s => !existing.Contains(s, new ListenComp())); var toAdd = toApply.Where(s => !existing.Contains(s, new ListenComp()));
var toRemove = existing.Where(s => !toApply.Contains(s, new ListenComp())); var toRemove = existing.Where(s => !toApply.Contains(s, new ListenComp()));
@ -111,4 +110,4 @@ namespace Selector
public int GetHashCode([DisallowNull] IListen obj) => obj.Timestamp.GetHashCode(); public int GetHashCode([DisallowNull] IListen obj) => obj.Timestamp.GetHashCode();
} }
} }
} }

@ -1,13 +1,9 @@
using IF.Lastfm.Core.Api; using System.Diagnostics;
using IF.Lastfm.Core.Api;
using IF.Lastfm.Core.Api.Helpers; using IF.Lastfm.Core.Api.Helpers;
using IF.Lastfm.Core.Objects; using IF.Lastfm.Core.Objects;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Selector.Operations; using Selector.Operations;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
namespace Selector namespace Selector
{ {
@ -34,7 +30,8 @@ namespace Selector
private TaskCompletionSource AggregateTaskSource { get; set; } = new(); private TaskCompletionSource AggregateTaskSource { get; set; } = new();
public Task Task => AggregateTaskSource.Task; 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; userClient = _userClient;
logger = _logger; logger = _logger;
@ -50,15 +47,24 @@ namespace Selector
public Task Execute() public Task Execute()
{ {
using var scope = logger.BeginScope(new Dictionary<string, object>() { { "username", username }, { "page_number", pageNumber }, { "page_size", pageSize }, { "from", from }, { "to", to } }); using var scope = logger.BeginScope(new Dictionary<string, object>()
{
{ "username", username }, { "page_number", pageNumber }, { "page_size", pageSize }, { "from", from },
{ "to", to }
});
logger.LogInformation("Starting request"); logger.LogInformation("Starting request");
var netTime = Stopwatch.StartNew(); var netTime = Stopwatch.StartNew();
currentTask = userClient.GetRecentScrobbles(username, pagenumber: pageNumber, count: pageSize, from: from, to: to); currentTask =
userClient.GetRecentScrobbles(username, pagenumber: pageNumber, count: pageSize, from: from, to: to);
currentTask.ContinueWith(async t => currentTask.ContinueWith(async t =>
{ {
using var scope = logger.BeginScope(new Dictionary<string, object>() { { "username", username }, { "page_number", pageNumber }, { "page_size", pageSize }, { "from", from }, { "to", to } }); using var scope = logger.BeginScope(new Dictionary<string, object>()
{
{ "username", username }, { "page_number", pageNumber }, { "page_size", pageSize },
{ "from", from }, { "to", to }
});
try try
{ {
@ -81,12 +87,14 @@ namespace Selector
{ {
if (Attempts < MaxAttempts) if (Attempts < MaxAttempts)
{ {
logger.LogDebug("Request failed: {}, retrying ({} of {})", result.Status, Attempts + 1, MaxAttempts); logger.LogDebug("Request failed: {}, retrying ({} of {})", result.Status, Attempts + 1,
MaxAttempts);
await Execute(); await Execute();
} }
else else
{ {
logger.LogDebug("Request failed: {}, max retries exceeded {}, not retrying", result.Status, MaxAttempts); logger.LogDebug("Request failed: {}, max retries exceeded {}, not retrying",
result.Status, MaxAttempts);
AggregateTaskSource.SetCanceled(); AggregateTaskSource.SetCanceled();
} }
} }
@ -97,7 +105,7 @@ namespace Selector
AggregateTaskSource.SetException(t.Exception); AggregateTaskSource.SetException(t.Exception);
} }
} }
catch(Exception e) 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; Succeeded = false;
@ -113,4 +121,4 @@ namespace Selector
Success?.Invoke(this, new EventArgs()); Success?.Invoke(this, new EventArgs());
} }
} }
} }

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0"/>
<PackageReference Include="SpotifyAPI.Web" Version="7.2.1"/>
<PackageReference Include="Inflatable.Lastfm" Version="1.2.0"/>
<PackageReference Include="System.Linq.Async" Version="6.0.1"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Selector\Selector.csproj"/>
</ItemGroup>
</Project>

@ -10,17 +10,16 @@ public partial class App : Application
private readonly ILogger<App> logger; private readonly ILogger<App> logger;
public App(NowHubClient nowClient, ILogger<App> logger) public App(NowHubClient nowClient, ILogger<App> logger)
{ {
InitializeComponent(); InitializeComponent();
MainPage = new MainPage();
this.nowClient = nowClient; this.nowClient = nowClient;
this.logger = logger; this.logger = logger;
} }
protected override Window CreateWindow(IActivationState activationState) protected override Window CreateWindow(IActivationState activationState)
{ {
Window window = base.CreateWindow(activationState); Window window = new Window(new MainPage());
window.Resumed += async (s, e) => window.Resumed += async (s, e) =>
{ {
@ -36,16 +35,13 @@ public partial class App : Application
await nowClient.OnConnected(); await nowClient.OnConnected();
logger.LogInformation("Hubs reconnected"); logger.LogInformation("Hubs reconnected");
} }
catch(Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error while reconnecting hubs"); logger.LogError(ex, "Error while reconnecting hubs");
} }
}; };
return window; return window;
} }
} }

@ -64,6 +64,8 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<CodesignKey>iPhone Developer</CodesignKey> <CodesignKey>iPhone Developer</CodesignKey>
<MtouchDebug>true</MtouchDebug>
<IOSDebugOverWiFi>true</IOSDebugOverWiFi>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' "> <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<CodesignKey>iPhone Developer</CodesignKey> <CodesignKey>iPhone Developer</CodesignKey>
@ -83,6 +85,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.14"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="9.0.14"/>
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageReference Include="System.Net.Http.Json" Version="9.0.0" /> <PackageReference Include="System.Net.Http.Json" Version="9.0.0" />

@ -1,5 +1,5 @@
@using SpotifyAPI.Web; @using Selector.Spotify.Consumer
@using SpotifyAPI.Web
@if (Count is not null) { @if (Count is not null) {
<div class="card info-card"> <div class="card info-card">

@ -65,6 +65,14 @@
width: 21px; width: 21px;
} }
.apple-logo {
width: 21px;
}
.apple-logo img {
width: 21px;
}
.lastfm-logo { .lastfm-logo {
width: 24px; width: 24px;
} }

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

@ -16,6 +16,12 @@ namespace Selector.Model
public string SpotifyAccessToken { get; set; } public string SpotifyAccessToken { get; set; }
public string SpotifyRefreshToken { get; set; } public string SpotifyRefreshToken { get; set; }
[PersonalData]
public bool AppleMusicLinked { get; set; }
public string AppleMusicKey { get; set; }
[PersonalData]
public DateTime AppleMusicLastRefresh { get; set; }
[PersonalData] [PersonalData]
public string LastFmUsername { get; set; } public string LastFmUsername { get; set; }
[PersonalData] [PersonalData]
@ -39,6 +45,10 @@ namespace Selector.Model
public string SpotifyAccessToken { get; set; } public string SpotifyAccessToken { get; set; }
public string SpotifyRefreshToken { get; set; } public string SpotifyRefreshToken { get; set; }
public bool AppleMusicLinked { get; set; }
public string AppleMusicKey { get; set; }
public DateTime AppleMusicLastRefresh { get; set; }
public string LastFmUsername { get; set; } public string LastFmUsername { get; set; }
public static explicit operator ApplicationUserDTO(ApplicationUser user) => new() { public static explicit operator ApplicationUserDTO(ApplicationUser user) => new() {
@ -54,6 +64,10 @@ namespace Selector.Model
SpotifyAccessToken = user.SpotifyAccessToken, SpotifyAccessToken = user.SpotifyAccessToken,
SpotifyRefreshToken = user.SpotifyRefreshToken, SpotifyRefreshToken = user.SpotifyRefreshToken,
AppleMusicLinked = user.AppleMusicLinked,
AppleMusicKey = user.AppleMusicKey,
AppleMusicLastRefresh = user.AppleMusicLastRefresh,
LastFmUsername = user.LastFmUsername LastFmUsername = user.LastFmUsername
}; };
} }

@ -2,11 +2,20 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Selector.Cache; using Selector.Cache;
using Selector.Model.Authorisation; using Selector.Model.Authorisation;
using Selector.Spotify;
using Selector.Spotify.Watcher;
namespace Selector.Model.Extensions namespace Selector.Model.Extensions
{ {
public static class ServiceExtensions public static class ServiceExtensions
{ {
public static IServiceCollection AddSpotifyWatcher(this IServiceCollection services)
{
services.AddSingleton<ISpotifyWatcherFactory, SpotifyWatcherFactory>();
return services;
}
public static void AddAuthorisationHandlers(this IServiceCollection services) public static void AddAuthorisationHandlers(this IServiceCollection services)
{ {
services.AddScoped<IAuthorizationHandler, WatcherIsOwnerAuthHandler>(); services.AddScoped<IAuthorizationHandler, WatcherIsOwnerAuthHandler>();
@ -23,4 +32,4 @@ namespace Selector.Model.Extensions
return services; return services;
} }
} }
} }

@ -0,0 +1,26 @@
using Selector.AppleMusic.Watcher;
namespace Selector.Model;
public class AppleMusicListen : Listen, IUserListen
{
public int Id { get; set; }
public string TrackId { get; set; }
public string Isrc { get; set; }
public string UserId { get; set; }
public ApplicationUser User { get; set; }
public static explicit operator AppleMusicListen(AppleMusicCurrentlyPlayingContext track) => new()
{
Timestamp = track.FirstSeen,
TrackId = track.Track.Id,
Isrc = track.Track.Attributes.Isrc,
TrackName = track.Track.Attributes.Name,
AlbumName = track.Track.Attributes.AlbumName,
ArtistName = track.Track.Attributes.ArtistName,
};
}

@ -0,0 +1,500 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Selector.Model;
#nullable disable
namespace Selector.Model.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250329231051_adding_apple_music_user_properties")]
partial class adding_apple_music_user_properties
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "en-u-ks-primary,en-u-ks-primary,icu,False")
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
b.HasData(
new
{
Id = "00c64c0a-3387-4933-9575-83443fa9092b",
Name = "Admin",
NormalizedName = "ADMIN"
});
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Selector.Model.AlbumLastfmSpotifyMapping", b =>
{
b.Property<string>("SpotifyUri")
.HasColumnType("text");
b.Property<string>("LastfmAlbumName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("LastfmArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.HasKey("SpotifyUri");
b.ToTable("AlbumMapping");
});
modelBuilder.Entity("Selector.Model.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("AppleMusicKey")
.HasColumnType("text");
b.Property<DateTime>("AppleMusicLastRefresh")
.HasColumnType("timestamp with time zone");
b.Property<bool>("AppleMusicLinked")
.HasColumnType("boolean");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("LastFmUsername")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<bool>("SaveScrobbles")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<string>("SpotifyAccessToken")
.HasColumnType("text");
b.Property<bool>("SpotifyIsLinked")
.HasColumnType("boolean");
b.Property<DateTime>("SpotifyLastRefresh")
.HasColumnType("timestamp with time zone");
b.Property<string>("SpotifyRefreshToken")
.HasColumnType("text");
b.Property<int>("SpotifyTokenExpiry")
.HasColumnType("integer");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Selector.Model.ArtistLastfmSpotifyMapping", b =>
{
b.Property<string>("SpotifyUri")
.HasColumnType("text");
b.Property<string>("LastfmArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.HasKey("SpotifyUri");
b.ToTable("ArtistMapping");
});
modelBuilder.Entity("Selector.Model.SpotifyListen", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AlbumName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("ArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<int?>("PlayedDuration")
.HasColumnType("integer");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("TrackName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("TrackUri")
.HasColumnType("text");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("SpotifyListen");
});
modelBuilder.Entity("Selector.Model.TrackLastfmSpotifyMapping", b =>
{
b.Property<string>("SpotifyUri")
.HasColumnType("text");
b.Property<string>("LastfmArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("LastfmTrackName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.HasKey("SpotifyUri");
b.ToTable("TrackMapping");
});
modelBuilder.Entity("Selector.Model.UserScrobble", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AlbumArtistName")
.HasColumnType("text");
b.Property<string>("AlbumName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("ArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("TrackName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Scrobble");
});
modelBuilder.Entity("Selector.Model.Watcher", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Watcher");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Selector.Model.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Selector.Model.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Selector.Model.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Selector.Model.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Selector.Model.SpotifyListen", b =>
{
b.HasOne("Selector.Model.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("Selector.Model.UserScrobble", b =>
{
b.HasOne("Selector.Model.ApplicationUser", "User")
.WithMany("Scrobbles")
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("Selector.Model.Watcher", b =>
{
b.HasOne("Selector.Model.ApplicationUser", "User")
.WithMany("Watchers")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Selector.Model.ApplicationUser", b =>
{
b.Navigation("Scrobbles");
b.Navigation("Watchers");
});
#pragma warning restore 612, 618
}
}
}

@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Selector.Model.Migrations
{
/// <inheritdoc />
public partial class adding_apple_music_user_properties : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AppleMusicKey",
table: "AspNetUsers",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "AppleMusicLastRefresh",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<bool>(
name: "AppleMusicLinked",
table: "AspNetUsers",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AppleMusicKey",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "AppleMusicLastRefresh",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "AppleMusicLinked",
table: "AspNetUsers");
}
}
}

@ -0,0 +1,548 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Selector.Model;
#nullable disable
namespace Selector.Model.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250331203003_add_apple_listen")]
partial class add_apple_listen
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "en-u-ks-primary,en-u-ks-primary,icu,False")
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
b.HasData(
new
{
Id = "00c64c0a-3387-4933-9575-83443fa9092b",
Name = "Admin",
NormalizedName = "ADMIN"
});
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Selector.Model.AlbumLastfmSpotifyMapping", b =>
{
b.Property<string>("SpotifyUri")
.HasColumnType("text");
b.Property<string>("LastfmAlbumName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("LastfmArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.HasKey("SpotifyUri");
b.ToTable("AlbumMapping");
});
modelBuilder.Entity("Selector.Model.AppleMusicListen", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AlbumName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("ArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("Isrc")
.HasColumnType("text");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("TrackId")
.HasColumnType("text");
b.Property<string>("TrackName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AppleMusicListen");
});
modelBuilder.Entity("Selector.Model.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("AppleMusicKey")
.HasColumnType("text");
b.Property<DateTime>("AppleMusicLastRefresh")
.HasColumnType("timestamp with time zone");
b.Property<bool>("AppleMusicLinked")
.HasColumnType("boolean");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("LastFmUsername")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<bool>("SaveScrobbles")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<string>("SpotifyAccessToken")
.HasColumnType("text");
b.Property<bool>("SpotifyIsLinked")
.HasColumnType("boolean");
b.Property<DateTime>("SpotifyLastRefresh")
.HasColumnType("timestamp with time zone");
b.Property<string>("SpotifyRefreshToken")
.HasColumnType("text");
b.Property<int>("SpotifyTokenExpiry")
.HasColumnType("integer");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Selector.Model.ArtistLastfmSpotifyMapping", b =>
{
b.Property<string>("SpotifyUri")
.HasColumnType("text");
b.Property<string>("LastfmArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.HasKey("SpotifyUri");
b.ToTable("ArtistMapping");
});
modelBuilder.Entity("Selector.Model.SpotifyListen", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AlbumName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("ArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<int?>("PlayedDuration")
.HasColumnType("integer");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("TrackName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("TrackUri")
.HasColumnType("text");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("SpotifyListen");
});
modelBuilder.Entity("Selector.Model.TrackLastfmSpotifyMapping", b =>
{
b.Property<string>("SpotifyUri")
.HasColumnType("text");
b.Property<string>("LastfmArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("LastfmTrackName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.HasKey("SpotifyUri");
b.ToTable("TrackMapping");
});
modelBuilder.Entity("Selector.Model.UserScrobble", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AlbumArtistName")
.HasColumnType("text");
b.Property<string>("AlbumName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("ArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("TrackName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Scrobble");
});
modelBuilder.Entity("Selector.Model.Watcher", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Watcher");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Selector.Model.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Selector.Model.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Selector.Model.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Selector.Model.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Selector.Model.AppleMusicListen", b =>
{
b.HasOne("Selector.Model.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("Selector.Model.SpotifyListen", b =>
{
b.HasOne("Selector.Model.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("Selector.Model.UserScrobble", b =>
{
b.HasOne("Selector.Model.ApplicationUser", "User")
.WithMany("Scrobbles")
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("Selector.Model.Watcher", b =>
{
b.HasOne("Selector.Model.ApplicationUser", "User")
.WithMany("Watchers")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Selector.Model.ApplicationUser", b =>
{
b.Navigation("Scrobbles");
b.Navigation("Watchers");
});
#pragma warning restore 612, 618
}
}
}

@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Selector.Model.Migrations
{
/// <inheritdoc />
public partial class add_apple_listen : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppleMusicListen",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TrackId = table.Column<string>(type: "text", nullable: true),
Isrc = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<string>(type: "text", nullable: true),
TrackName = table.Column<string>(type: "text", nullable: true, collation: "case_insensitive"),
AlbumName = table.Column<string>(type: "text", nullable: true, collation: "case_insensitive"),
ArtistName = table.Column<string>(type: "text", nullable: true, collation: "case_insensitive"),
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppleMusicListen", x => x.Id);
table.ForeignKey(
name: "FK_AppleMusicListen_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_AppleMusicListen_UserId",
table: "AppleMusicListen",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppleMusicListen");
}
}
}

@ -181,6 +181,45 @@ namespace Selector.Model.Migrations
b.ToTable("AlbumMapping"); b.ToTable("AlbumMapping");
}); });
modelBuilder.Entity("Selector.Model.AppleMusicListen", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AlbumName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("ArtistName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("Isrc")
.HasColumnType("text");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("TrackId")
.HasColumnType("text");
b.Property<string>("TrackName")
.HasColumnType("text")
.UseCollation("case_insensitive");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AppleMusicListen");
});
modelBuilder.Entity("Selector.Model.ApplicationUser", b => modelBuilder.Entity("Selector.Model.ApplicationUser", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@ -189,6 +228,15 @@ namespace Selector.Model.Migrations
b.Property<int>("AccessFailedCount") b.Property<int>("AccessFailedCount")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("AppleMusicKey")
.HasColumnType("text");
b.Property<DateTime>("AppleMusicLastRefresh")
.HasColumnType("timestamp with time zone");
b.Property<bool>("AppleMusicLinked")
.HasColumnType("boolean");
b.Property<string>("ConcurrencyStamp") b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken() .IsConcurrencyToken()
.HasColumnType("text"); .HasColumnType("text");
@ -447,6 +495,15 @@ namespace Selector.Model.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Selector.Model.AppleMusicListen", b =>
{
b.HasOne("Selector.Model.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("Selector.Model.SpotifyListen", b => modelBuilder.Entity("Selector.Model.SpotifyListen", b =>
{ {
b.HasOne("Selector.Model.ApplicationUser", "User") b.HasOne("Selector.Model.ApplicationUser", "User")

@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Selector.Model; using Selector.Model;
using Selector.Spotify.Consumer;
namespace Selector.Cache namespace Selector.Cache
{ {
@ -27,7 +28,8 @@ namespace Selector.Cache
var userScrobbleCount = ScrobbleRepository.Count(username: username); var userScrobbleCount = ScrobbleRepository.Count(username: username);
var artistScrobbles = ScrobbleRepository.GetAll(username: username, artistName: artist, tracking: false, orderTime: true).ToArray(); var artistScrobbles = ScrobbleRepository
.GetAll(username: username, artistName: artist, tracking: false, orderTime: true).ToArray();
var albumScrobbles = artistScrobbles.Where( var albumScrobbles = artistScrobbles.Where(
s => s.AlbumName.Equals(album, StringComparison.CurrentCultureIgnoreCase)).ToArray(); s => s.AlbumName.Equals(album, StringComparison.CurrentCultureIgnoreCase)).ToArray();
var trackScrobbles = artistScrobbles.Where( var trackScrobbles = artistScrobbles.Where(
@ -67,4 +69,4 @@ namespace Selector.Cache
return Task.FromResult(playCount); return Task.FromResult(playCount);
} }
} }
} }

@ -7,6 +7,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Selector.AppleMusic\Selector.AppleMusic.csproj"/>
<ProjectReference Include="..\Selector.LastFm\Selector.LastFm.csproj"/>
<ProjectReference Include="..\Selector.Spotify\Selector.Spotify.csproj"/>
<ProjectReference Include="..\Selector\Selector.csproj" /> <ProjectReference Include="..\Selector\Selector.csproj" />
</ItemGroup> </ItemGroup>
@ -30,7 +33,6 @@
<ItemGroup> <ItemGroup>
<Folder Include="Migrations\" /> <Folder Include="Migrations\" />
<Folder Include="Listen\" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

@ -1,6 +1,6 @@
using System; using Selector.Spotify;
using Selector.Spotify.Consumer;
using SpotifyAPI.Web; using SpotifyAPI.Web;
using System.Threading.Tasks;
namespace Selector.SignalR; namespace Selector.SignalR;
@ -20,5 +20,4 @@ public interface INowPlayingHub
Task SendFacts(string track, string artist, string album, string albumArtist); Task SendFacts(string track, string artist, string album, string albumArtist);
Task SendNewPlaying(); Task SendNewPlaying();
Task SendPlayCount(string track, string artist, string album, string albumArtist); Task SendPlayCount(string track, string artist, string album, string albumArtist);
} }

@ -1,41 +1,42 @@
using System; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging; using Selector.Spotify;
using Selector.Spotify.Consumer;
using SpotifyAPI.Web; using SpotifyAPI.Web;
namespace Selector.SignalR; namespace Selector.SignalR;
public class NowHubCache public class NowHubCache
{ {
private readonly NowHubClient _connection; 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(); public List<Card> LastCards { get; private set; } = new();
private readonly object updateLock = new(); private readonly object updateLock = new();
private readonly object bindingLock = new(); private readonly object bindingLock = new();
private bool isBound = false; private bool isBound = false;
public PlayCount LastPlayCount { get; private set; } public PlayCount LastPlayCount { get; private set; }
public CurrentlyPlayingDTO LastPlaying { get; private set; } public CurrentlyPlayingDTO LastPlaying { get; private set; }
public event EventHandler NewAudioFeature; public event EventHandler NewAudioFeature;
public event EventHandler NewCard; public event EventHandler NewCard;
public event EventHandler NewPlayCount; public event EventHandler NewPlayCount;
public event EventHandler NewNowPlaying; public event EventHandler NewNowPlaying;
public NowHubCache(NowHubClient connection, ILogger<NowHubCache> logger) public NowHubCache(NowHubClient connection, ILogger<NowHubCache> logger)
{ {
_connection = connection; _connection = connection;
this.logger = logger; this.logger = logger;
} }
public void BindClient() public void BindClient()
{ {
lock(bindingLock) lock (bindingLock)
{ {
if(!isBound) if (!isBound)
{ {
_connection.OnNewAudioFeature(af => _connection.OnNewAudioFeature(af =>
{ {
lock (updateLock) lock (updateLock)
@ -108,7 +109,6 @@ public class NowHubCache
isBound = true; isBound = true;
} }
} }
} }
} }

@ -1,11 +1,11 @@
using System; using Microsoft.AspNetCore.SignalR.Client;
using System.Threading.Tasks; using Selector.Spotify;
using Microsoft.AspNetCore.SignalR.Client; using Selector.Spotify.Consumer;
using SpotifyAPI.Web; using SpotifyAPI.Web;
namespace Selector.SignalR; namespace Selector.SignalR;
public class NowHubClient: BaseSignalRClient, INowPlayingHub, IDisposable public class NowHubClient : BaseSignalRClient, INowPlayingHub, IDisposable
{ {
private List<IDisposable> NewPlayingCallbacks = new(); private List<IDisposable> NewPlayingCallbacks = new();
private List<IDisposable> NewAudioFeatureCallbacks = new(); private List<IDisposable> NewAudioFeatureCallbacks = new();
@ -13,9 +13,9 @@ public class NowHubClient: BaseSignalRClient, INowPlayingHub, IDisposable
private List<IDisposable> NewCardCallbacks = new(); private List<IDisposable> NewCardCallbacks = new();
private bool disposedValue; private bool disposedValue;
public NowHubClient(string token = null): base("nowhub", token) public NowHubClient(string token = null) : base("nowhub", token)
{ {
} }
public void OnNewPlaying(Action<CurrentlyPlayingDTO> action) public void OnNewPlaying(Action<CurrentlyPlayingDTO> action)
{ {
@ -93,10 +93,10 @@ public class NowHubClient: BaseSignalRClient, INowPlayingHub, IDisposable
{ {
if (disposing) if (disposing)
{ {
foreach(var callback in NewPlayingCallbacks foreach (var callback in NewPlayingCallbacks
.Concat(NewAudioFeatureCallbacks) .Concat(NewAudioFeatureCallbacks)
.Concat(NewPlayCountCallbacks) .Concat(NewPlayCountCallbacks)
.Concat(NewCardCallbacks)) .Concat(NewCardCallbacks))
{ {
callback.Dispose(); callback.Dispose();
} }
@ -114,5 +114,4 @@ public class NowHubClient: BaseSignalRClient, INowPlayingHub, IDisposable
Dispose(disposing: true); Dispose(disposing: true);
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
} }

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

@ -1,10 +1,9 @@
using System.Threading.Tasks; using SpotifyAPI.Web;
using SpotifyAPI.Web;
namespace Selector namespace Selector.Spotify.ConfigFactory
{ {
public interface ISpotifyConfigFactory public interface ISpotifyConfigFactory
{ {
public Task<SpotifyClientConfig> GetConfig(); public Task<SpotifyClientConfig> GetConfig();
} }
} }

@ -1,9 +1,6 @@
using System.Threading.Tasks; using SpotifyAPI.Web;
using SpotifyAPI.Web; namespace Selector.Spotify.ConfigFactory
namespace Selector
{ {
/// <summary> /// <summary>
/// Get config from a refresh token /// Get config from a refresh token
@ -14,7 +11,8 @@ namespace Selector
private string ClientSecret { get; set; } private string ClientSecret { get; set; }
private string RefreshToken { get; set; } private string RefreshToken { get; set; }
public RefreshTokenFactory(string clientId, string clientSecret, string refreshToken) { public RefreshTokenFactory(string clientId, string clientSecret, string refreshToken)
{
ClientId = clientId; ClientId = clientId;
ClientSecret = clientSecret; ClientSecret = clientSecret;
RefreshToken = refreshToken; RefreshToken = refreshToken;
@ -27,7 +25,8 @@ namespace Selector
var config = SpotifyClientConfig var config = SpotifyClientConfig
.CreateDefault() .CreateDefault()
.WithAuthenticator(new AuthorizationCodeAuthenticator(ClientId, ClientSecret, new(){ .WithAuthenticator(new AuthorizationCodeAuthenticator(ClientId, ClientSecret, new()
{
AccessToken = refreshed.AccessToken, AccessToken = refreshed.AccessToken,
TokenType = refreshed.TokenType, TokenType = refreshed.TokenType,
ExpiresIn = refreshed.ExpiresIn, ExpiresIn = refreshed.ExpiresIn,
@ -39,4 +38,4 @@ namespace Selector
return config; return config;
} }
} }
} }

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

@ -1,13 +1,8 @@
using System; using Microsoft.Extensions.Logging;
using System.Collections.Generic; using Selector.Extensions;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using SpotifyAPI.Web; using SpotifyAPI.Web;
namespace Selector namespace Selector.Spotify.Consumer
{ {
public class DummyAudioFeatureInjector : AudioFeatureInjector public class DummyAudioFeatureInjector : AudioFeatureInjector
{ {
@ -30,18 +25,18 @@ namespace Selector
Valence = 0.5f, Valence = 0.5f,
} }
}; };
private int _contextIdx = 0; private int _contextIdx = 0;
private DateTime _lastNext = DateTime.UtcNow; private DateTime _lastNext = DateTime.UtcNow;
private TimeSpan _contextLifespan = TimeSpan.FromSeconds(30); private TimeSpan _contextLifespan = TimeSpan.FromSeconds(30);
public DummyAudioFeatureInjector( public DummyAudioFeatureInjector(
IPlayerWatcher watcher, ISpotifyPlayerWatcher watcher,
ILogger<DummyAudioFeatureInjector> logger = null, ILogger<DummyAudioFeatureInjector> logger = null,
CancellationToken token = default CancellationToken token = default
): base (watcher, null, logger, token) ) : base(watcher, null, logger, token)
{ {
} }
private bool ShouldCycle() => DateTime.UtcNow - _lastNext > _contextLifespan; private bool ShouldCycle() => DateTime.UtcNow - _lastNext > _contextLifespan;
@ -65,7 +60,7 @@ namespace Selector
return _features[_contextIdx]; return _features[_contextIdx];
} }
public override Task AsyncCallback(ListeningChangeEventArgs e) public override Task AsyncCallback(SpotifyListeningChangeEventArgs e)
{ {
using var scope = Logger.GetListeningEventArgsScope(e); using var scope = Logger.GetListeningEventArgsScope(e);
@ -98,4 +93,4 @@ namespace Selector
return Task.CompletedTask; return Task.CompletedTask;
} }
} }
} }

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

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

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

@ -0,0 +1,9 @@
namespace Selector.Spotify.Consumer;
public interface ISpotifyPlayerConsumer : IConsumer<SpotifyListeningChangeEventArgs>
{
}
public interface IPlaylistConsumer : IConsumer<PlaylistChangeEventArgs>
{
}

@ -1,21 +1,15 @@
using System; using IF.Lastfm.Core.Api;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Selector.Extensions;
using Selector.Spotify.Timeline;
using SpotifyAPI.Web; using SpotifyAPI.Web;
using IF.Lastfm.Core.Api;
using IF.Lastfm.Core.Objects;
using IF.Lastfm.Core.Api.Helpers;
namespace Selector namespace Selector.Spotify.Consumer
{ {
public class PlayCounter : IPlayerConsumer public class PlayCounter : ISpotifyPlayerConsumer
{ {
protected readonly IPlayerWatcher Watcher; protected readonly ISpotifyPlayerWatcher Watcher;
protected readonly ITrackApi TrackClient; protected readonly ITrackApi TrackClient;
protected readonly IAlbumApi AlbumClient; protected readonly IAlbumApi AlbumClient;
protected readonly IArtistApi ArtistClient; protected readonly IArtistApi ArtistClient;
@ -30,7 +24,7 @@ namespace Selector
public AnalysedTrackTimeline Timeline { get; set; } = new(); public AnalysedTrackTimeline Timeline { get; set; } = new();
public PlayCounter( public PlayCounter(
IPlayerWatcher watcher, ISpotifyPlayerWatcher watcher,
ITrackApi trackClient, ITrackApi trackClient,
IAlbumApi albumClient, IAlbumApi albumClient,
IArtistApi artistClient, IArtistApi artistClient,
@ -50,11 +44,12 @@ namespace Selector
CancelToken = token; CancelToken = token;
} }
public void Callback(object sender, ListeningChangeEventArgs e) public void Callback(object sender, SpotifyListeningChangeEventArgs e)
{ {
if (e.Current is null) return; if (e.Current is null) return;
Task.Run(async () => { Task.Run(async () =>
{
try try
{ {
await AsyncCallback(e); await AsyncCallback(e);
@ -66,9 +61,10 @@ namespace Selector
}, CancelToken); }, CancelToken);
} }
public async Task AsyncCallback(ListeningChangeEventArgs e) public async Task AsyncCallback(SpotifyListeningChangeEventArgs e)
{ {
using var scope = Logger.BeginScope(new Dictionary<string, object>() { { "spotify_username", e.SpotifyUsername }, { "id", e.Id }, { "username", Credentials.Username } }); using var scope = Logger.BeginScope(new Dictionary<string, object>()
{ { "spotify_username", e.SpotifyUsername }, { "id", e.Id }, { "username", Credentials.Username } });
if (Credentials is null || string.IsNullOrWhiteSpace(Credentials.Username)) if (Credentials is null || string.IsNullOrWhiteSpace(Credentials.Username))
{ {
@ -78,12 +74,15 @@ namespace Selector
if (e.Current.Item is FullTrack track) if (e.Current.Item is FullTrack track)
{ {
using var trackScope = Logger.BeginScope(new Dictionary<string, object>() { { "track", track.DisplayString() } }); using var trackScope = Logger.BeginScope(new Dictionary<string, object>()
{ { "track", track.DisplayString() } });
Logger.LogTrace("Making Last.fm call"); Logger.LogTrace("Making Last.fm call");
var trackInfo = TrackClient.GetInfoAsync(track.Name, track.Artists[0].Name, username: Credentials?.Username); var trackInfo =
var albumInfo = AlbumClient.GetInfoAsync(track.Album.Artists[0].Name, track.Album.Name, username: Credentials?.Username); TrackClient.GetInfoAsync(track.Name, track.Artists[0].Name, username: Credentials?.Username);
var albumInfo = AlbumClient.GetInfoAsync(track.Album.Artists[0].Name, track.Album.Name,
username: Credentials?.Username);
var artistInfo = ArtistClient.GetInfoAsync(track.Artists[0].Name); var artistInfo = ArtistClient.GetInfoAsync(track.Artists[0].Name);
var userInfo = UserClient.GetInfoAsync(Credentials.Username); var userInfo = UserClient.GetInfoAsync(Credentials.Username);
@ -104,7 +103,8 @@ namespace Selector
} }
else else
{ {
Logger.LogError(trackInfo.Exception, "Track info task faulted, [{context}]", e.Current.DisplayString()); Logger.LogError(trackInfo.Exception, "Track info task faulted, [{context}]",
e.Current.DisplayString());
} }
if (albumInfo.IsCompletedSuccessfully) if (albumInfo.IsCompletedSuccessfully)
@ -120,7 +120,8 @@ namespace Selector
} }
else else
{ {
Logger.LogError(albumInfo.Exception, "Album info task faulted, [{context}]", e.Current.DisplayString()); Logger.LogError(albumInfo.Exception, "Album info task faulted, [{context}]",
e.Current.DisplayString());
} }
//TODO: Add artist count //TODO: Add artist count
@ -138,10 +139,13 @@ namespace Selector
} }
else else
{ {
Logger.LogError(userInfo.Exception, "User info task faulted, [{context}]", e.Current.DisplayString()); Logger.LogError(userInfo.Exception, "User info task faulted, [{context}]",
e.Current.DisplayString());
} }
Logger.LogDebug("Adding Last.fm data [{username}], track: {track_count}, album: {album_count}, artist: {artist_count}, user: {user_count}", Credentials.Username, trackCount, albumCount, artistCount, userCount); Logger.LogDebug(
"Adding Last.fm data [{username}], track: {track_count}, album: {album_count}, artist: {artist_count}, user: {user_count}",
Credentials.Username, trackCount, albumCount, artistCount, userCount);
PlayCount playCount = new() PlayCount playCount = new()
{ {
@ -149,7 +153,7 @@ namespace Selector
Album = albumCount, Album = albumCount,
Artist = artistCount, Artist = artistCount,
User = userCount, User = userCount,
ListeningEvent = e SpotifyListeningEvent = e
}; };
if (!string.IsNullOrWhiteSpace(Credentials.Username)) if (!string.IsNullOrWhiteSpace(Credentials.Username))
@ -175,7 +179,7 @@ namespace Selector
{ {
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided"); var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast) if (watcher is ISpotifyPlayerWatcher watcherCast)
{ {
watcherCast.ItemChange += Callback; watcherCast.ItemChange += Callback;
} }
@ -189,7 +193,7 @@ namespace Selector
{ {
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided"); var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast) if (watcher is ISpotifyPlayerWatcher watcherCast)
{ {
watcherCast.ItemChange -= Callback; watcherCast.ItemChange -= Callback;
} }
@ -215,11 +219,11 @@ namespace Selector
public IEnumerable<CountSample> TrackCountData { get; set; } public IEnumerable<CountSample> TrackCountData { get; set; }
public IEnumerable<CountSample> AlbumCountData { get; set; } public IEnumerable<CountSample> AlbumCountData { get; set; }
public IEnumerable<CountSample> ArtistCountData { get; set; } public IEnumerable<CountSample> ArtistCountData { get; set; }
public ListeningChangeEventArgs ListeningEvent { get; set; } public SpotifyListeningChangeEventArgs SpotifyListeningEvent { get; set; }
} }
public class LastFmCredentials public class LastFmCredentials
{ {
public string Username { get; set; } public string Username { get; set; }
} }
} }

@ -1,25 +1,19 @@
using System;
using System.Net.Http;
using System.Linq;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Selector.Spotify.Timeline;
namespace Selector namespace Selector.Spotify.Consumer
{ {
public class WebHookConfig public class WebHookConfig
{ {
public string Name { get; set; } public string Name { get; set; }
public IEnumerable<Predicate<ListeningChangeEventArgs>> Predicates { get; set; } public IEnumerable<Predicate<SpotifyListeningChangeEventArgs>> Predicates { get; set; }
public string Url { get; set; } public string Url { get; set; }
public HttpContent Content { get; set; } public HttpContent Content { get; set; }
public bool ShouldRequest(ListeningChangeEventArgs e) public bool ShouldRequest(SpotifyListeningChangeEventArgs e)
{ {
if(Predicates is not null) if (Predicates is not null)
{ {
return Predicates.Select(p => p(e)).Aggregate((a, b) => a && b); return Predicates.Select(p => p(e)).Aggregate((a, b) => a && b);
} }
@ -30,9 +24,9 @@ namespace Selector
} }
} }
public class WebHook : IPlayerConsumer public class WebHook : ISpotifyPlayerConsumer
{ {
protected readonly IPlayerWatcher Watcher; protected readonly ISpotifyPlayerWatcher Watcher;
protected readonly HttpClient HttpClient; protected readonly HttpClient HttpClient;
protected readonly ILogger<WebHook> Logger; protected readonly ILogger<WebHook> Logger;
@ -47,7 +41,7 @@ namespace Selector
public AnalysedTrackTimeline Timeline { get; set; } = new(); public AnalysedTrackTimeline Timeline { get; set; } = new();
public WebHook( public WebHook(
IPlayerWatcher watcher, ISpotifyPlayerWatcher watcher,
HttpClient httpClient, HttpClient httpClient,
WebHookConfig config, WebHookConfig config,
ILogger<WebHook> logger = null, ILogger<WebHook> logger = null,
@ -61,11 +55,12 @@ namespace Selector
CancelToken = token; CancelToken = token;
} }
public void Callback(object sender, ListeningChangeEventArgs e) public void Callback(object sender, SpotifyListeningChangeEventArgs e)
{ {
if (e.Current is null) return; if (e.Current is null) return;
Task.Run(async () => { Task.Run(async () =>
{
try try
{ {
await AsyncCallback(e); await AsyncCallback(e);
@ -77,9 +72,13 @@ namespace Selector
}, CancelToken); }, CancelToken);
} }
public async Task AsyncCallback(ListeningChangeEventArgs e) public async Task AsyncCallback(SpotifyListeningChangeEventArgs e)
{ {
using var scope = Logger.BeginScope(new Dictionary<string, object>() { { "spotify_username", e.SpotifyUsername }, { "id", e.Id }, { "name", Config.Name }, { "url", Config.Url } }); using var scope = Logger.BeginScope(new Dictionary<string, object>()
{
{ "spotify_username", e.SpotifyUsername }, { "id", e.Id }, { "name", Config.Name },
{ "url", Config.Url }
});
if (Config.ShouldRequest(e)) if (Config.ShouldRequest(e))
{ {
@ -101,7 +100,7 @@ namespace Selector
OnFailedRequest(new EventArgs()); OnFailedRequest(new EventArgs());
} }
} }
catch(HttpRequestException ex) catch (HttpRequestException ex)
{ {
Logger.LogError(ex, "Exception occured during request"); Logger.LogError(ex, "Exception occured during request");
} }
@ -120,7 +119,7 @@ namespace Selector
{ {
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided"); var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast) if (watcher is ISpotifyPlayerWatcher watcherCast)
{ {
watcherCast.ItemChange += Callback; watcherCast.ItemChange += Callback;
} }
@ -134,7 +133,7 @@ namespace Selector
{ {
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided"); var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast) if (watcher is ISpotifyPlayerWatcher watcherCast)
{ {
watcherCast.ItemChange -= Callback; watcherCast.ItemChange -= Callback;
} }
@ -159,4 +158,4 @@ namespace Selector
FailedRequest?.Invoke(this, args); FailedRequest?.Invoke(this, args);
} }
} }
} }

@ -0,0 +1,17 @@
namespace Selector.Spotify;
public class SpotifyAppCredentials
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public SpotifyAppCredentials()
{
}
public SpotifyAppCredentials(string clientId, string clientSecret)
{
ClientId = clientId;
ClientSecret = clientSecret;
}
}

@ -1,10 +1,10 @@
using System; using Selector.Extensions;
using SpotifyAPI.Web; using SpotifyAPI.Web;
namespace Selector { namespace Selector.Spotify
{
public class CurrentlyPlayingDTO { public class CurrentlyPlayingDTO
{
public CurrentlyPlayingContextDTO Context { get; set; } public CurrentlyPlayingContextDTO Context { get; set; }
public string Username { get; set; } public string Username { get; set; }
public string UserId { get; set; } public string UserId { get; set; }
@ -12,9 +12,9 @@ namespace Selector {
public FullTrack Track { get; set; } public FullTrack Track { get; set; }
public FullEpisode Episode { get; set; } public FullEpisode Episode { get; set; }
public static explicit operator CurrentlyPlayingDTO(ListeningChangeEventArgs e) public static explicit operator CurrentlyPlayingDTO(SpotifyListeningChangeEventArgs e)
{ {
if(e.Current.Item is FullTrack track) if (e.Current.Item is FullTrack track)
{ {
return new() return new()
{ {
@ -52,13 +52,14 @@ namespace Selector {
public long Timestamp { get; set; } public long Timestamp { get; set; }
public int ProgressMs { get; set; } public int ProgressMs { get; set; }
public bool IsPlaying { get; set; } public bool IsPlaying { get; set; }
public string CurrentlyPlayingType { get; set; } public string CurrentlyPlayingType { get; set; }
public Actions Actions { get; set; } public Actions Actions { get; set; }
public static implicit operator CurrentlyPlayingContextDTO(CurrentlyPlayingContext context) public static implicit operator CurrentlyPlayingContextDTO(CurrentlyPlayingContext context)
{ {
return new CurrentlyPlayingContextDTO { return new CurrentlyPlayingContextDTO
{
Device = context.Device, Device = context.Device,
RepeatState = context.RepeatState, RepeatState = context.RepeatState,
ShuffleState = context.ShuffleState, ShuffleState = context.ShuffleState,

@ -1,11 +1,10 @@
using System; using System.Diagnostics.CodeAnalysis;
using System.Collections.Generic; using Selector.Extensions;
using System.Diagnostics.CodeAnalysis;
using SpotifyAPI.Web; using SpotifyAPI.Web;
namespace Selector.Equality namespace Selector.Spotify.Equality
{ {
public class PlayableItemEqualityComparer: IEqualityComparer<PlaylistTrack<IPlayableItem>> public class PlayableItemEqualityComparer : IEqualityComparer<PlaylistTrack<IPlayableItem>>
{ {
public bool Equals(PlaylistTrack<IPlayableItem> x, PlaylistTrack<IPlayableItem> y) public bool Equals(PlaylistTrack<IPlayableItem> x, PlaylistTrack<IPlayableItem> y)
{ {
@ -17,5 +16,4 @@ namespace Selector.Equality
return obj.GetUri().GetHashCode(); return obj.GetUri().GetHashCode();
} }
} }
} }

@ -1,12 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using SpotifyAPI.Web; using SpotifyAPI.Web;
namespace Selector namespace Selector.Spotify.Equality
{ {
public abstract class NoHashCode<T> : EqualityComparer<T>
public abstract class NoHashCode<T>: EqualityComparer<T>
{ {
public override int GetHashCode(T obj) public override int GetHashCode(T obj)
{ {
@ -16,79 +12,89 @@ namespace Selector
public class FullTrackStringComparer : NoHashCode<FullTrack> public class FullTrackStringComparer : NoHashCode<FullTrack>
{ {
public override bool Equals(FullTrack track1, FullTrack track2) => FullTrackStringComparer.IsEqual(track1, track2); public override bool Equals(FullTrack track1, FullTrack track2) => IsEqual(track1, track2);
public static bool IsEqual(FullTrack track1, FullTrack track2) => track1.Name == track2.Name 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)) && Enumerable.SequenceEqual(
&& SimpleAlbumStringComparer.IsEqual(track1.Album, track2.Album); track1.Artists.Select(a => a.Name),
track2.Artists.Select(a => a.Name))
&& SimpleAlbumStringComparer.IsEqual(
track1.Album, track2.Album);
} }
public class FullEpisodeStringComparer : NoHashCode<FullEpisode> public class FullEpisodeStringComparer : NoHashCode<FullEpisode>
{ {
public override bool Equals(FullEpisode ep1, FullEpisode ep2) => FullEpisodeStringComparer.IsEqual(ep1, ep2); public override bool Equals(FullEpisode ep1, FullEpisode ep2) => IsEqual(ep1, ep2);
public static bool IsEqual(FullEpisode ep1, FullEpisode ep2) => ep1.Name == ep2.Name public static bool IsEqual(FullEpisode ep1, FullEpisode ep2) => ep1.Name == ep2.Name
&& SimpleShowStringComparer.IsEqual(ep1.Show, ep2.Show); && SimpleShowStringComparer.IsEqual(ep1.Show,
ep2.Show);
} }
public class FullAlbumStringComparer : NoHashCode<FullAlbum> public class FullAlbumStringComparer : NoHashCode<FullAlbum>
{ {
public override bool Equals(FullAlbum album1, FullAlbum album2) => FullAlbumStringComparer.IsEqual(album1, album2); public override bool Equals(FullAlbum album1, FullAlbum album2) => IsEqual(album1, album2);
public static bool IsEqual(FullAlbum album1, FullAlbum album2) => album1.Name == album2.Name public static bool IsEqual(FullAlbum album1, FullAlbum album2) => album1.Name == album2.Name
&& Enumerable.SequenceEqual(album1.Artists.Select(a => a.Name), album2.Artists.Select(a => a.Name)); && Enumerable.SequenceEqual(
album1.Artists.Select(a => a.Name),
album2.Artists.Select(a => a.Name));
} }
public class FullShowStringComparer : NoHashCode<FullShow> public class FullShowStringComparer : NoHashCode<FullShow>
{ {
public override bool Equals(FullShow show1, FullShow show2) => FullShowStringComparer.IsEqual(show1, show2); public override bool Equals(FullShow show1, FullShow show2) => IsEqual(show1, show2);
public static bool IsEqual(FullShow show1, FullShow show2) => show1.Name == show2.Name public static bool IsEqual(FullShow show1, FullShow show2) => show1.Name == show2.Name
&& show1.Publisher == show2.Publisher; && show1.Publisher == show2.Publisher;
} }
public class FullArtistStringComparer : NoHashCode<FullArtist> public class FullArtistStringComparer : NoHashCode<FullArtist>
{ {
public override bool Equals(FullArtist artist1, FullArtist artist2) => FullArtistStringComparer.IsEqual(artist1, artist2); public override bool Equals(FullArtist artist1, FullArtist artist2) => IsEqual(artist1, artist2);
public static bool IsEqual(FullArtist artist1, FullArtist artist2) => artist1.Name == artist2.Name; public static bool IsEqual(FullArtist artist1, FullArtist artist2) => artist1.Name == artist2.Name;
} }
public class SimpleTrackStringComparer : NoHashCode<SimpleTrack> public class SimpleTrackStringComparer : NoHashCode<SimpleTrack>
{ {
public override bool Equals(SimpleTrack track1, SimpleTrack track2) => SimpleTrackStringComparer.IsEqual(track1, track2); public override bool Equals(SimpleTrack track1, SimpleTrack track2) => IsEqual(track1, track2);
public static bool IsEqual(SimpleTrack track1, SimpleTrack track2) => track1.Name == track2.Name public static bool IsEqual(SimpleTrack track1, SimpleTrack track2) => track1.Name == track2.Name
&& Enumerable.SequenceEqual(track1.Artists.Select(a => a.Name), track2.Artists.Select(a => a.Name)); && Enumerable.SequenceEqual(
track1.Artists.Select(a => a.Name),
track2.Artists.Select(a => a.Name));
} }
public class SimpleEpisodeStringComparer : NoHashCode<SimpleEpisode> public class SimpleEpisodeStringComparer : NoHashCode<SimpleEpisode>
{ {
public override bool Equals(SimpleEpisode ep1, SimpleEpisode ep2) => SimpleEpisodeStringComparer.IsEqual(ep1, ep2); public override bool Equals(SimpleEpisode ep1, SimpleEpisode ep2) => IsEqual(ep1, ep2);
public static bool IsEqual(SimpleEpisode ep1, SimpleEpisode ep2) => ep1.Name == ep2.Name; public static bool IsEqual(SimpleEpisode ep1, SimpleEpisode ep2) => ep1.Name == ep2.Name;
} }
public class SimpleAlbumStringComparer : NoHashCode<SimpleAlbum> public class SimpleAlbumStringComparer : NoHashCode<SimpleAlbum>
{ {
public override bool Equals(SimpleAlbum album1, SimpleAlbum album2) => SimpleAlbumStringComparer.IsEqual(album1, album2); public override bool Equals(SimpleAlbum album1, SimpleAlbum album2) => IsEqual(album1, album2);
public static bool IsEqual(SimpleAlbum album1, SimpleAlbum album2) => album1.Name == album2.Name public static bool IsEqual(SimpleAlbum album1, SimpleAlbum album2) => album1.Name == album2.Name
&& Enumerable.SequenceEqual(album1.Artists.Select(a => a.Name), album2.Artists.Select(a => a.Name)); && Enumerable.SequenceEqual(
album1.Artists.Select(a => a.Name),
album2.Artists.Select(a => a.Name));
} }
public class SimpleShowStringComparer : NoHashCode<SimpleShow> public class SimpleShowStringComparer : NoHashCode<SimpleShow>
{ {
public override bool Equals(SimpleShow show1, SimpleShow show2) => SimpleShowStringComparer.IsEqual(show1, show2); public override bool Equals(SimpleShow show1, SimpleShow show2) => IsEqual(show1, show2);
public static bool IsEqual(SimpleShow show1, SimpleShow show2) => show1.Name == show2.Name public static bool IsEqual(SimpleShow show1, SimpleShow show2) => show1.Name == show2.Name
&& show1.Publisher == show2.Publisher; && show1.Publisher == show2.Publisher;
} }
public class SimpleArtistStringComparer : NoHashCode<SimpleArtist> public class SimpleArtistStringComparer : NoHashCode<SimpleArtist>
{ {
public override bool Equals(SimpleArtist artist1, SimpleArtist artist2) => SimpleArtistStringComparer.IsEqual(artist1, artist2); public override bool Equals(SimpleArtist artist1, SimpleArtist artist2) => IsEqual(artist1, artist2);
public static bool IsEqual(SimpleArtist artist1, SimpleArtist artist2) => artist1.Name == artist2.Name; public static bool IsEqual(SimpleArtist artist1, SimpleArtist artist2) => artist1.Name == artist2.Name;
} }
} }

Some files were not shown because too many files have changed in this diff Show More