Fix missing track labeling and add external manual mapping support

- Fixed syntax errors in AdminController.cs (missing braces, duplicate code)
- Implemented proper track status logic to distinguish between:
  * Local tracks: isLocal=true, externalProvider=null
  * External matched tracks: isLocal=false, externalProvider='SquidWTF'
  * Missing tracks: isLocal=null, externalProvider=null
- Added external manual mapping support for SquidWTF/Deezer/Qobuz IDs
- Updated frontend UI with dual mapping modes (Jellyfin vs External)
- Extended ManualMappingRequest class with ExternalProvider + ExternalId fields
- Updated SpotifyTrackMatchingService to handle external manual mappings
- Fixed variable name conflicts and dynamic argument casting issues
- All tests passing (225/225)

Resolves issue where missing tracks incorrectly showed provider name instead of 'Missing' status.
This commit is contained in:
2026-02-05 00:15:23 -05:00
parent b1cab0ddfc
commit 400ea31477
3 changed files with 209 additions and 134 deletions

View File

@@ -535,7 +535,7 @@ public class AdminController : ControllerBase
isLocal = false; isLocal = false;
externalProvider = provider; externalProvider = provider;
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}", _logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
track.Title, provider, externalId); track.Title, (object)provider, (object)externalId);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -544,29 +544,30 @@ public class AdminController : ControllerBase
} }
} }
else if (localTracks.Count > 0) else if (localTracks.Count > 0)
{
// SECOND: No manual mapping, try fuzzy matching
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; // SECOND: No manual mapping, try fuzzy matching
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;
}
} }
} }
@@ -575,14 +576,13 @@ public class AdminController : ControllerBase
{ {
if (matchedSpotifyIds.Contains(track.SpotifyId)) if (matchedSpotifyIds.Contains(track.SpotifyId))
{ {
// Track is externally matched // Track is externally matched (search succeeded)
isLocal = false; isLocal = false;
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ?? externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
_configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer";
} }
else else
{ {
// Track is missing (not local and not externally matched) // Track is missing (search failed)
isLocal = null; isLocal = null;
externalProvider = null; externalProvider = null;
} }
@@ -621,13 +621,10 @@ public class AdminController : ControllerBase
// If we get here, we couldn't get local tracks from Jellyfin // If we get here, we couldn't get local tracks from Jellyfin
// Just return tracks with basic external/missing status based on cache // Just return tracks with basic external/missing status based on cache
var tracksWithStatus = new List<object>(); var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}";
var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey);
// Get matched external tracks cache var fallbackMatchedSpotifyIds = new HashSet<string>(
var matchedTracksKey = $"spotify:matched:ordered:{decodedName}"; fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
var matchedSpotifyIds = new HashSet<string>(
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
); );
foreach (var track in spotifyTracks) foreach (var track in spotifyTracks)
@@ -665,16 +662,15 @@ public class AdminController : ControllerBase
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title); _logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
} }
} }
else if (matchedSpotifyIds.Contains(track.SpotifyId)) else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
{ {
// Track is externally matched // Track is externally matched (search succeeded)
isLocal = false; isLocal = false;
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ?? externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
_configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer";
} }
else else
{ {
// Track is missing // Track is missing (search failed)
isLocal = null; isLocal = null;
externalProvider = null; externalProvider = null;
} }
@@ -702,26 +698,6 @@ public class AdminController : ControllerBase
trackCount = spotifyTracks.Count, trackCount = spotifyTracks.Count,
tracks = tracksWithStatus tracks = tracksWithStatus
}); });
// Fallback: return tracks without local/external status
return Ok(new
{
name = decodedName,
trackCount = spotifyTracks.Count,
tracks = spotifyTracks.Select(t => new
{
position = t.Position,
title = t.Title,
artists = t.Artists,
album = t.Album,
isrc = t.Isrc,
spotifyId = t.SpotifyId,
durationMs = t.DurationMs,
albumArtUrl = t.AlbumArtUrl,
isLocal = (bool?)null, // Unknown
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
searchQuery = $"{t.Title} {t.PrimaryArtist}"
})
} }
/// <summary> /// <summary>
@@ -1066,14 +1042,6 @@ public class AdminController : ControllerBase
} }
} }
public class ManualMappingRequest
{
public string SpotifyId { get; set; } = "";
public string? JellyfinId { get; set; }
public string? ExternalProvider { get; set; }
public string? ExternalId { get; set; }
}
/// <summary> /// <summary>
/// Trigger track matching for all playlists /// Trigger track matching for all playlists
/// </summary> /// </summary>
@@ -2461,6 +2429,14 @@ public class AdminController : ControllerBase
#endregion #endregion
} }
public class ManualMappingRequest
{
public string SpotifyId { get; set; } = "";
public string? JellyfinId { get; set; }
public string? ExternalProvider { get; set; }
public string? ExternalId { get; set; }
}
public class ConfigUpdateRequest public class ConfigUpdateRequest
{ {
public Dictionary<string, string> Updates { get; set; } = new(); public Dictionary<string, string> Updates { get; set; } = new();

View File

@@ -839,7 +839,6 @@ public class SpotifyTrackMatchingService : BackgroundService
Album = spotifyTrack.Album, Album = spotifyTrack.Album,
Duration = spotifyTrack.DurationMs / 1000, Duration = spotifyTrack.DurationMs / 1000,
Isrc = spotifyTrack.Isrc, Isrc = spotifyTrack.Isrc,
SpotifyId = spotifyTrack.SpotifyId,
IsLocal = false, IsLocal = false,
ExternalProvider = provider, ExternalProvider = provider,
ExternalId = externalId ExternalId = externalId
@@ -853,7 +852,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}); });
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}", _logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
spotifyTrack.Title, provider, externalId); spotifyTrack.Title, (object)provider, (object)externalId);
continue; // Skip to next track continue; // Skip to next track
} }
} }

