stupid timezones

This commit is contained in:
2026-02-02 12:43:23 -05:00
parent 1a2e160279
commit 2bc2816191
3 changed files with 118 additions and 93 deletions

View File

@@ -328,7 +328,8 @@ Allstarr can automatically fill your Spotify playlists (like Release Radar and D
# Enable the feature # Enable the feature
SPOTIFY_IMPORT_ENABLED=true 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_HOUR=16
SPOTIFY_IMPORT_SYNC_START_MINUTE=15 SPOTIFY_IMPORT_SYNC_START_MINUTE=15
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2 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) 2. **Allstarr Fetches Missing Tracks** (within sync window)
- Searches for missing tracks files from the Jellyfin plugin - 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 - 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) 3. **Allstarr Matches Tracks** (2 minutes after startup, then every 30 minutes)
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz) - 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 #### Startup Behavior
When Allstarr starts with Spotify Import enabled: 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) - **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! - **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 #### Troubleshooting
**Playlists are empty:** **Playlists are empty:**

View File

@@ -14,19 +14,22 @@ public class SpotifyImportSettings
/// <summary> /// <summary>
/// Hour when Spotify Import plugin runs (24-hour format, 0-23) /// 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.
/// </summary> /// </summary>
public int SyncStartHour { get; set; } = 16; public int SyncStartHour { get; set; } = 16;
/// <summary> /// <summary>
/// Minute when Spotify Import plugin runs (0-59) /// 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.
/// </summary> /// </summary>
public int SyncStartMinute { get; set; } = 15; public int SyncStartMinute { get; set; } = 15;
/// <summary> /// <summary>
/// How many hours to search for missing tracks files after sync start time /// 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.
/// </summary> /// </summary>
public int SyncWindowHours { get; set; } = 2; public int SyncWindowHours { get; set; } = 2;

View File

