mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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
This commit is contained in:
@@ -1966,6 +1966,12 @@ public class AdminController : ControllerBase
|
|||||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||||
if (!response.IsSuccessStatusCode)
|
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);
|
_logger.LogWarning("Failed to fetch Spotify playlists: {StatusCode}", response.StatusCode);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using allstarr.Models.Spotify;
|
|||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Cronos;
|
||||||
|
|
||||||
namespace allstarr.Services.Spotify;
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ namespace allstarr.Services.Spotify;
|
|||||||
/// - ISRC codes available for exact matching
|
/// - ISRC codes available for exact matching
|
||||||
/// - Real-time data without waiting for plugin sync schedules
|
/// - Real-time data without waiting for plugin sync schedules
|
||||||
/// - Full track metadata (duration, release date, etc.)
|
/// - 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SpotifyPlaylistFetcher : BackgroundService
|
public class SpotifyPlaylistFetcher : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -45,6 +49,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the Spotify playlist tracks in order, using cache if available.
|
/// Gets the Spotify playlist tracks in order, using cache if available.
|
||||||
|
/// Cache persists until next cron run to prevent excess API calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
||||||
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
||||||
@@ -57,7 +62,38 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
if (cached != null && cached.Tracks.Count > 0)
|
if (cached != null && cached.Tracks.Count > 0)
|
||||||
{
|
{
|
||||||
var age = DateTime.UtcNow - cached.FetchedAt;
|
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)",
|
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
|
||||||
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
||||||
@@ -94,11 +130,11 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
||||||
{
|
{
|
||||||
// Check if we have a configured Spotify ID for this playlist
|
// Check if we have a configured Spotify ID for this playlist
|
||||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
|
if (config != null && !string.IsNullOrEmpty(config.Id))
|
||||||
{
|
{
|
||||||
// Use the configured Spotify playlist ID directly
|
// Use the configured Spotify playlist ID directly
|
||||||
spotifyId = playlistConfig.Id;
|
spotifyId = config.Id;
|
||||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||||
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", 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<SpotifyPlaylistTrack>();
|
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cache
|
// Calculate cache expiration based on cron schedule
|
||||||
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
|
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);
|
await SaveToFileCacheAsync(playlistName, playlist);
|
||||||
|
|
||||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
|
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
|
||||||
playlistName, playlist.Tracks.Count);
|
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
|
||||||
|
|
||||||
return playlist.Tracks;
|
return playlist.Tracks;
|
||||||
}
|
}
|
||||||
@@ -235,32 +298,102 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
_logger.LogInformation("Spotify API ENABLED");
|
_logger.LogInformation("Spotify API ENABLED");
|
||||||
_logger.LogInformation("Authenticated via sp_dc session cookie");
|
_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("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
||||||
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
||||||
|
|
||||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
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("========================================");
|
_logger.LogInformation("========================================");
|
||||||
|
|
||||||
// Initial fetch of all playlists
|
// Initial fetch of all playlists on startup
|
||||||
await FetchAllPlaylistsAsync(stoppingToken);
|
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)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
|
|
||||||
|
|
||||||
try
|
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<string>();
|
||||||
|
|
||||||
|
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<SpotifyPlaylist>(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)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using allstarr.Services.Jellyfin;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Cronos;
|
||||||
|
|
||||||
namespace allstarr.Services.Spotify;
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
@@ -17,6 +18,9 @@ namespace allstarr.Services.Spotify;
|
|||||||
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
||||||
///
|
///
|
||||||
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SpotifyTrackMatchingService : BackgroundService
|
public class SpotifyTrackMatchingService : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -27,8 +31,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
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 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<string, DateTime> _lastRunTimes = new();
|
||||||
|
private readonly TimeSpan _minimumRunInterval = TimeSpan.FromMinutes(5); // Cooldown between runs
|
||||||
|
|
||||||
public SpotifyTrackMatchingService(
|
public SpotifyTrackMatchingService(
|
||||||
IOptions<SpotifyImportSettings> spotifySettings,
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
@@ -57,17 +63,29 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||||
|
|
||||||
if (!_spotifySettings.Enabled)
|
if (!_spotifySettings.Enabled)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||||
? "ISRC-preferred" : "fuzzy";
|
? "ISRC-preferred" : "fuzzy";
|
||||||
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
_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
|
// Wait a bit for the fetcher to run first
|
||||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
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
|
// Run once on startup to match any existing missing tracks
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Running initial track matching on startup");
|
_logger.LogInformation("Running initial track matching on startup (one-time)");
|
||||||
await MatchAllPlaylistsAsync(stoppingToken);
|
await MatchAllPlaylistsAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -83,46 +101,100 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
_logger.LogError(ex, "Error during startup track matching");
|
_logger.LogError(ex, "Error during startup track matching");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now start the periodic matching loop
|
// Now start the cron-based scheduling loop
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
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
|
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)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Public method to trigger matching manually for all playlists (called from controller).
|
|
||||||
/// </summary>
|
|
||||||
public async Task TriggerMatchingAsync()
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Manual track matching triggered for all playlists");
|
|
||||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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
|
var playlist = _spotifySettings.Playlists
|
||||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
@@ -148,13 +220,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
// Use new direct API mode with ISRC support
|
// Use new direct API mode with ISRC support
|
||||||
await MatchPlaylistTracksWithIsrcAsync(
|
await MatchPlaylistTracksWithIsrcAsync(
|
||||||
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
|
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fall back to legacy mode
|
// Fall back to legacy mode
|
||||||
await MatchPlaylistTracksLegacyAsync(
|
await MatchPlaylistTracksLegacyAsync(
|
||||||
playlist.Name, metadataService, CancellationToken.None);
|
playlist.Name, metadataService, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -164,19 +236,43 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
/// <summary>
|
||||||
|
/// Public method to trigger matching manually for all playlists (called from controller).
|
||||||
|
/// This bypasses cron schedules and runs immediately.
|
||||||
|
/// </summary>
|
||||||
|
public async Task TriggerMatchingAsync()
|
||||||
{
|
{
|
||||||
// Check if we've run too recently (cooldown period)
|
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
|
||||||
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
|
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||||
if (timeSinceLastRun < _minimumMatchingInterval)
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public method to trigger matching for a specific playlist (called from controller).
|
||||||
|
/// This bypasses cron schedules and runs immediately.
|
||||||
|
/// </summary>
|
||||||
|
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)",
|
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||||
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
|
if (timeSinceLastRun < _minimumRunInterval)
|
||||||
return;
|
{
|
||||||
|
_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 ===");
|
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
|
||||||
_lastMatchingRun = DateTime.UtcNow;
|
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||||
|
|
||||||
var playlists = _spotifySettings.Playlists;
|
var playlists = _spotifySettings.Playlists;
|
||||||
if (playlists.Count == 0)
|
if (playlists.Count == 0)
|
||||||
@@ -185,34 +281,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
|
||||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
|
||||||
|
|
||||||
// Check if we should use the new SpotifyPlaylistFetcher
|
|
||||||
SpotifyPlaylistFetcher? playlistFetcher = null;
|
|
||||||
if (_spotifyApiSettings.Enabled)
|
|
||||||
{
|
|
||||||
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var playlist in playlists)
|
foreach (var playlist in playlists)
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested) break;
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (playlistFetcher != null)
|
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
|
||||||
{
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -220,7 +295,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
_logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -497,8 +572,37 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (matchedTracks.Count > 0)
|
if (matchedTracks.Count > 0)
|
||||||
{
|
{
|
||||||
// Cache matched tracks with position data
|
// Calculate cache expiration: until next cron run (not just cache duration from settings)
|
||||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
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
|
// Save matched tracks to file for persistence across restarts
|
||||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||||
@@ -506,15 +610,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Also update legacy cache for backward compatibility
|
// Also update legacy cache for backward compatibility
|
||||||
var legacyKey = $"spotify:matched:{playlistName}";
|
var legacyKey = $"spotify:matched:{playlistName}";
|
||||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
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(
|
_logger.LogInformation(
|
||||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
"✓ 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);
|
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
|
||||||
|
|
||||||
// Pre-build playlist items cache for instant serving
|
// Pre-build playlist items cache for instant serving
|
||||||
// This is what makes the UI show all matched tracks at once
|
// 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
|
else
|
||||||
{
|
{
|
||||||
@@ -849,6 +953,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
string? jellyfinPlaylistId,
|
string? jellyfinPlaylistId,
|
||||||
List<SpotifyPlaylistTrack> spotifyTracks,
|
List<SpotifyPlaylistTrack> spotifyTracks,
|
||||||
List<MatchedTrack> matchedTracks,
|
List<MatchedTrack> matchedTracks,
|
||||||
|
TimeSpan cacheExpiration,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -1196,9 +1301,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (finalItems.Count > 0)
|
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}";
|
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
|
// Save to file cache for persistence
|
||||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||||
@@ -1210,8 +1315,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
|
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
|
||||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
|
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
|
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||||
|
|||||||
@@ -1262,7 +1262,8 @@
|
|||||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
Cron format: <code>minute hour day month dayofweek</code><br>
|
Cron format: <code>minute hour day month dayofweek</code><br>
|
||||||
Default: <code>0 8 * * 1</code> = 8 AM every Monday<br>
|
Default: <code>0 8 * * 1</code> = 8 AM every Monday<br>
|
||||||
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM
|
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM<br>
|
||||||
|
<a href="https://crontab.guru/" target="_blank" style="color: var(--primary);">Use crontab.guru to build your schedule</a>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2578,7 +2579,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function editPlaylistSchedule(playlistName, currentSchedule) {
|
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;
|
if (!newSchedule || newSchedule === currentSchedule) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user