From 6949f8aed4c2517807fbeb04fa58361c5b73275b Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Mon, 9 Feb 2026 14:23:23 -0500 Subject: [PATCH] feat: implement per-playlist cron scheduling with persistent cache - Added Cronos package for cron expression parsing - Each playlist now has independent cron schedule (default: 0 8 * * 1) - Cache persists until next cron run, not just cache duration - Prevents excess Spotify API calls - only refreshes on cron trigger - Manual refresh still allowed with 5-minute cooldown - Added 429 rate limit handling for user playlist fetching - Added crontab.guru link to UI for easy schedule building - Both SpotifyPlaylistFetcher and SpotifyTrackMatchingService use cron - Automatic matching only runs when cron schedule triggers --- allstarr/Controllers/AdminController.cs | 6 + .../Spotify/SpotifyPlaylistFetcher.cs | 165 ++++++++++-- .../Spotify/SpotifyTrackMatchingService.cs | 251 +++++++++++++----- allstarr/allstarr.csproj | 1 + allstarr/wwwroot/index.html | 5 +- 5 files changed, 337 insertions(+), 91 deletions(-) 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;