mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 15:45:10 -05:00
Compare commits
14 Commits
6ccc6a4a0d
...
7cee0911b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
7cee0911b6
|
|||
|
a2b1eace5f
|
|||
|
ac1fbd4b34
|
|||
|
a6ac0dfbd2
|
|||
|
bb3140a247
|
|||
|
791e6a69d9
|
|||
|
3ffa09dcfa
|
|||
|
b366a4b771
|
|||
|
960d15175e
|
|||
|
1d774111e7
|
|||
|
99d701a355
|
|||
|
73509eb80b
|
|||
|
eb8e3196da
|
|||
|
401d0b4008
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -87,6 +87,7 @@ redis-data/
|
||||
apis/steering/
|
||||
apis/api-calls/*.json
|
||||
!apis/api-calls/jellyfin-openapi-stable.json
|
||||
apis/temp.json
|
||||
|
||||
# Log files for debugging
|
||||
apis/api-calls/*.log
|
||||
|
||||
@@ -374,12 +374,37 @@ public class AdminController : ControllerBase
|
||||
|
||||
foreach (var item in cachedPlaylistItems)
|
||||
{
|
||||
// Check if it's external by looking for ProviderIds (external songs have this)
|
||||
// Check if it's external by looking for external provider in ProviderIds
|
||||
// External providers: SquidWTF, Deezer, Qobuz, Tidal
|
||||
var isExternal = false;
|
||||
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
// Has ProviderIds = external track
|
||||
isExternal = true;
|
||||
// Handle both Dictionary<string, string> and JsonElement
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null)
|
||||
{
|
||||
// Check for external provider keys (not MusicBrainz, ISRC, Spotify, etc)
|
||||
isExternal = providerIds.Keys.Any(k =>
|
||||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("Deezer", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("Qobuz", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("Tidal", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
if (isExternal)
|
||||
@@ -673,72 +698,88 @@ public class AdminController : ControllerBase
|
||||
string? manualMappingType = null;
|
||||
string? manualMappingId = null;
|
||||
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
string? externalId = null;
|
||||
|
||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
if (extRoot.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
externalId = idEl.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||
{
|
||||
// External manual mapping exists
|
||||
isLocal = false;
|
||||
externalProvider = provider;
|
||||
isManualMapping = true;
|
||||
manualMappingType = "external";
|
||||
manualMappingId = externalId;
|
||||
_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);
|
||||
}
|
||||
// Manual Jellyfin mapping exists - this track is definitely local
|
||||
isLocal = true;
|
||||
isManualMapping = true;
|
||||
manualMappingType = "jellyfin";
|
||||
manualMappingId = manualJellyfinId;
|
||||
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
|
||||
track.Title, manualJellyfinId);
|
||||
}
|
||||
|
||||
// If no manual mapping, try fuzzy matching with local tracks
|
||||
if (!isManualMapping && localTracks.Count > 0)
|
||||
else
|
||||
{
|
||||
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();
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
// Use 70% threshold (same as playback matching)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
isLocal = true;
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
string? externalId = null;
|
||||
|
||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
if (extRoot.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
externalId = idEl.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||
{
|
||||
// External manual mapping exists
|
||||
isLocal = false;
|
||||
externalProvider = provider;
|
||||
isManualMapping = true;
|
||||
manualMappingType = "external";
|
||||
manualMappingId = externalId;
|
||||
_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 with local tracks
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,46 +865,57 @@ public class AdminController : ControllerBase
|
||||
bool? isLocal = null;
|
||||
string? externalProvider = null;
|
||||
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
// Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
|
||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
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 (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
// Track is externally matched (search succeeded)
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
||||
isLocal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Track is missing (search failed)
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
|
||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
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 (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
// Track is externally matched (search succeeded)
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
||||
}
|
||||
else
|
||||
{
|
||||
// Track is missing (search failed)
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
tracksWithStatus.Add(new
|
||||
@@ -935,6 +987,77 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear cache and rebuild for a specific playlist
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/clear-cache")]
|
||||
public async Task<IActionResult> ClearPlaylistCache(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Clear cache & rebuild triggered for playlist: {Name}", decodedName);
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "Track matching service is not available" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Clear all cache keys for this playlist
|
||||
var cacheKeys = new[]
|
||||
{
|
||||
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
|
||||
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
|
||||
$"spotify:matched:{decodedName}", // Legacy matched tracks
|
||||
$"spotify:missing:{decodedName}" // Missing tracks
|
||||
};
|
||||
|
||||
foreach (var key in cacheKeys)
|
||||
{
|
||||
await _cache.DeleteAsync(key);
|
||||
_logger.LogDebug("Cleared cache key: {Key}", key);
|
||||
}
|
||||
|
||||
// Delete file caches
|
||||
var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filesToDelete = new[]
|
||||
{
|
||||
Path.Combine(CacheDirectory, $"{safeName}_items.json"),
|
||||
Path.Combine(CacheDirectory, $"{safeName}_matched.json")
|
||||
};
|
||||
|
||||
foreach (var file in filesToDelete)
|
||||
{
|
||||
if (System.IO.File.Exists(file))
|
||||
{
|
||||
System.IO.File.Delete(file);
|
||||
_logger.LogDebug("Deleted cache file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ Cleared all caches for playlist: {Name}", decodedName);
|
||||
|
||||
// Trigger rebuild
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
InvalidatePlaylistSummaryCache();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = $"Cache cleared and rebuild triggered for {decodedName}",
|
||||
timestamp = DateTime.UtcNow,
|
||||
clearedKeys = cacheKeys.Length,
|
||||
clearedFiles = filesToDelete.Count(System.IO.File.Exists)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to clear cache for {Name}", decodedName);
|
||||
return StatusCode(500, new { error = "Failed to clear cache", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search Jellyfin library for tracks (for manual mapping)
|
||||
/// </summary>
|
||||
@@ -1087,8 +1210,7 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save manual external track mapping (SquidWTF/Deezer/Qobuz)
|
||||
/// Note: Local Jellyfin mappings should be done via Spotify Import plugin
|
||||
/// Save manual track mapping (local Jellyfin or external provider)
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/map")]
|
||||
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
|
||||
@@ -1100,26 +1222,50 @@ public class AdminController : ControllerBase
|
||||
return BadRequest(new { error = "SpotifyId is required" });
|
||||
}
|
||||
|
||||
// Only external mappings are supported now
|
||||
// Local Jellyfin mappings should be done via Spotify Import plugin
|
||||
if (string.IsNullOrWhiteSpace(request.ExternalProvider) || string.IsNullOrWhiteSpace(request.ExternalId))
|
||||
// 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 = "ExternalProvider and ExternalId are required. Use Spotify Import plugin for local Jellyfin mappings." });
|
||||
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 external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||
var normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
|
||||
await _cache.SetAsync(externalMappingKey, externalMapping);
|
||||
string? normalizedProvider = null;
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
||||
|
||||
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
||||
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
||||
if (hasJellyfinMapping)
|
||||
{
|
||||
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null);
|
||||
|
||||
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||
decodedName, request.SpotifyId, request.JellyfinId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
|
||||
await _cache.SetAsync(externalMappingKey, externalMapping);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
||||
|
||||
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
||||
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
||||
}
|
||||
|
||||
// Clear all related caches to force rebuild
|
||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||
@@ -1157,33 +1303,36 @@ public class AdminController : ControllerBase
|
||||
|
||||
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
||||
|
||||
// Fetch external provider track details to return to the UI
|
||||
// Fetch external provider track details to return to the UI (only for external mappings)
|
||||
string? trackTitle = null;
|
||||
string? trackArtist = null;
|
||||
string? trackAlbum = null;
|
||||
|
||||
try
|
||||
if (hasExternalMapping && normalizedProvider != null)
|
||||
{
|
||||
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
||||
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
||||
|
||||
if (externalSong != null)
|
||||
try
|
||||
{
|
||||
trackTitle = externalSong.Title;
|
||||
trackArtist = externalSong.Artist;
|
||||
trackAlbum = externalSong.Album;
|
||||
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
||||
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
||||
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
||||
|
||||
if (externalSong != null)
|
||||
{
|
||||
trackTitle = externalSong.Title;
|
||||
trackArtist = externalSong.Artist;
|
||||
trackAlbum = externalSong.Album;
|
||||
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
|
||||
normalizedProvider, request.ExternalId);
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
|
||||
normalizedProvider, request.ExternalId);
|
||||
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||
}
|
||||
|
||||
// Trigger immediate playlist rebuild with the new mapping
|
||||
if (_matchingService != null)
|
||||
@@ -3173,3 +3322,187 @@ public class LinkPlaylistRequest
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads
|
||||
/// Lists all downloaded files in the downloads directory
|
||||
/// </summary>
|
||||
[HttpGet("downloads")]
|
||||
public IActionResult GetDownloads()
|
||||
{
|
||||
try
|
||||
{
|
||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
|
||||
|
||||
if (!Directory.Exists(downloadPath))
|
||||
{
|
||||
return Ok(new { files = new List<object>(), totalSize = 0 });
|
||||
}
|
||||
|
||||
var files = new List<object>();
|
||||
long totalSize = 0;
|
||||
|
||||
// Recursively get all audio files
|
||||
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
||||
var allFiles = Directory.GetFiles(downloadPath, "*.*", SearchOption.AllDirectories)
|
||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||
.ToList();
|
||||
|
||||
foreach (var filePath in allFiles)
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
var relativePath = Path.GetRelativePath(downloadPath, filePath);
|
||||
|
||||
// Parse artist/album/track from path structure
|
||||
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var artist = parts.Length > 0 ? parts[0] : "";
|
||||
var album = parts.Length > 1 ? parts[1] : "";
|
||||
var fileName = parts.Length > 2 ? parts[^1] : Path.GetFileName(filePath);
|
||||
|
||||
files.Add(new
|
||||
{
|
||||
path = relativePath,
|
||||
fullPath = filePath,
|
||||
artist,
|
||||
album,
|
||||
fileName,
|
||||
size = fileInfo.Length,
|
||||
sizeFormatted = FormatFileSize(fileInfo.Length),
|
||||
lastModified = fileInfo.LastWriteTimeUtc,
|
||||
extension = fileInfo.Extension
|
||||
});
|
||||
|
||||
totalSize += fileInfo.Length;
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName),
|
||||
totalSize,
|
||||
totalSizeFormatted = FormatFileSize(totalSize),
|
||||
count = files.Count
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list downloads");
|
||||
return StatusCode(500, new { error = "Failed to list downloads" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/admin/downloads
|
||||
/// Deletes a specific downloaded file
|
||||
/// </summary>
|
||||
[HttpDelete("downloads")]
|
||||
public IActionResult DeleteDownload([FromQuery] string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
|
||||
var fullPath = Path.Combine(downloadPath, path);
|
||||
|
||||
// Security: Ensure the path is within the download directory
|
||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||
var normalizedDownloadPath = Path.GetFullPath(downloadPath);
|
||||
|
||||
if (!normalizedFullPath.StartsWith(normalizedDownloadPath))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
{
|
||||
return NotFound(new { error = "File not found" });
|
||||
}
|
||||
|
||||
System.IO.File.Delete(fullPath);
|
||||
_logger.LogInformation("Deleted download: {Path}", path);
|
||||
|
||||
// Clean up empty directories
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
while (directory != null && directory != downloadPath)
|
||||
{
|
||||
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
_logger.LogDebug("Deleted empty directory: {Dir}", directory);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "File deleted successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete download: {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to delete file" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads/file
|
||||
/// Downloads a specific file
|
||||
/// </summary>
|
||||
[HttpGet("downloads/file")]
|
||||
public IActionResult DownloadFile([FromQuery] string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
|
||||
var fullPath = Path.Combine(downloadPath, path);
|
||||
|
||||
// Security: Ensure the path is within the download directory
|
||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||
var normalizedDownloadPath = Path.GetFullPath(downloadPath);
|
||||
|
||||
if (!normalizedFullPath.StartsWith(normalizedDownloadPath))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
{
|
||||
return NotFound(new { error = "File not found" });
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(fullPath);
|
||||
var fileStream = System.IO.File.OpenRead(fullPath);
|
||||
|
||||
return File(fileStream, "application/octet-stream", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download file: {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to download file" });
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len = len / 1024;
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
/// <summary>
|
||||
/// Searches local Jellyfin library and external providers.
|
||||
/// Dedupes artists, combines songs/albums. Works with /Items and /Users/{userId}/Items.
|
||||
/// Combines songs/albums/artists. Works with /Items and /Users/{userId}/Items.
|
||||
/// </summary>
|
||||
[HttpGet("Items", Order = 1)]
|
||||
[HttpGet("Users/{userId}/Items", Order = 1)]
|
||||
@@ -294,14 +294,24 @@ public class JellyfinController : ControllerBase
|
||||
.Select(x => x.Item)
|
||||
.ToList();
|
||||
|
||||
// Dedupe artists by name, keeping highest scored version
|
||||
// NO deduplication - just merge and sort by relevance score
|
||||
// Show ALL matches (local + external) sorted by best match first
|
||||
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
|
||||
.GroupBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.OrderByDescending(x => x.Score).First())
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Item)
|
||||
.ToList();
|
||||
|
||||
// Log results for debugging
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var localArtistNames = scoredLocalArtists.Select(x => $"{x.Item.Name} (local, score: {x.Score:F2})").ToList();
|
||||
var externalArtistNames = scoredExternalArtists.Select(x => $"{x.Item.Name} ({x.Item.ExternalProvider}, score: {x.Score:F2})").ToList();
|
||||
_logger.LogDebug("🎤 Artist results: Local={LocalArtists}, External={ExternalArtists}, Total={TotalCount}",
|
||||
string.Join(", ", localArtistNames),
|
||||
string.Join(", ", externalArtistNames),
|
||||
artistScores.Count);
|
||||
}
|
||||
|
||||
// Convert to Jellyfin format
|
||||
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
|
||||
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
|
||||
@@ -492,20 +502,10 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||
|
||||
// Merge and convert to search hints format
|
||||
// NO deduplication - merge all results and take top matches
|
||||
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
|
||||
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
|
||||
|
||||
// Dedupe artists by name
|
||||
var artistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var allArtists = new List<Artist>();
|
||||
foreach (var artist in localArtists.Concat(externalResult.Artists))
|
||||
{
|
||||
if (artistNames.Add(artist.Name))
|
||||
{
|
||||
allArtists.Add(artist);
|
||||
}
|
||||
}
|
||||
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
|
||||
|
||||
return _responseBuilder.CreateSearchHintsResponse(
|
||||
allSongs.Take(limit).ToList(),
|
||||
@@ -680,27 +680,11 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and deduplicate by name
|
||||
var artistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var mergedArtists = new List<Artist>();
|
||||
// NO deduplication - merge all artists and sort by relevance
|
||||
// Show ALL matches (local + external) sorted by best match first
|
||||
var mergedArtists = localArtists.Concat(externalArtists).ToList();
|
||||
|
||||
foreach (var artist in localArtists)
|
||||
{
|
||||
if (artistNames.Add(artist.Name))
|
||||
{
|
||||
mergedArtists.Add(artist);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var artist in externalArtists)
|
||||
{
|
||||
if (artistNames.Add(artist.Name))
|
||||
{
|
||||
mergedArtists.Add(artist);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Returning {Count} merged artists", mergedArtists.Count);
|
||||
_logger.LogInformation("Returning {Count} total artists (local + external, no deduplication)", mergedArtists.Count);
|
||||
|
||||
// Convert to Jellyfin format
|
||||
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
||||
@@ -1067,7 +1051,8 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (imageBytes == null || contentType == null)
|
||||
{
|
||||
return NotFound();
|
||||
// Return placeholder if Jellyfin doesn't have image
|
||||
return await GetPlaceholderImageAsync();
|
||||
}
|
||||
|
||||
return File(imageBytes, contentType);
|
||||
@@ -1084,7 +1069,8 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (string.IsNullOrEmpty(coverUrl))
|
||||
{
|
||||
return NotFound();
|
||||
// Return placeholder "no image available" image
|
||||
return await GetPlaceholderImageAsync();
|
||||
}
|
||||
|
||||
// Fetch and return the image using the proxy service's HttpClient
|
||||
@@ -1093,7 +1079,8 @@ public class JellyfinController : ControllerBase
|
||||
var response = await _proxyService.HttpClient.GetAsync(coverUrl);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return NotFound();
|
||||
// Return placeholder on fetch failure
|
||||
return await GetPlaceholderImageAsync();
|
||||
}
|
||||
|
||||
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
@@ -1103,10 +1090,34 @@ public class JellyfinController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch cover art from {Url}", coverUrl);
|
||||
return NotFound();
|
||||
// Return placeholder on exception
|
||||
return await GetPlaceholderImageAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a placeholder "no image available" image.
|
||||
/// Generates a simple 1x1 transparent PNG as a minimal placeholder.
|
||||
/// TODO: Replace with actual "no image available" graphic from wwwroot/placeholder.png
|
||||
/// </summary>
|
||||
private async Task<IActionResult> GetPlaceholderImageAsync()
|
||||
{
|
||||
// Check if custom placeholder exists in wwwroot
|
||||
var placeholderPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "placeholder.png");
|
||||
if (System.IO.File.Exists(placeholderPath))
|
||||
{
|
||||
var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
|
||||
return File(imageBytes, "image/png");
|
||||
}
|
||||
|
||||
// Fallback: Return a 1x1 transparent PNG as minimal placeholder
|
||||
var transparentPng = Convert.FromBase64String(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
);
|
||||
|
||||
return File(transparentPng, "image/png");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lyrics
|
||||
@@ -1158,9 +1169,20 @@ public class JellyfinController : ControllerBase
|
||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
_logger.LogInformation("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId}",
|
||||
_logger.LogInformation("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache",
|
||||
spotifyTrackId, provider, externalId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If no cached Spotify ID, try to convert via Odesli/song.link
|
||||
// This works for SquidWTF (Tidal), Deezer, Qobuz, etc.
|
||||
spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider!, externalId!);
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
|
||||
provider, externalId, spotifyTrackId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -1377,6 +1399,12 @@ public class JellyfinController : ControllerBase
|
||||
if (song != null)
|
||||
{
|
||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||
|
||||
// If no cached Spotify ID, try Odesli conversion
|
||||
if (string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider, externalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -2190,8 +2218,35 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
});
|
||||
|
||||
// For external tracks, we can't report to Jellyfin since it doesn't know about them
|
||||
// Just return success so the client is happy
|
||||
// CRITICAL: Create session for external tracks too!
|
||||
// Even though Jellyfin doesn't know about the track, we need a session
|
||||
// for the client to appear in the dashboard and receive remote control commands
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogInformation("🔧 SESSION: Creating session for external track playback");
|
||||
var sessionCreated = await _sessionManager.EnsureSessionAsync(
|
||||
deviceId,
|
||||
client ?? "Unknown",
|
||||
device ?? "Unknown",
|
||||
version ?? "1.0",
|
||||
Request.Headers);
|
||||
|
||||
if (sessionCreated)
|
||||
{
|
||||
_logger.LogInformation("✓ SESSION: Session created for external track playback on device {DeviceId}", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Failed to create session for external track playback");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: No device ID found for external track playback");
|
||||
}
|
||||
|
||||
// For external tracks, we can't report playback to Jellyfin since it doesn't know about them
|
||||
// But the session is now active and will appear in the dashboard
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -2344,7 +2399,25 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
// For external tracks, just acknowledge (no logging to avoid spam)
|
||||
// For external tracks, update session activity to keep it alive
|
||||
// This ensures the session remains visible in Jellyfin dashboard
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_sessionManager.UpdateActivity(deviceId);
|
||||
|
||||
// Log progress occasionally for debugging (every ~30 seconds)
|
||||
if (positionTicks.HasValue)
|
||||
{
|
||||
var position = TimeSpan.FromTicks(positionTicks.Value);
|
||||
if (position.Seconds % 30 == 0 && position.Milliseconds < 500)
|
||||
{
|
||||
_logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId})",
|
||||
position, provider, externalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Just acknowledge (no reporting to Jellyfin for external tracks)
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -4356,6 +4429,122 @@ public class JellyfinController : ControllerBase
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an external track URL (Tidal/Deezer/Qobuz) to a Spotify track ID using Odesli/song.link API.
|
||||
/// This enables Spotify lyrics for external tracks that aren't in injected playlists.
|
||||
/// </summary>
|
||||
private async Task<string?> ConvertToSpotifyIdViaOdesliAsync(Song song, string provider, string externalId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build the source URL based on provider
|
||||
string? sourceUrl = null;
|
||||
|
||||
switch (provider.ToLowerInvariant())
|
||||
{
|
||||
case "squidwtf":
|
||||
// SquidWTF uses Tidal IDs
|
||||
sourceUrl = $"https://tidal.com/browse/track/{externalId}";
|
||||
break;
|
||||
|
||||
case "deezer":
|
||||
sourceUrl = $"https://www.deezer.com/track/{externalId}";
|
||||
break;
|
||||
|
||||
case "qobuz":
|
||||
sourceUrl = $"https://www.qobuz.com/us-en/album/-/-/{externalId}";
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogDebug("Provider {Provider} not supported for Odesli conversion", provider);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first (cache for 30 days since these mappings don't change)
|
||||
var cacheKey = $"odesli:{provider}:{externalId}";
|
||||
var cachedSpotifyId = await _cache.GetStringAsync(cacheKey);
|
||||
if (!string.IsNullOrEmpty(cachedSpotifyId))
|
||||
{
|
||||
_logger.LogDebug("Returning cached Odesli conversion: {Provider}/{ExternalId} → {SpotifyId}",
|
||||
provider, externalId, cachedSpotifyId);
|
||||
return cachedSpotifyId;
|
||||
}
|
||||
|
||||
// RATE LIMITING: Odesli allows 10 requests per minute
|
||||
// Use a simple semaphore-based rate limiter
|
||||
await OdesliRateLimiter.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Call Odesli API
|
||||
var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(sourceUrl)}&userCountry=US";
|
||||
|
||||
_logger.LogDebug("Calling Odesli API: {Url}", odesliUrl);
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
var response = await httpClient.GetAsync(odesliUrl);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Odesli API returned {StatusCode} for {Provider}/{ExternalId}",
|
||||
response.StatusCode, provider, externalId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract Spotify URL from linksByPlatform.spotify.url
|
||||
if (root.TryGetProperty("linksByPlatform", out var platforms) &&
|
||||
platforms.TryGetProperty("spotify", out var spotify) &&
|
||||
spotify.TryGetProperty("url", out var spotifyUrlEl))
|
||||
{
|
||||
var spotifyUrl = spotifyUrlEl.GetString();
|
||||
if (!string.IsNullOrEmpty(spotifyUrl))
|
||||
{
|
||||
// Extract Spotify ID from URL: https://open.spotify.com/track/{id}
|
||||
var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)");
|
||||
if (match.Success)
|
||||
{
|
||||
var spotifyId = match.Groups[1].Value;
|
||||
|
||||
// Cache the result (30 days)
|
||||
await _cache.SetStringAsync(cacheKey, spotifyId, TimeSpan.FromDays(30));
|
||||
|
||||
_logger.LogInformation("✓ Odesli converted {Provider}/{ExternalId} → Spotify ID {SpotifyId}",
|
||||
provider, externalId, spotifyId);
|
||||
|
||||
return spotifyId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("No Spotify link found in Odesli response for {Provider}/{ExternalId}", provider, externalId);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release rate limiter after 6 seconds (10 requests per 60 seconds = 1 request per 6 seconds)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(6));
|
||||
OdesliRateLimiter.Release();
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error converting {Provider}/{ExternalId} via Odesli", provider, externalId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Static rate limiter for Odesli API (10 requests per minute = 1 request per 6 seconds)
|
||||
private static readonly SemaphoreSlim OdesliRateLimiter = new SemaphoreSlim(10, 10);
|
||||
}
|
||||
|
||||
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
||||
|
||||
@@ -2,12 +2,64 @@ namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Provides fuzzy string matching for search result scoring.
|
||||
/// OPTIMAL ORDER: 1. Strip decorators → 2. Substring matching → 3. Levenshtein → 4. Greedy assignment
|
||||
/// </summary>
|
||||
public static class FuzzyMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates a similarity score between two strings (0-100).
|
||||
/// Higher score means better match.
|
||||
/// STEP 1: Strips common decorators from track titles to improve matching.
|
||||
/// Removes: (feat. X), (with Y), (ft. Z), - From "Album", [Remix], etc.
|
||||
/// This MUST be done first to avoid systematic noise in matching.
|
||||
/// </summary>
|
||||
public static string StripDecorators(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var cleaned = title;
|
||||
|
||||
// Remove (feat. ...), (ft. ...), (with ...), (featuring ...)
|
||||
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||
cleaned,
|
||||
@"\s*[\(\[]?\s*(feat\.?|ft\.?|with|featuring)\s+[^\)\]]+[\)\]]?",
|
||||
"",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
// Remove - From "Album Name" or - From Album Name
|
||||
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||
cleaned,
|
||||
@"\s*-\s*from\s+[""']?[^""']+[""']?",
|
||||
"",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
// Remove - Remastered, - Radio Edit, etc.
|
||||
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||
cleaned,
|
||||
@"\s*-\s*(remaster|radio edit|single version|album version|extended|original mix)[^\-]*",
|
||||
"",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
// Remove [Remix], [Remaster], [Live], [Explicit], etc.
|
||||
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||
cleaned,
|
||||
@"\s*[\[\(](remix|remaster|live|acoustic|radio edit|explicit|clean|official|audio|video|lyric)[^\]\)]*[\]\)]",
|
||||
"",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
// Remove trailing/leading whitespace and normalize
|
||||
cleaned = cleaned.Trim();
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates similarity score following OPTIMAL ORDER:
|
||||
/// 1. Strip decorators (already done by caller)
|
||||
/// 2. Substring matching (cheap, high-precision)
|
||||
/// 3. Levenshtein distance (expensive, fuzzy)
|
||||
/// Returns score 0-100.
|
||||
/// </summary>
|
||||
public static int CalculateSimilarity(string query, string target)
|
||||
{
|
||||
@@ -16,47 +68,87 @@ public static class FuzzyMatcher
|
||||
return 0;
|
||||
}
|
||||
|
||||
var queryLower = NormalizeForMatching(query);
|
||||
var targetLower = NormalizeForMatching(target);
|
||||
var queryNorm = NormalizeForMatching(query);
|
||||
var targetNorm = NormalizeForMatching(target);
|
||||
|
||||
// STEP 2: SUBSTRING MATCHING (cheap, high-precision)
|
||||
|
||||
// Exact match
|
||||
if (queryLower == targetLower)
|
||||
if (queryNorm == targetNorm)
|
||||
{
|
||||
return 100;
|
||||
}
|
||||
|
||||
// One string fully contains the other (substring match)
|
||||
// Example: "luther" ⊂ "luther remastered" → instant win
|
||||
if (targetNorm.Contains(queryNorm) || queryNorm.Contains(targetNorm))
|
||||
{
|
||||
return 95;
|
||||
}
|
||||
|
||||
// Starts with query
|
||||
if (targetLower.StartsWith(queryLower))
|
||||
if (targetNorm.StartsWith(queryNorm) || queryNorm.StartsWith(targetNorm))
|
||||
{
|
||||
return 90;
|
||||
}
|
||||
|
||||
// Contains query as whole word
|
||||
if (targetLower.Contains($" {queryLower} ") ||
|
||||
targetLower.StartsWith($"{queryLower} ") ||
|
||||
targetLower.EndsWith($" {queryLower}"))
|
||||
if (targetNorm.Contains($" {queryNorm} ") ||
|
||||
targetNorm.StartsWith($"{queryNorm} ") ||
|
||||
targetNorm.EndsWith($" {queryNorm}") ||
|
||||
queryNorm.Contains($" {targetNorm} ") ||
|
||||
queryNorm.StartsWith($"{targetNorm} ") ||
|
||||
queryNorm.EndsWith($" {targetNorm}"))
|
||||
{
|
||||
return 80;
|
||||
return 85;
|
||||
}
|
||||
|
||||
// Contains query anywhere
|
||||
if (targetLower.Contains(queryLower))
|
||||
{
|
||||
return 70;
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance for fuzzy matching
|
||||
var distance = LevenshteinDistance(queryLower, targetLower);
|
||||
var maxLength = Math.Max(queryLower.Length, targetLower.Length);
|
||||
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||
// Only use this for candidates that survived substring checks
|
||||
|
||||
var distance = LevenshteinDistance(queryNorm, targetNorm);
|
||||
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
|
||||
|
||||
if (maxLength == 0)
|
||||
{
|
||||
return 100;
|
||||
}
|
||||
|
||||
// Convert distance to similarity score (0-60 range for fuzzy matches)
|
||||
var similarity = (1.0 - (double)distance / maxLength) * 60;
|
||||
return (int)Math.Max(0, similarity);
|
||||
// Normalize distance by length: score = 1 - (distance / max_length)
|
||||
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
||||
|
||||
// Convert to 0-80 range (reserve 80-100 for substring matches)
|
||||
var score = (int)(normalizedSimilarity * 80);
|
||||
|
||||
return Math.Max(0, score);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AGGRESSIVE matching that follows optimal order:
|
||||
/// 1. Strip decorators FIRST
|
||||
/// 2. Substring matching
|
||||
/// 3. Levenshtein distance
|
||||
/// Returns the best score.
|
||||
/// </summary>
|
||||
public static int CalculateSimilarityAggressive(string query, string target)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// STEP 1: Strip decorators FIRST (always)
|
||||
var queryStripped = StripDecorators(query);
|
||||
var targetStripped = StripDecorators(target);
|
||||
|
||||
// STEP 2-3: Substring matching + Levenshtein
|
||||
var strippedScore = CalculateSimilarity(queryStripped, targetStripped);
|
||||
|
||||
// Also try without stripping in case decorators are part of the actual title
|
||||
var rawScore = CalculateSimilarity(query, target);
|
||||
|
||||
// Return the best score
|
||||
return Math.Max(rawScore, strippedScore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -227,6 +227,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// New matching mode that uses ISRC when available for exact matches.
|
||||
/// Preserves track position for correct playlist ordering.
|
||||
/// Only matches tracks that aren't already in the Jellyfin playlist.
|
||||
/// Uses GREEDY ASSIGNMENT to maximize total matches.
|
||||
/// </summary>
|
||||
private async Task MatchPlaylistTracksWithIsrcAsync(
|
||||
string playlistName,
|
||||
@@ -320,7 +321,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled})",
|
||||
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)",
|
||||
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
|
||||
|
||||
// Check cache - use snapshot/timestamp to detect changes
|
||||
@@ -366,6 +367,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var isrcMatches = 0;
|
||||
var fuzzyMatches = 0;
|
||||
var noMatch = 0;
|
||||
|
||||
// GREEDY ASSIGNMENT: Collect all possible matches first, then assign optimally
|
||||
var allCandidates = new List<(SpotifyPlaylistTrack SpotifyTrack, Song MatchedSong, double Score, string MatchType)>();
|
||||
|
||||
// Process tracks in batches for parallel searching
|
||||
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
|
||||
@@ -382,92 +386,114 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
try
|
||||
{
|
||||
Song? matchedSong = null;
|
||||
var matchType = "none";
|
||||
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
||||
|
||||
// Try ISRC match first if available and enabled
|
||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||
{
|
||||
matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||
if (matchedSong != null)
|
||||
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||
if (isrcSong != null)
|
||||
{
|
||||
matchType = "isrc";
|
||||
candidates.Add((isrcSong, 100.0, "isrc"));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to fuzzy matching
|
||||
if (matchedSong == null)
|
||||
// Always try fuzzy matching to get more candidates
|
||||
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
||||
spotifyTrack.Title,
|
||||
spotifyTrack.Artists,
|
||||
metadataService);
|
||||
|
||||
foreach (var (song, score) in fuzzySongs)
|
||||
{
|
||||
matchedSong = await TryMatchByFuzzyAsync(
|
||||
spotifyTrack.Title,
|
||||
spotifyTrack.Artists,
|
||||
metadataService);
|
||||
|
||||
if (matchedSong != null)
|
||||
{
|
||||
matchType = "fuzzy";
|
||||
}
|
||||
candidates.Add((song, score, "fuzzy"));
|
||||
}
|
||||
|
||||
if (matchedSong != null)
|
||||
{
|
||||
var matched = new MatchedTrack
|
||||
{
|
||||
Position = spotifyTrack.Position,
|
||||
SpotifyId = spotifyTrack.SpotifyId,
|
||||
SpotifyTitle = spotifyTrack.Title,
|
||||
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
||||
Isrc = spotifyTrack.Isrc,
|
||||
MatchType = matchType,
|
||||
MatchedSong = matchedSong
|
||||
};
|
||||
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match: {MatchedTitle}",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||
matchType, matchedSong.Title);
|
||||
|
||||
return ((MatchedTrack?)matched, matchType);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
return ((MatchedTrack?)null, "none");
|
||||
}
|
||||
return (spotifyTrack, candidates);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
return ((MatchedTrack?)null, "none");
|
||||
return (spotifyTrack, new List<(Song, double, string)>());
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
// Wait for all tracks in this batch to complete
|
||||
var batchResults = await Task.WhenAll(batchTasks);
|
||||
|
||||
// Collect results
|
||||
foreach (var result in batchResults)
|
||||
// Collect all candidates
|
||||
foreach (var (spotifyTrack, candidates) in batchResults)
|
||||
{
|
||||
var (matched, matchType) = result;
|
||||
if (matched != null)
|
||||
foreach (var (song, score, matchType) in candidates)
|
||||
{
|
||||
matchedTracks.Add(matched);
|
||||
if (matchType == "isrc") isrcMatches++;
|
||||
else if (matchType == "fuzzy") fuzzyMatches++;
|
||||
}
|
||||
else
|
||||
{
|
||||
noMatch++;
|
||||
allCandidates.Add((spotifyTrack, song, score, matchType));
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting between batches (not between individual tracks)
|
||||
// Rate limiting between batches
|
||||
if (i + BatchSize < orderedTracks.Count)
|
||||
{
|
||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// GREEDY ASSIGNMENT: Assign each Spotify track to its best unique match
|
||||
var usedSongIds = new HashSet<string>();
|
||||
var assignments = new Dictionary<string, (Song Song, double Score, string MatchType)>();
|
||||
|
||||
// Sort candidates by score (highest first)
|
||||
var sortedCandidates = allCandidates
|
||||
.OrderByDescending(c => c.Score)
|
||||
.ToList();
|
||||
|
||||
foreach (var (spotifyTrack, song, score, matchType) in sortedCandidates)
|
||||
{
|
||||
// Skip if this Spotify track already has a match
|
||||
if (assignments.ContainsKey(spotifyTrack.SpotifyId))
|
||||
continue;
|
||||
|
||||
// Skip if this song is already used
|
||||
if (usedSongIds.Contains(song.Id))
|
||||
continue;
|
||||
|
||||
// Assign this match
|
||||
assignments[spotifyTrack.SpotifyId] = (song, score, matchType);
|
||||
usedSongIds.Add(song.Id);
|
||||
}
|
||||
|
||||
// Build final matched tracks list
|
||||
foreach (var spotifyTrack in orderedTracks)
|
||||
{
|
||||
if (assignments.TryGetValue(spotifyTrack.SpotifyId, out var match))
|
||||
{
|
||||
var matched = new MatchedTrack
|
||||
{
|
||||
Position = spotifyTrack.Position,
|
||||
SpotifyId = spotifyTrack.SpotifyId,
|
||||
SpotifyTitle = spotifyTrack.Title,
|
||||
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
||||
Isrc = spotifyTrack.Isrc,
|
||||
MatchType = match.MatchType,
|
||||
MatchedSong = match.Song
|
||||
};
|
||||
|
||||
matchedTracks.Add(matched);
|
||||
|
||||
if (match.MatchType == "isrc") isrcMatches++;
|
||||
else if (match.MatchType == "fuzzy") fuzzyMatches++;
|
||||
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match (score: {Score:F1}): {MatchedTitle}",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||
match.MatchType, match.Score, match.Song.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
noMatch++;
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedTracks.Count > 0)
|
||||
{
|
||||
@@ -483,7 +509,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
||||
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via search (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||
|
||||
// Pre-build playlist items cache for instant serving
|
||||
@@ -495,6 +521,64 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns multiple candidate matches with scores for greedy assignment.
|
||||
/// FOLLOWS OPTIMAL ORDER:
|
||||
/// 1. Strip decorators (done in FuzzyMatcher)
|
||||
/// 2. Substring matching (done in FuzzyMatcher)
|
||||
/// 3. Levenshtein distance (done in FuzzyMatcher)
|
||||
/// This method just collects candidates; greedy assignment happens later.
|
||||
/// </summary>
|
||||
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
||||
string title,
|
||||
List<string> artists,
|
||||
IMusicMetadataService metadataService)
|
||||
{
|
||||
try
|
||||
{
|
||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||
|
||||
// STEP 1: Strip decorators FIRST (before searching)
|
||||
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||
var query = $"{titleStripped} {primaryArtist}";
|
||||
|
||||
var results = await metadataService.SearchSongsAsync(query, limit: 10);
|
||||
|
||||
if (results.Count == 0) return new List<(Song, double)>();
|
||||
|
||||
// STEP 2-3: Score all results (substring + Levenshtein already in CalculateSimilarityAggressive)
|
||||
var scoredResults = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
// Use aggressive matching which follows optimal order internally
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.Where(x =>
|
||||
x.TotalScore >= 40 ||
|
||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||
x.TitleScore >= 85)
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.Select(x => (x.Song, x.TotalScore))
|
||||
.ToList();
|
||||
|
||||
return scoredResults;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<(Song, double)>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to match a track by ISRC using provider search.
|
||||
/// </summary>
|
||||
@@ -524,7 +608,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to match a track by title and artist using fuzzy matching.
|
||||
/// Attempts to match a track by title and artist using AGGRESSIVE fuzzy matching.
|
||||
/// FOLLOWS OPTIMAL ORDER:
|
||||
/// 1. Strip decorators FIRST (before searching)
|
||||
/// 2. Substring matching (in FuzzyMatcher)
|
||||
/// 3. Levenshtein distance (in FuzzyMatcher)
|
||||
/// PRIORITY: Match as many tracks as possible, even with lower confidence.
|
||||
/// </summary>
|
||||
private async Task<Song?> TryMatchByFuzzyAsync(
|
||||
string title,
|
||||
@@ -534,16 +623,22 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
try
|
||||
{
|
||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||
var query = $"{title} {primaryArtist}";
|
||||
var results = await metadataService.SearchSongsAsync(query, limit: 5);
|
||||
|
||||
// STEP 1: Strip decorators FIRST (before searching)
|
||||
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||
var query = $"{titleStripped} {primaryArtist}";
|
||||
|
||||
var results = await metadataService.SearchSongsAsync(query, limit: 10);
|
||||
|
||||
if (results.Count == 0) return null;
|
||||
|
||||
var bestMatch = results
|
||||
// STEP 2-3: Score all results (substring + Levenshtein in CalculateSimilarityAggressive)
|
||||
var scoredResults = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(title, song.Title),
|
||||
// Use aggressive matching which follows optimal order internally
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
@@ -551,13 +646,39 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
|
||||
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
.ToList();
|
||||
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 60)
|
||||
var bestMatch = scoredResults.FirstOrDefault();
|
||||
|
||||
if (bestMatch == null) return null;
|
||||
|
||||
// AGGRESSIVE: Accept matches with score >= 40 (was 50)
|
||||
if (bestMatch.TotalScore >= 40)
|
||||
{
|
||||
_logger.LogDebug("✓ Matched (score: {Score:F1}, title: {TitleScore}, artist: {ArtistScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||
bestMatch.TotalScore, bestMatch.TitleScore, bestMatch.ArtistScore, title, bestMatch.Song.Title);
|
||||
return bestMatch.Song;
|
||||
}
|
||||
|
||||
// SUPER AGGRESSIVE: If artist matches well (70+), accept even lower title scores
|
||||
// This handles cases like "a" → "a-blah" where artist is the same
|
||||
if (bestMatch.ArtistScore >= 70 && bestMatch.TitleScore >= 30)
|
||||
{
|
||||
_logger.LogDebug("✓ Matched via artist priority (artist: {ArtistScore}, title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||
bestMatch.ArtistScore, bestMatch.TitleScore, title, bestMatch.Song.Title);
|
||||
return bestMatch.Song;
|
||||
}
|
||||
|
||||
// ULTRA AGGRESSIVE: If title has high substring match (85+), accept it
|
||||
// This handles "luther" → "luther (feat. sza)"
|
||||
if (bestMatch.TitleScore >= 85)
|
||||
{
|
||||
_logger.LogDebug("✓ Matched via substring (title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||
bestMatch.TitleScore, title, bestMatch.Song.Title);
|
||||
return bestMatch.Song;
|
||||
}
|
||||
|
||||
@@ -814,7 +935,60 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
JsonElement? matchedJellyfinItem = null;
|
||||
string? matchedKey = null;
|
||||
|
||||
// Check for external manual mapping
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
// Find the Jellyfin item by ID
|
||||
foreach (var kvp in jellyfinItemsByName)
|
||||
{
|
||||
var item = kvp.Value;
|
||||
if (item.TryGetProperty("Id", out var idEl) && idEl.GetString() == manualJellyfinId)
|
||||
{
|
||||
matchedJellyfinItem = item;
|
||||
matchedKey = kvp.Key;
|
||||
_logger.LogInformation("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
|
||||
spotifyTrack.Title, manualJellyfinId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedJellyfinItem.HasValue)
|
||||
{
|
||||
// Use the raw Jellyfin item (preserves ALL metadata)
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
// Add Spotify ID to ProviderIds so lyrics can work for local tracks too
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
{
|
||||
if (!itemDict.ContainsKey("ProviderIds"))
|
||||
{
|
||||
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
|
||||
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||
{
|
||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||
_logger.LogDebug("Added Spotify ID {SpotifyId} to local track for lyrics support", spotifyTrack.SpotifyId);
|
||||
}
|
||||
}
|
||||
|
||||
finalItems.Add(itemDict);
|
||||
if (matchedKey != null)
|
||||
{
|
||||
usedJellyfinItems.Add(matchedKey);
|
||||
}
|
||||
localUsedCount++;
|
||||
}
|
||||
continue; // Skip to next track
|
||||
}
|
||||
}
|
||||
|
||||
// SECOND: Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
@@ -925,7 +1099,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
// If no manual external mapping, try fuzzy matching with local Jellyfin tracks
|
||||
// If no manual external mapping, try AGGRESSIVE fuzzy matching with local Jellyfin tracks
|
||||
double bestScore = 0;
|
||||
|
||||
foreach (var kvp in jellyfinItemsByName)
|
||||
@@ -940,11 +1114,18 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
|
||||
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
|
||||
// Use AGGRESSIVE matching with decorator stripping
|
||||
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(spotifyTrack.Title, title);
|
||||
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
||||
|
||||
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
||||
|
||||
if (totalScore > bestScore && totalScore >= 70)
|
||||
// AGGRESSIVE: Accept score >= 40 (was 70)
|
||||
// Also accept if artist matches well (70+) and title is decent (30+)
|
||||
var isGoodMatch = totalScore >= 40 || (artistScore >= 70 && titleScore >= 30);
|
||||
|
||||
if (totalScore > bestScore && isGoodMatch)
|
||||
{
|
||||
bestScore = totalScore;
|
||||
matchedJellyfinItem = item;
|
||||
@@ -958,6 +1139,22 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
// Add Spotify ID to ProviderIds so lyrics can work for fuzzy-matched local tracks too
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
{
|
||||
if (!itemDict.ContainsKey("ProviderIds"))
|
||||
{
|
||||
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
|
||||
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||
{
|
||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||
_logger.LogDebug("Added Spotify ID {SpotifyId} to fuzzy-matched local track for lyrics support", spotifyTrack.SpotifyId);
|
||||
}
|
||||
}
|
||||
|
||||
finalItems.Add(itemDict);
|
||||
if (matchedKey != null)
|
||||
{
|
||||
|
||||
@@ -176,10 +176,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
|
||||
return new List<Artist>();
|
||||
}
|
||||
|
||||
@@ -196,11 +199,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
if (count >= limit) break;
|
||||
|
||||
artists.Add(ParseTidalArtist(artist));
|
||||
var parsedArtist = ParseTidalArtist(artist);
|
||||
artists.Add(parsedArtist);
|
||||
_logger.LogDebug("🎤 SQUIDWTF: Found artist: {Name} (ID: {Id})", parsedArtist.Name, parsedArtist.ExternalId);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
|
||||
return artists;
|
||||
}, new List<Artist>());
|
||||
}
|
||||
|
||||
@@ -718,6 +718,84 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Missing Tracks Section -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
Missing Tracks (All Playlists)
|
||||
<div class="actions">
|
||||
<button onclick="fetchMissingTracks()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
Tracks that couldn't be matched locally or externally. Map them manually to add them to your playlists.
|
||||
</p>
|
||||
<div id="missing-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Missing:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);" id="missing-total">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Playlist</th>
|
||||
<th>Track</th>
|
||||
<th>Artist</th>
|
||||
<th>Album</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="missing-tracks-table-body">
|
||||
<tr>
|
||||
<td colspan="5" class="loading">
|
||||
<span class="spinner"></span> Loading missing tracks...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Kept Downloads Section -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
Kept Downloads
|
||||
<div class="actions">
|
||||
<button onclick="fetchDownloads()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
Downloaded files stored permanently. Download or delete individual tracks.
|
||||
</p>
|
||||
<div id="downloads-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Files:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Size:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0 B</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artist</th>
|
||||
<th>Album</th>
|
||||
<th>File</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="downloads-table-body">
|
||||
<tr>
|
||||
<td colspan="5" class="loading">
|
||||
<span class="spinner"></span> Loading downloads...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Tab -->
|
||||
@@ -1331,6 +1409,7 @@
|
||||
</td>
|
||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||
<td>
|
||||
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
||||
<button onclick="prefetchLyrics('${escapeJs(p.name)}')">Prefetch Lyrics</button>
|
||||
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
||||
@@ -1428,6 +1507,145 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMissingTracks() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists');
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('missing-tracks-table-body');
|
||||
const missingTracks = [];
|
||||
|
||||
// Collect all missing tracks from all playlists
|
||||
for (const playlist of data.playlists) {
|
||||
if (playlist.externalMissing > 0) {
|
||||
// Fetch tracks for this playlist
|
||||
try {
|
||||
const tracksRes = await fetch(`/api/admin/playlists/${encodeURIComponent(playlist.name)}/tracks`);
|
||||
const tracksData = await tracksRes.json();
|
||||
|
||||
// Filter to only missing tracks (isLocal === null)
|
||||
const missing = tracksData.tracks.filter(t => t.isLocal === null);
|
||||
missing.forEach(t => {
|
||||
missingTracks.push({
|
||||
playlist: playlist.name,
|
||||
...t
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch tracks for ${playlist.name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update summary
|
||||
document.getElementById('missing-total').textContent = missingTracks.length;
|
||||
|
||||
if (missingTracks.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">🎉 No missing tracks! All tracks are matched.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = missingTracks.map(t => {
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(t.playlist)}</strong></td>
|
||||
<td>${escapeHtml(t.title)}</td>
|
||||
<td>${escapeHtml(t.artist)}</td>
|
||||
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : '-'}</td>
|
||||
<td>
|
||||
<button onclick="openMapToLocal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(t.artist)}')"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--success);border-color:var(--success);">Map to Local</button>
|
||||
<button onclick="openMapToExternal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(t.artist)}')"
|
||||
style="font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch missing tracks:', error);
|
||||
showToast('Failed to fetch missing tracks', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDownloads() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/downloads');
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('downloads-table-body');
|
||||
|
||||
// Update summary
|
||||
document.getElementById('downloads-count').textContent = data.count;
|
||||
document.getElementById('downloads-size').textContent = data.totalSizeFormatted;
|
||||
|
||||
if (data.count === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.files.map(f => {
|
||||
return `
|
||||
<tr data-path="${escapeHtml(f.path)}">
|
||||
<td><strong>${escapeHtml(f.artist)}</strong></td>
|
||||
<td>${escapeHtml(f.album)}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||
<td>
|
||||
<button onclick="downloadFile('${escapeJs(f.path)}')"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
||||
<button onclick="deleteDownload('${escapeJs(f.path)}')"
|
||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch downloads:', error);
|
||||
showToast('Failed to fetch downloads', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(path) {
|
||||
try {
|
||||
window.open(`/api/admin/downloads/file?path=${encodeURIComponent(path)}`, '_blank');
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
showToast('Failed to download file', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDownload(path) {
|
||||
if (!confirm(`Delete this file?\n\n${path}\n\nThis action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/downloads?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast('File deleted successfully', 'success');
|
||||
|
||||
// Remove the row immediately for live update
|
||||
const escapedPath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const row = document.querySelector(`tr[data-path="${escapedPath}"]`);
|
||||
if (row) {
|
||||
row.remove();
|
||||
}
|
||||
|
||||
// Refresh to update counts
|
||||
await fetchDownloads();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
showToast(error.error || 'Failed to delete file', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error);
|
||||
showToast('Failed to delete file', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/config');
|
||||
@@ -1672,6 +1890,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPlaylistCache(name) {
|
||||
if (!confirm(`Clear cache and rebuild for "${name}"?\n\nThis will:\n• Clear Redis cache\n• Delete file caches\n• Rebuild with latest Spotify IDs\n\nThis may take a minute.`)) return;
|
||||
|
||||
try {
|
||||
showToast(`Clearing cache for ${name}...`, 'info');
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ ${data.message} (Cleared ${data.clearedKeys} cache keys, ${data.clearedFiles} files)`, 'success', 5000);
|
||||
// Refresh the playlists table after a delay to show updated counts
|
||||
setTimeout(fetchPlaylists, 3000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to clear cache', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to clear cache', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function matchPlaylistTracks(name) {
|
||||
try {
|
||||
showToast(`Matching tracks for ${name}...`, 'success');
|
||||
@@ -2515,6 +2753,8 @@
|
||||
fetchStatus();
|
||||
fetchPlaylists();
|
||||
fetchTrackMappings();
|
||||
fetchMissingTracks();
|
||||
fetchDownloads();
|
||||
fetchJellyfinUsers();
|
||||
fetchJellyfinPlaylists();
|
||||
fetchConfig();
|
||||
@@ -2524,6 +2764,8 @@
|
||||
fetchStatus();
|
||||
fetchPlaylists();
|
||||
fetchTrackMappings();
|
||||
fetchMissingTracks();
|
||||
fetchDownloads();
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
BIN
allstarr/wwwroot/placeholder.png
Normal file
BIN
allstarr/wwwroot/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Reference in New Issue
Block a user