mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
feat: pre-build playlist cache and make matching interval configurable
- Pre-build playlist items cache during track matching for instant serving - Add PreBuildPlaylistItemsCacheAsync() to SpotifyTrackMatchingService - Combines local Jellyfin tracks + external matched tracks in correct Spotify order - Saves to both Redis and file cache for persistence across restarts - Change matching interval from hardcoded 30 minutes to configurable (default: 24 hours) - Add SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS environment variable - Set to 0 to only run once on startup (manual trigger still works) - Add endpoint usage files to .gitignore - Update documentation in README and .env.example Rationale: Spotify playlists like Discover Weekly update once per week, so running every 24 hours is more than sufficient. Pre-building the cache eliminates slow 'on the fly' playlist building. All 225 tests pass.
This commit is contained in:
@@ -80,6 +80,15 @@ public class SpotifyImportSettings
|
||||
/// </summary>
|
||||
public int SyncWindowHours { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// How often to run track matching in hours.
|
||||
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.
|
||||
/// Most playlists don't change frequently, so running every 24 hours is reasonable.
|
||||
/// Set to 0 to only run once on startup (manual trigger via admin UI still works).
|
||||
/// Default: 24 hours
|
||||
/// </summary>
|
||||
public int MatchingIntervalHours { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Combined playlist configuration as JSON array.
|
||||
/// Format: [["Name","Id","first|last"],...]
|
||||
|
||||
@@ -72,8 +72,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Now start the periodic matching loop
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Wait 30 minutes before next run
|
||||
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
|
||||
// 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
|
||||
{
|
||||
@@ -421,6 +428,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})",
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||
|
||||
// Pre-build playlist items cache for instant serving
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -650,4 +660,192 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
return avgScore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-builds the playlist items cache for instant serving.
|
||||
/// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order.
|
||||
/// </summary>
|
||||
private async Task PreBuildPlaylistItemsCacheAsync(
|
||||
string playlistName,
|
||||
string? jellyfinPlaylistId,
|
||||
List<SpotifyPlaylistTrack> spotifyTracks,
|
||||
List<MatchedTrack> matchedTracks,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
|
||||
|
||||
if (string.IsNullOrEmpty(jellyfinPlaylistId))
|
||||
{
|
||||
_logger.LogWarning("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing tracks from Jellyfin playlist
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
var responseBuilder = scope.ServiceProvider.GetService<JellyfinResponseBuilder>();
|
||||
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
|
||||
|
||||
if (proxyService == null || responseBuilder == null || jellyfinSettings == null)
|
||||
{
|
||||
_logger.LogWarning("Required services not available for pre-building cache");
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = jellyfinSettings.UserId;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, null);
|
||||
|
||||
if (statusCode != 200 || existingTracksResponse == null)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Index Jellyfin items by title+artist for matching
|
||||
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
|
||||
|
||||
if (existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
var key = $"{title}|{artist}".ToLowerInvariant();
|
||||
if (!jellyfinItemsByName.ContainsKey(key))
|
||||
{
|
||||
jellyfinItemsByName[key] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the final track list in correct Spotify order
|
||||
var finalItems = new List<Dictionary<string, object?>>();
|
||||
var usedJellyfinItems = new HashSet<string>();
|
||||
var localUsedCount = 0;
|
||||
var externalUsedCount = 0;
|
||||
|
||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
// Try to find matching Jellyfin item by fuzzy matching
|
||||
JsonElement? matchedJellyfinItem = null;
|
||||
string? matchedKey = null;
|
||||
double bestScore = 0;
|
||||
|
||||
foreach (var kvp in jellyfinItemsByName)
|
||||
{
|
||||
if (usedJellyfinItems.Contains(kvp.Key)) continue;
|
||||
|
||||
var item = kvp.Value;
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
|
||||
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
|
||||
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
||||
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
||||
|
||||
if (totalScore > bestScore && totalScore >= 70)
|
||||
{
|
||||
bestScore = totalScore;
|
||||
matchedJellyfinItem = item;
|
||||
matchedKey = kvp.Key;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedJellyfinItem.HasValue && matchedKey != null)
|
||||
{
|
||||
// Use the raw Jellyfin item (preserves ALL metadata)
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
finalItems.Add(itemDict);
|
||||
usedJellyfinItems.Add(matchedKey);
|
||||
localUsedCount++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No local match - try to find external track
|
||||
var matched = matchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||
if (matched != null && matched.MatchedSong != null)
|
||||
{
|
||||
// Convert external song to Jellyfin item format
|
||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
||||
finalItems.Add(externalItem);
|
||||
externalUsedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalItems.Count > 0)
|
||||
{
|
||||
// Save to Redis cache
|
||||
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
||||
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
||||
|
||||
// Save to file cache for persistence
|
||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||
|
||||
_logger.LogInformation(
|
||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No items to cache for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to pre-build playlist items cache for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves playlist items to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved {Count} playlist items to file cache: {Path}", items.Count, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"SyncStartHour": 16,
|
||||
"SyncStartMinute": 15,
|
||||
"SyncWindowHours": 2,
|
||||
"MatchingIntervalHours": 24,
|
||||
"Playlists": []
|
||||
},
|
||||
"SpotifyApi": {
|
||||
|
||||
Reference in New Issue
Block a user