mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-25 03:12:54 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ebdd8d4e2a
|
+3
-15
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:\n• Clear 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:\n• Fetch 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
|
||||
|
||||
Reference in New Issue
Block a user