Working on playlist generator, adding sort and recommender
This commit is contained in:
parent
77c1e3c79e
commit
e1094b131f
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
6
Mixonomer/Exceptions/MixonomerException.cs
Normal file
6
Mixonomer/Exceptions/MixonomerException.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Mixonomer.Exceptions;
|
||||||
|
|
||||||
|
public class MixonomerException: Exception
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
6
Mixonomer/Exceptions/PlaylistNotFoundException.cs
Normal file
6
Mixonomer/Exceptions/PlaylistNotFoundException.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Mixonomer.Exceptions;
|
||||||
|
|
||||||
|
public class PlaylistNotFoundException: MixonomerException
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
@ -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)
|
||||||
{
|
{
|
||||||
|
71
Mixonomer/Playlist/CommonTrack.cs
Normal file
71
Mixonomer/Playlist/CommonTrack.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
6
Mixonomer/Playlist/IRecommend.cs
Normal file
6
Mixonomer/Playlist/IRecommend.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Mixonomer.Playlist;
|
||||||
|
|
||||||
|
public interface IRecommend
|
||||||
|
{
|
||||||
|
Task<IEnumerable<CommonTrack>> GetRecommendations(Fire.Playlist playlist, IEnumerable<CommonTrack> currentTrackList);
|
||||||
|
}
|
9
Mixonomer/Playlist/PlaylistGeneratingContext.cs
Normal file
9
Mixonomer/Playlist/PlaylistGeneratingContext.cs
Normal 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; }
|
||||||
|
}
|
148
Mixonomer/Playlist/PlaylistGenerator.cs
Normal file
148
Mixonomer/Playlist/PlaylistGenerator.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
Mixonomer/Playlist/SortExtensions.cs
Normal file
19
Mixonomer/Playlist/SortExtensions.cs
Normal 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());
|
||||||
|
}
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
||||||
await user.Reference.SetAsync(new
|
|
||||||
{
|
{
|
||||||
access_token = resp.AccessToken,
|
access_token = resp.AccessToken,
|
||||||
refresh_token = resp.RefreshToken,
|
refresh_token = resp.RefreshToken,
|
||||||
last_refreshed = resp.CreatedAt,
|
last_refreshed = resp.CreatedAt,
|
||||||
token_expiry = resp.ExpiresIn
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
30
Mixonomer/Spotify/SpotifyRecommender.cs
Normal file
30
Mixonomer/Spotify/SpotifyRecommender.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user