using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace SpotiGrabber { using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using YoutubeExplode; public sealed class ScoreResult { public YoutubeExplode.Videos.Video Video { get; set; } = default!; public float Score { get; set; } } public sealed class YouTubeTrackMatcher { private readonly YoutubeClient _ytClient; public YouTubeTrackMatcher(YoutubeClient ytClient) { _ytClient = ytClient; } public async Task> SearchAsync(SpotifyAPI.Web.FullTrack track, int maxResults = 5) { var results = new List(); string artistString = track.Artists.First().Name; var batch = _ytClient.Search.GetResultsAsync($"{artistString} - {track.Name}"); await foreach (var query in batch) { try { var video = await _ytClient.Videos.GetAsync(query.Url); float score = 0f; string videoTitle = Normalize((string)video.Title); string trackTitle = Normalize((string)track.Name); string videoArtist = Normalize( (string)video.Author.ChannelTitle); string expectedArtist = Normalize(artistString); string description = Normalize( (string)(video.Description ?? "")); // 1. Track title matching if (videoTitle == trackTitle) { score += 2.0f; } else if (videoTitle.Contains(trackTitle)) { score += 1.5f; } else { score += 1.5f * TokenSimilarity( videoTitle, trackTitle); } // 2. Artist / channel matching if (videoArtist == expectedArtist) { score += 1.5f; } else if (videoArtist.Contains(expectedArtist) || expectedArtist.Contains(videoArtist)) { score += 1.0f; } else { score += TokenSimilarity( videoArtist, expectedArtist); } // 3. Track ID in the description string trackId = (string)track.Id; if (!string.IsNullOrWhiteSpace(trackId) && description.Contains(Normalize(trackId))) { score += 5.0f; } // 4. Duration similarity if (video.Duration.HasValue) { double differenceSeconds = Math.Abs( video.Duration.Value.TotalSeconds - ((double)track.DurationMs / 1000.0)); score += 0.8f * (float)Math.Exp( -differenceSeconds / 5.0); if (differenceSeconds > 15) score -= 1.0f; } // 5. Official-upload indicators if (videoTitle.Contains("official audio")) score += 0.5f; if (videoTitle.Contains("official video")) score += 0.5f; if (videoTitle.Contains("provided to youtube")) score += 0.5f; // 6. Penalize alternate versions if (ContainsAny(videoTitle, "cover", "karaoke", "nightcore", "sped up", "slowed", "8d audio", "instrumental", "reaction")) { score -= 2.0f; } if (ContainsAny(videoTitle, "live", "remix", "acoustic", "remaster", "radio edit")) { score -= 0.75f; } results.Add(new ScoreResult { Video = video, Score = score }); if (results.Count >= maxResults) break; } catch { // Skip unavailable videos or individual lookup failures. } } return results .OrderByDescending(x => x.Score) .ToList(); } private static string Normalize(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; value = value.ToLowerInvariant(); // Remove common punctuation while retaining letters and digits. value = Regex.Replace(value, @"[^\p{L}\p{N}]+", " "); return Regex.Replace(value, @"\s+", " ").Trim(); } private static float TokenSimilarity(string a, string b) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0f; var tokensA = a.Split(' ') .Where(x => x.Length > 0) .ToHashSet(); var tokensB = b.Split(' ') .Where(x => x.Length > 0) .ToHashSet(); int intersection = tokensA.Intersect(tokensB).Count(); int union = tokensA.Union(tokensB).Count(); return union == 0 ? 0f : (float)intersection / union; } private static bool ContainsAny(string text, params string[] terms) { return terms.Any(term => Regex.IsMatch( text, $@"(?