adding last.fm spotify mappings from watcher event

This commit is contained in:
andy 2022-09-30 08:41:44 +01:00
parent 1ac02d14a8
commit 5084f2bd07
13 changed files with 232 additions and 3 deletions

View File

@ -100,6 +100,7 @@ namespace Selector.CLI
.ConfigureDb(config); .ConfigureDb(config);
services.AddConsumerFactories(); services.AddConsumerFactories();
services.AddCLIConsumerFactories();
if (config.RedisOptions.Enabled) if (config.RedisOptions.Enabled)
{ {
Console.WriteLine("> Adding caching consumers..."); Console.WriteLine("> Adding caching consumers...");

View File

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

View File

@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Selector.Model;
using SpotifyAPI.Web;
namespace Selector.CLI.Consumer
{
/// <summary>
/// Save name -> Spotify URI mappings as new objects come through the watcher without making extra queries of the Spotify API
/// </summary>
public class MappingPersister: IPlayerConsumer
{
protected readonly IPlayerWatcher Watcher;
protected readonly IServiceScopeFactory ScopeFactory;
protected readonly ILogger<MappingPersister> Logger;
public CancellationToken CancelToken { get; set; }
public MappingPersister(
IPlayerWatcher watcher,
IServiceScopeFactory scopeFactory,
ILogger<MappingPersister> logger = null,
CancellationToken token = default
)
{
Watcher = watcher;
ScopeFactory = scopeFactory;
Logger = logger ?? NullLogger<MappingPersister>.Instance;
CancelToken = token;
}
public void Callback(object sender, ListeningChangeEventArgs e)
{
if (e.Current is null) return;
Task.Run(async () => {
try
{
await AsyncCallback(e);
}
catch (DbUpdateException)
{
Logger.LogWarning("Failed to update database, likely a duplicate Spotify URI");
}
catch (Exception e)
{
Logger.LogError(e, "Error occured during callback");
}
}, CancelToken);
}
public async Task AsyncCallback(ListeningChangeEventArgs e)
{
using var serviceScope = ScopeFactory.CreateScope();
using var scope = Logger.BeginScope(new Dictionary<string, object>() { { "spotify_username", e.SpotifyUsername }, { "id", e.Id } });
if (e.Current.Item is FullTrack track)
{
var mappingRepo = serviceScope.ServiceProvider.GetRequiredService<IScrobbleMappingRepository>();
if(!mappingRepo.GetTracks().Select(t => t.SpotifyUri).Contains(track.Uri))
{
mappingRepo.Add(new TrackLastfmSpotifyMapping()
{
SpotifyUri = track.Uri,
LastfmTrackName = track.Name,
LastfmArtistName = track.Artists.FirstOrDefault()?.Name
});
}
if (!mappingRepo.GetAlbums().Select(t => t.SpotifyUri).Contains(track.Album.Uri))
{
mappingRepo.Add(new AlbumLastfmSpotifyMapping()
{
SpotifyUri = track.Album.Uri,
LastfmAlbumName = track.Album.Name,
LastfmArtistName = track.Album.Artists.FirstOrDefault()?.Name
});
}
var artistUris = mappingRepo.GetArtists().Select(t => t.SpotifyUri).ToArray();
foreach (var artist in track.Artists)
{
if (!artistUris.Contains(artist.Uri))
{
mappingRepo.Add(new ArtistLastfmSpotifyMapping()
{
SpotifyUri = artist.Uri,
LastfmArtistName = artist.Name
});
}
}
await mappingRepo.Save();
Logger.LogDebug("Adding Spotify <-> Last.fm mapping [{username}]", e.SpotifyUsername);
}
else if (e.Current.Item is FullEpisode episode)
{
Logger.LogDebug("Ignoring podcast episdoe [{episode}]", episode.DisplayString());
}
else if (e.Current.Item is null)
{
Logger.LogDebug("Skipping play count pulling for null item [{context}]", e.Current.DisplayString());
}
else
{
Logger.LogError("Unknown item pulled from API [{item}]", e.Current.Item);
}
}
public void Subscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
public void Unsubscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException("No watcher provided");
if (watcher is IPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
}
}

View File

@ -2,6 +2,7 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Quartz; using Quartz;
using Selector.Cache.Extensions; using Selector.Cache.Extensions;
using Selector.CLI.Consumer;
using Selector.CLI.Jobs; using Selector.CLI.Jobs;
using Selector.Extensions; using Selector.Extensions;
using Selector.Model; using Selector.Model;
@ -142,5 +143,13 @@ namespace Selector.CLI.Extensions
return services; return services;
} }
public static IServiceCollection AddCLIConsumerFactories(this IServiceCollection services)
{
services.AddTransient<IMappingPersisterFactory, MappingPersisterFactory>();
services.AddTransient<MappingPersisterFactory>();
return services;
}
} }
} }