View File

@@ -895,10 +895,12 @@
<!-- Manual Track Mapping Modal --> <!-- Manual Track Mapping Modal -->
<div class="modal" id="manual-map-modal"> <div class="modal" id="manual-map-modal">
<div class="modal-content" style="max-width: 600px;"> <div class="modal-content" style="max-width: 600px;">
<h3>Map Track to Local File</h3> <h3>Map Track</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;"> <p style="color: var(--text-secondary); margin-bottom: 16px;">
This track is currently using an external provider. Search for and select the local Jellyfin track to use instead. Map this track to either a local Jellyfin track or provide an external provider ID.
</p> </p>
<!-- Track Info -->
<div class="form-group"> <div class="form-group">
<label>Spotify Track (Position <span id="map-position"></span>)</label> <label>Spotify Track (Position <span id="map-position"></span>)</label>
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;"> <div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
@@ -906,26 +908,61 @@
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span> <span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
</div> </div>
</div> </div>
<!-- Mapping Type Selection -->
<div class="form-group"> <div class="form-group">
<label>Search Jellyfin Tracks</label> <label>Mapping Type</label>
<input type="text" id="map-search-query" placeholder="Search by title, artist, or album..." oninput="searchJellyfinTracks()"> <select id="map-type-select" onchange="toggleMappingType()" style="width: 100%;">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;"> <option value="jellyfin">Map to Local Jellyfin Track</option>
Tip: Use commas to search multiple terms (e.g., "It Ain't Easy, David Bowie") <option value="external">Map to External Provider ID</option>
</small> </select>
</div> </div>
<div style="text-align: center; color: var(--text-secondary); margin: 12px 0;">— OR —</div>
<div class="form-group"> <!-- Jellyfin Mapping Section -->
<label>Paste Jellyfin Track URL</label> <div id="jellyfin-mapping-section">
<input type="text" id="map-jellyfin-url" placeholder="https://jellyfin.example.com/web/#/details?id=..." oninput="extractJellyfinId()"> <div class="form-group">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;"> <label>Search Jellyfin Tracks</label>
Paste the full URL from your Jellyfin web interface <input type="text" id="map-search-query" placeholder="Search by title, artist, or album..." oninput="searchJellyfinTracks()">
</small> <small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Tip: Use commas to search multiple terms (e.g., "It Ain't Easy, David Bowie")
</small>
</div>
<div style="text-align: center; color: var(--text-secondary); margin: 12px 0;">— OR —</div>
<div class="form-group">
<label>Paste Jellyfin Track URL</label>
<input type="text" id="map-jellyfin-url" placeholder="https://jellyfin.example.com/web/#/details?id=..." oninput="extractJellyfinId()">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Paste the full URL from your Jellyfin web interface
</small>
</div>
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
Type to search for local tracks or paste a Jellyfin URL...
</p>
</div>
</div> </div>
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
<p style="text-align: center; color: var(--text-secondary); padding: 20px;"> <!-- External Mapping Section -->
Type to search for local tracks or paste a Jellyfin URL... <div id="external-mapping-section" style="display: none;">
</p> <div class="form-group">
<label>External Provider</label>
<select id="map-external-provider" style="width: 100%;">
<option value="SquidWTF">SquidWTF</option>
<option value="Deezer">Deezer</option>
<option value="Qobuz">Qobuz</option>
</select>
</div>
<div class="form-group">
<label>External Provider ID</label>
<input type="text" id="map-external-id" placeholder="Enter the provider-specific track ID..." oninput="validateExternalMapping()">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
For SquidWTF: Use the track ID from the search results or URL<br>
For Deezer: Use the track ID from Deezer URLs<br>
For Qobuz: Use the track ID from Qobuz URLs
</small>
</div>
</div> </div>
<input type="hidden" id="map-playlist-name"> <input type="hidden" id="map-playlist-name">
<input type="hidden" id="map-spotify-id"> <input type="hidden" id="map-spotify-id">
<input type="hidden" id="map-selected-jellyfin-id"> <input type="hidden" id="map-selected-jellyfin-id">
@@ -1892,20 +1929,6 @@
// Manual track mapping // Manual track mapping
let searchTimeout = null; let searchTimeout = null;
function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('map-playlist-name').value = playlistName;
document.getElementById('map-position').textContent = position + 1;
document.getElementById('map-spotify-title').textContent = title;
document.getElementById('map-spotify-artist').textContent = artist;
document.getElementById('map-spotify-id').value = spotifyId;
document.getElementById('map-search-query').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-save-btn').disabled = true;
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
openModal('manual-map-modal');
}
async function searchJellyfinTracks() { async function searchJellyfinTracks() {
const query = document.getElementById('map-search-query').value.trim(); const query = document.getElementById('map-search-query').value.trim();
@@ -2035,15 +2058,93 @@
document.getElementById('map-save-btn').disabled = false; document.getElementById('map-save-btn').disabled = false;
} }
// Toggle between Jellyfin and external mapping modes
function toggleMappingType() {
const mappingType = document.getElementById('map-type-select').value;
const jellyfinSection = document.getElementById('jellyfin-mapping-section');
const externalSection = document.getElementById('external-mapping-section');
const saveBtn = document.getElementById('map-save-btn');
if (mappingType === 'jellyfin') {
jellyfinSection.style.display = 'block';
externalSection.style.display = 'none';
// Reset external fields
document.getElementById('map-external-id').value = '';
// Check if Jellyfin track is selected
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
saveBtn.disabled = !jellyfinId;
} else {
jellyfinSection.style.display = 'none';
externalSection.style.display = 'block';
// Reset Jellyfin fields
document.getElementById('map-search-query').value = '';
document.getElementById('map-jellyfin-url').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Enter an external provider ID above</p>';
// Check if external mapping is valid
validateExternalMapping();
}
}
// Validate external mapping input
function validateExternalMapping() {
const externalId = document.getElementById('map-external-id').value.trim();
const saveBtn = document.getElementById('map-save-btn');
// Enable save button if external ID is provided
saveBtn.disabled = !externalId;
}
// Update the openManualMap function to reset the modal state
function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('map-playlist-name').value = playlistName;
document.getElementById('map-position').textContent = position + 1;
document.getElementById('map-spotify-title').textContent = title;
document.getElementById('map-spotify-artist').textContent = artist;
document.getElementById('map-spotify-id').value = spotifyId;
// Reset to Jellyfin mapping mode
document.getElementById('map-type-select').value = 'jellyfin';
document.getElementById('jellyfin-mapping-section').style.display = 'block';
document.getElementById('external-mapping-section').style.display = 'none';
// Reset all fields
document.getElementById('map-search-query').value = '';
document.getElementById('map-jellyfin-url').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-external-id').value = '';
document.getElementById('map-external-provider').value = 'SquidWTF';
document.getElementById('map-save-btn').disabled = true;
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
openModal('manual-map-modal');
}
// Update the saveManualMapping function to handle both types
async function saveManualMapping() { async function saveManualMapping() {
const playlistName = document.getElementById('map-playlist-name').value; const playlistName = document.getElementById('map-playlist-name').value;
const spotifyId = document.getElementById('map-spotify-id').value; const spotifyId = document.getElementById('map-spotify-id').value;
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value; const mappingType = document.getElementById('map-type-select').value;
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
if (!jellyfinId) { let requestBody = { spotifyId };
showToast('Please select a track', 'error');
return; if (mappingType === 'jellyfin') {
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
if (!jellyfinId) {
showToast('Please select a track', 'error');
return;
}
requestBody.jellyfinId = jellyfinId;
} else {
const externalProvider = document.getElementById('map-external-provider').value;
const externalId = document.getElementById('map-external-id').value.trim();
if (!externalId) {
showToast('Please enter an external provider ID', 'error');
return;
}
requestBody.externalProvider = externalProvider;
requestBody.externalId = externalId;
} }
// Show loading state // Show loading state
@@ -2059,7 +2160,7 @@
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', { const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotifyId, jellyfinId }), body: JSON.stringify(requestBody),
signal: controller.signal signal: controller.signal
}); });
@@ -2067,7 +2168,8 @@
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
showToast('✓ Track mapped successfully - rebuilding playlist...', 'success'); const mappingTypeText = mappingType === 'jellyfin' ? 'local Jellyfin track' : `${requestBody.externalProvider} ID`;
showToast(`✓ Track mapped to ${mappingTypeText} - rebuilding playlist...`, 'success');
closeModal('manual-map-modal'); closeModal('manual-map-modal');
// Show rebuilding indicator // Show rebuilding indicator
@@ -2075,38 +2177,36 @@
// Show detailed info toast after a moment // Show detailed info toast after a moment
setTimeout(() => { setTimeout(() => {
showToast('🔄 Searching external providers to rebuild playlist with your manual mapping...', 'info', 8000); if (mappingType === 'jellyfin') {
showToast('🔄 Rebuilding playlist with your local track mapping...', 'info', 8000);
} else {
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
}
}, 1000); }, 1000);
// Update the track in the UI without refreshing // Update the track in the UI without refreshing
if (data.track) { const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`); if (trackItem) {
if (trackItem) { const titleEl = trackItem.querySelector('.track-info h4');
// Update the track info if (titleEl && mappingType === 'jellyfin' && data.track) {
const titleEl = trackItem.querySelector('.track-info h4'); // For Jellyfin mappings, update with actual track info
const titleText = data.track.title;
const newStatusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
titleEl.innerHTML = escapeHtml(titleText) + newStatusBadge;
const artistEl = trackItem.querySelector('.track-info .artists'); const artistEl = trackItem.querySelector('.track-info .artists');
const statusBadge = trackItem.querySelector('.status-badge');
const mapButton = trackItem.querySelector('.map-track-btn');
const searchLink = trackItem.querySelector('.track-meta a');
if (titleEl) {
// Remove the old status badge and map button, add new content
const titleText = data.track.title;
const newStatusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
titleEl.innerHTML = escapeHtml(titleText) + newStatusBadge;
}
if (artistEl) artistEl.textContent = data.track.artist; if (artistEl) artistEl.textContent = data.track.artist;
} else if (titleEl && mappingType === 'external') {
// Remove the search link since it's now local // For external mappings, update status badge to show provider
if (searchLink) { const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
const metaEl = trackItem.querySelector('.track-meta'); const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(requestBody.externalProvider)}</span>`;
if (metaEl) { titleEl.innerHTML = escapeHtml(currentTitle) + newStatusBadge;
// Keep album and ISRC, remove search link }
const albumText = data.track.album ? escapeHtml(data.track.album) : '';
metaEl.innerHTML = albumText; // Remove search link since it's now mapped
} const searchLink = trackItem.querySelector('.track-meta a');
} if (searchLink) {
searchLink.remove();
} }
} }