using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using AngleSharp.Extensions; using AngleSharp.Parser.Html; using MoreLinq; using NLog; using Sony.Filtr.Contracts.Definitions; using Sony.Filtr.Contracts.Entities; using Sony.Filtr.DspWebAPI; using Sony.Filtr.Functional; using Sony.Filtr.PlaylistSynchronization.Data; using Sony.Filtr.Utility; using Sony.Filtr.Utility.Extensions; using Sony.Filtr.YouTube.Api; namespace Sony.Filtr.PlaylistSynchronization.Synchronizer { /* https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas?authuser=3&project=apollosyncjob smedataanalytics@gmail.com D@t@Passw0rd API key: AIzaSyBfZgHGPxxFiNJ16DOHFV1wCbaD2IzfyVU 1066394547140-qosf2sh1t6k7no7d9165jftac6caupke.apps.googleusercontent.com PtoOjxwW3RuTUDpeS98NVNIS */ public class YoutubeSynchronizer : ISynchronizer { private readonly YouTubeApi _youtubeApi; private readonly SynchronizationTrackManager _synchronizationTrackManager; private readonly FlaggedChannelsManager _flaggedChannelsManager; private readonly IDspWebApi _dspApi; private readonly Logger _logger; private readonly List _officialLookingSuffixes = new List() { "official", "officiel", "offiziell", "ufficiale", "offisiell", "oficial", "officiell", "channel", }; private readonly List _blacklistedPartialPhrases = new List() { "remaster", "album", "full length", }; private readonly List _blacklistedExactPhrases = new List() { "single version", "radio edit", "single edit", "radio version", "radio mix", "single mix", "stereo version", "extended version", "original mix", "single remix", }; private readonly List _blacklistedYouTubeTitleWords = new List() { "karaoke", "behind the scenes" }; public YoutubeSynchronizer(YouTubeApi youtubeApi, SynchronizationTrackManager synchronizationTrackManager, FlaggedChannelsManager flaggedChannelsManager, IDspWebApi dspApi) { _youtubeApi = youtubeApi; _synchronizationTrackManager = synchronizationTrackManager; _flaggedChannelsManager = flaggedChannelsManager; _dspApi = dspApi; _logger = LogManager.GetLogger("PlaylistSynchronization"); } public async Task<(PlaylistSynchronizationResult, string, string)> CopyToPlaylistAsync(GenericPlaylist genericPlaylist, Data.PlaylistSynchronization playlistSynchronization, ServiceAccount toAccount) { //return (PlaylistSynchronizationResult.FromError(SyncError.Unknown, "Temporary stop processing YouTube"), null, null); _logger.Debug($"Begin copy from Spotify playlist {genericPlaylist.Name} to youtube: {playlistSynchronization.ToPlaylistId}"); var authClient = _youtubeApi.GetAuthenticatedYoutubeApi(toAccount); (SyncCounts, string) counts = await UpdatePlaylistTracks(playlistSynchronization, genericPlaylist, authClient); return (PlaylistSynchronizationResult.FromSuccess(counts.Item1, genericPlaylist.Tracks.Count), counts.Item2, null); } private async Task<(SyncCounts, string)> UpdatePlaylistTracks(Data.PlaylistSynchronization playlistSynchronization, GenericPlaylist sourcePlaylist, AuthenticatedYoutubeApi youtubeApi) { var sourceSynchronizationTracks = await GetTrackMappings(sourcePlaylist.Tracks, youtubeApi); Func>> getYoutubePlaylistTracks = playlistId => youtubeApi.GetAllPlaylistItemsAsync(playlistId); var youtubeTracksResult = await getYoutubePlaylistTracks.TryCatch()(playlistSynchronization.ToPlaylistId); if (youtubeTracksResult.IsFailed) { throw new PlaylistSynchronizationException($"Could not load youtube tracks. Error: '{youtubeTracksResult.Exception.Message}'", SyncError.VendorSpecific); } await InsertSelectedVideos(sourceSynchronizationTracks, playlistSynchronization, youtubeApi); int removedTracksCount = await RemoveExtraTracks(youtubeApi, youtubeTracksResult.Value, sourcePlaylist.Tracks, sourceSynchronizationTracks); int addedTracksCount = await AddMissingTracks(playlistSynchronization.ToPlaylistId, youtubeApi, youtubeTracksResult.Value, sourcePlaylist.Tracks, sourceSynchronizationTracks); bool reordered = await ReorderTracks(playlistSynchronization.ToPlaylistId, youtubeApi, sourceSynchronizationTracks, sourcePlaylist.Tracks); string title = await SetPlaylistInfo(sourcePlaylist, sourceSynchronizationTracks, playlistSynchronization, youtubeApi); return (new SyncCounts(addedTracksCount, reordered, removedTracksCount), title); } private async Task ReorderTracks(string youtubePlaylistId, AuthenticatedYoutubeApi youtubeApi, IEnumerable map, IEnumerable spotifyTracks) { var youtubeTracks = new LinkedList(await youtubeApi.GetAllPlaylistItemsAsync(youtubePlaylistId)); var targetTracks = youtubeTracks.ItemIndex(); bool reordered = false; foreach (var spotifyTrack in spotifyTracks.ItemIndex()) { var youtubeTrack = targetTracks.First(t => t.Item.snippet.resourceId.videoId == map.First(m => m.ISRC.Equals(spotifyTrack.Item.ISRC)).TrackId && t.Index >= spotifyTrack.Index); if (youtubeTrack.Index != spotifyTrack.Index) { _logger.Debug($"Moving track {spotifyTrack.Item.TrackId} from {youtubeTrack.Index} to {spotifyTrack.Index}"); youtubeTrack.Item.snippet.position = spotifyTrack.Index; await youtubeApi.UpdatePlaylistItemAsync(youtubeTrack.Item); Move(youtubeTracks, youtubeTrack.Index, spotifyTrack.Index); reordered = true; } } return reordered; } 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); } } private async Task RemoveExtraTracks(AuthenticatedYoutubeApi youtubeApi, List youtubeTracks, List spotifyTracks, List map) { var itemIdsToRemove = GetPlaylistItemIdsToRemove(youtubeTracks, spotifyTracks, map); _logger.Debug(() => $"In target playlist there are {itemIdsToRemove.Length} videos which need to be removed: {string.Join(",", itemIdsToRemove.Select(t => t.videoId))}"); if (itemIdsToRemove.Any()) { var failedPlaylistItemIds = await youtubeApi.DeletePlaylistItemsAsync(itemIdsToRemove.Select(t => t.itemId).ToArray()); _logger.Error($"Could not delete playlist items by id '{String.Join(", ", failedPlaylistItemIds)}'"); } return itemIdsToRemove.Length; } private async Task AddMissingTracks( string toPlaylistId, AuthenticatedYoutubeApi youtubeApi, List youtubeTracks, List spotifyTracks, List map) { var videosToAdd = GetTracksToAdd(youtubeTracks, spotifyTracks, map); _logger.Debug(() => $"There are {videosToAdd.Length} videos that need to be added: {string.Join(",", videosToAdd.Select(t => t))}"); foreach (var videoId in videosToAdd) { await youtubeApi.AddVideoToPlaylistAsync( toPlaylistId, new Id() { videoId = videoId }, spotifyTracks.ItemIndex().First(p => p.Item.ISRC == map.First(t => t.TrackId == videoId).ISRC).Index); } return videosToAdd.Length; } private async Task SetPlaylistInfo(GenericPlaylist sourcePlaylist, IEnumerable sourceTracks, Data.PlaylistSynchronization playlistSynchronization, AuthenticatedYoutubeApi authClient) { const int maxCharsInDescription = 5000; var playlist = await authClient.GetPlaylistAsync(playlistSynchronization.ToPlaylistId); var description = playlist.snippet.description; if (playlistSynchronization.DescriptionCopyMode == FieldCopyMode.CopySource) { description = sourcePlaylist.Description; } else if (playlistSynchronization.DescriptionCopyMode == FieldCopyMode.UseSetting) { description = playlistSynchronization.Description; } if (playlistSynchronization.AppendTrackList) { var tracksInPlaylist = sourcePlaylist.Tracks.Where(track => sourceTracks.Any(syncTrack => track.ISRC == syncTrack.ISRC)); // Adds two empty rows before song list, if a description text is set. description = description + (string.IsNullOrWhiteSpace(description) ? string.Empty : Environment.NewLine + Environment.NewLine); foreach (var trackTitle in tracksInPlaylist.Select(t => string.Format($"{string.Join(", ", t.Artists)} - {t.Name}"))) { if (description.Length + trackTitle.Length + Environment.NewLine.Length > maxCharsInDescription) { break; } description += trackTitle + Environment.NewLine; } } description = StripDescriptionLinks(description); var title = string.Empty; if (playlistSynchronization.TitleCopyMode == FieldCopyMode.CopySource) { title = playlist.snippet.title; } else if (playlistSynchronization.TitleCopyMode == FieldCopyMode.UseSetting) { title = playlistSynchronization.Title; } if (!String.Equals(playlist.snippet.title, title) || !String.Equals(playlist.snippet.description, description)) { if (!String.IsNullOrWhiteSpace(title)) { playlist.snippet.title = title; } playlist.snippet.description = description; await authClient.UpdatePlaylistAsync(playlist); } return title; } private static string[] GetTracksToAdd(IEnumerable youtubeTracks, IEnumerable spotifyTracks, IEnumerable map) { var sourceUris = spotifyTracks.GroupBy(s => s.ISRC).ToDictionary(g => g.Key, g => g.Count()); var targetUris = youtubeTracks.GroupBy(i => i.snippet.resourceId.videoId).ToDictionary(g => g.Key, g => g.Count()); return sourceUris.Keys.Aggregate( new Dictionary(), (acc, isrc) => { var video = map.FirstOrDefault(t => String.Equals(t.ISRC, isrc, StringComparison.InvariantCultureIgnoreCase)); if (video != null) { int toAdd = 0; if (targetUris.ContainsKey(video.TrackId)) { if (targetUris[video.TrackId] < sourceUris[isrc]) { toAdd = sourceUris[isrc] - targetUris[video.TrackId]; } } else { toAdd = sourceUris[isrc]; } if (toAdd > 0) { if (!acc.ContainsKey(video.TrackId)) { acc.Add(video.TrackId, toAdd); } else { acc[video.TrackId] = Math.Max(acc[video.TrackId], toAdd); } } } return acc; }) .SelectMany(pair => Enumerable.Repeat(pair.Key, pair.Value)) .ToArray(); } private static (string itemId, string videoId)[] GetPlaylistItemIdsToRemove(IEnumerable youtubeTracks, IEnumerable spotifyTracks, IEnumerable map) { var sourceUris = spotifyTracks.GroupBy(s => s.ISRC).ToDictionary(g => g.Key, g => g.Count()); var targetUris = youtubeTracks.GroupBy(i => i.snippet.resourceId.videoId).ToDictionary(g => g.Key, g => g.Count()); return targetUris.Keys.Aggregate( new List<(string, string)>(), (acc, videoId) => { var video = map.FirstOrDefault(t => String.Equals(t.TrackId, videoId, StringComparison.InvariantCultureIgnoreCase)); int playlistItemsToRemoveCount = 0; if (video != null && sourceUris.ContainsKey(video.ISRC)) { if (targetUris[videoId] > sourceUris[video.ISRC]) { playlistItemsToRemoveCount = targetUris[videoId] - sourceUris[video.ISRC]; } } else { playlistItemsToRemoveCount = targetUris[videoId]; } acc.AddRange( youtubeTracks .Where(yt => yt.snippet.resourceId.videoId == videoId) .Take(playlistItemsToRemoveCount) .Select(yt => (yt.id, yt.snippet.resourceId.videoId))); return acc; }) .ToArray(); } private static Regex DSP_VideoId_Prefix = new Regex("^youtube_", RegexOptions.Compiled); private async Task> GetTrackMappings(IEnumerable spotifyTracks, AuthenticatedYoutubeApi authClient) { var datePivot = DateTime.Now.AddDays(-7); var synchronizationTracks = await _synchronizationTrackManager .GetSynchronizedTracksByIsrcAsync(spotifyTracks.Select(t => t.ISRC), ServiceType.YouTube, datePivot); Func> getFromDsp = async t => { var video = await _dspApi.GetVideoByISrcAsync(t.ISRC); return video == null ? (null, TrackSource.DSP) : (DSP_VideoId_Prefix.Replace(video.Video_Id, String.Empty), TrackSource.DSP); }; Func> getFromDB = t => { var syncTrack = synchronizationTracks.FirstOrDefault(st => st.ISRC.Equals(t.ISRC)); string videoId = syncTrack == null ? null : syncTrack.TrackId; TrackSource source = syncTrack == null ? TrackSource.YouTube : syncTrack.Source; return Task.FromResult<(string, TrackSource)>((videoId, source)); }; Func> getFromYoutube = async t => { return (await FindYouTubeVideoAsync(t.Name, t.Artists, authClient), TrackSource.YouTube); }; Func>[] prompts = new[] { getFromDsp, getFromDB, getFromYoutube }; var mappedTracks = new List(); foreach (var spotifyTrack in spotifyTracks) { var videoTupleResult = await Result.Success(spotifyTrack) .Prompt(prompts, tuple => !String.IsNullOrWhiteSpace(tuple.VideoId)); if (videoTupleResult.IsOk) { var syncedTrack = await _synchronizationTrackManager.SaveSynchronizationTrackAsync( spotifyTrack.ISRC, ServiceType.YouTube, videoTupleResult.Value.VideoId, videoTupleResult.Value.Source); if (syncedTrack != null && !mappedTracks.Any(t => String.Equals(t.ISRC, syncedTrack.ISRC, StringComparison.InvariantCultureIgnoreCase))) { mappedTracks.Add(syncedTrack); } } } return mappedTracks; } public async Task FindYouTubeVideoAsync(string trackName, IEnumerable artistNames, AuthenticatedYoutubeApi authClient) { var searchItems = await SearchVideos(trackName, artistNames.First(), authClient); if (searchItems == null || !searchItems.Any()) { return null; } var displayNames = await authClient.GetChannelsAsync(searchItems.Select(r => r.snippet.channelId).ToList()); Dictionary displayNamesPerChannelId = new Dictionary(); if (displayNames != null && displayNames.items.Any()) { displayNamesPerChannelId = displayNames.items.DistinctBy(d => d.snippet.title).ToDictionary(d => d.id, d => d.snippet.title); } var matchedVideo = MatchVideos(searchItems, displayNamesPerChannelId, trackName, artistNames); return matchedVideo != null ? matchedVideo.id.videoId : null; } public SearchItem MatchVideos(List searchItems, Dictionary displayNamesPerChannelId, string trackName, IEnumerable artistNames) { //We want the video name to really contain the track and artist name var videosThatContainName = searchItems.Where(v => NameMatches(v.snippet.title, trackName, artistNames)).ToList(); //We exclude videos that have certain blacklisted words in the title (when the word is not in the track title) var videosWithoutBlacklistedWords = videosThatContainName.Where(v => ContainsNoBlacklistedWords(v.snippet.title, trackName, artistNames)).ToList(); //Videos are checked against a list of blacklisted channels, and not used if from one of those channels. var videosWithouthBlacklistedChannels = videosWithoutBlacklistedWords.Where(v => !FromBlacklistedChannel(v)).ToList(); //We prefer videos by VEVO users. var videosThatMayBeFromVevoUsers = videosWithouthBlacklistedChannels.Where(v => Contains(v.snippet.channelTitle, "vevo")).ToList(); if (videosThatMayBeFromVevoUsers.Any()) return videosThatMayBeFromVevoUsers.First(); //If the video is from a whitelisted channel, we like that. var videosFromWhitelistedChannels = videosWithouthBlacklistedChannels.Where(v => FromWhitelistedChannel(v)).ToList(); if (videosFromWhitelistedChannels.Any()) return videosFromWhitelistedChannels.First(); //We prefer users with artist name var videosThatMayBeFromArtist = videosWithouthBlacklistedChannels.Where(v => LooksOfficial(v, displayNamesPerChannelId.ContainsKey(v.snippet.channelId) ? displayNamesPerChannelId[v.snippet.channelId] : null, artistNames)).ToList(); //If all else fails, just return first video (after the filtering above) return videosThatMayBeFromArtist.FirstOrDefault() ?? videosWithouthBlacklistedChannels.FirstOrDefault(); } private bool FromWhitelistedChannel(SearchItem searchItem) { var whiteListedChannels = _flaggedChannelsManager.GetWhitelistedChannels(); return whiteListedChannels.Any( channel => channel.Equals(searchItem.snippet.channelTitle, StringComparison.InvariantCultureIgnoreCase) || channel.Equals(searchItem.snippet.channelId, StringComparison.InvariantCultureIgnoreCase)); } private bool FromBlacklistedChannel(SearchItem searchItem) { var blacklistedChannels = _flaggedChannelsManager.GetBlacklistedChannels(); return blacklistedChannels.Any( channel => channel.Equals(searchItem.snippet.channelTitle, StringComparison.InvariantCultureIgnoreCase) || channel.Equals(searchItem.snippet.channelId, StringComparison.InvariantCultureIgnoreCase)); } private bool LooksOfficialV1(SearchItem v, GenericTrack genericTrack) { var channelVariations = new List(); var artistName = genericTrack.Artists.First(); artistName = TextUtility.RemoveAccent(artistName); artistName = TextUtility.RemoveSpecialCharacters(artistName); artistName = artistName.Replace(" ", string.Empty); channelVariations.Add(artistName); channelVariations.Add(artistName + "official"); return channelVariations.Any(channelVariation => v.snippet.channelTitle.Equals(channelVariation, StringComparison.InvariantCultureIgnoreCase)); } private bool LooksOfficial(SearchItem v, string channelDisplayName, IEnumerable artistNames) { var normalizedArtistName = TextUtility.RemoveAccent(artistNames.First()); normalizedArtistName = TextUtility.RemoveSpecialCharacters(normalizedArtistName); normalizedArtistName = normalizedArtistName.Replace(" ", string.Empty); if (!string.IsNullOrWhiteSpace(channelDisplayName)) { var normalizedChannelName = TextUtility.RemoveAccent(channelDisplayName); normalizedChannelName = TextUtility.RemoveSpecialCharacters(normalizedChannelName); normalizedChannelName = normalizedChannelName.Replace(" ", string.Empty); var displayNameVariations = new List() { normalizedArtistName }; displayNameVariations.AddRange(_officialLookingSuffixes.Select(suffix => normalizedArtistName + suffix)); var displayNameLooksOfficial = displayNameVariations.Any( displayNameVariation => displayNameVariation.Equals(normalizedChannelName, StringComparison.CurrentCultureIgnoreCase)); if (displayNameLooksOfficial) return true; } var channelVariations = new List(); channelVariations.Add(normalizedArtistName); channelVariations.Add(normalizedArtistName + "official"); return channelVariations.Any(channelVariation => v.snippet.channelTitle.Equals(channelVariation, StringComparison.InvariantCultureIgnoreCase)); } private bool NameMatches(string title, string trackName, IEnumerable artistNames) { if (!ContainsFullTitle(GetNormalizedYoutubeTitle(title), GetNormalizedTrackName(trackName))) { return false; } if (!TextUtility.GetArtistVariations(artistNames.FirstOrDefault()).Any(a => Contains(title, a))) { return false; } return true; } private bool ContainsNoBlacklistedWords(string title, string trackName, IEnumerable artistNames) { // Check if blacklisted words are in track title var blacklistedWordsNotInTrack = _blacklistedYouTubeTitleWords.Where(word => trackName.IndexOf(word, StringComparison.CurrentCultureIgnoreCase) < 0).ToList(); // Check if blacklisted words are in artist names blacklistedWordsNotInTrack = blacklistedWordsNotInTrack.Where(word => !artistNames.Any(artist => artist.IndexOf(word, StringComparison.CurrentCultureIgnoreCase) >= 0)).ToList(); if (blacklistedWordsNotInTrack.Any(word => title.IndexOf(word, StringComparison.CurrentCultureIgnoreCase) >= 0)) return false; return true; } private bool Contains(string text, string searchTerm) { return text.IndexOf(searchTerm, StringComparison.InvariantCultureIgnoreCase) >= 0; } private bool ContainsFullTitle(string text, string searchTerm) { return Contains(text, " " + searchTerm + " ") || text.StartsWith(searchTerm + " ", StringComparison.InvariantCultureIgnoreCase) || text.EndsWith(" " + searchTerm, StringComparison.InvariantCultureIgnoreCase); } private string GetNormalizedYoutubeTitle(string title) { var normalizedTitle = title; normalizedTitle = TextUtility.RemoveAccent(normalizedTitle); normalizedTitle = TextUtility.RemoveSpecialCharacters(normalizedTitle); return normalizedTitle.Trim(); } private string GetNormalizedTrackName(string title) { var normalizedTitle = title; normalizedTitle = StripExtraTrackInfo(normalizedTitle); normalizedTitle = TextUtility.RemoveAccent(normalizedTitle); normalizedTitle = TextUtility.RemoveSpecialCharacters(normalizedTitle); return normalizedTitle.Trim(); } private static string StripExtraTrackInfo(string normalizedTitle) { normalizedTitle = Regex.Replace(normalizedTitle.ToLowerInvariant(), "\\(.*?\\)", string.Empty); normalizedTitle = Regex.Replace(normalizedTitle, "-.*$", string.Empty); return normalizedTitle; } public async Task> SearchVideos(string trackName, string artistName, AuthenticatedYoutubeApi authClient) { string trackNameWithoutSpecialInfo = StripExtraTrackInfo(trackName); string specialInfo = string.Empty; var parenthesisExtraInfoMatches = Regex.Match(trackName, "\\(.*?\\)"); if (parenthesisExtraInfoMatches.Groups.Count > 0) { specialInfo = parenthesisExtraInfoMatches.Groups[0].Value.Replace("(", string.Empty).Replace(")", string.Empty).Trim(); } var dashExtraInfoMatches = Regex.Match(trackName, "-.*$"); if (dashExtraInfoMatches.Groups.Count > 0) { specialInfo = dashExtraInfoMatches.Groups[0].Value.Replace("-", string.Empty).Trim(); } var searchQueryNoQuotes = artistName + " - " + trackNameWithoutSpecialInfo; var searchQuery = "\"" + searchQueryNoQuotes + "\""; if (!string.IsNullOrWhiteSpace(specialInfo)) { specialInfo = StripBlacklistedInfo(specialInfo); searchQuery += " " + specialInfo; searchQueryNoQuotes += " " + specialInfo; } var searchResult = await authClient.SearchVideosAsync(searchQuery, 25, CancellationToken.None); var searchResultNoQuotes = await authClient.SearchVideosAsync(searchQueryNoQuotes, 25, CancellationToken.None); var searchItems = searchResult.items.Union(searchResultNoQuotes.items).DistinctBy(i => i.id.videoId).ToList(); return searchItems; } private string StripBlacklistedInfo(string specialInfo) { if (_blacklistedPartialPhrases.Any( phrase => specialInfo.IndexOf(phrase, StringComparison.CurrentCultureIgnoreCase) > 0)) return string.Empty; _blacklistedExactPhrases.ForEach(exactPhrase => specialInfo = specialInfo.ToLowerInvariant().Replace(exactPhrase.ToLowerInvariant(), string.Empty)); return specialInfo; } private async Task InsertSelectedVideos(List allTracks, Data.PlaylistSynchronization playlistSync, AuthenticatedYoutubeApi authClient) { var videosToAdd = playlistSync.InsertMedia; // - 1 on position because of zero index in insert0 videosToAdd.ForEach(m => m.InsertPosition--); if (videosToAdd == null || !videosToAdd.Any()) { return; } var lookup = (await authClient.GetAllVideosAsync(videosToAdd.Select(v => v.MediaId).ToList())).Where(v => v.status.uploadStatus != "rejected"); videosToAdd = videosToAdd.Where(v => lookup.Any(l => l.id == v.MediaId)).ToList(); foreach (var video in videosToAdd) { var newSyncTrack = new SynchronizationTrack() { TrackId = video.MediaId }; _logger.Log(LogLevel.Info, string.Format("Inserting video with ID {0} into playlist at position {1}.", video.MediaId, video.InsertPosition + 1)); if (video.InsertPosition < 0) { video.InsertPosition = 0; } if (video.InsertPosition <= allTracks.Count) { allTracks.Insert(video.InsertPosition, newSyncTrack); } else { allTracks.Add(newSyncTrack); } } } private string StripDescriptionLinks(string description) { var originalDescription = description; try { var parser = new HtmlParser(); var document = parser.Parse(description); var links = document.GetElementsByTagName("a"); foreach (var link in links) { var linkHref = link.GetAttribute("href"); var content = link.Text(); var replacement = string.Empty; //Only tries to handle track, album, artist, user and playlist (playlist uri:s also start with "spotify:user:") as spotify uris if (!string.IsNullOrWhiteSpace(linkHref) && (linkHref.StartsWith("spotify:user:") || linkHref.StartsWith("spotify:artist:") || linkHref.StartsWith("spotify:album:") || linkHref.StartsWith("spotify:track:"))) { var spotifyLink = new SpotifyLink(linkHref).Url; if (!string.IsNullOrWhiteSpace(spotifyLink)) linkHref = spotifyLink; replacement = "(" + linkHref + ")"; } if (!string.IsNullOrWhiteSpace(content)) { replacement = content + (!string.IsNullOrWhiteSpace(replacement) ? " " + replacement : string.Empty); } description = description.Replace(link.OuterHtml, replacement); } } catch (Exception) { return originalDescription; } return description; } } }