View File

@ -100,7 +100,7 @@ namespace Selector.CLI
public enum Consumers public enum Consumers
{ {
AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher, PlayCounter AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher, PlayCounter, MappingPersister
} }
public class RedisOptions public class RedisOptions

View File

@ -34,6 +34,10 @@
<ProjectReference Include="..\Selector.Event\Selector.Event.csproj" /> <ProjectReference Include="..\Selector.Event\Selector.Event.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Remove="Consumer\" />
<None Remove="Consumer\Factory\" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="appsettings.Development.json" Condition="Exists('appsettings.Development.json')"> <None Update="appsettings.Development.json" Condition="Exists('appsettings.Development.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@ -49,4 +53,8 @@
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Consumer\" />
<Folder Include="Consumer\Factory\" />
</ItemGroup>
</Project> </Project>

View File

@ -13,6 +13,7 @@ using Selector.Model;
using Selector.Model.Extensions; using Selector.Model.Extensions;
using Selector.Events; using Selector.Events;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Selector.CLI.Consumer;
namespace Selector.CLI namespace Selector.CLI
{ {
@ -35,6 +36,9 @@ namespace Selector.CLI
private readonly IPublisherFactory PublisherFactory; private readonly IPublisherFactory PublisherFactory;
private readonly ICacheWriterFactory CacheWriterFactory; private readonly ICacheWriterFactory CacheWriterFactory;
private readonly IMappingPersisterFactory MappingPersisterFactory;
private ConcurrentDictionary<string, IWatcherCollection> Watchers { get; set; } = new(); private ConcurrentDictionary<string, IWatcherCollection> Watchers { get; set; } = new();
public DbWatcherService( public DbWatcherService(
@ -53,6 +57,8 @@ namespace Selector.CLI
IPublisherFactory publisherFactory = null, IPublisherFactory publisherFactory = null,
ICacheWriterFactory cacheWriterFactory = null, ICacheWriterFactory cacheWriterFactory = null,
IMappingPersisterFactory mappingPersisterFactory = null,
IUserEventFirerFactory userEventFirerFactory = null IUserEventFirerFactory userEventFirerFactory = null
) )
{ {
@ -71,6 +77,8 @@ namespace Selector.CLI
PublisherFactory = publisherFactory; PublisherFactory = publisherFactory;
CacheWriterFactory = cacheWriterFactory; CacheWriterFactory = cacheWriterFactory;
MappingPersisterFactory = mappingPersisterFactory;
} }
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
@ -130,6 +138,8 @@ namespace Selector.CLI
if (CacheWriterFactory is not null) consumers.Add(await CacheWriterFactory.Get()); if (CacheWriterFactory is not null) consumers.Add(await CacheWriterFactory.Get());
if (PublisherFactory is not null) consumers.Add(await PublisherFactory.Get()); if (PublisherFactory is not null) consumers.Add(await PublisherFactory.Get());
if (MappingPersisterFactory is not null) consumers.Add(await MappingPersisterFactory.Get());
if (UserEventFirerFactory is not null) consumers.Add(await UserEventFirerFactory.Get()); if (UserEventFirerFactory is not null) consumers.Add(await UserEventFirerFactory.Get());
if (dbWatcher.User.LastFmConnected()) if (dbWatcher.User.LastFmConnected())

View File

@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Selector.Cache; using Selector.Cache;
using Selector.CLI.Consumer;
namespace Selector.CLI namespace Selector.CLI
{ {
@ -136,6 +137,10 @@ namespace Selector.CLI
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:
consumers.Add(await ServiceProvider.GetService<MappingPersisterFactory>().Get());
break;
} }
} }
} }

