feat: song likes and initial implementation of Spotify playlist support, refs #9

This commit is contained in:
2025-07-20 03:18:22 +02:00
parent 8b91a13095
commit 2e876ad628
25 changed files with 2567 additions and 56 deletions

View 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);
}
}
}

View 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);
}
}
}

View File

@@ -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);
}
}
}