Compare commits

...

1 Commits

Author SHA1 Message Date
joshpatra ebdd8d4e2a v1.0.2-beta.1: WebUI refactored for better understanding, gitignore updated
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 23:17:08 -05:00
6 changed files with 121 additions and 57 deletions
+3 -15
View File
@@ -83,21 +83,9 @@ cache/
# Docker volumes
redis-data/
# API keys and specs (ignore markdown docs, keep OpenAPI spec)
apis/steering/
apis/api-calls/*.json
!apis/api-calls/jellyfin-openapi-stable.json
apis/temp.json
# Temporary documentation files
apis/*.md
# Log files for debugging
apis/api-calls/*.log
# Endpoint usage tracking
apis/api-calls/endpoint-usage.json
/app/cache/endpoint-usage/
# Ignore everything in apis folder except jellyfin-openapi-stable.json
apis/*
!apis/jellyfin-openapi-stable.json
# Original source code for reference
originals/
+37 -14
View File
@@ -951,13 +951,14 @@ public class AdminController : ControllerBase
}
/// <summary>
/// Trigger track matching for a specific playlist
/// Re-match tracks when LOCAL library has changed (checks if Jellyfin playlist changed).
/// This is a lightweight operation that reuses cached Spotify data.
/// </summary>
[HttpPost("playlists/{name}/match")]
public async Task<IActionResult> MatchPlaylistTracks(string name)
{
var decodedName = Uri.UnescapeDataString(name);
_logger.LogInformation("Manual track matching triggered for playlist: {Name}", decodedName);
_logger.LogInformation("Re-match tracks triggered for playlist: {Name} (checking for local changes)", decodedName);
if (_matchingService == null)
{
@@ -966,12 +967,31 @@ public class AdminController : ControllerBase
try
{
// Clear the Jellyfin playlist signature cache to force re-checking if local tracks changed
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{decodedName}";
await _cache.DeleteAsync(jellyfinSignatureCacheKey);
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
// Clear the matched results cache to force re-matching
var matchedTracksKey = $"spotify:matched:ordered:{decodedName}";
await _cache.DeleteAsync(matchedTracksKey);
_logger.LogDebug("Cleared matched tracks cache");
// Clear the playlist items cache
var playlistItemsCacheKey = $"spotify:playlist:items:{decodedName}";
await _cache.DeleteAsync(playlistItemsCacheKey);
_logger.LogDebug("Cleared playlist items cache");
// Trigger matching (will use cached Spotify data if still valid)
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
// Invalidate playlist summary cache
InvalidatePlaylistSummaryCache();
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
return Ok(new {
message = $"Re-matching tracks for {decodedName} (checking local changes)",
timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
@@ -981,13 +1001,14 @@ public class AdminController : ControllerBase
}
/// <summary>
/// Clear cache and rebuild for a specific playlist
/// Rebuild playlist from scratch when REMOTE (Spotify) playlist has changed.
/// Clears all caches including Spotify data and forces fresh fetch.
/// </summary>
[HttpPost("playlists/{name}/clear-cache")]
public async Task<IActionResult> ClearPlaylistCache(string name)
{
var decodedName = Uri.UnescapeDataString(name);
_logger.LogInformation("Clear cache & rebuild triggered for playlist: {Name}", decodedName);
_logger.LogInformation("Rebuild from scratch triggered for playlist: {Name} (clearing Spotify cache)", decodedName);
if (_matchingService == null)
{
@@ -996,13 +1017,15 @@ public class AdminController : ControllerBase
try
{
// Clear all cache keys for this playlist
// Clear ALL cache keys for this playlist (including Spotify data)
var cacheKeys = new[]
{
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
$"spotify:matched:{decodedName}", // Legacy matched tracks
$"spotify:missing:{decodedName}" // Missing tracks
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
$"spotify:matched:{decodedName}", // Legacy matched tracks
$"spotify:missing:{decodedName}", // Missing tracks
$"spotify:playlist:jellyfin-signature:{decodedName}", // Jellyfin signature
$"spotify:playlist:{decodedName}" // Spotify playlist data
};
foreach (var key in cacheKeys)
@@ -1028,9 +1051,9 @@ public class AdminController : ControllerBase
}
}
_logger.LogInformation("✓ Cleared all caches for playlist: {Name}", decodedName);
_logger.LogInformation("✓ Cleared all caches for playlist: {Name} (including Spotify data)", decodedName);
// Trigger rebuild
// Trigger rebuild (will fetch fresh Spotify data)
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
// Invalidate playlist summary cache
@@ -1038,10 +1061,10 @@ public class AdminController : ControllerBase
return Ok(new
{
message = $"Cache cleared and rebuild triggered for {decodedName}",
message = $"Rebuilding {decodedName} from scratch (fetching fresh Spotify data)",
timestamp = DateTime.UtcNow,
clearedKeys = cacheKeys.Length,
clearedFiles = filesToDelete.Count(System.IO.File.Exists)
clearedFiles = filesToDelete.Count(f => System.IO.File.Exists(f))
});
}
catch (Exception ex)
+67 -11
View File
@@ -3497,26 +3497,26 @@ public class JellyfinController : ControllerBase
/// <summary>
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
/// Optimized to only re-match when Jellyfin playlist changes (cheap check).
/// </summary>
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
{
// Check Redis cache first for fast serving
// Check if Jellyfin playlist has changed (cheap API call)
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature;
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}";
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
if (cachedItems != null && cachedItems.Count > 0)
if (cachedItems != null && cachedItems.Count > 0 && !jellyfinPlaylistChanged)
{
_logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist}",
_logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist} (Jellyfin unchanged)",
cachedItems.Count, spotifyPlaylistName);
// Log sample item to verify Spotify IDs are present
if (cachedItems.Count > 0 && cachedItems[0].ContainsKey("ProviderIds"))
{
var providerIds = cachedItems[0]["ProviderIds"] as Dictionary<string, object>;
var hasSpotifyId = providerIds?.ContainsKey("Spotify") ?? false;
_logger.LogDebug("Sample cached item has Spotify ID: {HasSpotifyId}", hasSpotifyId);
}
return new JsonResult(new
{
Items = cachedItems,
@@ -3525,6 +3525,11 @@ public class JellyfinController : ControllerBase
});
}
if (jellyfinPlaylistChanged)
{
_logger.LogInformation("🔄 Jellyfin playlist changed for {Playlist} - re-matching tracks", spotifyPlaylistName);
}
// Check file cache as fallback
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
if (fileItems != null && fileItems.Count > 0)
@@ -3736,6 +3741,9 @@ public class JellyfinController : ControllerBase
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
// Cache the Jellyfin playlist signature to detect future changes
await _cache.SetAsync(jellyfinSignatureCacheKey, currentJellyfinSignature, CacheExtensions.SpotifyPlaylistItemsTTL);
// Return raw Jellyfin response format
return new JsonResult(new
{
@@ -4396,6 +4404,54 @@ public class JellyfinController : ControllerBase
}
}
/// <summary>
/// Gets a signature (hash) of the Jellyfin playlist to detect changes.
/// This is a cheap operation compared to re-matching all tracks.
/// Signature includes: track count + concatenated track IDs.
/// </summary>
private async Task<string> GetJellyfinPlaylistSignatureAsync(string playlistId)
{
try
{
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
if (!string.IsNullOrEmpty(userId))
{
playlistItemsUrl += $"&UserId={userId}";
}
var (response, _) = await _proxyService.GetJsonAsync(playlistItemsUrl, null, Request.Headers);
if (response != null && response.RootElement.TryGetProperty("Items", out var items))
{
var trackIds = new List<string>();
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("Id", out var idEl))
{
trackIds.Add(idEl.GetString() ?? "");
}
}
// Create signature: count + sorted IDs (sorted for consistency)
trackIds.Sort();
var signature = $"{trackIds.Count}:{string.Join(",", trackIds)}";
// Hash it to keep it compact
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(signature));
return Convert.ToHexString(hashBytes);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get Jellyfin playlist signature for {PlaylistId}", playlistId);
}
// Return empty string if failed (will trigger re-match)
return string.Empty;
}
/// <summary>
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
/// </summary>
@@ -267,9 +267,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
_logger.LogInformation("========================================");
// Initial fetch of all playlists on startup
await FetchAllPlaylistsAsync(stoppingToken);
// Cron-based refresh loop - only fetch when cron schedule triggers
// This prevents excess Spotify API calls
while (!stoppingToken.IsCancellationRequested)
@@ -332,7 +329,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
// Rate limiting between playlists
if (playlistName != needsRefresh.Last())
{
_logger.LogWarning("Waiting 3 seconds before next playlist to avoid rate limits...");
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", playlistName);
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
}
}
@@ -392,7 +389,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
// Wait 3 seconds between each playlist to avoid 429 TooManyRequests errors
if (config != _spotifyImportSettings.Playlists.Last())
{
_logger.LogWarning("Waiting 3 seconds before next playlist to avoid rate limits...");
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", config.Name);
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
}
}
+12 -12
View File
@@ -655,9 +655,9 @@
<h2>
Injected Spotify Playlists
<div class="actions">
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
<button onclick="matchAllPlaylists()" title="Re-match tracks when local library changed (uses cached Spotify data)">Re-match All Local</button>
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
<button onclick="refreshAndMatchAll()" title="Clear caches, fetch fresh data from Spotify, and match all tracks. This is a full rebuild and may take several minutes." style="background:var(--accent);border-color:var(--accent);">Refresh & Match All</button>
<button onclick="refreshAndMatchAll()" title="Rebuild all playlists when Spotify playlists changed (fetches fresh data and re-matches)" style="background:var(--accent);border-color:var(--accent);">Rebuild All Remote</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
@@ -1716,8 +1716,8 @@
</td>
<td class="cache-age">${p.cacheAge || '-'}</td>
<td>
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')" title="Re-match when local library changed (uses cached Spotify data)">Re-match Local</button>
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')" title="Rebuild when Spotify playlist changed (fetches fresh data)" style="background:var(--accent);border-color:var(--accent);">Rebuild Remote</button>
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
</td>
@@ -2358,18 +2358,18 @@
}
async function clearPlaylistCache(name) {
if (!confirm(`Clear cache and rebuild for "${name}"?\n\nThis will:\nClear Redis cache\n• Delete file caches\n• Rebuild with latest Spotify IDs\n\nThis may take a minute.`)) return;
if (!confirm(`Rebuild "${name}" from scratch?\n\nThis will:\nFetch fresh Spotify playlist data\n• Clear all caches\n• Re-match all tracks\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`)) return;
try {
// Show warning banner
document.getElementById('matching-warning-banner').style.display = 'block';
showToast(`Clearing cache for ${name}...`, 'info');
showToast(`Rebuilding ${name} from scratch...`, 'info');
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast(`${data.message} (Cleared ${data.clearedKeys} cache keys, ${data.clearedFiles} files)`, 'success', 5000);
showToast(`${data.message}`, 'success', 5000);
// Refresh the playlists table after a delay to show updated counts
setTimeout(() => {
fetchPlaylists();
@@ -2377,7 +2377,7 @@
document.getElementById('matching-warning-banner').style.display = 'none';
}, 3000);
} else {
showToast(data.error || 'Failed to clear cache', 'error');
showToast(data.error || 'Failed to rebuild playlist', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
} catch (error) {
@@ -2391,7 +2391,7 @@
// Show warning banner
document.getElementById('matching-warning-banner').style.display = 'block';
showToast(`Matching tracks for ${name}...`, 'success');
showToast(`Re-matching local tracks for ${name}...`, 'info');
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
const data = await res.json();
@@ -2404,17 +2404,17 @@
document.getElementById('matching-warning-banner').style.display = 'none';
}, 2000);
} else {
showToast(data.error || 'Failed to match tracks', 'error');
showToast(data.error || 'Failed to re-match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
} catch (error) {
showToast('Failed to match tracks', 'error');
showToast('Failed to re-match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
}
async function matchAllPlaylists() {
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
if (!confirm('Re-match local tracks for ALL playlists?\n\nUse this when your local library has changed.\n\nThis may take a few minutes.')) return;
try {
// Show warning banner