Working on playlist generator, adding sort and recommender

This commit is contained in:
Andy Pack 2024-01-23 17:43:25 +00:00
parent 77c1e3c79e
commit e1094b131f
Signed by: sarsoo
GPG Key ID: A55BA3536A5E0ED7
13 changed files with 325 additions and 47 deletions

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Mixonomer.Fire.Extensions; using Mixonomer.Fire.Extensions;
using Mixonomer; using Mixonomer;
using Mixonomer.Playlist;
namespace Mixonomer.CLI; namespace Mixonomer.CLI;

View File

@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Mixonomer.Fire; using Mixonomer.Fire;
using Mixonomer.Playlist;
using SpotifyAPI.Web; using SpotifyAPI.Web;
namespace Mixonomer.Func; namespace Mixonomer.Func;

View File

@ -0,0 +1,6 @@
namespace Mixonomer.Exceptions;
public class MixonomerException: Exception
{
}

View File

@ -0,0 +1,6 @@
namespace Mixonomer.Exceptions;
public class PlaylistNotFoundException: MixonomerException
{
}

View File

@ -10,7 +10,7 @@ public class PartTreeWalker
private readonly HashSet<string> _processedPlaylists = new(); private readonly HashSet<string> _processedPlaylists = new();
public HashSet<string>? SpotifyPlaylistNames { get; private set; } public HashSet<string>? SpotifyPlaylistNames { get; private set; }
private List<Playlist> _userPlaylists; private List<Fire.Playlist> _userPlaylists;
public PartTreeWalker(UserRepo userRepo) public PartTreeWalker(UserRepo userRepo)
{ {

View File

@ -0,0 +1,71 @@
using SpotifyAPI.Web;
namespace Mixonomer.Playlist;
public class CommonTrack
{
public string TrackUri { get; set; }
public string TrackName { get; set; }
public string AlbumName { get; set; }
public IEnumerable<string> ArtistNames { get; set; }
public IEnumerable<string> AlbumArtistNames { get; set; }
public int DiscNumber { get; set; }
public int TrackNumber { get; set; }
public DateTime? AddedTime { get; set; }
public string ReleaseDate { get; set; }
public static explicit operator CommonTrack(PlaylistTrack<IPlayableItem> track)
{
if (track.Track is FullTrack fullTrack)
{
return new()
{
TrackUri = fullTrack.Uri,
TrackName = fullTrack.Name,
AlbumName = fullTrack.Album.Name,
ArtistNames = fullTrack.Artists.Select(x => x.Name).ToArray(),
AlbumArtistNames = fullTrack.Album.Artists.Select(x => x.Name).ToArray(),
AddedTime = track.AddedAt,
ReleaseDate = fullTrack.Album.ReleaseDate,
DiscNumber = fullTrack.DiscNumber,
TrackNumber = fullTrack.TrackNumber
};
}
else
{
throw new InvalidCastException($"Nested track of type {track.Track.GetType()}");
}
}
public static implicit operator CommonTrack(SavedTrack track)
{
return new()
{
TrackUri = track.Track.Uri,
TrackName = track.Track.Name,
AlbumName = track.Track.Album.Name,
ArtistNames = track.Track.Artists.Select(x => x.Name).ToArray(),
AlbumArtistNames = track.Track.Album.Artists.Select(x => x.Name).ToArray(),
AddedTime = track.AddedAt,
ReleaseDate = track.Track.Album.ReleaseDate,
DiscNumber = track.Track.DiscNumber,
TrackNumber = track.Track.TrackNumber
};
}
public static implicit operator CommonTrack(SimpleTrack track)
{
return new()
{
TrackUri = track.Uri,
TrackName = track.Name,
AlbumName = null,
ArtistNames = track.Artists.Select(x => x.Name).ToArray(),
AlbumArtistNames = Enumerable.Empty<string>(),
AddedTime = null,
ReleaseDate = null,
DiscNumber = track.DiscNumber,
TrackNumber = track.TrackNumber
};
}
}

View File

@ -0,0 +1,6 @@
namespace Mixonomer.Playlist;
public interface IRecommend
{
Task<IEnumerable<CommonTrack>> GetRecommendations(Fire.Playlist playlist, IEnumerable<CommonTrack> currentTrackList);
}

View File

@ -0,0 +1,9 @@
using SpotifyAPI.Web;
namespace Mixonomer.Playlist;
public class PlaylistGeneratingContext
{
public IList<PlaylistTrack<IPlayableItem>> PartTracks { get; set; }
public IList<SavedTrack> LibraryTracks { get; set; }
}

View File

@ -0,0 +1,148 @@
using Microsoft.Extensions.Logging;
using Mixonomer.Exceptions;
using Mixonomer.Fire;
using Mixonomer.Fire.Extensions;
using Mixonomer.Playlist.Sort;
using SpotifyAPI.Web;
namespace Mixonomer.Playlist;
public class PlaylistGenerator
{
private readonly ILogger<PlaylistGenerator> _logger;
protected readonly UserRepo _userRepo;
private readonly SpotifyNetworkProvider _spotifyMetworkProvider;
protected readonly PartTreeWalker _partTreeWalker;
public PlaylistGenerator(UserRepo userRepo, SpotifyNetworkProvider spotifyMetworkProvider, PartTreeWalker partTreeWalker, ILogger<PlaylistGenerator> logger)
{
_userRepo = userRepo;
_spotifyMetworkProvider = spotifyMetworkProvider;
_logger = logger;
_partTreeWalker = partTreeWalker;
}
public async Task GeneratePlaylist(string playlistName, string username)
{
using var logScope = _logger.BeginScope(new Dictionary<string, string> { {"username", username}, {"playlist", playlistName} });
var user = await _userRepo.GetUser(username);
var dbPlaylist = await _userRepo.GetPlaylists(user).FirstOrDefaultAsync(x => x.name == playlistName);
if (dbPlaylist is null)
{
_logger.LogError("Couldn't find playlist in database");
throw new PlaylistNotFoundException();
}
var spotifyClient = new SpotifyClient(await _spotifyMetworkProvider.GetUserConfig(user));
var userPlaylists = await spotifyClient.Playlists.CurrentUsers();
var allPlaylists = await spotifyClient.PaginateAll(userPlaylists);
var parts = await GetFullPartList(user, dbPlaylist);
var partPlaylists = GetPartPlaylists(dbPlaylist, allPlaylists, parts);
var context = new PlaylistGeneratingContext
{
PartTracks = await GetPlatlistTracks(spotifyClient, partPlaylists).ToListAsync(),
LibraryTracks = await GetLibraryTracks(spotifyClient, dbPlaylist).ToListAsync()
};
context = DoPlaylistTypeProcessing(context, user, dbPlaylist);
var combinedTracks = CollapseContextToCommonTracks(context);
combinedTracks = SortTracks(combinedTracks, dbPlaylist);
}
private async Task<IEnumerable<string>> GetFullPartList(User user, Fire.Playlist playlist)
{
var parts = (await _partTreeWalker.GetPlaylistParts(user, playlist.name) ?? Enumerable.Empty<string>()).ToList();
if (playlist.add_last_month)
{
parts.Add(Months.LastMonth());
}
if (playlist.add_this_month)
{
parts.Add(Months.ThisMonth());
}
if (parts.Count == 0)
{
_logger.LogInformation("No spotify playlist parts found");
}
return parts;
}
private IEnumerable<FullPlaylist> GetPartPlaylists(Fire.Playlist subjectPlaylist, IEnumerable<FullPlaylist> allPlaylists, IEnumerable<string> parts)
{
var allPlaylistDict = allPlaylists.ToDictionary(p => p.Name ?? "no name");
foreach (var part in parts)
{
if (allPlaylistDict.TryGetValue(part, out var playlist))
{
if (!subjectPlaylist.include_spotify_owned &&
(playlist.Owner?.DisplayName.Contains("spotify", StringComparison.InvariantCultureIgnoreCase) ?? false))
{
// skip
}
else
{
yield return playlist;
}
}
}
}
private async IAsyncEnumerable<PlaylistTrack<IPlayableItem>> GetPlatlistTracks(SpotifyClient client, IEnumerable<FullPlaylist> playlists)
{
foreach (var playlist in playlists)
{
if (playlist.Tracks is { } tracks)
{
foreach (var track in await client.PaginateAll(tracks))
{
yield return track;
}
}
}
}
private async IAsyncEnumerable<SavedTrack> GetLibraryTracks(SpotifyClient client, Fire.Playlist playlist)
{
if (playlist.include_library_tracks)
{
await foreach(var track in client.Paginate(await client.Library.GetTracks()))
{
yield return track;
}
}
}
protected virtual PlaylistGeneratingContext DoPlaylistTypeProcessing(PlaylistGeneratingContext context, User user, Fire.Playlist playlist)
{
return context;
}
protected virtual IEnumerable<CommonTrack> CollapseContextToCommonTracks(PlaylistGeneratingContext context)
{
return context.PartTracks.Select(x => (CommonTrack)x)
.Concat(context.LibraryTracks.Select(x => (CommonTrack)x));
}
protected virtual IEnumerable<CommonTrack> SortTracks(IEnumerable<CommonTrack> tracks, Fire.Playlist playlist)
{
if (playlist.shuffle)
{
return tracks.Shuffle();
}
else
{
return tracks.OrderByReleaseDate();
}
}
}

View File

@ -0,0 +1,19 @@
namespace Mixonomer.Playlist.Sort;
public static class SortExtensions
{
private static Random _rng = new Random();
public static IOrderedEnumerable<CommonTrack> OrderByArtistAlbumTrackNumber(this IEnumerable<CommonTrack> input) =>
input.OrderBy(x => x.AlbumArtistNames.First())
.ThenBy(x => x.AlbumName)
.ThenBy(x => x.DiscNumber)
.ThenBy(x => x.TrackNumber);
public static IOrderedEnumerable<CommonTrack> OrderByReleaseDate(this IEnumerable<CommonTrack> input) =>
input.OrderByArtistAlbumTrackNumber()
.ThenByDescending(x => x.ReleaseDate);
public static IOrderedEnumerable<CommonTrack> Shuffle(this IEnumerable<CommonTrack> input) =>
input.OrderBy(x => _rng.Next());
}

View File

@ -1,32 +0,0 @@
using Microsoft.Extensions.Logging;
using Mixonomer.Fire;
using SpotifyAPI.Web;
namespace Mixonomer;
public class PlaylistGenerator
{
private readonly ILogger<PlaylistGenerator> _logger;
private readonly UserRepo _userRepo;
private readonly SpotifyNetworkProvider _spotifyMetworkProvider;
private readonly PartTreeWalker _partTreeWalker;
public PlaylistGenerator(UserRepo userRepo, SpotifyNetworkProvider spotifyMetworkProvider, PartTreeWalker partTreeWalker, ILogger<PlaylistGenerator> logger)
{
_userRepo = userRepo;
_spotifyMetworkProvider = spotifyMetworkProvider;
_logger = logger;
_partTreeWalker = partTreeWalker;
}
public async Task GeneratePlaylist(string playlistName, string username)
{
var user = await _userRepo.GetUser(username);
var spotifyConfig = await _spotifyMetworkProvider.GetUserConfig(user);
var spotifyClient = new SpotifyClient(spotifyConfig);
var userPlaylists = await spotifyClient.Playlists.CurrentUsers();
var allPlaylists = await spotifyClient.PaginateAll(userPlaylists);
}
}

View File

@ -35,6 +35,14 @@ public class SpotifyNetworkProvider
var refreshed = await new OAuthClient() var refreshed = await new OAuthClient()
.RequestToken(new AuthorizationCodeRefreshRequest(spotifyClientStr, spotifySecretStr, user.refresh_token)); .RequestToken(new AuthorizationCodeRefreshRequest(spotifyClientStr, spotifySecretStr, user.refresh_token));
await WriteUserTokenUpdate(user, new
{
access_token = refreshed.AccessToken,
refresh_token = refreshed.RefreshToken,
last_refreshed = refreshed.CreatedAt,
token_expiry = refreshed.ExpiresIn
});
var authenticator = new AuthorizationCodeAuthenticator(spotifyClientStr, spotifySecretStr, new() var authenticator = new AuthorizationCodeAuthenticator(spotifyClientStr, spotifySecretStr, new()
{ {
AccessToken = refreshed.AccessToken, AccessToken = refreshed.AccessToken,
@ -47,21 +55,13 @@ public class SpotifyNetworkProvider
authenticator.TokenRefreshed += async (sender, resp) => authenticator.TokenRefreshed += async (sender, resp) =>
{ {
try await WriteUserTokenUpdate(user, new
{ {
_logger.LogInformation("Token refreshed for [{}], writing to database", user.username); access_token = resp.AccessToken,
await user.Reference.SetAsync(new refresh_token = resp.RefreshToken,
{ last_refreshed = resp.CreatedAt,
access_token = resp.AccessToken, token_expiry = resp.ExpiresIn
refresh_token = resp.RefreshToken, });
last_refreshed = resp.CreatedAt,
token_expiry = resp.ExpiresIn
}, SetOptions.MergeAll);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to write updated Spotify tokens to database for [{}]", user.username);
}
}; };
var config = SpotifyClientConfig var config = SpotifyClientConfig
@ -70,4 +70,17 @@ public class SpotifyNetworkProvider
return config; return config;
} }
private async Task WriteUserTokenUpdate(User user, object updates)
{
try
{
_logger.LogInformation("Token refreshed for [{}], writing to database", user.username);
await user.Reference.SetAsync(updates, SetOptions.MergeAll);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to write updated Spotify tokens to database for [{}]", user.username);
}
}
} }

View File

@ -0,0 +1,30 @@
using Mixonomer.Playlist;
using Mixonomer.Playlist.Sort;
using SpotifyAPI.Web;
namespace Mixonomer;
public class SpotifyRecommender: IRecommend
{
private readonly SpotifyClient _client;
public SpotifyRecommender(SpotifyClient client)
{
_client = client;
}
public async Task<IEnumerable<CommonTrack>> GetRecommendations(Fire.Playlist playlist,
IEnumerable<CommonTrack> currentTrackList)
{
if (playlist.include_recommendations)
{
var request = new RecommendationsRequest();
var response = await _client.Browse.GetRecommendations(request);
return response.Tracks.Select(x => (CommonTrack) x);
}
return Enumerable.Empty<CommonTrack>();
}
}