Initial commit

This commit is contained in:
2026-10-09 07:59:15 +02:00
commit a6efcd26d0
7 changed files with 745 additions and 0 deletions

197
YouTubeTrackMatcher.cs Normal file
View 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));
}
}
}