fix: accurate playlist counting and three-color progress bars

- Fix playlist counting logic to use fuzzy matching (same as track view)
- Count local tracks by matching Jellyfin tracks to Spotify tracks
- Count external matched tracks from cache
- Count missing tracks (not found locally or externally)
- Progress bars now show three colors:
  * Green: Local tracks in Jellyfin
  * Orange: External matched tracks (SquidWTF/Deezer/Qobuz)
  * Grey: Missing tracks (not found anywhere)
- Add 'Missing' badge to tracks that couldn't be found
- Missing tracks can still be manually mapped
- Fixes incorrect counts like '28 matched • 1 missing' showing 29 external tracks

All 225 tests pass.
This commit is contained in:
2026-02-04 17:49:10 -05:00
parent 506f39d606
commit 0937fcf163
2 changed files with 89 additions and 36 deletions

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using allstarr.Models.Settings; using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Spotify; using allstarr.Services.Spotify;
using allstarr.Services.Jellyfin; using allstarr.Services.Jellyfin;
using allstarr.Services.Common; using allstarr.Services.Common;
@@ -246,58 +247,96 @@ public class AdminController : ControllerBase
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items)) if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
{ {
var localCount = 0; // Build list of local tracks from Jellyfin (match by name only)
var externalMatchedCount = 0; var localTracks = new List<(string Title, string Artist)>();
// Count tracks in Jellyfin playlist
var jellyfinTrackCount = items.GetArrayLength();
// Count local vs external tracks
foreach (var item in items.EnumerateArray()) foreach (var item in items.EnumerateArray())
{ {
// Check if it's an external track by looking at the ID format var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
// External tracks have IDs like "deezer:123456" or "qobuz:123456" var artist = "";
var isExternal = false;
if (item.TryGetProperty("Id", out var idProp)) if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{ {
var id = idProp.GetString() ?? ""; artist = artistsEl[0].GetString() ?? "";
isExternal = id.Contains(":"); // External IDs contain provider prefix }
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
} }
if (isExternal) if (!string.IsNullOrEmpty(title))
{ {
externalMatchedCount++; localTracks.Add((title, artist));
} }
else }
// Get Spotify tracks to match against
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
// Get matched external tracks cache once
var matchedTracksKey = $"spotify:matched:ordered:{config.Name}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
var matchedSpotifyIds = new HashSet<string>(
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
);
var localCount = 0;
var externalMatchedCount = 0;
var externalMissingCount = 0;
// Match each Spotify track to determine if it's local, external, or missing
foreach (var track in spotifyTracks)
{
var isLocal = false;
if (localTracks.Count > 0)
{
var bestMatch = localTracks
.Select(local => new
{
Local = local,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
})
.Select(x => new
{
x.Local,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.OrderByDescending(x => x.TotalScore)
.FirstOrDefault();
// Use 70% threshold (same as playback matching)
if (bestMatch != null && bestMatch.TotalScore >= 70)
{
isLocal = true;
}
}
if (isLocal)
{ {
localCount++; localCount++;
} }
} else
// Check matched tracks cache to get accurate external count
// External tracks are injected on-the-fly, not stored in Jellyfin
var matchedTracksKey = $"spotify:matched:ordered:{config.Name}";
var matchedTracks = await _cache.GetAsync<List<object>>(matchedTracksKey);
if (matchedTracks != null && matchedTracks.Count > 0)
{
// Count how many matched tracks are external (not local)
// Assume tracks beyond local count are external
var totalMatched = matchedTracks.Count;
if (totalMatched > localCount)
{ {
externalMatchedCount = totalMatched - localCount; // Check if external track is matched
if (matchedSpotifyIds.Contains(track.SpotifyId))
{
externalMatchedCount++;
}
else
{
externalMissingCount++;
}
} }
} }
var totalInJellyfin = localCount + externalMatchedCount;
var externalMissingCount = Math.Max(0, spotifyTrackCount - totalInJellyfin);
playlistInfo["localTracks"] = localCount; playlistInfo["localTracks"] = localCount;
playlistInfo["externalMatched"] = externalMatchedCount; playlistInfo["externalMatched"] = externalMatchedCount;
playlistInfo["externalMissing"] = externalMissingCount; playlistInfo["externalMissing"] = externalMissingCount;
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount; playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
playlistInfo["totalInJellyfin"] = totalInJellyfin; playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
_logger.LogDebug("Playlist {Name}: {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing", _logger.LogDebug("Playlist {Name}: {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing",
config.Name, spotifyTrackCount, localCount, externalMatchedCount, externalMissingCount); config.Name, spotifyTrackCount, localCount, externalMatchedCount, externalMissingCount);

View File

@@ -1209,10 +1209,11 @@
const completionPct = spotifyTotal > 0 ? Math.round((totalInJellyfin / spotifyTotal) * 100) : 0; const completionPct = spotifyTotal > 0 ? Math.round((totalInJellyfin / spotifyTotal) * 100) : 0;
const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0; const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0;
const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0; const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0;
const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0;
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)'; const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
// Debug logging // Debug logging
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, total=${completionPct}%`); console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
return ` return `
<tr> <tr>
@@ -1223,7 +1224,8 @@
<div style="display:flex;align-items:center;gap:8px;"> <div style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;"> <div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div> <div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
<div style="width:${externalPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMatched} external tracks"></div> <div style="width:${externalPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMatched} external matched tracks"></div>
<div style="width:${missingPct}%;height:100%;background:#6b7280;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
</div> </div>
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span> <span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
</div> </div>
@@ -1757,6 +1759,18 @@
data-artist="${escapeHtml(firstArtist)}" data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}" data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`; style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
} else {
// isLocal is null/undefined - track is missing (not found locally or externally)
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
// Add manual map button for missing tracks too
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
mapButton = `<button class="small map-track-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
data-title="${escapeHtml(t.title || '')}"
data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
} }
return ` return `