mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
260 lines
9.9 KiB
C#
260 lines
9.9 KiB
C#
using allstarr.Models.Domain;
|
|
using allstarr.Models.Settings;
|
|
using allstarr.Models.Spotify;
|
|
using allstarr.Services.Common;
|
|
using Microsoft.Extensions.Options;
|
|
using System.Text.Json;
|
|
|
|
namespace allstarr.Services.Spotify;
|
|
|
|
/// <summary>
|
|
/// Background service that pre-matches Spotify missing tracks with external providers.
|
|
/// Runs after SpotifyMissingTracksFetcher completes to avoid rate limiting during playlist loading.
|
|
/// </summary>
|
|
public class SpotifyTrackMatchingService : BackgroundService
|
|
{
|
|
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
|
|
private readonly RedisCacheService _cache;
|
|
private readonly ILogger<SpotifyTrackMatchingService> _logger;
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
|
|
|
public SpotifyTrackMatchingService(
|
|
IOptions<SpotifyImportSettings> spotifySettings,
|
|
RedisCacheService cache,
|
|
IServiceProvider serviceProvider,
|
|
ILogger<SpotifyTrackMatchingService> logger)
|
|
{
|
|
_spotifySettings = spotifySettings;
|
|
_cache = cache;
|
|
_serviceProvider = serviceProvider;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
|
|
|
if (!_spotifySettings.Value.Enabled)
|
|
{
|
|
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
|
return;
|
|
}
|
|
|
|
// Wait a bit for the fetcher to run first
|
|
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
|
|
|
// Run once on startup to match any existing missing tracks
|
|
try
|
|
{
|
|
_logger.LogInformation("Running initial track matching on startup");
|
|
await MatchAllPlaylistsAsync(stoppingToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error during startup track matching");
|
|
}
|
|
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
await MatchAllPlaylistsAsync(stoppingToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error in track matching service");
|
|
}
|
|
|
|
// Run every 30 minutes to catch new missing tracks
|
|
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Public method to trigger matching manually (called from controller).
|
|
/// </summary>
|
|
public async Task TriggerMatchingAsync()
|
|
{
|
|
_logger.LogInformation("Manual track matching triggered");
|
|
await MatchAllPlaylistsAsync(CancellationToken.None);
|
|
}
|
|
|
|
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
|
{
|
|
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
|
|
|
var playlistNames = _spotifySettings.Value.PlaylistNames;
|
|
if (playlistNames.Count == 0)
|
|
{
|
|
_logger.LogInformation("No playlists configured for matching");
|
|
return;
|
|
}
|
|
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
|
|
|
foreach (var playlistName in playlistNames)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested) break;
|
|
|
|
try
|
|
{
|
|
await MatchPlaylistTracksAsync(playlistName, metadataService, cancellationToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlistName);
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
|
}
|
|
|
|
private async Task MatchPlaylistTracksAsync(
|
|
string playlistName,
|
|
IMusicMetadataService metadataService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var missingTracksKey = $"spotify:missing:{playlistName}";
|
|
var matchedTracksKey = $"spotify:matched:{playlistName}";
|
|
|
|
// Check if we already have matched tracks cached
|
|
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
|
|
if (existingMatched != null && existingMatched.Count > 0)
|
|
{
|
|
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
|
playlistName, existingMatched.Count);
|
|
return;
|
|
}
|
|
|
|
// Get missing tracks
|
|
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
|
|
if (missingTracks == null || missingTracks.Count == 0)
|
|
{
|
|
_logger.LogInformation("No missing tracks found for {Playlist}, skipping matching", playlistName);
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Matching {Count} tracks for {Playlist} (with rate limiting)",
|
|
missingTracks.Count, playlistName);
|
|
|
|
var matchedSongs = new List<Song>();
|
|
var matchCount = 0;
|
|
|
|
foreach (var track in missingTracks)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested) break;
|
|
|
|
try
|
|
{
|
|
var query = $"{track.Title} {track.PrimaryArtist}";
|
|
var results = await metadataService.SearchSongsAsync(query, limit: 5);
|
|
|
|
if (results.Count > 0)
|
|
{
|
|
// Fuzzy match to find best result
|
|
// Check that ALL artists match (not just some)
|
|
var bestMatch = results
|
|
.Select(song => new
|
|
{
|
|
Song = song,
|
|
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
|
// Calculate artist score by checking ALL artists match
|
|
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
|
})
|
|
.Select(x => new
|
|
{
|
|
x.Song,
|
|
x.TitleScore,
|
|
x.ArtistScore,
|
|
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
|
|
})
|
|
.OrderByDescending(x => x.TotalScore)
|
|
.FirstOrDefault();
|
|
|
|
if (bestMatch != null && bestMatch.TotalScore >= 60)
|
|
{
|
|
matchedSongs.Add(bestMatch.Song);
|
|
matchCount++;
|
|
|
|
if (matchCount % 10 == 0)
|
|
{
|
|
_logger.LogInformation("Matched {Count}/{Total} tracks for {Playlist}",
|
|
matchCount, missingTracks.Count, playlistName);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rate limiting: delay between searches
|
|
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
|
track.Title, track.PrimaryArtist);
|
|
}
|
|
}
|
|
|
|
if (matchedSongs.Count > 0)
|
|
{
|
|
// Cache matched tracks for 1 hour
|
|
await _cache.SetAsync(matchedTracksKey, matchedSongs, TimeSpan.FromHours(1));
|
|
_logger.LogInformation("✓ Cached {Matched}/{Total} matched tracks for {Playlist}",
|
|
matchedSongs.Count, missingTracks.Count, playlistName);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("No tracks matched for {Playlist}", playlistName);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates artist match score ensuring ALL artists are present.
|
|
/// Penalizes if artist counts don't match or if any artist is missing.
|
|
/// </summary>
|
|
private static double CalculateArtistMatchScore(List<string> spotifyArtists, string songMainArtist, List<string> songContributors)
|
|
{
|
|
if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist))
|
|
return 0;
|
|
|
|
// Build list of all song artists (main + contributors)
|
|
var allSongArtists = new List<string> { songMainArtist };
|
|
allSongArtists.AddRange(songContributors);
|
|
|
|
// If artist counts differ significantly, penalize
|
|
var countDiff = Math.Abs(spotifyArtists.Count - allSongArtists.Count);
|
|
if (countDiff > 1) // Allow 1 artist difference (sometimes features are listed differently)
|
|
return 0;
|
|
|
|
// Check that each Spotify artist has a good match in song artists
|
|
var spotifyScores = new List<double>();
|
|
foreach (var spotifyArtist in spotifyArtists)
|
|
{
|
|
var bestMatch = allSongArtists.Max(songArtist =>
|
|
FuzzyMatcher.CalculateSimilarity(spotifyArtist, songArtist));
|
|
spotifyScores.Add(bestMatch);
|
|
}
|
|
|
|
// Check that each song artist has a good match in Spotify artists
|
|
var songScores = new List<double>();
|
|
foreach (var songArtist in allSongArtists)
|
|
{
|
|
var bestMatch = spotifyArtists.Max(spotifyArtist =>
|
|
FuzzyMatcher.CalculateSimilarity(songArtist, spotifyArtist));
|
|
songScores.Add(bestMatch);
|
|
}
|
|
|
|
// Average all scores - this ensures ALL artists must match well
|
|
var allScores = spotifyScores.Concat(songScores);
|
|
var avgScore = allScores.Average();
|
|
|
|
// Penalize if any individual artist match is poor (< 70)
|
|
var minScore = allScores.Min();
|
|
if (minScore < 70)
|
|
avgScore *= 0.7; // 30% penalty for poor individual match
|
|
|
|
return avgScore;
|
|
}
|
|
}
|