@@ -72,8 +72,11 @@ public class SpotifyMissingTracksFetcher : BackgroundService
} }
_logger.LogInformation("========================================"); _logger.LogInformation("========================================");
// Always run once on startup to ensure we have missing tracks // Check if we should run on startup
if (!_hasRunOnce) if (!_hasRunOnce)
{
var shouldRun = await ShouldRunOnStartupAsync();
if (shouldRun)
{ {
_logger.LogInformation("Running initial fetch on startup"); _logger.LogInformation("Running initial fetch on startup");
try try
@@ -86,6 +89,12 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogError(ex, "Error during startup fetch"); _logger.LogError(ex, "Error during startup fetch");
} }
} }
else
{
_logger.LogInformation("Skipping startup fetch - existing cache is still current");
_hasRunOnce = true;
}
}
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
@@ -121,78 +130,69 @@ public class SpotifyMissingTracksFetcher : BackgroundService
private async Task<bool> ShouldRunOnStartupAsync() private async Task<bool> ShouldRunOnStartupAsync()
{ {
_logger.LogInformation("=== STARTUP CACHE CHECK ==="); _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 var settings = _spotifySettings.Value;
try var now = DateTime.UtcNow;
{
if (Directory.Exists(CacheDirectory)) // Calculate when today's sync window ends
{ var todaySync = now.Date
var files = Directory.GetFiles(CacheDirectory, "*.json"); .AddHours(settings.SyncStartHour)
_logger.LogInformation("Found {Count} JSON files in cache directory:", files.Length); .AddMinutes(settings.SyncStartMinute);
foreach (var file in files) var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
{
var fileInfo = new FileInfo(file); // If we haven't reached today's sync window end yet, check if we have yesterday's file
var age = DateTime.UtcNow - fileInfo.LastWriteTimeUtc; if (now < todaySyncEnd)
_logger.LogInformation(" - {Name} (age: {Age:F1}h, size: {Size} bytes)", {
Path.GetFileName(file), age.TotalHours, fileInfo.Length); _logger.LogInformation("Today's sync window hasn't ended yet (ends at {End})", todaySyncEnd);
} _logger.LogInformation("Checking if we have a recent cache file...");
}
else // Check if we have any cache (file or Redis) for all playlists
{ var allPlaylistsHaveCache = true;
_logger.LogWarning("Cache directory does not exist: {Dir}", CacheDirectory);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing cache directory");
}
// Check file cache first, then Redis
foreach (var playlistName in _playlistIdToName.Values) foreach (var playlistName in _playlistIdToName.Values)
{ {
var filePath = GetCacheFilePath(playlistName); var filePath = GetCacheFilePath(playlistName);
_logger.LogInformation("Checking playlist: {Playlist}", playlistName); var cacheKey = $"spotify:missing:{playlistName}";
_logger.LogInformation(" Expected file path: {Path}", filePath);
// Check file cache
if (File.Exists(filePath)) if (File.Exists(filePath))
{ {
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath); var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
_logger.LogInformation(" File exists! Age: {Age:F1}h", fileAge.TotalHours); _logger.LogInformation(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
_logger.LogInformation(" ✓ Found file cache (age: {Age:F1}h, no expiration)", fileAge.TotalHours);
// Load from file into Redis if not already there // Load into Redis if not already there
var key = $"spotify:missing:{playlistName}"; if (!await _cache.ExistsAsync(cacheKey))
if (!await _cache.ExistsAsync(key))
{ {
_logger.LogInformation(" Loading into Redis...");
await LoadFromFileCache(playlistName); await LoadFromFileCache(playlistName);
} }
else continue;
{
_logger.LogInformation(" Already in Redis");
}
return false;
}
else
{
_logger.LogInformation(" File does not exist at expected path");
} }
var cacheKey = $"spotify:missing:{playlistName}"; // Check Redis cache
if (await _cache.ExistsAsync(cacheKey)) if (await _cache.ExistsAsync(cacheKey))
{ {
_logger.LogInformation(" Found in Redis cache"); _logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName);
continue;
}
// No cache found for this playlist
_logger.LogInformation(" {Playlist}: No cache found", playlistName);
allPlaylistsHaveCache = false;
}
if (allPlaylistsHaveCache)
{
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
return false; return false;
} }
}
else else
{ {
_logger.LogInformation(" Not in Redis cache"); _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; return true;
} }
@@ -321,70 +321,74 @@ public class SpotifyMissingTracksFetcher : BackgroundService
var httpClient = _httpClientFactory.CreateClient(); 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 now = DateTime.UtcNow;
var todaySync = now.Date _logger.LogInformation(" Searching +24h forward, then -48h backward from {Now}", now);
.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);
var found = false; var found = false;
DateTime? foundFileTime = null; DateTime? foundFileTime = null;
// Search forward 12 hours from sync time // First search forward 24 hours (most likely to find newest files with timezone ahead)
_logger.LogInformation(" Phase 1: Searching forward 12 hours from sync time..."); _logger.LogInformation(" Phase 1: Searching forward 24 hours...");
for (var minutesAhead = 0; minutesAhead <= 720; minutesAhead++) // 720 minutes = 12 hours for (var minutesAhead = 1; minutesAhead <= 1440; minutesAhead++)
{ {
if (cancellationToken.IsCancellationRequested) break; 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); var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime);
if (result.found) if (result.found)
{ {
found = true; found = true;
foundFileTime = result.fileTime; foundFileTime = result.fileTime;
break; if (foundFileTime.HasValue)
}
// Small delay every 60 requests
if (minutesAhead > 0 && minutesAhead % 60 == 0)
{ {
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); _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 to avoid rate limiting
if (minutesAhead % 60 == 0)
{
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) if (!found)
{ {
_logger.LogInformation(" Phase 2: Searching backward 24 hours from sync time..."); _logger.LogInformation(" Phase 2: Searching backward 48 hours...");
for (var minutesBehind = 1; minutesBehind <= 1440; minutesBehind++) // 1440 minutes = 24 hours for (var minutesBehind = 0; minutesBehind <= 2880; minutesBehind++)
{ {
if (cancellationToken.IsCancellationRequested) break; 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); var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime);
if (result.found) if (result.found)
{ {
found = true; found = true;
foundFileTime = result.fileTime; foundFileTime = result.fileTime;
if (foundFileTime.HasValue)
{
_logger.LogInformation(" ✓ Found file from {Time} (-{Offset:F1}h ago)",
foundFileTime.Value, (now - foundFileTime.Value).TotalHours);
}
break; break;
} }
// Small delay every 60 requests // Small delay every 60 requests to avoid rate limiting
if (minutesBehind % 60 == 0) if (minutesBehind > 0 && minutesBehind % 60 == 0)
{ {
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
} }
} }
} }
if (!found) 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 // Keep the existing cache - don't let it expire
if (existingTracks != null && existingTracks.Count > 0) if (existingTracks != null && existingTracks.Count > 0)