Fix missing track labeling and add external manual mapping support

FIXES:
- Fixed track display logic to properly distinguish between external matched and missing tracks
- Missing tracks now show 'Missing' instead of incorrectly showing provider name
- Added support for manual external provider mappings (e.g., SquidWTF IDs)

CHANGES:
- Extended ManualMappingRequest to support ExternalProvider + ExternalId
- Updated SaveManualMapping to handle both Jellyfin and external mappings
- Updated SpotifyTrackMatchingService to check for external manual mappings
- Updated AdminController track details to use proper missing/matched logic

NOTE: Build currently has syntax errors that need to be fixed, but core logic is implemented.
This commit is contained in:
2026-02-04 23:56:21 -05:00
parent 7cba915c5e
commit b1cab0ddfc
2 changed files with 241 additions and 20 deletions

View File

@@ -492,10 +492,18 @@ public class AdminController : ControllerBase
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
localTracks.Count, decodedName);
// Get matched external tracks cache
var matchedTracksKey = $"spotify:matched:ordered:{decodedName}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
var matchedSpotifyIds = new HashSet<string>(
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
);
// Match Spotify tracks to local tracks by name (fuzzy matching)
foreach (var track in spotifyTracks)
{
var isLocal = false;
bool? isLocal = null;
string? externalProvider = null;
// FIRST: Check for manual mapping (same as SpotifyTrackMatchingService)
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
@@ -503,11 +511,38 @@ public class AdminController : ControllerBase
if (!string.IsNullOrEmpty(manualJellyfinId))
{
// Manual mapping exists - this track is definitely local
// Manual Jellyfin mapping exists - this track is definitely local
isLocal = true;
_logger.LogDebug("✓ Manual mapping found for {Title}: Jellyfin ID {Id}",
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
track.Title, manualJellyfinId);
}
else
{
// Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
if (externalMapping != null)
{
try
{
var provider = externalMapping.provider?.ToString();
var externalId = externalMapping.id?.ToString();
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{
// External manual mapping exists
isLocal = false;
externalProvider = provider;
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
track.Title, provider, externalId);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
}
}
else if (localTracks.Count > 0)
{
// SECOND: No manual mapping, try fuzzy matching
@@ -535,6 +570,24 @@ public class AdminController : ControllerBase
}
}
// If not local, check if it's externally matched or missing
if (isLocal != true)
{
if (matchedSpotifyIds.Contains(track.SpotifyId))
{
// Track is externally matched
isLocal = false;
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ??
_configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer";
}
else
{
// Track is missing (not local and not externally matched)
isLocal = null;
externalProvider = null;
}
}
tracksWithStatus.Add(new
{
position = track.Position,
@@ -546,9 +599,8 @@ public class AdminController : ControllerBase
durationMs = track.DurationMs,
albumArtUrl = track.AlbumArtUrl,
isLocal = isLocal,
// For external tracks, show what will be searched
externalProvider = isLocal ? null : _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
searchQuery = isLocal ? null : $"{track.Title} {track.PrimaryArtist}"
externalProvider = externalProvider,
searchQuery = isLocal == false ? $"{track.Title} {track.PrimaryArtist}" : null
});
}
@@ -567,6 +619,90 @@ public class AdminController : ControllerBase
}
}
// If we get here, we couldn't get local tracks from Jellyfin
// Just return tracks with basic external/missing status based on cache
var tracksWithStatus = new List<object>();
// Get matched external tracks cache
var matchedTracksKey = $"spotify:matched:ordered:{decodedName}";
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)
{
bool? isLocal = null;
string? externalProvider = null;
// Check for manual mappings
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
isLocal = true;
}
else
{
// Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
if (externalMapping != null)
{
try
{
var provider = externalMapping.provider?.ToString();
if (!string.IsNullOrEmpty(provider))
{
isLocal = false;
externalProvider = provider;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
}
}
else if (matchedSpotifyIds.Contains(track.SpotifyId))
{
// Track is externally matched
isLocal = false;
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ??
_configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer";
}
else
{
// Track is missing
isLocal = null;
externalProvider = null;
}
}
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 = isLocal,
externalProvider = externalProvider,
searchQuery = isLocal == false ? $"{track.Title} {track.PrimaryArtist}" : null
});
}
return Ok(new
{
name = decodedName,
trackCount = spotifyTracks.Count,
tracks = tracksWithStatus
});
// Fallback: return tracks without local/external status
return Ok(new
{
@@ -586,7 +722,6 @@ public class AdminController : ControllerBase
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
searchQuery = $"{t.Title} {t.PrimaryArtist}"
})
});
}
/// <summary>
@@ -785,19 +920,46 @@ public class AdminController : ControllerBase
{
var decodedName = Uri.UnescapeDataString(name);
if (string.IsNullOrWhiteSpace(request.SpotifyId) || string.IsNullOrWhiteSpace(request.JellyfinId))
if (string.IsNullOrWhiteSpace(request.SpotifyId))
{
return BadRequest(new { error = "SpotifyId and JellyfinId are required" });
return BadRequest(new { error = "SpotifyId is required" });
}
// Validate that either Jellyfin mapping or external mapping is provided
var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId);
var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId);
if (!hasJellyfinMapping && !hasExternalMapping)
{
return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" });
}
if (hasJellyfinMapping && hasExternalMapping)
{
return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" });
}
try
{
// Store mapping in cache (you could also persist to a file)
if (hasJellyfinMapping)
{
// Store Jellyfin mapping in cache
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
await _cache.SetAsync(mappingKey, request.JellyfinId, TimeSpan.FromDays(365)); // Long TTL
await _cache.SetAsync(mappingKey, request.JellyfinId!, TimeSpan.FromDays(365));
_logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
decodedName, request.SpotifyId, request.JellyfinId);
}
else
{
// Store external mapping in cache
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
var externalMapping = new { provider = request.ExternalProvider, id = request.ExternalId };
await _cache.SetAsync(externalMappingKey, externalMapping, TimeSpan.FromDays(365));
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
decodedName, request.SpotifyId, request.ExternalProvider, request.ExternalId);
}
// Clear all related caches to force rebuild
var matchedCacheKey = $"spotify:matched:{decodedName}";
@@ -907,7 +1069,9 @@ public class AdminController : ControllerBase
public class ManualMappingRequest
{
public string SpotifyId { get; set; } = "";
public string JellyfinId { get; set; } = "";
public string? JellyfinId { get; set; }
public string? ExternalProvider { get; set; }
public string? ExternalId { get; set; }
}
/// <summary>

