diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs
index 7a271e3..7d5217b 100644
--- a/allstarr/Controllers/AdminController.cs
+++ b/allstarr/Controllers/AdminController.cs
@@ -1966,6 +1966,12 @@ public class AdminController : ControllerBase
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
+ if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
+ {
+ _logger.LogWarning("Spotify rate limit hit (429) when fetching playlists");
+ return StatusCode(429, new { error = "Spotify rate limit exceeded. Please wait a moment and try again." });
+ }
+
_logger.LogWarning("Failed to fetch Spotify playlists: {StatusCode}", response.StatusCode);
break;
}
diff --git a/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs b/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs
index aefb1a2..d17c511 100644
--- a/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs
+++ b/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs
@@ -3,6 +3,7 @@ using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.Extensions.Options;
using System.Text.Json;
+using Cronos;
namespace allstarr.Services.Spotify;
@@ -14,6 +15,9 @@ namespace allstarr.Services.Spotify;
/// - ISRC codes available for exact matching
/// - Real-time data without waiting for plugin sync schedules
/// - Full track metadata (duration, release date, etc.)
+///
+/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
+/// Cache persists until next cron run to prevent excess Spotify API calls.
///
public class SpotifyPlaylistFetcher : BackgroundService
{
@@ -45,6 +49,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
///
/// Gets the Spotify playlist tracks in order, using cache if available.
+ /// Cache persists until next cron run to prevent excess API calls.
///
/// Playlist name (e.g., "Release Radar", "Discover Weekly")
/// List of tracks in playlist order, or empty list if not found
@@ -57,7 +62,38 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (cached != null && cached.Tracks.Count > 0)
{
var age = DateTime.UtcNow - cached.FetchedAt;
- if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
+
+ // Calculate if cache should still be valid based on cron schedule
+ var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
+ var shouldRefresh = false;
+
+ if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.SyncSchedule))
+ {
+ try
+ {
+ var cron = CronExpression.Parse(playlistConfig.SyncSchedule);
+ var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
+
+ if (nextRun.HasValue && DateTime.UtcNow >= nextRun.Value)
+ {
+ shouldRefresh = true;
+ _logger.LogInformation("Cache expired for '{Name}' - next cron run was at {NextRun} UTC",
+ playlistName, nextRun.Value);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Could not parse cron schedule for '{Name}', falling back to cache duration", playlistName);
+ shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
+ }
+ }
+ else
+ {
+ // No cron schedule, use cache duration from settings
+ shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
+ }
+
+ if (!shouldRefresh)
{
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
playlistName, cached.Tracks.Count, age.TotalMinutes);
@@ -94,11 +130,11 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
{
// Check if we have a configured Spotify ID for this playlist
- var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
- if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
+ var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
+ if (config != null && !string.IsNullOrEmpty(config.Id))
{
// Use the configured Spotify playlist ID directly
- spotifyId = playlistConfig.Id;
+ spotifyId = config.Id;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
}
@@ -144,12 +180,39 @@ public class SpotifyPlaylistFetcher : BackgroundService
return cached?.Tracks ?? new List();
}
- // Update cache
- await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
+ // Calculate cache expiration based on cron schedule
+ var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
+ var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
+
+ if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
+ {
+ try
+ {
+ var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
+ var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
+
+ if (nextRun.HasValue)
+ {
+ var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
+ // Add 5 minutes buffer
+ cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
+
+ _logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
+ playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
+ }
+ }
+
+ // Update cache with cron-based expiration
+ await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
await SaveToFileCacheAsync(playlistName, playlist);
- _logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
- playlistName, playlist.Tracks.Count);
+ _logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
+ playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
return playlist.Tracks;
}
@@ -235,32 +298,102 @@ public class SpotifyPlaylistFetcher : BackgroundService
_logger.LogInformation("Spotify API ENABLED");
_logger.LogInformation("Authenticated via sp_dc session cookie");
- _logger.LogInformation("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes);
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
foreach (var playlist in _spotifyImportSettings.Playlists)
{
- _logger.LogInformation(" - {Name}", playlist.Name);
+ var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
+ _logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
}
_logger.LogInformation("========================================");
- // Initial fetch of all playlists
+ // Initial fetch of all playlists on startup
await FetchAllPlaylistsAsync(stoppingToken);
- // Periodic refresh loop
+ // Cron-based refresh loop - only fetch when cron schedule triggers
+ // This prevents excess Spotify API calls
while (!stoppingToken.IsCancellationRequested)
{
- await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
-
try
{
- await FetchAllPlaylistsAsync(stoppingToken);
+ // Check each playlist to see if it needs refreshing based on cron schedule
+ var now = DateTime.UtcNow;
+ var needsRefresh = new List();
+
+ foreach (var config in _spotifyImportSettings.Playlists)
+ {
+ var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * 1" : config.SyncSchedule;
+
+ try
+ {
+ var cron = CronExpression.Parse(schedule);
+
+ // Check if we have cached data
+ var cacheKey = $"{CacheKeyPrefix}{config.Name}";
+ var cached = await _cache.GetAsync(cacheKey);
+
+ if (cached != null)
+ {
+ // Calculate when the next run should be after the last fetch
+ var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
+
+ if (nextRun.HasValue && now >= nextRun.Value)
+ {
+ needsRefresh.Add(config.Name);
+ _logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
+ config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
+ }
+ }
+ else
+ {
+ // No cache, fetch it
+ needsRefresh.Add(config.Name);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", config.Name, schedule);
+ }
+ }
+
+ // Fetch playlists that need refreshing
+ if (needsRefresh.Count > 0)
+ {
+ _logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
+
+ foreach (var playlistName in needsRefresh)
+ {
+ if (stoppingToken.IsCancellationRequested) break;
+
+ try
+ {
+ await GetPlaylistTracksAsync(playlistName);
+
+ // Rate limiting between playlists
+ if (playlistName != needsRefresh.Last())
+ {
+ _logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
+ await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
+ }
+ }
+
+ _logger.LogInformation("=== FINISHED FETCHING PLAYLISTS ===");
+ }
+
+ // Sleep for 1 hour before checking again
+ await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error during periodic playlist refresh");
+ _logger.LogError(ex, "Error in playlist fetcher loop");
+ await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs
index 793a5f8..bd45db5 100644
--- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs
+++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs
@@ -6,6 +6,7 @@ using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using System.Text.Json;
+using Cronos;
namespace allstarr.Services.Spotify;
@@ -17,6 +18,9 @@ namespace allstarr.Services.Spotify;
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
///
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
+///
+/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
+/// Manual refresh is always allowed. Cache persists until next cron run.
///
public class SpotifyTrackMatchingService : BackgroundService
{
@@ -27,8 +31,10 @@ public class SpotifyTrackMatchingService : BackgroundService
private readonly IServiceProvider _serviceProvider;
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)
- private DateTime _lastMatchingRun = DateTime.MinValue;
- private readonly TimeSpan _minimumMatchingInterval = TimeSpan.FromMinutes(5); // Don't run more than once per 5 minutes
+
+ // Track last run time per playlist to prevent duplicate runs
+ private readonly Dictionary _lastRunTimes = new();
+ private readonly TimeSpan _minimumRunInterval = TimeSpan.FromMinutes(5); // Cooldown between runs
public SpotifyTrackMatchingService(
IOptions spotifySettings,
@@ -57,17 +63,29 @@ public class SpotifyTrackMatchingService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
+ _logger.LogInformation("========================================");
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
if (!_spotifySettings.Enabled)
{
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
+ _logger.LogInformation("========================================");
return;
}
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
? "ISRC-preferred" : "fuzzy";
_logger.LogInformation("Matching mode: {Mode}", matchMode);
+ _logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
+
+ // Log all playlist schedules
+ foreach (var playlist in _spotifySettings.Playlists)
+ {
+ var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
+ _logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
+ }
+
+ _logger.LogInformation("========================================");
// Wait a bit for the fetcher to run first
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
@@ -75,7 +93,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Run once on startup to match any existing missing tracks
try
{
- _logger.LogInformation("Running initial track matching on startup");
+ _logger.LogInformation("Running initial track matching on startup (one-time)");
await MatchAllPlaylistsAsync(stoppingToken);
}
catch (Exception ex)
@@ -83,46 +101,100 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogError(ex, "Error during startup track matching");
}
- // Now start the periodic matching loop
+ // Now start the cron-based scheduling loop
while (!stoppingToken.IsCancellationRequested)
{
- // Wait for configured interval before next run (default 24 hours)
- var intervalHours = _spotifySettings.MatchingIntervalHours;
- if (intervalHours <= 0)
- {
- _logger.LogInformation("Periodic matching disabled (MatchingIntervalHours = {Hours}), only startup run will execute", intervalHours);
- break; // Exit loop - only run once on startup
- }
-
- await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
-
try
{
- await MatchAllPlaylistsAsync(stoppingToken);
+ // Calculate next run time for each playlist
+ var now = DateTime.UtcNow;
+ var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
+
+ foreach (var playlist in _spotifySettings.Playlists)
+ {
+ var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
+
+ try
+ {
+ var cron = CronExpression.Parse(schedule);
+ var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
+
+ if (nextRun.HasValue)
+ {
+ nextRuns.Add((playlist.Name, nextRun.Value, cron));
+ }
+ else
+ {
+ _logger.LogWarning("Could not calculate next run for playlist {Name} with schedule {Schedule}",
+ playlist.Name, schedule);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}",
+ playlist.Name, schedule);
+ }
+ }
+
+ if (nextRuns.Count == 0)
+ {
+ _logger.LogWarning("No valid cron schedules found, sleeping for 1 hour");
+ await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
+ continue;
+ }
+
+ // Find the next playlist that needs to run
+ var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
+ var waitTime = nextPlaylist.NextRun - now;
+
+ if (waitTime.TotalSeconds > 0)
+ {
+ _logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
+ nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
+
+ // Wait until next run (or max 1 hour to re-check schedules)
+ var maxWait = TimeSpan.FromHours(1);
+ var actualWait = waitTime > maxWait ? maxWait : waitTime;
+ await Task.Delay(actualWait, stoppingToken);
+ continue;
+ }
+
+ // Time to run this playlist
+ _logger.LogInformation("=== CRON TRIGGER: Running scheduled match for {Playlist} ===", nextPlaylist.PlaylistName);
+
+ // Check cooldown to prevent duplicate runs
+ if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
+ {
+ var timeSinceLastRun = now - lastRun;
+ if (timeSinceLastRun < _minimumRunInterval)
+ {
+ _logger.LogInformation("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
+ nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
+ await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
+ continue;
+ }
+ }
+
+ // Run matching for this playlist
+ await MatchSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
+ _lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
+
+ _logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
+ nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error in track matching service");
+ _logger.LogError(ex, "Error in cron scheduling loop");
+ await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
-
- ///
- /// Public method to trigger matching manually for all playlists (called from controller).
- ///
- public async Task TriggerMatchingAsync()
- {
- _logger.LogInformation("Manual track matching triggered for all playlists");
- await MatchAllPlaylistsAsync(CancellationToken.None);
- }
///
- /// Public method to trigger matching for a specific playlist (called from controller).
+ /// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
///
- public async Task TriggerMatchingForPlaylistAsync(string playlistName)
+ private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
{
- _logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
-
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
@@ -148,13 +220,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
- playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
+ playlist.Name, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
- playlist.Name, metadataService, CancellationToken.None);
+ playlist.Name, metadataService, cancellationToken);
}
}
catch (Exception ex)
@@ -164,19 +236,43 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
- private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
+ ///
+ /// Public method to trigger matching manually for all playlists (called from controller).
+ /// This bypasses cron schedules and runs immediately.
+ ///
+ public async Task TriggerMatchingAsync()
{
- // Check if we've run too recently (cooldown period)
- var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
- if (timeSinceLastRun < _minimumMatchingInterval)
+ _logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
+ await MatchAllPlaylistsAsync(CancellationToken.None);
+ }
+
+ ///
+ /// Public method to trigger matching for a specific playlist (called from controller).
+ /// This bypasses cron schedules and runs immediately.
+ ///
+ public async Task TriggerMatchingForPlaylistAsync(string playlistName)
+ {
+ _logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (bypassing cron schedule)", playlistName);
+
+ // Check cooldown to prevent abuse
+ if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
{
- _logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
- (int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
- return;
+ var timeSinceLastRun = DateTime.UtcNow - lastRun;
+ if (timeSinceLastRun < _minimumRunInterval)
+ {
+ _logger.LogWarning("Skipping manual refresh for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
+ playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
+ throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before refreshing again");
+ }
}
- _logger.LogInformation("=== STARTING TRACK MATCHING ===");
- _lastMatchingRun = DateTime.UtcNow;
+ await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
+ _lastRunTimes[playlistName] = DateTime.UtcNow;
+ }
+
+ private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
var playlists = _spotifySettings.Playlists;
if (playlists.Count == 0)
@@ -185,34 +281,13 @@ public class SpotifyTrackMatchingService : BackgroundService
return;
}
- using var scope = _serviceProvider.CreateScope();
- var metadataService = scope.ServiceProvider.GetRequiredService();
-
- // Check if we should use the new SpotifyPlaylistFetcher
- SpotifyPlaylistFetcher? playlistFetcher = null;
- if (_spotifyApiSettings.Enabled)
- {
- playlistFetcher = scope.ServiceProvider.GetService();
- }
-
foreach (var playlist in playlists)
{
if (cancellationToken.IsCancellationRequested) break;
try
{
- if (playlistFetcher != null)
- {
- // Use new direct API mode with ISRC support
- await MatchPlaylistTracksWithIsrcAsync(
- playlist.Name, playlistFetcher, metadataService, cancellationToken);
- }
- else
- {
- // Fall back to legacy mode
- await MatchPlaylistTracksLegacyAsync(
- playlist.Name, metadataService, cancellationToken);
- }
+ await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
}
catch (Exception ex)
{
@@ -220,7 +295,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
- _logger.LogInformation("=== FINISHED TRACK MATCHING ===");
+ _logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
}
///
@@ -497,8 +572,37 @@ public class SpotifyTrackMatchingService : BackgroundService
if (matchedTracks.Count > 0)
{
- // Cache matched tracks with position data
- await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
+ // 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))
+ {
+ try
+ {
+ var cron = CronExpression.Parse(playlist.SyncSchedule);
+ var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
+
+ if (nextRun.HasValue)
+ {
+ var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
+ // Add 5 minutes buffer to ensure cache doesn't expire before next run
+ cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
+
+ _logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)",
+ nextRun.Value, timeUntilNextRun.TotalHours);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName);
+ }
+ }
+
+ // Cache matched tracks with position data until next cron run
+ await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
// Save matched tracks to file for persistence across restarts
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
@@ -506,15 +610,15 @@ public class SpotifyTrackMatchingService : BackgroundService
// Also update legacy cache for backward compatibility
var legacyKey = $"spotify:matched:{playlistName}";
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
- await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
+ await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
_logger.LogInformation(
- "✓ 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);
+ "✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h",
+ matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
// 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, cancellationToken);
+ await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
}
else
{
@@ -849,6 +953,7 @@ public class SpotifyTrackMatchingService : BackgroundService
string? jellyfinPlaylistId,
List spotifyTracks,
List matchedTracks,
+ TimeSpan cacheExpiration,
CancellationToken cancellationToken)
{
try
@@ -1196,9 +1301,9 @@ public class SpotifyTrackMatchingService : BackgroundService
if (finalItems.Count > 0)
{
- // Save to Redis cache
+ // Save to Redis cache with same expiration as matched tracks (until next cron run)
var cacheKey = $"spotify:playlist:items:{playlistName}";
- await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
+ await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
// Save to file cache for persistence
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
@@ -1210,8 +1315,8 @@ public class SpotifyTrackMatchingService : BackgroundService
}
_logger.LogInformation(
- "✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
- playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
+ "✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
+ playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
}
else
{
diff --git a/allstarr/allstarr.csproj b/allstarr/allstarr.csproj
index a9e9b3e..6923be7 100644
--- a/allstarr/allstarr.csproj
+++ b/allstarr/allstarr.csproj
@@ -12,6 +12,7 @@
+
diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html
index d40785d..263bb2b 100644
--- a/allstarr/wwwroot/index.html
+++ b/allstarr/wwwroot/index.html
@@ -1262,7 +1262,8 @@
Cron format: minute hour day month dayofweek
Default: 0 8 * * 1 = 8 AM every Monday
- Examples: 0 6 * * * = daily at 6 AM, 0 20 * * 5 = Fridays at 8 PM
+ Examples: 0 6 * * * = daily at 6 AM, 0 20 * * 5 = Fridays at 8 PM
+ Use crontab.guru to build your schedule
@@ -2578,7 +2579,7 @@
}
async function editPlaylistSchedule(playlistName, currentSchedule) {
- const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM`, currentSchedule);
+ const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM\n\nUse https://crontab.guru/ to build your schedule`, currentSchedule);
if (!newSchedule || newSchedule === currentSchedule) return;