From 1492778b14059d7e3a79f31331b0536f252cd39e Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 3 Feb 2026 18:27:29 -0500 Subject: [PATCH] UI fixes: Match per playlist, Match All button, local/external labels, preserve tab on reload --- allstarr/Controllers/AdminController.cs | 87 +++++++++++++++++++++++-- allstarr/wwwroot/index.html | 72 ++++++++++++++------ 2 files changed, 135 insertions(+), 24 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 0ab62a0..81cdeff 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -278,19 +278,95 @@ public class AdminController : ControllerBase } /// - /// Get tracks for a specific playlist + /// Get tracks for a specific playlist with local/external status /// [HttpGet("playlists/{name}/tracks")] public async Task GetPlaylistTracks(string name) { var decodedName = Uri.UnescapeDataString(name); - var tracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName); + // Get Spotify tracks + var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName); + + // Get the playlist config to find Jellyfin ID + var playlistConfig = _spotifyImportSettings.Playlists + .FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase)); + + var tracksWithStatus = new List(); + + if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId)) + { + // Get existing tracks from Jellyfin to determine local/external status + var userId = _jellyfinSettings.UserId; + if (!string.IsNullOrEmpty(userId)) + { + try + { + var url = $"{_jellyfinSettings.Url}/Playlists/{playlistConfig.JellyfinId}/Items?UserId={userId}"; + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + + var response = await _jellyfinHttpClient.SendAsync(request); + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var localSpotifyIds = new HashSet(); + if (doc.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + if (item.TryGetProperty("ProviderIds", out var providerIds) && + providerIds.TryGetProperty("Spotify", out var spotifyId)) + { + var id = spotifyId.GetString(); + if (!string.IsNullOrEmpty(id)) + { + localSpotifyIds.Add(id); + } + } + } + } + + // Mark tracks as local or external + foreach (var track in spotifyTracks) + { + tracksWithStatus.Add(new + { + position = track.Position, + title = track.Title, + artists = track.Artists, + album = track.Album, + isrc = track.Isrc, + spotifyId = track.SpotifyId, + durationMs = track.DurationMs, + albumArtUrl = track.AlbumArtUrl, + isLocal = localSpotifyIds.Contains(track.SpotifyId) + }); + } + + return Ok(new + { + name = decodedName, + trackCount = spotifyTracks.Count, + tracks = tracksWithStatus + }); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get local track status for {Playlist}", decodedName); + } + } + } + + // Fallback: return tracks without local/external status return Ok(new { name = decodedName, - trackCount = tracks.Count, - tracks = tracks.Select(t => new + trackCount = spotifyTracks.Count, + tracks = spotifyTracks.Select(t => new { position = t.Position, title = t.Title, @@ -299,7 +375,8 @@ public class AdminController : ControllerBase isrc = t.Isrc, spotifyId = t.SpotifyId, durationMs = t.DurationMs, - albumArtUrl = t.AlbumArtUrl + albumArtUrl = t.AlbumArtUrl, + isLocal = (bool?)null // Unknown }) }); } diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index af966a6..26edaef 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -911,16 +911,35 @@ document.getElementById('restart-banner').classList.remove('active'); } - // Tab switching + // Tab switching with URL hash support + function switchTab(tabName) { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + + const tab = document.querySelector(`.tab[data-tab="${tabName}"]`); + const content = document.getElementById('tab-' + tabName); + + if (tab && content) { + tab.classList.add('active'); + content.classList.add('active'); + window.location.hash = tabName; + } + } + document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { - document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); - tab.classList.add('active'); - document.getElementById('tab-' + tab.dataset.tab).classList.add('active'); + switchTab(tab.dataset.tab); }); }); + // Restore tab from URL hash on page load + window.addEventListener('load', () => { + const hash = window.location.hash.substring(1); + if (hash) { + switchTab(hash); + } + }); + // Toast notification function showToast(message, type = 'success') { const toast = document.createElement('div'); @@ -1345,7 +1364,9 @@ const data = await res.json(); if (res.ok) { - showToast(data.message, 'success'); + showToast(`✓ ${data.message}`, 'success'); + // Refresh the playlists table after a delay to show updated counts + setTimeout(fetchPlaylists, 2000); } else { showToast(data.error || 'Failed to match tracks', 'error'); } @@ -1355,13 +1376,17 @@ } async function matchAllPlaylists() { + if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return; + try { showToast('Matching tracks for all playlists...', 'success'); const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' }); const data = await res.json(); if (res.ok) { - showToast(data.message, 'success'); + showToast(`✓ ${data.message}`, 'success'); + // Refresh the playlists table after a delay to show updated counts + setTimeout(fetchPlaylists, 3000); } else { showToast(data.error || 'Failed to match tracks', 'error'); } @@ -1516,19 +1541,28 @@ return; } - document.getElementById('tracks-list').innerHTML = data.tracks.map(t => ` -
- ${t.position + 1} -
-

${escapeHtml(t.title)}

- ${escapeHtml(t.artists.join(', '))} + document.getElementById('tracks-list').innerHTML = data.tracks.map(t => { + let statusBadge = ''; + if (t.isLocal === true) { + statusBadge = 'Local'; + } else if (t.isLocal === false) { + statusBadge = 'External'; + } + + return ` +
+ ${t.position + 1} +
+

${escapeHtml(t.title)}${statusBadge}

+ ${escapeHtml(t.artists.join(', '))} +
+
+ ${t.album ? escapeHtml(t.album) : ''} + ${t.isrc ? '
ISRC: ' + t.isrc + '' : ''} +
-
- ${t.album ? escapeHtml(t.album) : ''} - ${t.isrc ? '
ISRC: ' + t.isrc + '' : ''} -
-
- `).join(''); + `; + }).join(''); } catch (error) { document.getElementById('tracks-list').innerHTML = '

Failed to load tracks

'; }