198 lines
4.4 KiB
C#
198 lines
4.4 KiB
C#
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<List<ScoreResult>> SearchAsync(SpotifyAPI.Web.FullTrack track, int maxResults = 5)
|
|
{
|
|
var results = new List<ScoreResult>();
|
|
|
|
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,
|
|
$@"(?<!\w){Regex.Escape(term)}(?!\w)",
|
|
RegexOptions.IgnoreCase));
|
|
}
|
|
}
|
|
}
|