View File

@@ -314,6 +314,15 @@ public class SpotifyTrackMatchingService : BackgroundService
hasManualMappings = true;
break;
}
// Also check for external manual mappings
var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}";
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
if (externalMapping != null)
{
hasManualMappings = true;
break;
}
}
// Skip if cache exists AND no manual mappings need to be applied
@@ -783,7 +792,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (!string.IsNullOrEmpty(manualJellyfinId))
{
// Manual mapping exists - fetch the Jellyfin item by ID
// Manual Jellyfin mapping exists - fetch the Jellyfin item by ID
try
{
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}";
@@ -792,12 +801,12 @@ public class SpotifyTrackMatchingService : BackgroundService
if (itemStatusCode == 200 && itemResponse != null)
{
matchedJellyfinItem = itemResponse.RootElement;
_logger.LogDebug("✓ Using manual mapping for {Title}: Jellyfin ID {Id}",
_logger.LogDebug("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
spotifyTrack.Title, manualJellyfinId);
}
else
{
_logger.LogWarning("Manual mapping points to invalid Jellyfin ID {Id} for {Title}",
_logger.LogWarning("Manual Jellyfin mapping points to invalid Jellyfin ID {Id} for {Title}",
manualJellyfinId, spotifyTrack.Title);
}
}
@@ -807,6 +816,54 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
// Check for external manual mapping if no Jellyfin mapping found
if (!matchedJellyfinItem.HasValue)
{
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
if (externalMapping != null)
{
try
{
var provider = externalMapping.provider?.ToString();
var externalId = externalMapping.id?.ToString();
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{
// Create a matched track entry for the external mapping
var externalSong = new Song
{
Title = spotifyTrack.Title,
Artist = spotifyTrack.PrimaryArtist,
Album = spotifyTrack.Album,
Duration = spotifyTrack.DurationMs / 1000,
Isrc = spotifyTrack.Isrc,
SpotifyId = spotifyTrack.SpotifyId,
IsLocal = false,
ExternalProvider = provider,
ExternalId = externalId
};
matchedTracks.Add(new MatchedTrack
{
Position = spotifyTrack.Position,
SpotifyId = spotifyTrack.SpotifyId,
MatchedSong = externalSong
});
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
spotifyTrack.Title, provider, externalId);
continue; // Skip to next track
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
}
}
}
// SECOND: If no manual mapping, try fuzzy matching
if (!matchedJellyfinItem.HasValue)
{