View File

@ -4,13 +4,19 @@
"ClientSecret": "", "ClientSecret": "",
"Equality": "uri", "Equality": "uri",
"Watcher": { "Watcher": {
"localenabled": false, "LocalEnabled": false,
"Instances": [ "Instances": [
// {
// "Name": "test watcher",
// "type": "playlist",
// "PlaylistUri": "spotify:playlist:4o5IArXmDeJByESaUJoEFS",
// "pollperiod": 2000
// },
{ {
"type": "player", "type": "player",
"lastfmusername": "sarsoo", "lastfmusername": "sarsoo",
"pollperiod": 2000, "pollperiod": 2000,
"consumers": [ "audiofeaturescache", "cachewriter", "publisher", "playcounter" ] "consumers": [ "audiofeaturescache", "cachewriter", "publisher", "playcounter", "mappingpersister" ]
} }
] ]
}, },

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
namespace Selector namespace Selector
{ {
/// <inheritdoc/>
public class ScrobbleAlbumMapping : ScrobbleMapping public class ScrobbleAlbumMapping : ScrobbleMapping
{ {
public string AlbumName { get; set; } public string AlbumName { get; set; }
@ -18,6 +19,7 @@ namespace Selector
} }
private SimpleAlbum result; private SimpleAlbum result;
public SimpleAlbum Album => result;
public override object Result => result; public override object Result => result;
public override string Query => $"{AlbumName} {ArtistName}"; public override string Query => $"{AlbumName} {ArtistName}";

View File

@ -8,6 +8,7 @@ using System.Threading.Tasks;
namespace Selector namespace Selector
{ {
/// <inheritdoc/>
public class ScrobbleArtistMapping : ScrobbleMapping public class ScrobbleArtistMapping : ScrobbleMapping
{ {
public string ArtistName { get; set; } public string ArtistName { get; set; }
@ -18,6 +19,7 @@ namespace Selector
} }
private FullArtist result; private FullArtist result;
public FullArtist Artist => result;
public override object Result => result; public override object Result => result;
public override string Query => ArtistName; public override string Query => ArtistName;

View File

@ -11,6 +11,9 @@ namespace Selector
Track, Album, Artist Track, Album, Artist
} }
/// <summary>
/// Map Last.fm resources to Spotify resources using the Spotify search endpoint before saving mappings to database
/// </summary>
public abstract class ScrobbleMapping : IOperation public abstract class ScrobbleMapping : IOperation
{ {
private readonly ILogger<ScrobbleMapping> logger; private readonly ILogger<ScrobbleMapping> logger;

View File

@ -8,6 +8,7 @@ using System.Threading.Tasks;
namespace Selector namespace Selector
{ {
/// <inheritdoc/>
public class ScrobbleTrackMapping : ScrobbleMapping public class ScrobbleTrackMapping : ScrobbleMapping
{ {
public string TrackName { get; set; } public string TrackName { get; set; }
@ -20,6 +21,7 @@ namespace Selector
} }
private FullTrack result; private FullTrack result;
public FullTrack Track => result;
public override object Result => result; public override object Result => result;
public override string Query => $"{TrackName} {ArtistName}"; public override string Query => $"{TrackName} {ArtistName}";