mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-25 03:12:54 -04:00
fix scoped injected playlist matching
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user