diff --git a/README.md b/README.md index af49d51..a78507f 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,8 @@ Allstarr can automatically fill your Spotify playlists (like Release Radar and D # Enable the feature SPOTIFY_IMPORT_ENABLED=true -# Match your Spotify Import plugin schedule (e.g., 4:15 PM daily) +# Sync window settings (optional - used to prevent fetching too frequently) +# The fetcher searches backwards from current time for the last 48 hours SPOTIFY_IMPORT_SYNC_START_HOUR=16 SPOTIFY_IMPORT_SYNC_START_MINUTE=15 SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2 @@ -350,8 +351,11 @@ SPOTIFY_IMPORT_PLAYLIST_NAMES=Release Radar,Discover Weekly 2. **Allstarr Fetches Missing Tracks** (within sync window) - Searches for missing tracks files from the Jellyfin plugin + - Searches **+24 hours forward first** (newest files), then **-48 hours backward** if not found + - This efficiently finds the most recent file regardless of timezone differences + - Example: Server time 12 PM EST, file timestamped 9 PM UTC (same day) → Found in forward search - Caches the list of missing tracks in Redis + file cache - - Runs automatically on startup and every 5 minutes during the sync window + - Runs automatically on startup (if needed) and every 5 minutes during the sync window 3. **Allstarr Matches Tracks** (2 minutes after startup, then every 30 minutes) - For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz) @@ -387,10 +391,24 @@ curl "https://your-jellyfin-proxy.com/spotify/clear-cache?api_key=YOUR_API_KEY" #### Startup Behavior When Allstarr starts with Spotify Import enabled: -- **T+0s**: Fetches missing tracks from Jellyfin plugin (if configured) + +**Smart Cache Check:** +- Checks if today's sync window has passed (e.g., if sync is at 4 PM + 2 hour window = 6 PM) +- If before 6 PM and yesterday's cache exists → **Skips fetch** (cache is still current) +- If after 6 PM or no cache exists → **Fetches missing tracks** from Jellyfin plugin + +**Track Matching:** - **T+2min**: Matches tracks with streaming provider (with rate limiting) +- Only matches playlists that don't already have cached matches - **Result**: Playlists load instantly when you open them! +**Example Timeline:** +- Plugin runs daily at 4:15 PM, creates files at ~4:16 PM +- You restart Allstarr at 12:00 PM (noon) the next day +- Startup check: "Today's sync window ends at 6 PM, and I have yesterday's 4:16 PM file" +- **Decision**: Skip fetch, use existing cache +- At 6:01 PM: Next scheduled check will search for new files + #### Troubleshooting **Playlists are empty:** diff --git a/allstarr/Models/Settings/SpotifyImportSettings.cs b/allstarr/Models/Settings/SpotifyImportSettings.cs index 716f35f..a2feb0b 100644 --- a/allstarr/Models/Settings/SpotifyImportSettings.cs +++ b/allstarr/Models/Settings/SpotifyImportSettings.cs @@ -14,19 +14,22 @@ public class SpotifyImportSettings /// /// Hour when Spotify Import plugin runs (24-hour format, 0-23) - /// Example: 16 for 4:00 PM + /// NOTE: This setting is now optional and only used for the sync window check. + /// The fetcher will search backwards from current time for the last 48 hours, + /// so timezone confusion is avoided. /// public int SyncStartHour { get; set; } = 16; /// /// Minute when Spotify Import plugin runs (0-59) - /// Example: 15 for 4:15 PM + /// NOTE: This setting is now optional and only used for the sync window check. /// public int SyncStartMinute { get; set; } = 15; /// /// How many hours to search for missing tracks files after sync start time - /// Example: 2 means search from 4:00 PM to 6:00 PM + /// This prevents the fetcher from running too frequently. + /// Set to 0 to disable the sync window check and always search on startup. /// public int SyncWindowHours { get; set; } = 2; diff --git a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs index 97ff395..ae9bc80 100644 --- a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs +++ b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs @@ -72,18 +72,27 @@ public class SpotifyMissingTracksFetcher : BackgroundService } _logger.LogInformation("========================================"); - // Always run once on startup to ensure we have missing tracks + // Check if we should run on startup if (!_hasRunOnce) { - _logger.LogInformation("Running initial fetch on startup"); - try + var shouldRun = await ShouldRunOnStartupAsync(); + if (shouldRun) { - await FetchMissingTracksAsync(stoppingToken, bypassSyncWindowCheck: true); - _hasRunOnce = true; + _logger.LogInformation("Running initial fetch on startup"); + try + { + await FetchMissingTracksAsync(stoppingToken, bypassSyncWindowCheck: true); + _hasRunOnce = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during startup fetch"); + } } - catch (Exception ex) + else { - _logger.LogError(ex, "Error during startup fetch"); + _logger.LogInformation("Skipping startup fetch - existing cache is still current"); + _hasRunOnce = true; } } @@ -121,78 +130,69 @@ public class SpotifyMissingTracksFetcher : BackgroundService private async Task ShouldRunOnStartupAsync() { _logger.LogInformation("=== STARTUP CACHE CHECK ==="); - _logger.LogInformation("Cache directory: {Dir}", CacheDirectory); - _logger.LogInformation("Checking {Count} playlists", _playlistIdToName.Count); - // List all files in cache directory for debugging - try - { - if (Directory.Exists(CacheDirectory)) - { - var files = Directory.GetFiles(CacheDirectory, "*.json"); - _logger.LogInformation("Found {Count} JSON files in cache directory:", files.Length); - foreach (var file in files) - { - var fileInfo = new FileInfo(file); - var age = DateTime.UtcNow - fileInfo.LastWriteTimeUtc; - _logger.LogInformation(" - {Name} (age: {Age:F1}h, size: {Size} bytes)", - Path.GetFileName(file), age.TotalHours, fileInfo.Length); - } - } - else - { - _logger.LogWarning("Cache directory does not exist: {Dir}", CacheDirectory); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error listing cache directory"); - } + var settings = _spotifySettings.Value; + var now = DateTime.UtcNow; - // Check file cache first, then Redis - foreach (var playlistName in _playlistIdToName.Values) + // Calculate when today's sync window ends + var todaySync = now.Date + .AddHours(settings.SyncStartHour) + .AddMinutes(settings.SyncStartMinute); + var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours); + + // If we haven't reached today's sync window end yet, check if we have yesterday's file + if (now < todaySyncEnd) { - var filePath = GetCacheFilePath(playlistName); - _logger.LogInformation("Checking playlist: {Playlist}", playlistName); - _logger.LogInformation(" Expected file path: {Path}", filePath); + _logger.LogInformation("Today's sync window hasn't ended yet (ends at {End})", todaySyncEnd); + _logger.LogInformation("Checking if we have a recent cache file..."); - if (File.Exists(filePath)) + // Check if we have any cache (file or Redis) for all playlists + var allPlaylistsHaveCache = true; + + foreach (var playlistName in _playlistIdToName.Values) { - var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath); - _logger.LogInformation(" File exists! Age: {Age:F1}h", fileAge.TotalHours); - _logger.LogInformation(" ✓ Found file cache (age: {Age:F1}h, no expiration)", fileAge.TotalHours); + var filePath = GetCacheFilePath(playlistName); + var cacheKey = $"spotify:missing:{playlistName}"; - // Load from file into Redis if not already there - var key = $"spotify:missing:{playlistName}"; - if (!await _cache.ExistsAsync(key)) + // Check file cache + if (File.Exists(filePath)) { - _logger.LogInformation(" Loading into Redis..."); - await LoadFromFileCache(playlistName); + var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath); + _logger.LogInformation(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours); + + // Load into Redis if not already there + if (!await _cache.ExistsAsync(cacheKey)) + { + await LoadFromFileCache(playlistName); + } + continue; } - else + + // Check Redis cache + if (await _cache.ExistsAsync(cacheKey)) { - _logger.LogInformation(" Already in Redis"); + _logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName); + continue; } - return false; - } - else - { - _logger.LogInformation(" File does not exist at expected path"); + + // No cache found for this playlist + _logger.LogInformation(" {Playlist}: No cache found", playlistName); + allPlaylistsHaveCache = false; } - var cacheKey = $"spotify:missing:{playlistName}"; - if (await _cache.ExistsAsync(cacheKey)) + if (allPlaylistsHaveCache) { - _logger.LogInformation(" ✓ Found in Redis cache"); + _logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ==="); return false; } - else - { - _logger.LogInformation(" Not in Redis cache"); - } + } + else + { + _logger.LogInformation("Today's sync window has passed (ended at {End})", todaySyncEnd); + _logger.LogInformation("Will search for new files"); } - _logger.LogInformation("=== NO RECENT CACHE FOUND - WILL FETCH ==="); + _logger.LogInformation("=== WILL FETCH ON STARTUP ==="); return true; } @@ -321,70 +321,74 @@ public class SpotifyMissingTracksFetcher : BackgroundService var httpClient = _httpClientFactory.CreateClient(); - // Start from the configured sync time (most likely time) + // Search forward first (newest files), then backwards to handle timezone differences + // We want the file with the furthest forward timestamp (most recent) var now = DateTime.UtcNow; - var todaySync = now.Date - .AddHours(settings.SyncStartHour) - .AddMinutes(settings.SyncStartMinute); - - // If we haven't reached today's sync time yet, start from yesterday's sync time - var syncTime = now >= todaySync ? todaySync : todaySync.AddDays(-1); - - _logger.LogInformation(" Searching +12h forward, -24h backward from {SyncTime}", syncTime); + _logger.LogInformation(" Searching +24h forward, then -48h backward from {Now}", now); var found = false; DateTime? foundFileTime = null; - // Search forward 12 hours from sync time - _logger.LogInformation(" Phase 1: Searching forward 12 hours from sync time..."); - for (var minutesAhead = 0; minutesAhead <= 720; minutesAhead++) // 720 minutes = 12 hours + // First search forward 24 hours (most likely to find newest files with timezone ahead) + _logger.LogInformation(" Phase 1: Searching forward 24 hours..."); + for (var minutesAhead = 1; minutesAhead <= 1440; minutesAhead++) { if (cancellationToken.IsCancellationRequested) break; - var time = syncTime.AddMinutes(minutesAhead); + var time = now.AddMinutes(minutesAhead); var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime); if (result.found) { found = true; foundFileTime = result.fileTime; - break; + if (foundFileTime.HasValue) + { + _logger.LogInformation(" ✓ Found file from {Time} (+{Offset:F1}h ahead)", + foundFileTime.Value, (foundFileTime.Value - now).TotalHours); + } + break; // Found newest file, stop searching } - // Small delay every 60 requests - if (minutesAhead > 0 && minutesAhead % 60 == 0) + // Small delay every 60 requests to avoid rate limiting + if (minutesAhead % 60 == 0) { - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); } } - // Then search backwards 24 hours from sync time to catch yesterday's file + // If not found forward, search backwards 48 hours if (!found) { - _logger.LogInformation(" Phase 2: Searching backward 24 hours from sync time..."); - for (var minutesBehind = 1; minutesBehind <= 1440; minutesBehind++) // 1440 minutes = 24 hours + _logger.LogInformation(" Phase 2: Searching backward 48 hours..."); + for (var minutesBehind = 0; minutesBehind <= 2880; minutesBehind++) { if (cancellationToken.IsCancellationRequested) break; - var time = syncTime.AddMinutes(-minutesBehind); + var time = now.AddMinutes(-minutesBehind); var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime); if (result.found) { found = true; foundFileTime = result.fileTime; + if (foundFileTime.HasValue) + { + _logger.LogInformation(" ✓ Found file from {Time} (-{Offset:F1}h ago)", + foundFileTime.Value, (now - foundFileTime.Value).TotalHours); + } break; } - // Small delay every 60 requests - if (minutesBehind % 60 == 0) + // Small delay every 60 requests to avoid rate limiting + if (minutesBehind > 0 && minutesBehind % 60 == 0) { - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); } } } if (!found) { - _logger.LogWarning(" ✗ Could not find new missing tracks file (searched +12h/-24h window)"); + _logger.LogWarning(" ✗ Could not find new missing tracks file (searched +24h forward, -48h backward)"); // Keep the existing cache - don't let it expire if (existingTracks != null && existingTracks.Count > 0)