Initial commit
This commit is contained in:
197
YouTubeTrackMatcher.cs
Normal file
197
YouTubeTrackMatcher.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user