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.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Spotify;
using allstarr.Services.Jellyfin;
using allstarr.Services.Common;
@@ -246,58 +247,96 @@ public class AdminController : ControllerBase
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
{
var localCount = 0;
var externalMatchedCount = 0;
// Count tracks in Jellyfin playlist
var jellyfinTrackCount = items.GetArrayLength();
// Count local vs external tracks
// Build list of local tracks from Jellyfin (match by name only)
var localTracks = new List<(string Title, string Artist)>();
foreach (var item in items.EnumerateArray())
{
// Check if it's an external track by looking at the ID format
// External tracks have IDs like "deezer:123456" or "qobuz:123456"
var isExternal = false;
if (item.TryGetProperty("Id", out var idProp))
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
var id = idProp.GetString() ?? "";
isExternal = id.Contains(":"); // External IDs contain provider prefix
artist = artistsEl[0].GetString() ?? "";
}
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++;
}
}
// 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)
else
{
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["externalMatched"] = externalMatchedCount;
playlistInfo["externalMissing"] = 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",
config.Name, spotifyTrackCount, localCount, externalMatchedCount, externalMissingCount);