mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55: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/steering/
|
||||||
apis/api-calls/*.json
|
apis/api-calls/*.json
|
||||||
!apis/api-calls/jellyfin-openapi-stable.json
|
!apis/api-calls/jellyfin-openapi-stable.json
|
||||||
|
apis/temp.json
|
||||||
|
|
||||||
# Log files for debugging
|
# Log files for debugging
|
||||||
apis/api-calls/*.log
|
apis/api-calls/*.log
|
||||||
|
|||||||
@@ -374,12 +374,37 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
foreach (var item in cachedPlaylistItems)
|
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;
|
var isExternal = false;
|
||||||
|
|
||||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||||
{
|
{
|
||||||
// Has ProviderIds = external track
|
// Handle both Dictionary<string, string> and JsonElement
|
||||||
isExternal = true;
|
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)
|
if (isExternal)
|
||||||
@@ -673,72 +698,88 @@ public class AdminController : ControllerBase
|
|||||||
string? manualMappingType = null;
|
string? manualMappingType = null;
|
||||||
string? manualMappingId = null;
|
string? manualMappingId = null;
|
||||||
|
|
||||||
// Check for external manual mapping
|
// FIRST: Check for manual Jellyfin mapping
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
{
|
{
|
||||||
try
|
// Manual Jellyfin mapping exists - this track is definitely local
|
||||||
{
|
isLocal = true;
|
||||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
isManualMapping = true;
|
||||||
var extRoot = extDoc.RootElement;
|
manualMappingType = "jellyfin";
|
||||||
|
manualMappingId = manualJellyfinId;
|
||||||
string? provider = null;
|
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
|
||||||
string? externalId = null;
|
track.Title, manualJellyfinId);
|
||||||
|
|
||||||
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 no manual mapping, try fuzzy matching with local tracks
|
|
||||||
if (!isManualMapping && localTracks.Count > 0)
|
|
||||||
{
|
{
|
||||||
var bestMatch = localTracks
|
// Check for external manual mapping
|
||||||
.Select(local => new
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||||
{
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
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 (!string.IsNullOrEmpty(externalMappingJson))
|
||||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
|
||||||
{
|
{
|
||||||
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;
|
bool? isLocal = null;
|
||||||
string? externalProvider = null;
|
string? externalProvider = null;
|
||||||
|
|
||||||
// Check for external manual mapping
|
// Check for manual Jellyfin mapping
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
{
|
{
|
||||||
try
|
isLocal = true;
|
||||||
{
|
|
||||||
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
|
else
|
||||||
{
|
{
|
||||||
// Track is missing (search failed)
|
// Check for external manual mapping
|
||||||
isLocal = null;
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||||
externalProvider = null;
|
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
|
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>
|
/// <summary>
|
||||||
/// Search Jellyfin library for tracks (for manual mapping)
|
/// Search Jellyfin library for tracks (for manual mapping)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1087,8 +1210,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save manual external track mapping (SquidWTF/Deezer/Qobuz)
|
/// Save manual track mapping (local Jellyfin or external provider)
|
||||||
/// Note: Local Jellyfin mappings should be done via Spotify Import plugin
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("playlists/{name}/map")]
|
[HttpPost("playlists/{name}/map")]
|
||||||
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
|
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" });
|
return BadRequest(new { error = "SpotifyId is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only external mappings are supported now
|
// Validate that either Jellyfin mapping or external mapping is provided
|
||||||
// Local Jellyfin mappings should be done via Spotify Import plugin
|
var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId);
|
||||||
if (string.IsNullOrWhiteSpace(request.ExternalProvider) || string.IsNullOrWhiteSpace(request.ExternalId))
|
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
|
try
|
||||||
{
|
{
|
||||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
string? normalizedProvider = null;
|
||||||
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);
|
|
||||||
|
|
||||||
// Also save to file for persistence across restarts
|
if (hasJellyfinMapping)
|
||||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
{
|
||||||
|
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||||
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||||
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
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
|
// Clear all related caches to force rebuild
|
||||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||||
@@ -1157,33 +1303,36 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
_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? trackTitle = null;
|
||||||
string? trackArtist = null;
|
string? trackArtist = null;
|
||||||
string? trackAlbum = null;
|
string? trackAlbum = null;
|
||||||
|
|
||||||
try
|
if (hasExternalMapping && normalizedProvider != null)
|
||||||
{
|
{
|
||||||
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
try
|
||||||
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
|
||||||
|
|
||||||
if (externalSong != null)
|
|
||||||
{
|
{
|
||||||
trackTitle = externalSong.Title;
|
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
||||||
trackArtist = externalSong.Artist;
|
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
||||||
trackAlbum = externalSong.Album;
|
|
||||||
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
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}",
|
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||||
normalizedProvider, request.ExternalId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger immediate playlist rebuild with the new mapping
|
// Trigger immediate playlist rebuild with the new mapping
|
||||||
if (_matchingService != null)
|
if (_matchingService != null)
|
||||||
@@ -3173,3 +3322,187 @@ public class LinkPlaylistRequest
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string SpotifyPlaylistId { 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>
|
/// <summary>
|
||||||
/// Searches local Jellyfin library and external providers.
|
/// 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>
|
/// </summary>
|
||||||
[HttpGet("Items", Order = 1)]
|
[HttpGet("Items", Order = 1)]
|
||||||
[HttpGet("Users/{userId}/Items", Order = 1)]
|
[HttpGet("Users/{userId}/Items", Order = 1)]
|
||||||
@@ -294,14 +294,24 @@ public class JellyfinController : ControllerBase
|
|||||||
.Select(x => x.Item)
|
.Select(x => x.Item)
|
||||||
.ToList();
|
.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)
|
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
|
||||||
.GroupBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.Select(g => g.OrderByDescending(x => x.Score).First())
|
|
||||||
.OrderByDescending(x => x.Score)
|
.OrderByDescending(x => x.Score)
|
||||||
.Select(x => x.Item)
|
.Select(x => x.Item)
|
||||||
.ToList();
|
.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
|
// Convert to Jellyfin format
|
||||||
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
|
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
|
||||||
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).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);
|
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 allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
|
||||||
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
|
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
|
||||||
|
var allArtists = localArtists.Concat(externalResult.Artists).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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return _responseBuilder.CreateSearchHintsResponse(
|
return _responseBuilder.CreateSearchHintsResponse(
|
||||||
allSongs.Take(limit).ToList(),
|
allSongs.Take(limit).ToList(),
|
||||||
@@ -680,27 +680,11 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge and deduplicate by name
|
// NO deduplication - merge all artists and sort by relevance
|
||||||
var artistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
// Show ALL matches (local + external) sorted by best match first
|
||||||
var mergedArtists = new List<Artist>();
|
var mergedArtists = localArtists.Concat(externalArtists).ToList();
|
||||||
|
|
||||||
foreach (var artist in localArtists)
|
_logger.LogInformation("Returning {Count} total artists (local + external, no deduplication)", mergedArtists.Count);
|
||||||
{
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Convert to Jellyfin format
|
// Convert to Jellyfin format
|
||||||
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
||||||
@@ -1067,7 +1051,8 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (imageBytes == null || contentType == null)
|
if (imageBytes == null || contentType == null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
// Return placeholder if Jellyfin doesn't have image
|
||||||
|
return await GetPlaceholderImageAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return File(imageBytes, contentType);
|
return File(imageBytes, contentType);
|
||||||
@@ -1084,7 +1069,8 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(coverUrl))
|
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
|
// 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);
|
var response = await _proxyService.HttpClient.GetAsync(coverUrl);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
return NotFound();
|
// Return placeholder on fetch failure
|
||||||
|
return await GetPlaceholderImageAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
@@ -1103,10 +1090,34 @@ public class JellyfinController : ControllerBase
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch cover art from {Url}", coverUrl);
|
_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
|
#endregion
|
||||||
|
|
||||||
#region Lyrics
|
#region Lyrics
|
||||||
@@ -1158,9 +1169,20 @@ public class JellyfinController : ControllerBase
|
|||||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
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);
|
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
|
else
|
||||||
@@ -1377,6 +1399,12 @@ public class JellyfinController : ControllerBase
|
|||||||
if (song != null)
|
if (song != null)
|
||||||
{
|
{
|
||||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||||
|
|
||||||
|
// If no cached Spotify ID, try Odesli conversion
|
||||||
|
if (string.IsNullOrEmpty(spotifyTrackId))
|
||||||
|
{
|
||||||
|
spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider, externalId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
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
|
// CRITICAL: Create session for external tracks too!
|
||||||
// Just return success so the client is happy
|
// 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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2344,7 +2399,25 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (isExternal)
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4356,6 +4429,122 @@ public class JellyfinController : ControllerBase
|
|||||||
return null;
|
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
|
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
||||||
|
|||||||
@@ -2,12 +2,64 @@ namespace allstarr.Services.Common;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides fuzzy string matching for search result scoring.
|
/// Provides fuzzy string matching for search result scoring.
|
||||||
|
/// OPTIMAL ORDER: 1. Strip decorators → 2. Substring matching → 3. Levenshtein → 4. Greedy assignment
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class FuzzyMatcher
|
public static class FuzzyMatcher
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates a similarity score between two strings (0-100).
|
/// STEP 1: Strips common decorators from track titles to improve matching.
|
||||||
/// Higher score means better match.
|
/// 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>
|
/// </summary>
|
||||||
public static int CalculateSimilarity(string query, string target)
|
public static int CalculateSimilarity(string query, string target)
|
||||||
{
|
{
|
||||||
@@ -16,47 +68,87 @@ public static class FuzzyMatcher
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var queryLower = NormalizeForMatching(query);
|
var queryNorm = NormalizeForMatching(query);
|
||||||
var targetLower = NormalizeForMatching(target);
|
var targetNorm = NormalizeForMatching(target);
|
||||||
|
|
||||||
|
// STEP 2: SUBSTRING MATCHING (cheap, high-precision)
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if (queryLower == targetLower)
|
if (queryNorm == targetNorm)
|
||||||
{
|
{
|
||||||
return 100;
|
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
|
// Starts with query
|
||||||
if (targetLower.StartsWith(queryLower))
|
if (targetNorm.StartsWith(queryNorm) || queryNorm.StartsWith(targetNorm))
|
||||||
{
|
{
|
||||||
return 90;
|
return 90;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contains query as whole word
|
// Contains query as whole word
|
||||||
if (targetLower.Contains($" {queryLower} ") ||
|
if (targetNorm.Contains($" {queryNorm} ") ||
|
||||||
targetLower.StartsWith($"{queryLower} ") ||
|
targetNorm.StartsWith($"{queryNorm} ") ||
|
||||||
targetLower.EndsWith($" {queryLower}"))
|
targetNorm.EndsWith($" {queryNorm}") ||
|
||||||
|
queryNorm.Contains($" {targetNorm} ") ||
|
||||||
|
queryNorm.StartsWith($"{targetNorm} ") ||
|
||||||
|
queryNorm.EndsWith($" {targetNorm}"))
|
||||||
{
|
{
|
||||||
return 80;
|
return 85;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contains query anywhere
|
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||||
if (targetLower.Contains(queryLower))
|
// Only use this for candidates that survived substring checks
|
||||||
{
|
|
||||||
return 70;
|
var distance = LevenshteinDistance(queryNorm, targetNorm);
|
||||||
}
|
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
|
||||||
|
|
||||||
// Calculate Levenshtein distance for fuzzy matching
|
|
||||||
var distance = LevenshteinDistance(queryLower, targetLower);
|
|
||||||
var maxLength = Math.Max(queryLower.Length, targetLower.Length);
|
|
||||||
|
|
||||||
if (maxLength == 0)
|
if (maxLength == 0)
|
||||||
{
|
{
|
||||||
return 100;
|
return 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert distance to similarity score (0-60 range for fuzzy matches)
|
// Normalize distance by length: score = 1 - (distance / max_length)
|
||||||
var similarity = (1.0 - (double)distance / maxLength) * 60;
|
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
||||||
return (int)Math.Max(0, similarity);
|
|
||||||
|
// 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>
|
/// <summary>
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
/// New matching mode that uses ISRC when available for exact matches.
|
/// New matching mode that uses ISRC when available for exact matches.
|
||||||
/// Preserves track position for correct playlist ordering.
|
/// Preserves track position for correct playlist ordering.
|
||||||
/// Only matches tracks that aren't already in the Jellyfin playlist.
|
/// Only matches tracks that aren't already in the Jellyfin playlist.
|
||||||
|
/// Uses GREEDY ASSIGNMENT to maximize total matches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task MatchPlaylistTracksWithIsrcAsync(
|
private async Task MatchPlaylistTracksWithIsrcAsync(
|
||||||
string playlistName,
|
string playlistName,
|
||||||
@@ -320,7 +321,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
return;
|
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);
|
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
|
||||||
|
|
||||||
// Check cache - use snapshot/timestamp to detect changes
|
// Check cache - use snapshot/timestamp to detect changes
|
||||||
@@ -366,6 +367,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var isrcMatches = 0;
|
var isrcMatches = 0;
|
||||||
var fuzzyMatches = 0;
|
var fuzzyMatches = 0;
|
||||||
var noMatch = 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
|
// Process tracks in batches for parallel searching
|
||||||
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
|
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
|
||||||
@@ -382,92 +386,114 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Song? matchedSong = null;
|
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
||||||
var matchType = "none";
|
|
||||||
|
|
||||||
// Try ISRC match first if available and enabled
|
// Try ISRC match first if available and enabled
|
||||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||||
{
|
{
|
||||||
matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||||
if (matchedSong != null)
|
if (isrcSong != null)
|
||||||
{
|
{
|
||||||
matchType = "isrc";
|
candidates.Add((isrcSong, 100.0, "isrc"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to fuzzy matching
|
// Always try fuzzy matching to get more candidates
|
||||||
if (matchedSong == null)
|
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
||||||
|
spotifyTrack.Title,
|
||||||
|
spotifyTrack.Artists,
|
||||||
|
metadataService);
|
||||||
|
|
||||||
|
foreach (var (song, score) in fuzzySongs)
|
||||||
{
|
{
|
||||||
matchedSong = await TryMatchByFuzzyAsync(
|
candidates.Add((song, score, "fuzzy"));
|
||||||
spotifyTrack.Title,
|
|
||||||
spotifyTrack.Artists,
|
|
||||||
metadataService);
|
|
||||||
|
|
||||||
if (matchedSong != null)
|
|
||||||
{
|
|
||||||
matchType = "fuzzy";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedSong != null)
|
return (spotifyTrack, candidates);
|
||||||
{
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||||
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
return ((MatchedTrack?)null, "none");
|
return (spotifyTrack, new List<(Song, double, string)>());
|
||||||
}
|
}
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
// Wait for all tracks in this batch to complete
|
// Wait for all tracks in this batch to complete
|
||||||
var batchResults = await Task.WhenAll(batchTasks);
|
var batchResults = await Task.WhenAll(batchTasks);
|
||||||
|
|
||||||
// Collect results
|
// Collect all candidates
|
||||||
foreach (var result in batchResults)
|
foreach (var (spotifyTrack, candidates) in batchResults)
|
||||||
{
|
{
|
||||||
var (matched, matchType) = result;
|
foreach (var (song, score, matchType) in candidates)
|
||||||
if (matched != null)
|
|
||||||
{
|
{
|
||||||
matchedTracks.Add(matched);
|
allCandidates.Add((spotifyTrack, song, score, matchType));
|
||||||
if (matchType == "isrc") isrcMatches++;
|
|
||||||
else if (matchType == "fuzzy") fuzzyMatches++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
noMatch++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting between batches (not between individual tracks)
|
// Rate limiting between batches
|
||||||
if (i + BatchSize < orderedTracks.Count)
|
if (i + BatchSize < orderedTracks.Count)
|
||||||
{
|
{
|
||||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
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)
|
if (matchedTracks.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -483,7 +509,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
||||||
|
|
||||||
_logger.LogInformation(
|
_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);
|
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||||
|
|
||||||
// Pre-build playlist items cache for instant serving
|
// 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>
|
/// <summary>
|
||||||
/// Attempts to match a track by ISRC using provider search.
|
/// Attempts to match a track by ISRC using provider search.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -524,7 +608,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
private async Task<Song?> TryMatchByFuzzyAsync(
|
private async Task<Song?> TryMatchByFuzzyAsync(
|
||||||
string title,
|
string title,
|
||||||
@@ -534,16 +623,22 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
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;
|
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
|
.Select(song => new
|
||||||
{
|
{
|
||||||
Song = song,
|
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)
|
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
})
|
})
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
@@ -551,13 +646,39 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
x.Song,
|
x.Song,
|
||||||
x.TitleScore,
|
x.TitleScore,
|
||||||
x.ArtistScore,
|
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)
|
.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;
|
return bestMatch.Song;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,7 +935,60 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
JsonElement? matchedJellyfinItem = null;
|
JsonElement? matchedJellyfinItem = null;
|
||||||
string? matchedKey = 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 externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
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;
|
double bestScore = 0;
|
||||||
|
|
||||||
foreach (var kvp in jellyfinItemsByName)
|
foreach (var kvp in jellyfinItemsByName)
|
||||||
@@ -940,11 +1114,18 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
artist = artistsEl[0].GetString() ?? "";
|
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);
|
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
||||||
|
|
||||||
|
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||||
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
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;
|
bestScore = totalScore;
|
||||||
matchedJellyfinItem = item;
|
matchedJellyfinItem = item;
|
||||||
@@ -958,6 +1139,22 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||||
if (itemDict != null)
|
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);
|
finalItems.Add(itemDict);
|
||||||
if (matchedKey != null)
|
if (matchedKey != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -176,10 +176,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||||
|
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
|
||||||
return new List<Artist>();
|
return new List<Artist>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,11 +199,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (count >= limit) break;
|
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++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
|
||||||
return artists;
|
return artists;
|
||||||
}, new List<Artist>());
|
}, new List<Artist>());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -718,6 +718,84 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Configuration Tab -->
|
<!-- Configuration Tab -->
|
||||||
@@ -1331,6 +1409,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
|
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
||||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
||||||
<button onclick="prefetchLyrics('${escapeJs(p.name)}')">Prefetch Lyrics</button>
|
<button onclick="prefetchLyrics('${escapeJs(p.name)}')">Prefetch Lyrics</button>
|
||||||
<button onclick="viewTracks('${escapeJs(p.name)}')">View</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() {
|
async function fetchConfig() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/config');
|
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) {
|
async function matchPlaylistTracks(name) {
|
||||||
try {
|
try {
|
||||||
showToast(`Matching tracks for ${name}...`, 'success');
|
showToast(`Matching tracks for ${name}...`, 'success');
|
||||||
@@ -2515,6 +2753,8 @@
|
|||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
fetchTrackMappings();
|
fetchTrackMappings();
|
||||||
|
fetchMissingTracks();
|
||||||
|
fetchDownloads();
|
||||||
fetchJellyfinUsers();
|
fetchJellyfinUsers();
|
||||||
fetchJellyfinPlaylists();
|
fetchJellyfinPlaylists();
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
@@ -2524,6 +2764,8 @@
|
|||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
fetchTrackMappings();
|
fetchTrackMappings();
|
||||||
|
fetchMissingTracks();
|
||||||
|
fetchDownloads();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</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