feat: song likes and initial implementation of Spotify playlist support, refs #9
This commit is contained in:
65
song_of_the_day/SpotifyIntegration/PlayListSynchronizer.cs
Normal file
65
song_of_the_day/SpotifyIntegration/PlayListSynchronizer.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class PlayListSynchronizer
|
||||
{
|
||||
private SpotifyApiClient _spotifyAPIClient;
|
||||
|
||||
public PlayListSynchronizer(SpotifyApiClient spotifyAPIClient)
|
||||
{
|
||||
_spotifyAPIClient = spotifyAPIClient;
|
||||
}
|
||||
|
||||
public async Task SynchronizePlaylistAsync(SmartPlaylistDefinition playlist)
|
||||
{
|
||||
var songsToInclude = new List<Song>();
|
||||
using (var dci = DataContext.Instance)
|
||||
{
|
||||
songsToInclude.AddRange(dci.SongSuggestions.Where(ss => ss.HasUsedSuggestion && playlist.Categories.Contains(ss.SuggestionHelper) && !songsToInclude.Contains(ss.Song)).Select(ss => ss.Song));
|
||||
songsToInclude.AddRange(playlist.ExplicitlyIncludedSongs.Where(ss => !songsToInclude.Contains(ss)));
|
||||
if (playlist.IncludesUnCategorizedSongs)
|
||||
{
|
||||
songsToInclude.AddRange(dci.SongSuggestions.Where(ss => !ss.HasUsedSuggestion && !songsToInclude.Contains(ss.Song)).Select(ss => ss.Song));
|
||||
}
|
||||
if (playlist.IncludesLikedSongs)
|
||||
{
|
||||
var userWithLikes = dci.Users.Include(u => u.LikedSongs).Where(u => u.UserId == playlist.CreatedBy.UserId).SingleOrDefault();
|
||||
var likedSongs = userWithLikes.LikedSongs;
|
||||
songsToInclude.AddRange(likedSongs.Where(s => !songsToInclude.Contains(s)));
|
||||
}
|
||||
}
|
||||
songsToInclude.RemoveAll(song => playlist.ExplicitlyExcludedSongs.Contains(song));
|
||||
var spotifyIdsToInclude = songsToInclude.Select(s => s.SpotifyId);
|
||||
|
||||
var apiClient = await _spotifyAPIClient.WithUserAuthorizationAsync(playlist.CreatedBy);
|
||||
|
||||
var songsAlreadyInPlaylist = await apiClient.GetSongsInPlaylist(playlist.SpotifyPlaylistId);
|
||||
|
||||
var songsToAdd = spotifyIdsToInclude.Where(sti => !songsAlreadyInPlaylist.Contains(sti)).ToList();
|
||||
var songsToRemove = songsAlreadyInPlaylist.Where(sai => !spotifyIdsToInclude.Contains(sai)).ToList();
|
||||
|
||||
apiClient.AddSongsToPlaylist(playlist.SpotifyPlaylistId, songsToAdd);
|
||||
apiClient.RemoveSongsFromPlaylist(playlist.SpotifyPlaylistId, songsToRemove);
|
||||
}
|
||||
|
||||
public async Task SynchronizePlaylistsAsync(IList<SmartPlaylistDefinition> playlists)
|
||||
{
|
||||
foreach(var playlist in playlists)
|
||||
{
|
||||
await SynchronizePlaylistAsync(playlist);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SynchronizeUserPlaylistsAsync(User user)
|
||||
{
|
||||
using(var dci = DataContext.Instance)
|
||||
{
|
||||
var userPlayLists = dci.SmartPlaylistDefinitions
|
||||
.Include(pl => pl.ExplicitlyIncludedSongs)
|
||||
.Include(pl => pl.ExplicitlyExcludedSongs)
|
||||
.Include(pl => pl.Categories)
|
||||
.Include(pl => pl.CreatedBy)
|
||||
.Where(pl => pl.CreatedBy == user).ToList();
|
||||
await SynchronizePlaylistsAsync(userPlayLists);
|
||||
}
|
||||
}
|
||||
}
|
287
song_of_the_day/SpotifyIntegration/SpotifyApiClient.cs
Normal file
287
song_of_the_day/SpotifyIntegration/SpotifyApiClient.cs
Normal file
@@ -0,0 +1,287 @@
|
||||
using SpotifyAPI.Web;
|
||||
using System.Web;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class SpotifyApiClient
|
||||
{
|
||||
private SpotifyClient _spotifyClient;
|
||||
private SpotifyClient _userAuthorizedSpotifyClient;
|
||||
private ILogger<SpotifyApiClient> _logger;
|
||||
|
||||
public SpotifyApiClient(ILogger<SpotifyApiClient> logger)
|
||||
{
|
||||
var config = SpotifyClientConfig.CreateDefault()
|
||||
.WithAuthenticator(new ClientCredentialsAuthenticator(
|
||||
AppConfiguration.Instance.SpotifyClientId,
|
||||
AppConfiguration.Instance.SpotifyClientSecret));
|
||||
|
||||
_spotifyClient = new SpotifyClient(config);
|
||||
_userAuthorizedSpotifyClient = null;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SpotifyApiClient> WithUserAuthorizationAsync(User user)
|
||||
{
|
||||
var refreshResponse = await new OAuthClient().RequestToken(
|
||||
new AuthorizationCodeRefreshRequest(
|
||||
AppConfiguration.Instance.SpotifyClientId,
|
||||
AppConfiguration.Instance.SpotifyClientSecret,
|
||||
user.SpotifyAuthRefreshToken)
|
||||
);
|
||||
var config = SpotifyClientConfig
|
||||
.CreateDefault()
|
||||
.WithAuthenticator(new AuthorizationCodeAuthenticator(
|
||||
AppConfiguration.Instance.SpotifyClientId,
|
||||
AppConfiguration.Instance.SpotifyClientSecret,
|
||||
new AuthorizationCodeTokenResponse() {
|
||||
RefreshToken = refreshResponse.RefreshToken,
|
||||
AccessToken = refreshResponse.AccessToken,
|
||||
TokenType = refreshResponse.TokenType,
|
||||
ExpiresIn = refreshResponse.ExpiresIn,
|
||||
Scope = refreshResponse.Scope,
|
||||
CreatedAt = refreshResponse.CreatedAt
|
||||
}));
|
||||
|
||||
_userAuthorizedSpotifyClient = new SpotifyClient(config);
|
||||
return this;
|
||||
}
|
||||
|
||||
private SpotifyClient UserAuthorizedSpotifyClient {
|
||||
get {
|
||||
if (_userAuthorizedSpotifyClient == null)
|
||||
{
|
||||
throw new Exception("Cannot perform Spotify API call without user authorization. Authorize Spotify access from your user page first!");
|
||||
}
|
||||
return _userAuthorizedSpotifyClient;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<FullTrack>> GetTrackCandidatesAsync(string trackName, string artistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var searchResponse = await _spotifyClient.Search.Item(new SearchRequest(SearchRequest.Types.Track, $"{trackName} {artistName}")
|
||||
{
|
||||
Limit = 5
|
||||
});
|
||||
return searchResponse.Tracks.Items ?? new List<FullTrack>();
|
||||
}
|
||||
catch (APIException ex)
|
||||
{
|
||||
throw new Exception($"Error fetching tracks by query: \"{trackName} {artistName}\": {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public string GetLoginRedirectUri()
|
||||
{
|
||||
return "http://127.0.0.1:5000/SpotifyLogin";
|
||||
}
|
||||
|
||||
private bool IsAuthTokenExpired(User user)
|
||||
{
|
||||
if (user.SpotifyAuthCreatedAt == null || user.SpotifyAuthExpiresAfterSeconds == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
var expirationdate = user.SpotifyAuthCreatedAt.Value.AddSeconds(user.SpotifyAuthExpiresAfterSeconds.Value);
|
||||
return expirationdate < DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public async Task DeAuthorizeUserAsync(User user)
|
||||
{
|
||||
using(var dci = DataContext.Instance)
|
||||
{
|
||||
var isEntityTracked = dci.Entry(user).State != EntityState.Detached;
|
||||
|
||||
if(!isEntityTracked)
|
||||
{
|
||||
user = dci.Users.Find(user.UserId);
|
||||
}
|
||||
user.SpotifyAuthAccessToken = string.Empty;
|
||||
user.SpotifyAuthRefreshToken = string.Empty;
|
||||
user.SpotifyAuthExpiresAfterSeconds = null;
|
||||
user.SpotifyAuthCreatedAt = null;
|
||||
await dci.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetValidAuthorizationTokenAsync(User user)
|
||||
{
|
||||
if(string.IsNullOrEmpty(user.SpotifyAuthAccessToken))
|
||||
{
|
||||
// user either never connected Spotify or we failed to refresh token - user needs to re-authenticate
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if(!this.IsAuthTokenExpired(user))
|
||||
{
|
||||
return user.SpotifyAuthAccessToken;
|
||||
}
|
||||
|
||||
// if token is expired, attempt a refresh
|
||||
var dci = DataContext.Instance;
|
||||
|
||||
var isEntityTracked = dci.Entry(user).State != EntityState.Detached;
|
||||
|
||||
if(!isEntityTracked)
|
||||
{
|
||||
user = dci.Users.Find(user.UserId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var oAuthResponse = await new OAuthClient().RequestToken(
|
||||
new AuthorizationCodeRefreshRequest(AppConfiguration.Instance.SpotifyClientId, AppConfiguration.Instance.SpotifyClientSecret, user.SpotifyAuthRefreshToken)
|
||||
);
|
||||
user.SpotifyAuthAccessToken = oAuthResponse.AccessToken;
|
||||
user.SpotifyAuthExpiresAfterSeconds = oAuthResponse.ExpiresIn;
|
||||
user.SpotifyAuthCreatedAt = oAuthResponse.CreatedAt;
|
||||
user.SpotifyAuthRefreshToken = oAuthResponse.RefreshToken;
|
||||
return user.SpotifyAuthAccessToken;
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogWarning($"Failed to refresh SpotifyAuth token for user {user.LdapUserName}: {ex.Message}");
|
||||
await DeAuthorizeUserAsync(user);
|
||||
return string.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await dci.SaveChangesAsync();
|
||||
dci.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsUserAuthenticatedAsync(User user)
|
||||
{
|
||||
return !string.IsNullOrEmpty(await this.GetValidAuthorizationTokenAsync(user));
|
||||
}
|
||||
|
||||
public async Task<FullTrack> GetTrackByIdAsync(string trackId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _spotifyClient.Tracks.Get(trackId);
|
||||
}
|
||||
catch (APIException ex)
|
||||
{
|
||||
throw new Exception($"Error fetching track by ID: {trackId}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SmartPlaylistDefinition> CreateSpotifyPlaylist(string playlistTitle,
|
||||
string description,
|
||||
bool IncludesUnCategorizedSongs,
|
||||
bool IncludesLikedSongs,
|
||||
User createdBy)
|
||||
{
|
||||
try
|
||||
{
|
||||
// for now hardcoded with my user ID
|
||||
var playlistCreationRequest = new PlaylistCreateRequest(playlistTitle);
|
||||
playlistCreationRequest.Public = true;
|
||||
playlistCreationRequest.Collaborative = false;
|
||||
playlistCreationRequest.Description = description;
|
||||
var currentUser = await UserAuthorizedSpotifyClient.UserProfile.Current();
|
||||
var playlist = await UserAuthorizedSpotifyClient.Playlists.Create(currentUser.Id, playlistCreationRequest);
|
||||
|
||||
_logger.LogWarning($"Creating new playlist '{playlistTitle}'");
|
||||
using(var dci = DataContext.Instance)
|
||||
{
|
||||
var trackedUserEntity = dci.Users.Find(createdBy.UserId);
|
||||
var newPlaylist = new SmartPlaylistDefinition()
|
||||
{
|
||||
Title = playlistTitle,
|
||||
Description = description,
|
||||
Categories = new List<SuggestionHelper>(),
|
||||
IncludesLikedSongs = IncludesLikedSongs,
|
||||
IncludesUnCategorizedSongs = IncludesUnCategorizedSongs,
|
||||
SpotifyPlaylistId = playlist.Id,
|
||||
CreatedBy = trackedUserEntity,
|
||||
ExplicitlyExcludedSongs = new List<Song>(),
|
||||
ExplicitlyIncludedSongs = new List<Song>()
|
||||
};
|
||||
|
||||
var trackedEntity = dci.SmartPlaylistDefinitions.Add(newPlaylist);
|
||||
await dci.SaveChangesAsync();
|
||||
return trackedEntity.Entity;
|
||||
}
|
||||
}
|
||||
catch (APIException ex)
|
||||
{
|
||||
throw new Exception($"Error creating playlist with title: {playlistTitle}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetSongsInPlaylist(string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// for now hardcoded with my user ID
|
||||
var ids = new List<string>();
|
||||
var firstContentPage = await UserAuthorizedSpotifyClient.Playlists.GetItems(playlistId);
|
||||
var allPages = await UserAuthorizedSpotifyClient.PaginateAll(firstContentPage);
|
||||
ids.AddRange(allPages.Select(track => (track.Track as FullTrack).Id));
|
||||
|
||||
return ids;
|
||||
|
||||
}
|
||||
catch (APIException ex)
|
||||
{
|
||||
throw new Exception($"Error fetching playlist contents for playlist with id: {playlistId}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> AddSongsToPlaylist(string playlistId, List<string> songIds)
|
||||
{
|
||||
if (songIds.Count == 0)
|
||||
{
|
||||
_logger.LogWarning($"No songs to add to playlist with id '{playlistId}'");
|
||||
return string.Empty;
|
||||
}
|
||||
try
|
||||
{
|
||||
// for now hardcoded with my user ID
|
||||
var addItemRequest = new PlaylistAddItemsRequest(songIds.Select(id => $"spotify:track:{id}").ToList());
|
||||
_logger.LogWarning($"Adding songs to playlist with id '{playlistId}'");
|
||||
var response = await UserAuthorizedSpotifyClient.Playlists.AddItems(playlistId, addItemRequest);
|
||||
return response.SnapshotId;
|
||||
|
||||
}
|
||||
catch (APIException ex)
|
||||
{
|
||||
throw new Exception($"Error adding songs to playlist with id: {playlistId}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> RemoveSongsFromPlaylist(string playlistId, List<string> songIds)
|
||||
{
|
||||
if (songIds.Count == 0)
|
||||
{
|
||||
_logger.LogWarning($"No songs to remove from playlist with id '{playlistId}'");
|
||||
return string.Empty;
|
||||
}
|
||||
try
|
||||
{
|
||||
// for now hardcoded with my user ID
|
||||
var removeItemsRequest = new PlaylistRemoveItemsRequest();
|
||||
removeItemsRequest.Tracks = new List<PlaylistRemoveItemsRequest.Item>();
|
||||
foreach (var song in songIds)
|
||||
{
|
||||
var item = new PlaylistRemoveItemsRequest.Item()
|
||||
{
|
||||
Uri = $"spotify:track:{song}",
|
||||
};
|
||||
removeItemsRequest.Tracks.Add(item);
|
||||
}
|
||||
|
||||
_logger.LogWarning($"Removing songs from playlist with id '{playlistId}'");
|
||||
var response = await UserAuthorizedSpotifyClient.Playlists.RemoveItems(playlistId, removeItemsRequest);
|
||||
return response.SnapshotId;
|
||||
}
|
||||
catch (APIException ex)
|
||||
{
|
||||
throw new Exception($"Error removing songs from playlist with id: {playlistId}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,44 +0,0 @@
|
||||
using SpotifyAPI.Web;
|
||||
|
||||
public class SpotifyApiClient
|
||||
{
|
||||
private SpotifyClient _spotifyClient;
|
||||
|
||||
public SpotifyApiClient()
|
||||
{
|
||||
var config = SpotifyClientConfig.CreateDefault()
|
||||
.WithAuthenticator(new ClientCredentialsAuthenticator(
|
||||
AppConfiguration.Instance.SpotifyClientId,
|
||||
AppConfiguration.Instance.SpotifyClientSecret));
|
||||
|
||||
_spotifyClient = new SpotifyClient(config);
|
||||
}
|
||||
|
||||
public async Task<List<FullTrack>> GetTrackCandidatesAsync(string trackName, string artistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var searchResponse = await _spotifyClient.Search.Item(new SearchRequest(SearchRequest.Types.Track, $"{trackName} {artistName}")
|
||||
{
|
||||
Limit = 5
|
||||
});
|
||||
return searchResponse.Tracks.Items ?? new List<FullTrack>();
|
||||
}
|
||||
catch (APIException ex)
|
||||
{
|
||||
throw new Exception($"Error fetching tracks by query: \"{trackName} {artistName}\": {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FullTrack> GetTrackByIdAsync(string trackId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _spotifyClient.Tracks.Get(trackId);
|
||||
}
|
||||
catch (APIException ex)
|
||||
{
|
||||
throw new Exception($"Error fetching track by ID: {trackId}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user