mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
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:
@@ -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);
|
||||||
|
|||||||
@@ -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 `
|
||||||
|
|||||||
Reference in New Issue
Block a user