Optimize track matching with parallel batch processing

- Process tracks in batches of 11 (matches SquidWTF provider count)
- Each batch runs 11 parallel searches (one per provider)
- Wait 150ms between batches (not between individual tracks)
- This is ~11x faster: 50 tracks now takes ~1 second instead of ~7.5 seconds
- Round-robin in SquidWTF ensures each parallel request hits a different provider
- Maintains rate limiting while maximizing throughput
This commit is contained in:
2026-02-03 17:20:05 -05:00
parent 5646aa07ea
commit e3546425eb

View File

@@ -24,6 +24,7 @@ public class SpotifyTrackMatchingService : BackgroundService
private readonly ILogger<SpotifyTrackMatchingService> _logger; private readonly ILogger<SpotifyTrackMatchingService> _logger;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
public SpotifyTrackMatchingService( public SpotifyTrackMatchingService(
IOptions<SpotifyImportSettings> spotifySettings, IOptions<SpotifyImportSettings> spotifySettings,
@@ -179,72 +180,104 @@ public class SpotifyTrackMatchingService : BackgroundService
var fuzzyMatches = 0; var fuzzyMatches = 0;
var noMatch = 0; var noMatch = 0;
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) // Process tracks in batches for parallel searching
var orderedTracks = spotifyTracks.OrderBy(t => t.Position).ToList();
for (int i = 0; i < orderedTracks.Count; i += BatchSize)
{ {
if (cancellationToken.IsCancellationRequested) break; if (cancellationToken.IsCancellationRequested) break;
try var batch = orderedTracks.Skip(i).Take(BatchSize).ToList();
_logger.LogDebug("Processing batch {Start}-{End} of {Total}",
i + 1, Math.Min(i + BatchSize, orderedTracks.Count), orderedTracks.Count);
// Process all tracks in this batch in parallel
var batchTasks = batch.Select(async spotifyTrack =>
{ {
Song? matchedSong = null; try
var matchType = "none";
// Try ISRC match first if available and enabled
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
{ {
matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService); Song? matchedSong = null;
if (matchedSong != null) var matchType = "none";
// Try ISRC match first if available and enabled
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
{ {
matchType = "isrc"; matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
isrcMatches++; if (matchedSong != null)
{
matchType = "isrc";
}
}
// Fall back to fuzzy matching
if (matchedSong == null)
{
matchedSong = await TryMatchByFuzzyAsync(
spotifyTrack.Title,
spotifyTrack.Artists,
metadataService);
if (matchedSong != null)
{
matchType = "fuzzy";
}
} }
}
// Fall back to fuzzy matching
if (matchedSong == null)
{
matchedSong = await TryMatchByFuzzyAsync(
spotifyTrack.Title,
spotifyTrack.Artists,
metadataService);
if (matchedSong != null) if (matchedSong != null)
{ {
matchType = "fuzzy"; var matched = new MatchedTrack
fuzzyMatches++; {
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 (matched, matchType);
}
else
{
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
return (null, "none");
} }
} }
catch (Exception ex)
if (matchedSong != null)
{ {
matchedTracks.Add(new MatchedTrack _logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
{ spotifyTrack.Title, spotifyTrack.PrimaryArtist);
Position = spotifyTrack.Position, return (null, "none");
SpotifyId = spotifyTrack.SpotifyId, }
SpotifyTitle = spotifyTrack.Title, }).ToList();
SpotifyArtist = spotifyTrack.PrimaryArtist,
Isrc = spotifyTrack.Isrc, // Wait for all tracks in this batch to complete
MatchType = matchType, var batchResults = await Task.WhenAll(batchTasks);
MatchedSong = matchedSong
}); // Collect results
foreach (var (matched, matchType) in batchResults)
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match: {MatchedTitle}", {
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist, if (matched != null)
matchType, matchedSong.Title); {
matchedTracks.Add(matched);
if (matchType == "isrc") isrcMatches++;
else if (matchType == "fuzzy") fuzzyMatches++;
} }
else else
{ {
noMatch++; noMatch++;
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
} }
// Rate limiting
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
} }
catch (Exception ex)
// Rate limiting between batches (not between individual tracks)
if (i + BatchSize < orderedTracks.Count)
{ {
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}", await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
} }
} }