From e1094b131f6c53f1fb9cb382d8ace9b7da6912f2 Mon Sep 17 00:00:00 2001 From: Andy Pack Date: Tue, 23 Jan 2024 17:43:25 +0000 Subject: [PATCH] Working on playlist generator, adding sort and recommender --- Mixonomer.CLI/Program.cs | 1 + Mixonomer.Func/RunUserPlaylist.cs | 1 + Mixonomer/Exceptions/MixonomerException.cs | 6 + .../Exceptions/PlaylistNotFoundException.cs | 6 + Mixonomer/PartTreeWalker.cs | 2 +- Mixonomer/Playlist/CommonTrack.cs | 71 +++++++++ Mixonomer/Playlist/IRecommend.cs | 6 + .../Playlist/PlaylistGeneratingContext.cs | 9 ++ Mixonomer/Playlist/PlaylistGenerator.cs | 148 ++++++++++++++++++ Mixonomer/Playlist/SortExtensions.cs | 19 +++ Mixonomer/PlaylistGenerator.cs | 32 ---- Mixonomer/Spotify/SpotifyNetworkProvider.cs | 41 +++-- Mixonomer/Spotify/SpotifyRecommender.cs | 30 ++++ 13 files changed, 325 insertions(+), 47 deletions(-) create mode 100644 Mixonomer/Exceptions/MixonomerException.cs create mode 100644 Mixonomer/Exceptions/PlaylistNotFoundException.cs create mode 100644 Mixonomer/Playlist/CommonTrack.cs create mode 100644 Mixonomer/Playlist/IRecommend.cs create mode 100644 Mixonomer/Playlist/PlaylistGeneratingContext.cs create mode 100644 Mixonomer/Playlist/PlaylistGenerator.cs create mode 100644 Mixonomer/Playlist/SortExtensions.cs delete mode 100644 Mixonomer/PlaylistGenerator.cs create mode 100644 Mixonomer/Spotify/SpotifyRecommender.cs diff --git a/Mixonomer.CLI/Program.cs b/Mixonomer.CLI/Program.cs index 4dd0dca..2f216f4 100644 --- a/Mixonomer.CLI/Program.cs +++ b/Mixonomer.CLI/Program.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Mixonomer.Fire.Extensions; using Mixonomer; +using Mixonomer.Playlist; namespace Mixonomer.CLI; diff --git a/Mixonomer.Func/RunUserPlaylist.cs b/Mixonomer.Func/RunUserPlaylist.cs index 46f2252..ef836aa 100644 --- a/Mixonomer.Func/RunUserPlaylist.cs +++ b/Mixonomer.Func/RunUserPlaylist.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Mixonomer.Fire; +using Mixonomer.Playlist; using SpotifyAPI.Web; namespace Mixonomer.Func; diff --git a/Mixonomer/Exceptions/MixonomerException.cs b/Mixonomer/Exceptions/MixonomerException.cs new file mode 100644 index 0000000..e6b44f8 --- /dev/null +++ b/Mixonomer/Exceptions/MixonomerException.cs @@ -0,0 +1,6 @@ +namespace Mixonomer.Exceptions; + +public class MixonomerException: Exception +{ + +} \ No newline at end of file diff --git a/Mixonomer/Exceptions/PlaylistNotFoundException.cs b/Mixonomer/Exceptions/PlaylistNotFoundException.cs new file mode 100644 index 0000000..5998053 --- /dev/null +++ b/Mixonomer/Exceptions/PlaylistNotFoundException.cs @@ -0,0 +1,6 @@ +namespace Mixonomer.Exceptions; + +public class PlaylistNotFoundException: MixonomerException +{ + +} \ No newline at end of file diff --git a/Mixonomer/PartTreeWalker.cs b/Mixonomer/PartTreeWalker.cs index 05f02d6..d971196 100644 --- a/Mixonomer/PartTreeWalker.cs +++ b/Mixonomer/PartTreeWalker.cs @@ -10,7 +10,7 @@ public class PartTreeWalker private readonly HashSet _processedPlaylists = new(); public HashSet? SpotifyPlaylistNames { get; private set; } - private List _userPlaylists; + private List _userPlaylists; public PartTreeWalker(UserRepo userRepo) { diff --git a/Mixonomer/Playlist/CommonTrack.cs b/Mixonomer/Playlist/CommonTrack.cs new file mode 100644 index 0000000..8cc346c --- /dev/null +++ b/Mixonomer/Playlist/CommonTrack.cs @@ -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 ArtistNames { get; set; } + public IEnumerable 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 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(), + AddedTime = null, + ReleaseDate = null, + DiscNumber = track.DiscNumber, + TrackNumber = track.TrackNumber + }; + } +} \ No newline at end of file diff --git a/Mixonomer/Playlist/IRecommend.cs b/Mixonomer/Playlist/IRecommend.cs new file mode 100644 index 0000000..de0ce5c --- /dev/null +++ b/Mixonomer/Playlist/IRecommend.cs @@ -0,0 +1,6 @@ +namespace Mixonomer.Playlist; + +public interface IRecommend +{ + Task> GetRecommendations(Fire.Playlist playlist, IEnumerable currentTrackList); +} \ No newline at end of file diff --git a/Mixonomer/Playlist/PlaylistGeneratingContext.cs b/Mixonomer/Playlist/PlaylistGeneratingContext.cs new file mode 100644 index 0000000..5e51b61 --- /dev/null +++ b/Mixonomer/Playlist/PlaylistGeneratingContext.cs @@ -0,0 +1,9 @@ +using SpotifyAPI.Web; + +namespace Mixonomer.Playlist; + +public class PlaylistGeneratingContext +{ + public IList> PartTracks { get; set; } + public IList LibraryTracks { get; set; } +} \ No newline at end of file diff --git a/Mixonomer/Playlist/PlaylistGenerator.cs b/Mixonomer/Playlist/PlaylistGenerator.cs new file mode 100644 index 0000000..d965f9f --- /dev/null +++ b/Mixonomer/Playlist/PlaylistGenerator.cs @@ -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 _logger; + protected readonly UserRepo _userRepo; + private readonly SpotifyNetworkProvider _spotifyMetworkProvider; + protected readonly PartTreeWalker _partTreeWalker; + + public PlaylistGenerator(UserRepo userRepo, SpotifyNetworkProvider spotifyMetworkProvider, PartTreeWalker partTreeWalker, ILogger logger) + { + _userRepo = userRepo; + _spotifyMetworkProvider = spotifyMetworkProvider; + _logger = logger; + _partTreeWalker = partTreeWalker; + } + + public async Task GeneratePlaylist(string playlistName, string username) + { + using var logScope = _logger.BeginScope(new Dictionary { {"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> GetFullPartList(User user, Fire.Playlist playlist) + { + var parts = (await _partTreeWalker.GetPlaylistParts(user, playlist.name) ?? Enumerable.Empty()).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 GetPartPlaylists(Fire.Playlist subjectPlaylist, IEnumerable allPlaylists, IEnumerable 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> GetPlatlistTracks(SpotifyClient client, IEnumerable 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 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 CollapseContextToCommonTracks(PlaylistGeneratingContext context) + { + return context.PartTracks.Select(x => (CommonTrack)x) + .Concat(context.LibraryTracks.Select(x => (CommonTrack)x)); + } + + protected virtual IEnumerable SortTracks(IEnumerable tracks, Fire.Playlist playlist) + { + if (playlist.shuffle) + { + return tracks.Shuffle(); + } + else + { + return tracks.OrderByReleaseDate(); + } + } +} \ No newline at end of file diff --git a/Mixonomer/Playlist/SortExtensions.cs b/Mixonomer/Playlist/SortExtensions.cs new file mode 100644 index 0000000..ecab758 --- /dev/null +++ b/Mixonomer/Playlist/SortExtensions.cs @@ -0,0 +1,19 @@ +namespace Mixonomer.Playlist.Sort; + +public static class SortExtensions +{ + private static Random _rng = new Random(); + + public static IOrderedEnumerable OrderByArtistAlbumTrackNumber(this IEnumerable input) => + input.OrderBy(x => x.AlbumArtistNames.First()) + .ThenBy(x => x.AlbumName) + .ThenBy(x => x.DiscNumber) + .ThenBy(x => x.TrackNumber); + + public static IOrderedEnumerable OrderByReleaseDate(this IEnumerable input) => + input.OrderByArtistAlbumTrackNumber() + .ThenByDescending(x => x.ReleaseDate); + + public static IOrderedEnumerable Shuffle(this IEnumerable input) => + input.OrderBy(x => _rng.Next()); +} \ No newline at end of file diff --git a/Mixonomer/PlaylistGenerator.cs b/Mixonomer/PlaylistGenerator.cs deleted file mode 100644 index 80365b5..0000000 --- a/Mixonomer/PlaylistGenerator.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.Extensions.Logging; -using Mixonomer.Fire; -using SpotifyAPI.Web; - -namespace Mixonomer; - -public class PlaylistGenerator -{ - private readonly ILogger _logger; - private readonly UserRepo _userRepo; - private readonly SpotifyNetworkProvider _spotifyMetworkProvider; - private readonly PartTreeWalker _partTreeWalker; - - public PlaylistGenerator(UserRepo userRepo, SpotifyNetworkProvider spotifyMetworkProvider, PartTreeWalker partTreeWalker, ILogger 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); - } -} \ No newline at end of file diff --git a/Mixonomer/Spotify/SpotifyNetworkProvider.cs b/Mixonomer/Spotify/SpotifyNetworkProvider.cs index 82d08d8..65af292 100644 --- a/Mixonomer/Spotify/SpotifyNetworkProvider.cs +++ b/Mixonomer/Spotify/SpotifyNetworkProvider.cs @@ -35,6 +35,14 @@ public class SpotifyNetworkProvider var refreshed = await new OAuthClient() .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() { AccessToken = refreshed.AccessToken, @@ -47,21 +55,13 @@ public class SpotifyNetworkProvider authenticator.TokenRefreshed += async (sender, resp) => { - try + await WriteUserTokenUpdate(user, new { - _logger.LogInformation("Token refreshed for [{}], writing to database", user.username); - await user.Reference.SetAsync(new - { - access_token = resp.AccessToken, - 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); - } + access_token = resp.AccessToken, + refresh_token = resp.RefreshToken, + last_refreshed = resp.CreatedAt, + token_expiry = resp.ExpiresIn + }); }; var config = SpotifyClientConfig @@ -70,4 +70,17 @@ public class SpotifyNetworkProvider 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); + } + } } \ No newline at end of file diff --git a/Mixonomer/Spotify/SpotifyRecommender.cs b/Mixonomer/Spotify/SpotifyRecommender.cs new file mode 100644 index 0000000..c7bf127 --- /dev/null +++ b/Mixonomer/Spotify/SpotifyRecommender.cs @@ -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> GetRecommendations(Fire.Playlist playlist, + IEnumerable 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(); + } +} \ No newline at end of file