using MoreLinq; using NLog; using Sony.Filtr.ApolloAPI; using Sony.Filtr.Contracts.Abstractions; using Sony.Filtr.Contracts.Entities; using Sony.Filtr.Functional; using Sony.Filtr.PlaylistSynchronization.Data; using Sony.Filtr.SpotifyWebAPI; using Sony.Filtr.SpotifyWebAPI.Model; using Sony.Filtr.Utility.Extensions; using Sony.Filtr.VendorToSpotify; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace Sony.Filtr.PlaylistSynchronization.Synchronizer { public class SpotifySynchronizer : ISynchronizer { private readonly SpotifyWebApi _spotifyWebApi_Default; private readonly IApolloWebApi _vendorApi; private readonly IServiceAccountManager _serviceAccountManager; private readonly Logger _logger; private readonly bool _safeMode = false; private readonly Func> GetAuthenticatedSpotifyApiForServiceAccountAsync; public SpotifySynchronizer(SpotifyWebApi spotifyWebApi, IServiceAccountManager serviceAccountManager, IApolloWebApi vendorApi) { _spotifyWebApi_Default = spotifyWebApi; _vendorApi = vendorApi; _serviceAccountManager = serviceAccountManager; _logger = LogManager.GetLogger("PlaylistSynchronization"); this.GetAuthenticatedSpotifyApiForServiceAccountAsync = Create_GetAuthenticatedSpotifyApiForServiceAccountFunc(); } private Func> Create_GetAuthenticatedSpotifyApiForServiceAccountFunc() { Func> f = async account => { var tuple = await this._serviceAccountManager.GetServiceAccountSpotifyClientIdAndSecretAsync(account.Id); return tuple.HasValue ? SpotifyWebApi.GetAuthenticatedSession(account, this._serviceAccountManager, tuple.Value.ClientId, tuple.Value.SecretId) : this._spotifyWebApi_Default.GetAuthenticatedSession(account, this._serviceAccountManager); }; return f.LazyMemoizeThreadSafe(); } public async Task<(PlaylistSynchronizationResult, string, string)> CopyToPlaylistAsync(GenericPlaylist genericPlaylist, Data.PlaylistSynchronization playlistSynchronization, ServiceAccount serviceAccount) { var playlistUri = new SpotifyLink(playlistSynchronization.ToPlaylistId); _logger.Debug("Begin copy from Spotify playlist {0} to spotify {1}", genericPlaylist.Name, playlistSynchronization.ToPlaylistId); var toSpotifyPlaylist = (await _vendorApi.GetPlaylistByIdWithAllTracksAsync(playlistUri.ExtractPlaylistID(), cacheExpiration: System.TimeSpan.FromSeconds(0))).ToSpotify(); if (toSpotifyPlaylist == null) { return (PlaylistSynchronizationResult.FromError(SyncError.NoTargetPlaylist, $"Could not find playlist with id {playlistSynchronization.ToPlaylistId}"), null, null); } var authClient = await this.GetAuthenticatedSpotifyApiForServiceAccountAsync(serviceAccount); this._logger.Info($"For service account id: {serviceAccount.Id} ('{serviceAccount.UserIdentifier}') using client id '{authClient.ClientId?.Substring(0, 6)}'"); SyncCounts counts = await Sync(toSpotifyPlaylist, toSpotifyPlaylist.Tracks.items, genericPlaylist, serviceAccount, authClient); if (playlistSynchronization.TitleCopyMode != FieldCopyMode.NoUpdate) { await UpdatePlaylistTitle(toSpotifyPlaylist, playlistSynchronization, genericPlaylist, authClient, serviceAccount); } return (PlaylistSynchronizationResult.FromSuccess(counts, genericPlaylist.Tracks.Count), toSpotifyPlaylist.name, toSpotifyPlaylist.images.FirstOrDefault()?.url); } private async Task UpdatePlaylistTitle(PlaylistResponse toSpotifyPlaylist, Data.PlaylistSynchronization playlistSynchronization, GenericPlaylist genericPlaylist, AuthenticatedSpotifyWebApi authClient, ServiceAccount serviceAccount) { var title = string.Empty; if (playlistSynchronization.TitleCopyMode == FieldCopyMode.CopySource) { _logger.Debug($"Updating title to {genericPlaylist.Name} from source"); title = genericPlaylist.Name; } else if (playlistSynchronization.TitleCopyMode == FieldCopyMode.UseSetting) { _logger.Debug($"Updating title to {playlistSynchronization.Title} according to setting"); title = playlistSynchronization.Title; } await authClient.SetPlaylistDetails(serviceAccount.UserIdentifier, toSpotifyPlaylist.id, title, toSpotifyPlaylist.@public); } private async Task Sync(PlaylistResponse targetPlaylist, IEnumerable toPlaylistTrackItems, GenericPlaylist sourcePlaylist, ServiceAccount serviceAccount, AuthenticatedSpotifyWebApi spotifyApi) { var sourceTracks = sourcePlaylist.Tracks.ToList(); var targetTracks = toPlaylistTrackItems.ToList(); var removeLocalTuple = await RemoveLocalTracks(targetTracks, targetPlaylist.id, serviceAccount.UserIdentifier, spotifyApi, targetPlaylist.snapshot_id); var removeWithLowerAddedDateSnapshot = await RemoveTracksWithLowerTrackAddDate(sourceTracks, targetTracks, targetPlaylist.id, serviceAccount.UserIdentifier, spotifyApi, removeLocalTuple.Snapshot); var removeTuple = await RemoveExtraTracks(targetTracks, sourceTracks, targetPlaylist.id, serviceAccount.UserIdentifier, spotifyApi, removeWithLowerAddedDateSnapshot); var addTuple = await AddMissingTracks(targetTracks, sourceTracks, targetPlaylist.id, serviceAccount.UserIdentifier, spotifyApi, removeTuple.Snapshot); var reorderTuple = await ReorderTracks(targetPlaylist.id, sourceTracks, serviceAccount.UserIdentifier, spotifyApi, addTuple.Snapshot); _logger.Info($"Done with playlist sync for {targetPlaylist.uri}"); return new SyncCounts(addTuple.TracksAddedCount, reorderTuple.Reordered, removeTuple.TracksRemovedCount); } private async Task RemoveTracksWithLowerTrackAddDate( IEnumerable sourceTracks, List targetTracks, string targetPlaylistId, string userAccount, AuthenticatedSpotifyWebApi authenticatedApi, string snapshot) { var indexesToRemove = GetTrackIndexesWithLowerAddedDates(sourceTracks, targetTracks); indexesToRemove.OrderByDescending(i => i).ForEach(i => targetTracks.RemoveAt(i)); return indexesToRemove.Any() ? await authenticatedApi.DeleteAllPlaylistTracksInPositionAsync( userAccount, targetPlaylistId, indexesToRemove, snapshot) : snapshot; } private static List GetTrackIndexesWithLowerAddedDates(IEnumerable sourceTracks, IEnumerable targtTracks) { var sourceUris = sourceTracks .ItemIndex() .GroupBy(item => item.Item.SpotifyUri) .ToDictionary(g => g.Key, g => g.OrderByDescending(i => i.Index).ToArray()); var targetUris = targtTracks .Where(t => !t.is_local) .Where(t => t.track.uri != null) .ItemIndex() .GroupBy(item => item.Item.track.uri) .ToDictionary(g => g.Key, g => g.OrderByDescending(i => i.Index).ToArray()); return sourceUris.Keys.Aggregate( new List(), (acc, trackUri) => { if (targetUris.ContainsKey(trackUri)) { for (int i = 0; i < sourceUris[trackUri].Length; ++i) { if (targetUris[trackUri].Length > i) { if (targetUris[trackUri][i].Item.added_at < sourceUris[trackUri][i].Item.TrackAddedDate) { acc.Add(targetUris[trackUri][i].Index); } } } } return acc; }); } private async Task<(bool Reordered, string Snapshot, PlaylistTrackItem[] ReorderedTracks)> ReorderTracks( string targetPlaylistId, IEnumerable sourceTracks, string userAccount, AuthenticatedSpotifyWebApi authenticatedApi, string snapshot) { var toPlaylistTrackItems = (await _vendorApi.GetAllPlaylistTracksByIdAsync(targetPlaylistId, cacheExpiration: TimeSpan.FromSeconds(0))).Select(t => t.ToSpotify()).ToList(); var targetTracks = new LinkedList(toPlaylistTrackItems.Where(t => t.track != null)); bool reordered = false; var destinationTracks = targetTracks.ItemIndex(); foreach (var sourceItem in sourceTracks.ItemIndex()) { var targetTrackItem = destinationTracks.First(i => i.Item.track.uri == sourceItem.Item.SpotifyUri && i.Index >= sourceItem.Index); if (targetTrackItem.Index != sourceItem.Index) { _logger.Debug(() => $"Moving track {new SpotifyLink(sourceItem.Item.SpotifyUri).ExtractTrackID()} '{sourceItem.Item.Name}' from {targetTrackItem.Index + 1} to {sourceItem.Index + 1}"); if (!_safeMode) { var orderResponse = await authenticatedApi.OrderPlaylistTracksAsync(userAccount, targetPlaylistId, targetTrackItem.Index, 1, sourceItem.Index, snapshot); snapshot = orderResponse.snapshot_id; reordered = true; } Move(targetTracks, targetTrackItem.Index, sourceItem.Index); } } return (reordered, snapshot, targetTracks.ToArray()); } private async Task<(int TracksRemovedCount, string Snapshot)> RemoveLocalTracks( List toTracks, string targetPlaylistId, string userAccount, AuthenticatedSpotifyWebApi api, string snapshot) { var localPlayListTracksIndex = GetLocalTracksToRemove(toTracks); if (localPlayListTracksIndex.Any()) { _logger.Debug($"Deleting local tracks. User: '{userAccount}' Playlist: '{targetPlaylistId}' Tracks indeces to remove: {String.Join(", ", localPlayListTracksIndex)}"); if (!_safeMode) { snapshot = await api.DeleteAllPlaylistTracksInPositionAsync(userAccount, targetPlaylistId, localPlayListTracksIndex, snapshot); localPlayListTracksIndex.OrderByDescending(i => i).ForEach(i => toTracks.RemoveAt(i)); } } return (localPlayListTracksIndex.Count, snapshot); } private async Task<(int TracksAddedCount, string Snapshot)> AddMissingTracks( IEnumerable toTracks, IEnumerable sourceTracks, string targetPlaylistId, string userAccount, AuthenticatedSpotifyWebApi api, string snapshot) { var trackUrisToAdd = GetTracksToAdd(toTracks, sourceTracks); if (trackUrisToAdd.Any()) { _logger.Debug($"Adding tracks. User: '{userAccount}' Playlist: '{targetPlaylistId}' Tracks: {String.Join(", ", trackUrisToAdd)}"); if (!_safeMode) { var addResponse = await api.AddAllTrackAsync(userAccount, targetPlaylistId, trackUrisToAdd); snapshot = addResponse.Last().snapshot_id; } } return (trackUrisToAdd.Length, snapshot); } private async Task<(int TracksRemovedCount, string Snapshot)> RemoveExtraTracks( List toTracks, IEnumerable sourceTracks, string targetPlaylistId, string userAccount, AuthenticatedSpotifyWebApi api, string snapshot) { var trackUrisToRemove = GetTracksToRemove(toTracks, sourceTracks); if (trackUrisToRemove.Any()) { _logger.Debug($"Deleting tracks. User: '{userAccount}' Playlist: '{targetPlaylistId}'. Tracks: {String.Join(", ", trackUrisToRemove)}"); if (!_safeMode) { snapshot = await api.DeleteMultiplePlaylistTracksAsync(userAccount, targetPlaylistId, trackUrisToRemove, snapshot); } toTracks.RemoveAll(track => trackUrisToRemove.Contains(track.track.uri)); } return (trackUrisToRemove.Length, snapshot); } private List GetLocalTracksToRemove(IEnumerable allTracksItem) { if (allTracksItem == null) { return new List(); } return allTracksItem .ItemIndex() .Where(i => i.Item.is_local) .Select(i => i.Index) .ToList(); } private static string[] GetTracksToAdd(IEnumerable toTracks, IEnumerable sourceTracks) { var sourceUris = sourceTracks.GroupBy(s => s.ISRC).ToDictionary(g => g.Key, g => g.Count()); var targetUris = toTracks.GroupBy(s => s.track.external_ids.isrc).ToDictionary(g => g.Key, g => g.Count()); return sourceUris.Keys.Aggregate( new Dictionary(), (acc, key) => { if (targetUris.ContainsKey(key)) { if (targetUris[key] < sourceUris[key]) { acc.Add(key, sourceUris[key] - targetUris[key]); } } else { acc.Add(key, sourceUris[key]); } return acc; }) .SelectMany(pair => Enumerable.Repeat(sourceTracks.First(t => t.ISRC.Equals(pair.Key)).SpotifyUri, pair.Value)) .ToArray(); } private static string[] GetTracksToRemove(IEnumerable toTracks, IEnumerable sourceTracks) { var sourceUris = sourceTracks.GroupBy(s => s.ISRC).ToDictionary(g => g.Key, g => g.Count()); var targetUris = toTracks.Where(t => !t.is_local).GroupBy(s => s.track.external_ids.isrc).ToDictionary(g => g.Key, g => g.Count()); return targetUris.Keys.Aggregate( new Dictionary(), (acc, key) => { if (sourceUris.ContainsKey(key)) { if (targetUris[key] > sourceUris[key]) { acc.Add(key, targetUris[key] - sourceUris[key]); } } else { acc.Add(key, targetUris[key]); } return acc; }) .SelectMany(pair => Enumerable.Repeat(toTracks.First(t => t.track.external_ids.isrc.Equals(pair.Key)).track.uri, pair.Value)) .ToArray(); } private static void Move(LinkedList list, int oldIndex, int newIndex) { var oldNode = list.GetNodeByIndex(oldIndex); var newNode = list.GetNodeByIndex(newIndex); list.Remove(oldNode); if (newNode.Previous == null) { list.AddFirst(oldNode.Value); } else { list.AddAfter(newNode.Previous, oldNode.Value); } } } }