adding last.fm spotify mappings from watcher event
This commit is contained in:
parent
1ac02d14a8
commit
5084f2bd07
@ -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...");
|
||||||
|
33
Selector.CLI/Consumer/Factory/MappingPersisterFactory.cs
Normal file
33
Selector.CLI/Consumer/Factory/MappingPersisterFactory.cs
Normal 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>()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
148
Selector.CLI/Consumer/MappingPersister.cs
Normal file
148
Selector.CLI/Consumer/MappingPersister.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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())
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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" ]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -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}";
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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}";
|
||||||
|
Loading…
Reference in New Issue
Block a user