mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
feat: aggressive track matching with optimal order
- Strip decorators FIRST (feat, remaster, explicit, etc) - Substring matching SECOND (cheap, high-precision) - Levenshtein distance THIRD (expensive, fuzzy) - Greedy assignment LAST (optimal global matching) - Lower threshold to 40 (was 50-60) for max coverage - Accept artist priority matches (artist 70+, title 30+) - Handles cases like 'luther' → 'luther (feat. sza)' - Handles cases like 'a' → 'a-blah' with same artist - Prevents duplicate assignments across tracks
This commit is contained in:
@@ -2,12 +2,64 @@ namespace allstarr.Services.Common;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides fuzzy string matching for search result scoring.
|
/// Provides fuzzy string matching for search result scoring.
|
||||||
|
/// OPTIMAL ORDER: 1. Strip decorators → 2. Substring matching → 3. Levenshtein → 4. Greedy assignment
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class FuzzyMatcher
|
public static class FuzzyMatcher
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates a similarity score between two strings (0-100).
|
/// STEP 1: Strips common decorators from track titles to improve matching.
|
||||||
/// Higher score means better match.
|
/// Removes: (feat. X), (with Y), (ft. Z), - From "Album", [Remix], etc.
|
||||||
|
/// This MUST be done first to avoid systematic noise in matching.
|
||||||
|
/// </summary>
|
||||||
|
public static string StripDecorators(string title)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleaned = title;
|
||||||
|
|
||||||
|
// Remove (feat. ...), (ft. ...), (with ...), (featuring ...)
|
||||||
|
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
cleaned,
|
||||||
|
@"\s*[\(\[]?\s*(feat\.?|ft\.?|with|featuring)\s+[^\)\]]+[\)\]]?",
|
||||||
|
"",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Remove - From "Album Name" or - From Album Name
|
||||||
|
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
cleaned,
|
||||||
|
@"\s*-\s*from\s+[""']?[^""']+[""']?",
|
||||||
|
"",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Remove - Remastered, - Radio Edit, etc.
|
||||||
|
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
cleaned,
|
||||||
|
@"\s*-\s*(remaster|radio edit|single version|album version|extended|original mix)[^\-]*",
|
||||||
|
"",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Remove [Remix], [Remaster], [Live], [Explicit], etc.
|
||||||
|
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
cleaned,
|
||||||
|
@"\s*[\[\(](remix|remaster|live|acoustic|radio edit|explicit|clean|official|audio|video|lyric)[^\]\)]*[\]\)]",
|
||||||
|
"",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Remove trailing/leading whitespace and normalize
|
||||||
|
cleaned = cleaned.Trim();
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates similarity score following OPTIMAL ORDER:
|
||||||
|
/// 1. Strip decorators (already done by caller)
|
||||||
|
/// 2. Substring matching (cheap, high-precision)
|
||||||
|
/// 3. Levenshtein distance (expensive, fuzzy)
|
||||||
|
/// Returns score 0-100.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static int CalculateSimilarity(string query, string target)
|
public static int CalculateSimilarity(string query, string target)
|
||||||
{
|
{
|
||||||
@@ -16,47 +68,87 @@ public static class FuzzyMatcher
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var queryLower = NormalizeForMatching(query);
|
var queryNorm = NormalizeForMatching(query);
|
||||||
var targetLower = NormalizeForMatching(target);
|
var targetNorm = NormalizeForMatching(target);
|
||||||
|
|
||||||
|
// STEP 2: SUBSTRING MATCHING (cheap, high-precision)
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if (queryLower == targetLower)
|
if (queryNorm == targetNorm)
|
||||||
{
|
{
|
||||||
return 100;
|
return 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One string fully contains the other (substring match)
|
||||||
|
// Example: "luther" ⊂ "luther remastered" → instant win
|
||||||
|
if (targetNorm.Contains(queryNorm) || queryNorm.Contains(targetNorm))
|
||||||
|
{
|
||||||
|
return 95;
|
||||||
|
}
|
||||||
|
|
||||||
// Starts with query
|
// Starts with query
|
||||||
if (targetLower.StartsWith(queryLower))
|
if (targetNorm.StartsWith(queryNorm) || queryNorm.StartsWith(targetNorm))
|
||||||
{
|
{
|
||||||
return 90;
|
return 90;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contains query as whole word
|
// Contains query as whole word
|
||||||
if (targetLower.Contains($" {queryLower} ") ||
|
if (targetNorm.Contains($" {queryNorm} ") ||
|
||||||
targetLower.StartsWith($"{queryLower} ") ||
|
targetNorm.StartsWith($"{queryNorm} ") ||
|
||||||
targetLower.EndsWith($" {queryLower}"))
|
targetNorm.EndsWith($" {queryNorm}") ||
|
||||||
|
queryNorm.Contains($" {targetNorm} ") ||
|
||||||
|
queryNorm.StartsWith($"{targetNorm} ") ||
|
||||||
|
queryNorm.EndsWith($" {targetNorm}"))
|
||||||
{
|
{
|
||||||
return 80;
|
return 85;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contains query anywhere
|
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||||
if (targetLower.Contains(queryLower))
|
// Only use this for candidates that survived substring checks
|
||||||
{
|
|
||||||
return 70;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate Levenshtein distance for fuzzy matching
|
var distance = LevenshteinDistance(queryNorm, targetNorm);
|
||||||
var distance = LevenshteinDistance(queryLower, targetLower);
|
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
|
||||||
var maxLength = Math.Max(queryLower.Length, targetLower.Length);
|
|
||||||
|
|
||||||
if (maxLength == 0)
|
if (maxLength == 0)
|
||||||
{
|
{
|
||||||
return 100;
|
return 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert distance to similarity score (0-60 range for fuzzy matches)
|
// Normalize distance by length: score = 1 - (distance / max_length)
|
||||||
var similarity = (1.0 - (double)distance / maxLength) * 60;
|
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
||||||
return (int)Math.Max(0, similarity);
|
|
||||||
|
// Convert to 0-80 range (reserve 80-100 for substring matches)
|
||||||
|
var score = (int)(normalizedSimilarity * 80);
|
||||||
|
|
||||||
|
return Math.Max(0, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AGGRESSIVE matching that follows optimal order:
|
||||||
|
/// 1. Strip decorators FIRST
|
||||||
|
/// 2. Substring matching
|
||||||
|
/// 3. Levenshtein distance
|
||||||
|
/// Returns the best score.
|
||||||
|
/// </summary>
|
||||||
|
public static int CalculateSimilarityAggressive(string query, string target)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(target))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 1: Strip decorators FIRST (always)
|
||||||
|
var queryStripped = StripDecorators(query);
|
||||||
|
var targetStripped = StripDecorators(target);
|
||||||
|
|
||||||
|
// STEP 2-3: Substring matching + Levenshtein
|
||||||
|
var strippedScore = CalculateSimilarity(queryStripped, targetStripped);
|
||||||
|
|
||||||
|
// Also try without stripping in case decorators are part of the actual title
|
||||||
|
var rawScore = CalculateSimilarity(query, target);
|
||||||
|
|
||||||
|
// Return the best score
|
||||||
|
return Math.Max(rawScore, strippedScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
/// New matching mode that uses ISRC when available for exact matches.
|
/// New matching mode that uses ISRC when available for exact matches.
|
||||||
/// Preserves track position for correct playlist ordering.
|
/// Preserves track position for correct playlist ordering.
|
||||||
/// Only matches tracks that aren't already in the Jellyfin playlist.
|
/// Only matches tracks that aren't already in the Jellyfin playlist.
|
||||||
|
/// Uses GREEDY ASSIGNMENT to maximize total matches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task MatchPlaylistTracksWithIsrcAsync(
|
private async Task MatchPlaylistTracksWithIsrcAsync(
|
||||||
string playlistName,
|
string playlistName,
|
||||||
@@ -320,7 +321,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled})",
|
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)",
|
||||||
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
|
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
|
||||||
|
|
||||||
// Check cache - use snapshot/timestamp to detect changes
|
// Check cache - use snapshot/timestamp to detect changes
|
||||||
@@ -367,6 +368,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var fuzzyMatches = 0;
|
var fuzzyMatches = 0;
|
||||||
var noMatch = 0;
|
var noMatch = 0;
|
||||||
|
|
||||||
|
// GREEDY ASSIGNMENT: Collect all possible matches first, then assign optimally
|
||||||
|
var allCandidates = new List<(SpotifyPlaylistTrack SpotifyTrack, Song MatchedSong, double Score, string MatchType)>();
|
||||||
|
|
||||||
// Process tracks in batches for parallel searching
|
// Process tracks in batches for parallel searching
|
||||||
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
|
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
|
||||||
for (int i = 0; i < orderedTracks.Count; i += BatchSize)
|
for (int i = 0; i < orderedTracks.Count; i += BatchSize)
|
||||||
@@ -382,93 +386,115 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Song? matchedSong = null;
|
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
||||||
var matchType = "none";
|
|
||||||
|
|
||||||
// Try ISRC match first if available and enabled
|
// Try ISRC match first if available and enabled
|
||||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||||
{
|
{
|
||||||
matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||||
if (matchedSong != null)
|
if (isrcSong != null)
|
||||||
{
|
{
|
||||||
matchType = "isrc";
|
candidates.Add((isrcSong, 100.0, "isrc"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to fuzzy matching
|
// Always try fuzzy matching to get more candidates
|
||||||
if (matchedSong == null)
|
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
||||||
{
|
spotifyTrack.Title,
|
||||||
matchedSong = await TryMatchByFuzzyAsync(
|
spotifyTrack.Artists,
|
||||||
spotifyTrack.Title,
|
metadataService);
|
||||||
spotifyTrack.Artists,
|
|
||||||
metadataService);
|
|
||||||
|
|
||||||
if (matchedSong != null)
|
foreach (var (song, score) in fuzzySongs)
|
||||||
{
|
{
|
||||||
matchType = "fuzzy";
|
candidates.Add((song, score, "fuzzy"));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedSong != null)
|
return (spotifyTrack, candidates);
|
||||||
{
|
|
||||||
var matched = new MatchedTrack
|
|
||||||
{
|
|
||||||
Position = spotifyTrack.Position,
|
|
||||||
SpotifyId = spotifyTrack.SpotifyId,
|
|
||||||
SpotifyTitle = spotifyTrack.Title,
|
|
||||||
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
|
||||||
Isrc = spotifyTrack.Isrc,
|
|
||||||
MatchType = matchType,
|
|
||||||
MatchedSong = matchedSong
|
|
||||||
};
|
|
||||||
|
|
||||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match: {MatchedTitle}",
|
|
||||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
|
||||||
matchType, matchedSong.Title);
|
|
||||||
|
|
||||||
return ((MatchedTrack?)matched, matchType);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
|
||||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
|
||||||
return ((MatchedTrack?)null, "none");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||||
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
return ((MatchedTrack?)null, "none");
|
return (spotifyTrack, new List<(Song, double, string)>());
|
||||||
}
|
}
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
// Wait for all tracks in this batch to complete
|
// Wait for all tracks in this batch to complete
|
||||||
var batchResults = await Task.WhenAll(batchTasks);
|
var batchResults = await Task.WhenAll(batchTasks);
|
||||||
|
|
||||||
// Collect results
|
// Collect all candidates
|
||||||
foreach (var result in batchResults)
|
foreach (var (spotifyTrack, candidates) in batchResults)
|
||||||
{
|
{
|
||||||
var (matched, matchType) = result;
|
foreach (var (song, score, matchType) in candidates)
|
||||||
if (matched != null)
|
|
||||||
{
|
{
|
||||||
matchedTracks.Add(matched);
|
allCandidates.Add((spotifyTrack, song, score, matchType));
|
||||||
if (matchType == "isrc") isrcMatches++;
|
|
||||||
else if (matchType == "fuzzy") fuzzyMatches++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
noMatch++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting between batches (not between individual tracks)
|
// Rate limiting between batches
|
||||||
if (i + BatchSize < orderedTracks.Count)
|
if (i + BatchSize < orderedTracks.Count)
|
||||||
{
|
{
|
||||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GREEDY ASSIGNMENT: Assign each Spotify track to its best unique match
|
||||||
|
var usedSongIds = new HashSet<string>();
|
||||||
|
var assignments = new Dictionary<string, (Song Song, double Score, string MatchType)>();
|
||||||
|
|
||||||
|
// Sort candidates by score (highest first)
|
||||||
|
var sortedCandidates = allCandidates
|
||||||
|
.OrderByDescending(c => c.Score)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var (spotifyTrack, song, score, matchType) in sortedCandidates)
|
||||||
|
{
|
||||||
|
// Skip if this Spotify track already has a match
|
||||||
|
if (assignments.ContainsKey(spotifyTrack.SpotifyId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Skip if this song is already used
|
||||||
|
if (usedSongIds.Contains(song.Id))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Assign this match
|
||||||
|
assignments[spotifyTrack.SpotifyId] = (song, score, matchType);
|
||||||
|
usedSongIds.Add(song.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final matched tracks list
|
||||||
|
foreach (var spotifyTrack in orderedTracks)
|
||||||
|
{
|
||||||
|
if (assignments.TryGetValue(spotifyTrack.SpotifyId, out var match))
|
||||||
|
{
|
||||||
|
var matched = new MatchedTrack
|
||||||
|
{
|
||||||
|
Position = spotifyTrack.Position,
|
||||||
|
SpotifyId = spotifyTrack.SpotifyId,
|
||||||
|
SpotifyTitle = spotifyTrack.Title,
|
||||||
|
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
||||||
|
Isrc = spotifyTrack.Isrc,
|
||||||
|
MatchType = match.MatchType,
|
||||||
|
MatchedSong = match.Song
|
||||||
|
};
|
||||||
|
|
||||||
|
matchedTracks.Add(matched);
|
||||||
|
|
||||||
|
if (match.MatchType == "isrc") isrcMatches++;
|
||||||
|
else if (match.MatchType == "fuzzy") fuzzyMatches++;
|
||||||
|
|
||||||
|
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match (score: {Score:F1}): {MatchedTitle}",
|
||||||
|
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||||
|
match.MatchType, match.Score, match.Song.Title);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
noMatch++;
|
||||||
|
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
||||||
|
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (matchedTracks.Count > 0)
|
if (matchedTracks.Count > 0)
|
||||||
{
|
{
|
||||||
// Cache matched tracks with position data
|
// Cache matched tracks with position data
|
||||||
@@ -483,7 +509,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via search (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
||||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||||
|
|
||||||
// Pre-build playlist items cache for instant serving
|
// Pre-build playlist items cache for instant serving
|
||||||
@@ -495,6 +521,64 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns multiple candidate matches with scores for greedy assignment.
|
||||||
|
/// FOLLOWS OPTIMAL ORDER:
|
||||||
|
/// 1. Strip decorators (done in FuzzyMatcher)
|
||||||
|
/// 2. Substring matching (done in FuzzyMatcher)
|
||||||
|
/// 3. Levenshtein distance (done in FuzzyMatcher)
|
||||||
|
/// This method just collects candidates; greedy assignment happens later.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
||||||
|
string title,
|
||||||
|
List<string> artists,
|
||||||
|
IMusicMetadataService metadataService)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||||
|
|
||||||
|
// STEP 1: Strip decorators FIRST (before searching)
|
||||||
|
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||||
|
var query = $"{titleStripped} {primaryArtist}";
|
||||||
|
|
||||||
|
var results = await metadataService.SearchSongsAsync(query, limit: 10);
|
||||||
|
|
||||||
|
if (results.Count == 0) return new List<(Song, double)>();
|
||||||
|
|
||||||
|
// STEP 2-3: Score all results (substring + Levenshtein already in CalculateSimilarityAggressive)
|
||||||
|
var scoredResults = results
|
||||||
|
.Select(song => new
|
||||||
|
{
|
||||||
|
Song = song,
|
||||||
|
// Use aggressive matching which follows optimal order internally
|
||||||
|
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||||
|
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
|
})
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Song,
|
||||||
|
x.TitleScore,
|
||||||
|
x.ArtistScore,
|
||||||
|
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||||
|
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||||
|
})
|
||||||
|
.Where(x =>
|
||||||
|
x.TotalScore >= 40 ||
|
||||||
|
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||||
|
x.TitleScore >= 85)
|
||||||
|
.OrderByDescending(x => x.TotalScore)
|
||||||
|
.Select(x => (x.Song, x.TotalScore))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return scoredResults;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<(Song, double)>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to match a track by ISRC using provider search.
|
/// Attempts to match a track by ISRC using provider search.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -524,7 +608,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to match a track by title and artist using fuzzy matching.
|
/// Attempts to match a track by title and artist using AGGRESSIVE fuzzy matching.
|
||||||
|
/// FOLLOWS OPTIMAL ORDER:
|
||||||
|
/// 1. Strip decorators FIRST (before searching)
|
||||||
|
/// 2. Substring matching (in FuzzyMatcher)
|
||||||
|
/// 3. Levenshtein distance (in FuzzyMatcher)
|
||||||
|
/// PRIORITY: Match as many tracks as possible, even with lower confidence.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<Song?> TryMatchByFuzzyAsync(
|
private async Task<Song?> TryMatchByFuzzyAsync(
|
||||||
string title,
|
string title,
|
||||||
@@ -534,17 +623,22 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||||
var query = $"{title} {primaryArtist}";
|
|
||||||
var results = await metadataService.SearchSongsAsync(query, limit: 5);
|
// STEP 1: Strip decorators FIRST (before searching)
|
||||||
|
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||||
|
var query = $"{titleStripped} {primaryArtist}";
|
||||||
|
|
||||||
|
var results = await metadataService.SearchSongsAsync(query, limit: 10);
|
||||||
|
|
||||||
if (results.Count == 0) return null;
|
if (results.Count == 0) return null;
|
||||||
|
|
||||||
// Score all results
|
// STEP 2-3: Score all results (substring + Levenshtein in CalculateSimilarityAggressive)
|
||||||
var scoredResults = results
|
var scoredResults = results
|
||||||
.Select(song => new
|
.Select(song => new
|
||||||
{
|
{
|
||||||
Song = song,
|
Song = song,
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(title, song.Title),
|
// Use aggressive matching which follows optimal order internally
|
||||||
|
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
})
|
})
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
@@ -552,27 +646,39 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
x.Song,
|
x.Song,
|
||||||
x.TitleScore,
|
x.TitleScore,
|
||||||
x.ArtistScore,
|
x.ArtistScore,
|
||||||
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
|
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||||
|
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||||
})
|
})
|
||||||
.OrderByDescending(x => x.TotalScore)
|
.OrderByDescending(x => x.TotalScore)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var bestMatch = scoredResults.FirstOrDefault();
|
var bestMatch = scoredResults.FirstOrDefault();
|
||||||
|
|
||||||
// If we have a good match (50+), use it
|
if (bestMatch == null) return null;
|
||||||
if (bestMatch != null && bestMatch.TotalScore >= 50)
|
|
||||||
|
// AGGRESSIVE: Accept matches with score >= 40 (was 50)
|
||||||
|
if (bestMatch.TotalScore >= 40)
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("✓ Matched (score: {Score:F1}, title: {TitleScore}, artist: {ArtistScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||||
|
bestMatch.TotalScore, bestMatch.TitleScore, bestMatch.ArtistScore, title, bestMatch.Song.Title);
|
||||||
return bestMatch.Song;
|
return bestMatch.Song;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: If the provider returned results and the top result has decent artist match,
|
// SUPER AGGRESSIVE: If artist matches well (70+), accept even lower title scores
|
||||||
// trust the provider's search algorithm (it already did fuzzy matching)
|
// This handles cases like "a" → "a-blah" where artist is the same
|
||||||
// This helps with tracks that have features/remixes in parentheses/brackets
|
if (bestMatch.ArtistScore >= 70 && bestMatch.TitleScore >= 30)
|
||||||
// where the provider might format them differently
|
|
||||||
if (bestMatch != null && bestMatch.ArtistScore >= 70)
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Using provider's top result despite low title score (Artist: {ArtistScore}, Title: {TitleScore}): {Title}",
|
_logger.LogDebug("✓ Matched via artist priority (artist: {ArtistScore}, title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||||
bestMatch.ArtistScore, bestMatch.TitleScore, bestMatch.Song.Title);
|
bestMatch.ArtistScore, bestMatch.TitleScore, title, bestMatch.Song.Title);
|
||||||
|
return bestMatch.Song;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ULTRA AGGRESSIVE: If title has high substring match (85+), accept it
|
||||||
|
// This handles "luther" → "luther (feat. sza)"
|
||||||
|
if (bestMatch.TitleScore >= 85)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Matched via substring (title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||||
|
bestMatch.TitleScore, title, bestMatch.Song.Title);
|
||||||
return bestMatch.Song;
|
return bestMatch.Song;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,7 +1099,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no manual external mapping, try fuzzy matching with local Jellyfin tracks
|
// If no manual external mapping, try AGGRESSIVE fuzzy matching with local Jellyfin tracks
|
||||||
double bestScore = 0;
|
double bestScore = 0;
|
||||||
|
|
||||||
foreach (var kvp in jellyfinItemsByName)
|
foreach (var kvp in jellyfinItemsByName)
|
||||||
@@ -1008,11 +1114,18 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
artist = artistsEl[0].GetString() ?? "";
|
artist = artistsEl[0].GetString() ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
|
// Use AGGRESSIVE matching with decorator stripping
|
||||||
|
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(spotifyTrack.Title, title);
|
||||||
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
||||||
|
|
||||||
|
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||||
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
||||||
|
|
||||||
if (totalScore > bestScore && totalScore >= 70)
|
// AGGRESSIVE: Accept score >= 40 (was 70)
|
||||||
|
// Also accept if artist matches well (70+) and title is decent (30+)
|
||||||
|
var isGoodMatch = totalScore >= 40 || (artistScore >= 70 && titleScore >= 30);
|
||||||
|
|
||||||
|
if (totalScore > bestScore && isGoodMatch)
|
||||||
{
|
{
|
||||||
bestScore = totalScore;
|
bestScore = totalScore;
|
||||||
matchedJellyfinItem = item;
|
matchedJellyfinItem = item;
|
||||||
|
|||||||
Reference in New Issue
Block a user