mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -492,10 +492,18 @@ public class AdminController : ControllerBase
|
|||||||
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
|
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
|
||||||
localTracks.Count, decodedName);
|
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)
|
// Match Spotify tracks to local tracks by name (fuzzy matching)
|
||||||
foreach (var track in spotifyTracks)
|
foreach (var track in spotifyTracks)
|
||||||
{
|
{
|
||||||
var isLocal = false;
|
bool? isLocal = null;
|
||||||
|
string? externalProvider = null;
|
||||||
|
|
||||||
// FIRST: Check for manual mapping (same as SpotifyTrackMatchingService)
|
// FIRST: Check for manual mapping (same as SpotifyTrackMatchingService)
|
||||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||||
@@ -503,12 +511,39 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
{
|
{
|
||||||
// Manual mapping exists - this track is definitely local
|
// Manual Jellyfin mapping exists - this track is definitely local
|
||||||
isLocal = true;
|
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);
|
track.Title, manualJellyfinId);
|
||||||
}
|
}
|
||||||
else if (localTracks.Count > 0)
|
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
|
// SECOND: No manual mapping, try fuzzy matching
|
||||||
var bestMatch = localTracks
|
var bestMatch = localTracks
|
||||||
@@ -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
|
tracksWithStatus.Add(new
|
||||||
{
|
{
|
||||||
position = track.Position,
|
position = track.Position,
|
||||||
@@ -546,9 +599,8 @@ public class AdminController : ControllerBase
|
|||||||
durationMs = track.DurationMs,
|
durationMs = track.DurationMs,
|
||||||
albumArtUrl = track.AlbumArtUrl,
|
albumArtUrl = track.AlbumArtUrl,
|
||||||
isLocal = isLocal,
|
isLocal = isLocal,
|
||||||
// For external tracks, show what will be searched
|
externalProvider = externalProvider,
|
||||||
externalProvider = isLocal ? null : _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
|
searchQuery = isLocal == false ? $"{track.Title} {track.PrimaryArtist}" : null
|
||||||
searchQuery = isLocal ? null : $"{track.Title} {track.PrimaryArtist}"
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
// Fallback: return tracks without local/external status
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -586,7 +722,6 @@ public class AdminController : ControllerBase
|
|||||||
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
|
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
|
||||||
searchQuery = $"{t.Title} {t.PrimaryArtist}"
|
searchQuery = $"{t.Title} {t.PrimaryArtist}"
|
||||||
})
|
})
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -785,19 +920,46 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
var decodedName = Uri.UnescapeDataString(name);
|
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
|
try
|
||||||
{
|
{
|
||||||
// Store mapping in cache (you could also persist to a file)
|
if (hasJellyfinMapping)
|
||||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
{
|
||||||
await _cache.SetAsync(mappingKey, request.JellyfinId, TimeSpan.FromDays(365)); // Long TTL
|
// Store Jellyfin mapping in cache
|
||||||
|
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||||
_logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
await _cache.SetAsync(mappingKey, request.JellyfinId!, TimeSpan.FromDays(365));
|
||||||
decodedName, request.SpotifyId, request.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
|
// Clear all related caches to force rebuild
|
||||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||||
@@ -907,7 +1069,9 @@ public class AdminController : ControllerBase
|
|||||||
public class ManualMappingRequest
|
public class ManualMappingRequest
|
||||||
{
|
{
|
||||||
public string SpotifyId { get; set; } = "";
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -314,6 +314,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
hasManualMappings = true;
|
hasManualMappings = true;
|
||||||
break;
|
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
|
// Skip if cache exists AND no manual mappings need to be applied
|
||||||
@@ -783,7 +792,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
{
|
{
|
||||||
// Manual mapping exists - fetch the Jellyfin item by ID
|
// Manual Jellyfin mapping exists - fetch the Jellyfin item by ID
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}";
|
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}";
|
||||||
@@ -792,12 +801,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
if (itemStatusCode == 200 && itemResponse != null)
|
if (itemStatusCode == 200 && itemResponse != null)
|
||||||
{
|
{
|
||||||
matchedJellyfinItem = itemResponse.RootElement;
|
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);
|
spotifyTrack.Title, manualJellyfinId);
|
||||||
}
|
}
|
||||||
else
|
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);
|
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
|
// SECOND: If no manual mapping, try fuzzy matching
|
||||||
if (!matchedJellyfinItem.HasValue)
|
if (!matchedJellyfinItem.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user