fix scoped injected playlist matching

This commit is contained in:
2026-04-06 04:10:04 -04:00
parent 579c1e04d8
commit 67db8b185f
5 changed files with 270 additions and 97 deletions
@@ -0,0 +1,76 @@
using allstarr.Models.Settings;
using allstarr.Services.Common;
namespace allstarr.Tests;
public class SpotifyPlaylistScopeResolverTests
{
[Fact]
public void ResolveConfig_PrefersExactJellyfinIdOverDuplicatePlaylistName()
{
var settings = new SpotifyImportSettings
{
Playlists =
{
new SpotifyPlaylistConfig
{
Name = "Discover Weekly",
Id = "spotify-a",
JellyfinId = "jellyfin-a",
UserId = "user-a"
},
new SpotifyPlaylistConfig
{
Name = "Discover Weekly",
Id = "spotify-b",
JellyfinId = "jellyfin-b",
UserId = "user-b"
}
}
};
var resolved = SpotifyPlaylistScopeResolver.ResolveConfig(
settings,
"Discover Weekly",
userId: "user-a",
jellyfinPlaylistId: "jellyfin-b");
Assert.NotNull(resolved);
Assert.Equal("spotify-b", resolved!.Id);
Assert.Equal("jellyfin-b", resolved.JellyfinId);
Assert.Equal("user-b", resolved.UserId);
}
[Fact]
public void GetUserId_PrefersConfiguredValueAndTrimsFallback()
{
var configured = new SpotifyPlaylistConfig
{
UserId = " configured-user "
};
Assert.Equal("configured-user", SpotifyPlaylistScopeResolver.GetUserId(configured, "fallback"));
Assert.Equal("fallback-user", SpotifyPlaylistScopeResolver.GetUserId(null, " fallback-user "));
Assert.Null(SpotifyPlaylistScopeResolver.GetUserId(null, " "));
}
[Fact]
public void GetScopeId_PrefersJellyfinIdThenSpotifyIdThenFallback()
{
var jellyfinScoped = new SpotifyPlaylistConfig
{
Id = "spotify-id",
JellyfinId = " jellyfin-id "
};
var spotifyScoped = new SpotifyPlaylistConfig
{
Id = " spotify-id "
};
Assert.Equal("jellyfin-id", SpotifyPlaylistScopeResolver.GetScopeId(jellyfinScoped, "fallback"));
Assert.Equal("spotify-id", SpotifyPlaylistScopeResolver.GetScopeId(spotifyScoped, "fallback"));
Assert.Equal("fallback-id", SpotifyPlaylistScopeResolver.GetScopeId(null, " fallback-id "));
Assert.Null(SpotifyPlaylistScopeResolver.GetScopeId(null, " "));
}
}
@@ -166,21 +166,45 @@ public partial class JellyfinController
if (orderedTracks == null || orderedTracks.Count == 0)
{
_logger.LogInformation(
"No ordered matched tracks in cache for {Playlist}; attempting on-demand matching before fallback",
"No ordered matched tracks in cache for {Playlist}; attempting exact-scope rebuild before fallback",
spotifyPlaylistName);
if (_spotifyTrackMatchingService != null)
{
try
{
await _spotifyTrackMatchingService.TriggerMatchingForPlaylistAsync(spotifyPlaylistName);
await _spotifyTrackMatchingService.TriggerRebuildForPlaylistAsync(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"On-demand matching failed for {Playlist}; falling back to passthrough playlist response",
"On-demand rebuild failed for {Playlist}; falling back to cached compatibility paths",
spotifyPlaylistName);
}
}
if (orderedTracks == null || orderedTracks.Count == 0)
{
var legacyCacheKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
var legacySongs = await _cache.GetAsync<List<Song>>(legacyCacheKey);
if (legacySongs != null && legacySongs.Count > 0)
{
orderedTracks = legacySongs.Select((song, index) => new MatchedTrack
{
Position = index,
MatchedSong = song
}).ToList();
_logger.LogInformation(
"Loaded {Count} legacy matched tracks for {Playlist} after ordered cache miss",
orderedTracks.Count,
spotifyPlaylistName);
}
}
@@ -0,0 +1,49 @@
using allstarr.Models.Settings;
namespace allstarr.Services.Common;
public static class SpotifyPlaylistScopeResolver
{
public static SpotifyPlaylistConfig? ResolveConfig(
SpotifyImportSettings settings,
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
if (!string.IsNullOrWhiteSpace(jellyfinPlaylistId))
{
var byJellyfinId = settings.GetPlaylistByJellyfinId(jellyfinPlaylistId.Trim());
if (byJellyfinId != null)
{
return byJellyfinId;
}
}
return settings.GetPlaylistByName(playlistName, userId, jellyfinPlaylistId);
}
public static string? GetUserId(SpotifyPlaylistConfig? playlist, string? fallbackUserId = null)
{
if (!string.IsNullOrWhiteSpace(playlist?.UserId))
{
return playlist.UserId.Trim();
}
return string.IsNullOrWhiteSpace(fallbackUserId) ? null : fallbackUserId.Trim();
}
public static string? GetScopeId(SpotifyPlaylistConfig? playlist, string? fallbackScopeId = null)
{
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
{
return playlist.JellyfinId.Trim();
}
if (!string.IsNullOrWhiteSpace(playlist?.Id))
{
return playlist.Id.Trim();
}
return string.IsNullOrWhiteSpace(fallbackScopeId) ? null : fallbackScopeId.Trim();
}
}
@@ -60,11 +60,13 @@ public class SpotifyPlaylistFetcher : BackgroundService
string? userId = null,
string? jellyfinPlaylistId = null)
{
var playlistConfig = !string.IsNullOrWhiteSpace(jellyfinPlaylistId)
? _spotifyImportSettings.GetPlaylistByJellyfinId(jellyfinPlaylistId)
: _spotifyImportSettings.GetPlaylistByName(playlistName, userId);
var playlistScopeUserId = playlistConfig?.UserId ?? userId;
var playlistScopeId = playlistConfig?.JellyfinId ?? playlistConfig?.Id ?? jellyfinPlaylistId;
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, jellyfinPlaylistId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName, playlistScopeUserId, playlistScopeId);
var playlistScope = CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, playlistScopeUserId, playlistScopeId);
@@ -246,21 +248,30 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// <summary>
/// Manual trigger to refresh a specific playlist.
/// </summary>
public async Task RefreshPlaylistAsync(string playlistName)
public async Task RefreshPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
// Clear cache to force refresh
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, jellyfinPlaylistId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
playlistName,
playlistConfig?.UserId,
playlistConfig?.JellyfinId ?? playlistConfig?.Id);
playlistScopeUserId,
playlistScopeId);
await _cache.DeleteAsync(cacheKey);
// Re-fetch
await GetPlaylistTracksAsync(playlistName, playlistConfig?.UserId, playlistConfig?.JellyfinId);
await ClearPlaylistImageCacheAsync(playlistName);
await GetPlaylistTracksAsync(playlistName, playlistScopeUserId, playlistConfig?.JellyfinId ?? jellyfinPlaylistId);
await ClearPlaylistImageCacheAsync(playlistName, userId, jellyfinPlaylistId);
}
/// <summary>
@@ -272,13 +283,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
foreach (var config in _spotifyImportSettings.Playlists)
{
await RefreshPlaylistAsync(config.Name);
await RefreshPlaylistAsync(config.Name, config.UserId, config.JellyfinId);
}
}
private async Task ClearPlaylistImageCacheAsync(string playlistName)
private async Task ClearPlaylistImageCacheAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
{
return;
@@ -344,7 +362,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
{
// Check each playlist to see if it needs refreshing based on cron schedule
var now = DateTime.UtcNow;
var needsRefresh = new List<string>();
var needsRefresh = new List<SpotifyPlaylistConfig>();
foreach (var config in _spotifyImportSettings.Playlists)
{
@@ -368,7 +386,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (nextRun.HasValue && now >= nextRun.Value)
{
needsRefresh.Add(config.Name);
needsRefresh.Add(config);
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
}
@@ -376,7 +394,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
else
{
// No cache, fetch it
needsRefresh.Add(config.Name);
needsRefresh.Add(config);
}
}
catch (Exception ex)
@@ -390,25 +408,24 @@ public class SpotifyPlaylistFetcher : BackgroundService
{
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
foreach (var playlistName in needsRefresh)
foreach (var config in needsRefresh)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
await GetPlaylistTracksAsync(playlistName, config?.UserId, config?.JellyfinId);
await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
// Rate limiting between playlists
if (playlistName != needsRefresh.Last())
if (!ReferenceEquals(config, needsRefresh.Last()))
{
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", playlistName);
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", config.Name);
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
_logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name);
}
}
@@ -74,17 +74,22 @@ public class SpotifyTrackMatchingService : BackgroundService
}
private static string? GetPlaylistScopeUserId(SpotifyPlaylistConfig? playlist) =>
string.IsNullOrWhiteSpace(playlist?.UserId) ? null : playlist.UserId.Trim();
SpotifyPlaylistScopeResolver.GetUserId(playlist);
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist)
{
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
{
return playlist.JellyfinId.Trim();
}
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist) =>
SpotifyPlaylistScopeResolver.GetScopeId(playlist);
return string.IsNullOrWhiteSpace(playlist?.Id) ? null : playlist.Id.Trim();
}
private SpotifyPlaylistConfig? ResolvePlaylistConfig(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null) =>
SpotifyPlaylistScopeResolver.ResolveConfig(_spotifySettings, playlistName, userId, jellyfinPlaylistId);
private static string BuildPlaylistRunKey(SpotifyPlaylistConfig playlist) =>
CacheKeyBuilder.BuildSpotifyPlaylistScope(
playlist.Name,
GetPlaylistScopeUserId(playlist),
GetPlaylistScopeId(playlist));
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -135,7 +140,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Use a small grace window so we don't miss exact-minute cron runs when waking slightly late.
var now = DateTime.UtcNow;
var schedulerReference = now.AddMinutes(-1);
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
var nextRuns = new List<(SpotifyPlaylistConfig Playlist, DateTime NextRun, CronExpression Cron)>();
foreach (var playlist in _spotifySettings.Playlists)
{
@@ -148,7 +153,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (nextRun.HasValue)
{
nextRuns.Add((playlist.Name, nextRun.Value, cron));
nextRuns.Add((playlist, nextRun.Value, cron));
}
else
{
@@ -183,7 +188,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var waitTime = nextPlaylist.NextRun - now;
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
nextPlaylist.Playlist.Name, nextPlaylist.NextRun, waitTime.TotalMinutes);
var maxWait = TimeSpan.FromHours(1);
var actualWait = waitTime > maxWait ? maxWait : waitTime;
@@ -204,10 +209,10 @@ public class SpotifyTrackMatchingService : BackgroundService
break;
}
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName);
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.Playlist.Name);
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
due.PlaylistName,
due.Playlist,
stoppingToken,
trigger: "cron");
@@ -218,7 +223,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
_logger.LogInformation("✓ Finished scheduled rebuild for {Playlist} - Next run at {NextRun} UTC",
due.PlaylistName, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
due.Playlist.Name, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
}
// Avoid a tight loop if one or more due playlists were skipped by cooldown.
@@ -239,19 +244,11 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
/// Used by individual per-playlist rebuild actions.
/// </summary>
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
private async Task RebuildSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
{
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
var playlistScopeId = GetPlaylistScopeId(playlist);
var playlistName = playlist.Name;
_logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName);
@@ -285,7 +282,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (playlistFetcher != null)
{
// Force refresh from Spotify (clears cache and re-fetches)
await playlistFetcher.RefreshPlaylistAsync(playlist.Name);
await playlistFetcher.RefreshPlaylistAsync(playlist.Name, playlistScopeUserId, playlist.JellyfinId);
}
}
@@ -297,13 +294,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken);
playlist, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
playlist, metadataService, cancellationToken);
}
}
catch (Exception ex)
@@ -320,16 +317,9 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify.
/// Used for lightweight re-matching when only local library has changed.
/// </summary>
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
private async Task MatchSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
{
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
var playlistName = playlist.Name;
using var scope = _serviceProvider.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
@@ -347,13 +337,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken);
playlist, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
playlist, metadataService, cancellationToken);
}
await ClearPlaylistImageCacheAsync(playlist);
@@ -392,17 +382,28 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button).
/// This clears cache, fetches fresh data, and re-matches - same workflow as scheduled cron rebuilds for a playlist.
/// </summary>
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
public async Task TriggerRebuildForPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist}", playlistName);
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
playlistName,
playlist,
CancellationToken.None,
trigger: "manual");
if (!rebuilt)
{
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
var runKey = BuildPlaylistRunKey(playlist);
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
var remaining = _minimumRunInterval - timeSinceLastRun;
@@ -416,11 +417,12 @@ public class SpotifyTrackMatchingService : BackgroundService
}
private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync(
string playlistName,
SpotifyPlaylistConfig playlist,
CancellationToken cancellationToken,
string trigger)
{
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
var runKey = BuildPlaylistRunKey(playlist);
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
@@ -428,15 +430,15 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogWarning(
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
trigger,
playlistName,
playlist.Name,
(int)timeSinceLastRun.TotalSeconds,
(int)_minimumRunInterval.TotalSeconds);
return false;
}
}
await RebuildSinglePlaylistAsync(playlistName, cancellationToken);
_lastRunTimes[playlistName] = DateTime.UtcNow;
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
_lastRunTimes[runKey] = DateTime.UtcNow;
return true;
}
@@ -456,14 +458,23 @@ public class SpotifyTrackMatchingService : BackgroundService
/// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify.
/// Use this when only the local library has changed, not when Spotify playlist changed.
/// </summary>
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
public async Task TriggerMatchingForPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (lightweight, no cache clear)", playlistName);
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
// Intentionally no cooldown here: this path should react immediately to
// local library changes and manual mapping updates without waiting for
// Spotify API cooldown windows.
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
await MatchSinglePlaylistAsync(playlist, CancellationToken.None);
}
private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken)
@@ -483,7 +494,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
await RebuildSinglePlaylistAsync(playlist.Name, cancellationToken);
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
}
catch (Exception ex)
{
@@ -511,7 +522,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
await MatchSinglePlaylistAsync(playlist, cancellationToken);
}
catch (Exception ex)
{
@@ -529,15 +540,15 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Uses GREEDY ASSIGNMENT to maximize total matches.
/// </summary>
private async Task MatchPlaylistTracksWithIsrcAsync(
string playlistName,
SpotifyPlaylistConfig playlistConfig,
SpotifyPlaylistFetcher playlistFetcher,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var playlistConfig = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
var playlist = playlistConfig ?? throw new ArgumentNullException(nameof(playlistConfig));
var playlistName = playlist.Name;
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
var playlistScopeId = GetPlaylistScopeId(playlist);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
playlistName,
playlistScopeUserId,
@@ -547,7 +558,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(
playlistName,
playlistScopeUserId,
playlistConfig?.JellyfinId);
playlist.JellyfinId);
if (spotifyTracks.Count == 0)
{
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
@@ -558,7 +569,7 @@ public class SpotifyTrackMatchingService : BackgroundService
HashSet<string> existingSpotifyIds = new();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
if (!string.IsNullOrEmpty(playlist.JellyfinId))
{
// Get existing tracks from Jellyfin playlist to avoid re-matching
using var scope = _serviceProvider.CreateScope();
@@ -570,8 +581,8 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = playlistConfig?.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlistConfig!.JellyfinId;
var userId = playlist.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlist.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId))
@@ -693,7 +704,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
var jellyfinTracks = new List<Song>();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
if (!string.IsNullOrEmpty(playlist.JellyfinId))
{
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
@@ -704,8 +715,8 @@ public class SpotifyTrackMatchingService : BackgroundService
{
try
{
var userId = playlistConfig?.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlistConfig!.JellyfinId;
var userId = playlist.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlist.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
if (!string.IsNullOrEmpty(userId))
@@ -987,12 +998,9 @@ public class SpotifyTrackMatchingService : BackgroundService
playlistName, statsLocalCount, statsExternalCount, statsMissingCount);
// Calculate cache expiration: until next cron run (not just cache duration from settings)
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
if (!string.IsNullOrEmpty(playlist.SyncSchedule))
{
try
{
@@ -1035,7 +1043,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Pre-build playlist items cache for instant serving
// This is what makes the UI show all matched tracks at once
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
await PreBuildPlaylistItemsCacheAsync(playlistName, playlist.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
}
else
{
@@ -1306,12 +1314,11 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
/// </summary>
private async Task MatchPlaylistTracksLegacyAsync(
string playlistName,
SpotifyPlaylistConfig playlistConfig,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var playlistConfig = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
var playlistName = playlistConfig.Name;
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(