mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
4 Commits
4229924f61
...
aadda9b873
| Author | SHA1 | Date | |
|---|---|---|---|
|
aadda9b873
|
|||
|
8a84237f13
|
|||
|
e3a118e578
|
|||
|
e17eee9bf3
|
@@ -553,6 +553,56 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get track details by Jellyfin ID (for URL-based mapping)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/track/{id}")]
|
||||
public async Task<IActionResult> GetJellyfinTrack(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return BadRequest(new { error = "Track ID is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return StatusCode((int)response.StatusCode, new { error = "Track not found" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var item = doc.RootElement;
|
||||
var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
||||
var artist = "";
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
return Ok(new { id = trackId, title, artist, album });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get Jellyfin track {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to get track details" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save manual track mapping
|
||||
/// </summary>
|
||||
@@ -896,7 +946,7 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
|
||||
// Clear ALL Redis cache keys for Spotify playlists
|
||||
// This includes matched tracks, ordered tracks, missing tracks, etc.
|
||||
// This includes matched tracks, ordered tracks, missing tracks, playlist items, etc.
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var keysToDelete = new[]
|
||||
@@ -904,7 +954,8 @@ public class AdminController : ControllerBase
|
||||
$"spotify:playlist:{playlist.Name}",
|
||||
$"spotify:missing:{playlist.Name}",
|
||||
$"spotify:matched:{playlist.Name}",
|
||||
$"spotify:matched:ordered:{playlist.Name}"
|
||||
$"spotify:matched:ordered:{playlist.Name}",
|
||||
$"spotify:playlist:items:{playlist.Name}" // NEW: Clear file-backed playlist items cache
|
||||
};
|
||||
|
||||
foreach (var key in keysToDelete)
|
||||
@@ -917,7 +968,16 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys", clearedFiles, clearedRedisKeys);
|
||||
// Clear all search cache keys (pattern-based deletion)
|
||||
var searchKeysDeleted = await _cache.DeleteByPatternAsync("search:*");
|
||||
clearedRedisKeys += searchKeysDeleted;
|
||||
|
||||
// Clear all image cache keys (pattern-based deletion)
|
||||
var imageKeysDeleted = await _cache.DeleteByPatternAsync("image:*");
|
||||
clearedRedisKeys += imageKeysDeleted;
|
||||
|
||||
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)",
|
||||
clearedFiles, clearedRedisKeys, searchKeysDeleted, imageKeysDeleted);
|
||||
|
||||
return Ok(new {
|
||||
message = "Cache cleared successfully",
|
||||
|
||||
@@ -102,6 +102,20 @@ public class JellyfinController : ControllerBase
|
||||
_logger.LogInformation("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
|
||||
searchTerm, includeItemTypes, parentId, artistIds, userId);
|
||||
|
||||
// Cache search results in Redis only (no file persistence, 15 min TTL)
|
||||
// Only cache actual searches, not browse operations
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
|
||||
{
|
||||
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
|
||||
var cachedResult = await _cache.GetAsync<object>(cacheKey);
|
||||
|
||||
if (cachedResult != null)
|
||||
{
|
||||
_logger.LogDebug("✅ Returning cached search results for '{SearchTerm}'", searchTerm);
|
||||
return new JsonResult(cachedResult);
|
||||
}
|
||||
}
|
||||
|
||||
// If filtering by artist, handle external artists
|
||||
if (!string.IsNullOrWhiteSpace(artistIds))
|
||||
{
|
||||
@@ -126,17 +140,50 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
// Ensure MediaSources is included in Fields parameter for bitrate info
|
||||
var queryString = Request.QueryString.Value ?? "";
|
||||
if (!queryString.Contains("Fields=", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
if (!string.IsNullOrEmpty(queryString))
|
||||
{
|
||||
// No Fields parameter, add MediaSources
|
||||
queryString = string.IsNullOrEmpty(queryString)
|
||||
? "?Fields=MediaSources"
|
||||
: $"{queryString}&Fields=MediaSources";
|
||||
// Parse query string to modify Fields parameter
|
||||
var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
|
||||
if (queryParams.ContainsKey("Fields"))
|
||||
{
|
||||
var fieldsValue = queryParams["Fields"].ToString();
|
||||
if (!fieldsValue.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Append MediaSources to existing Fields
|
||||
var newFields = string.IsNullOrEmpty(fieldsValue)
|
||||
? "MediaSources"
|
||||
: $"{fieldsValue},MediaSources";
|
||||
|
||||
// Rebuild query string with updated Fields
|
||||
var newQueryParams = new Dictionary<string, string>();
|
||||
foreach (var kvp in queryParams)
|
||||
{
|
||||
if (kvp.Key == "Fields")
|
||||
{
|
||||
newQueryParams[kvp.Key] = newFields;
|
||||
}
|
||||
else
|
||||
{
|
||||
newQueryParams[kvp.Key] = kvp.Value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
queryString = "?" + string.Join("&", newQueryParams.Select(kvp =>
|
||||
$"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No Fields parameter, add it
|
||||
queryString = $"{queryString}&Fields=MediaSources";
|
||||
}
|
||||
}
|
||||
else if (!queryString.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
|
||||
else
|
||||
{
|
||||
// Fields parameter exists but doesn't include MediaSources, append it
|
||||
queryString = $"{queryString},MediaSources";
|
||||
// No query string at all
|
||||
queryString = "?Fields=MediaSources";
|
||||
}
|
||||
|
||||
endpoint = $"{endpoint}{queryString}";
|
||||
@@ -301,6 +348,14 @@ public class JellyfinController : ControllerBase
|
||||
StartIndex = startIndex
|
||||
};
|
||||
|
||||
// Cache search results in Redis (15 min TTL, no file persistence)
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
|
||||
{
|
||||
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
|
||||
await _cache.SetAsync(cacheKey, response, TimeSpan.FromMinutes(15));
|
||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' (15 min TTL)", searchTerm);
|
||||
}
|
||||
|
||||
_logger.LogInformation("About to serialize response...");
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
||||
@@ -2898,6 +2953,41 @@ public class JellyfinController : ControllerBase
|
||||
/// </summary>
|
||||
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
|
||||
{
|
||||
// Check Redis cache first for fast serving
|
||||
var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}";
|
||||
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
||||
|
||||
if (cachedItems != null && cachedItems.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("✅ Loaded {Count} playlist items from Redis cache for {Playlist}",
|
||||
cachedItems.Count, spotifyPlaylistName);
|
||||
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = cachedItems,
|
||||
TotalRecordCount = cachedItems.Count,
|
||||
StartIndex = 0
|
||||
});
|
||||
}
|
||||
|
||||
// Check file cache as fallback
|
||||
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
||||
if (fileItems != null && fileItems.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("✅ Loaded {Count} playlist items from file cache for {Playlist}",
|
||||
fileItems.Count, spotifyPlaylistName);
|
||||
|
||||
// Restore to Redis cache
|
||||
await _cache.SetAsync(cacheKey, fileItems, TimeSpan.FromHours(24));
|
||||
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = fileItems,
|
||||
TotalRecordCount = fileItems.Count,
|
||||
StartIndex = 0
|
||||
});
|
||||
}
|
||||
|
||||
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
||||
var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}";
|
||||
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
||||
@@ -2912,7 +3002,7 @@ public class JellyfinController : ControllerBase
|
||||
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
||||
orderedTracks.Count, spotifyPlaylistName);
|
||||
|
||||
// Get existing Jellyfin playlist items (tracks the Spotify Import plugin already found)
|
||||
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||
var userId = _settings.UserId;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
@@ -2921,7 +3011,8 @@ public class JellyfinController : ControllerBase
|
||||
return null; // Fall back to legacy mode
|
||||
}
|
||||
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
|
||||
// Request MediaSources field to get bitrate info
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||
|
||||
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
||||
playlistId, userId);
|
||||
@@ -2937,24 +3028,41 @@ public class JellyfinController : ControllerBase
|
||||
return null;
|
||||
}
|
||||
|
||||
var existingTracks = new List<Song>();
|
||||
// Keep raw Jellyfin items - don't convert to Song objects!
|
||||
var jellyfinItems = new List<JsonElement>();
|
||||
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
|
||||
|
||||
if (existingTracksResponse != null &&
|
||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var song = _modelMapper.ParseSong(item);
|
||||
existingTracks.Add(song);
|
||||
_logger.LogDebug(" 📌 Local track: {Title} - {Artist}", song.Title, song.Artist);
|
||||
jellyfinItems.Add(item);
|
||||
|
||||
// Index by title+artist for matching
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
var key = $"{title}|{artist}".ToLowerInvariant();
|
||||
if (!jellyfinItemsByName.ContainsKey(key))
|
||||
{
|
||||
jellyfinItemsByName[key] = item;
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist - will match by name only",
|
||||
existingTracks.Count);
|
||||
|
||||
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId);
|
||||
// Don't return null - continue with external tracks only
|
||||
}
|
||||
|
||||
// Get the full playlist from Spotify to know the correct order
|
||||
@@ -2966,225 +3074,96 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// Build the final track list in correct Spotify order
|
||||
// STRATEGY: Match Jellyfin tracks to Spotify positions, then fill gaps with external
|
||||
var finalTracks = new List<Song>();
|
||||
var finalItems = new List<Dictionary<string, object?>>();
|
||||
var usedJellyfinItems = new HashSet<string>();
|
||||
var localUsedCount = 0;
|
||||
var externalUsedCount = 0;
|
||||
var skippedCount = 0;
|
||||
|
||||
_logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...",
|
||||
existingTracks.Count, spotifyTracks.Count);
|
||||
|
||||
// Step 1: Check for manual mappings first
|
||||
var manualMappings = new Dictionary<string, string>(); // Spotify ID -> Jellyfin ID
|
||||
foreach (var spotifyTrack in spotifyTracks)
|
||||
{
|
||||
var mappingKey = $"spotify:manual-map:{spotifyPlaylistName}:{spotifyTrack.SpotifyId}";
|
||||
var jellyfinId = await _cache.GetAsync<string>(mappingKey);
|
||||
if (!string.IsNullOrEmpty(jellyfinId))
|
||||
{
|
||||
manualMappings[spotifyTrack.SpotifyId] = jellyfinId;
|
||||
_logger.LogInformation("📌 Manual mapping found: Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||
spotifyTrack.SpotifyId, jellyfinId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: For each Spotify position, find the best matching Jellyfin track
|
||||
var spotifyToJellyfinMap = new Dictionary<int, Song>(); // Spotify position -> Jellyfin track
|
||||
var usedJellyfinTracks = new HashSet<string>(); // Track which Jellyfin tracks we've used
|
||||
_logger.LogInformation("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
|
||||
|
||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||
{
|
||||
if (existingTracks.Count == 0) break;
|
||||
// Try to find matching Jellyfin item by fuzzy matching
|
||||
JsonElement? matchedJellyfinItem = null;
|
||||
string? matchedKey = null;
|
||||
double bestScore = 0;
|
||||
|
||||
// Check for manual mapping first
|
||||
if (manualMappings.TryGetValue(spotifyTrack.SpotifyId, out var mappedJellyfinId))
|
||||
foreach (var kvp in jellyfinItemsByName)
|
||||
{
|
||||
var mappedTrack = existingTracks.FirstOrDefault(t => t.Id == mappedJellyfinId);
|
||||
if (mappedTrack != null && !usedJellyfinTracks.Contains(mappedTrack.Id))
|
||||
if (usedJellyfinItems.Contains(kvp.Key)) continue;
|
||||
|
||||
var item = kvp.Value;
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
spotifyToJellyfinMap[spotifyTrack.Position] = mappedTrack;
|
||||
usedJellyfinTracks.Add(mappedTrack.Id);
|
||||
_logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' → LOCAL (manual): '{JellyfinTitle}'",
|
||||
spotifyTrack.Position, spotifyTrack.Title, mappedTrack.Title);
|
||||
continue;
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
|
||||
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
|
||||
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
||||
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
||||
|
||||
if (totalScore > bestScore && totalScore >= 70)
|
||||
{
|
||||
bestScore = totalScore;
|
||||
matchedJellyfinItem = item;
|
||||
matchedKey = kvp.Key;
|
||||
}
|
||||
}
|
||||
|
||||
// Find best matching Jellyfin track that hasn't been used yet
|
||||
var bestMatch = existingTracks
|
||||
.Where(song => !usedJellyfinTracks.Contains(song.Id))
|
||||
.Select(song => new
|
||||
if (matchedJellyfinItem.HasValue && matchedKey != null)
|
||||
{
|
||||
// Use the raw Jellyfin item (preserves ALL metadata including MediaSources!)
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Use 70% threshold for matching
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||
{
|
||||
spotifyToJellyfinMap[spotifyTrack.Position] = bestMatch.Song;
|
||||
usedJellyfinTracks.Add(bestMatch.Song.Id);
|
||||
_logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' by {SpotifyArtist} → LOCAL: '{JellyfinTitle}' by {JellyfinArtist} (score: {Score:F1}%)",
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
spotifyTrack.PrimaryArtist,
|
||||
bestMatch.Song.Title,
|
||||
bestMatch.Song.Artist,
|
||||
bestMatch.TotalScore);
|
||||
}
|
||||
else if (bestMatch != null)
|
||||
{
|
||||
_logger.LogDebug(" ⚠️ Position #{Pos} '{SpotifyTitle}' - Best Jellyfin match too low: {Score:F1}% (need 70%)",
|
||||
spotifyTrack.Position, spotifyTrack.Title, bestMatch.TotalScore);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks ({Manual} manual)",
|
||||
spotifyToJellyfinMap.Count, spotifyTracks.Count, manualMappings.Count);
|
||||
|
||||
// Step 3: Build final playlist in Spotify order
|
||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||
{
|
||||
// Check if we have a Jellyfin track for this position
|
||||
if (spotifyToJellyfinMap.TryGetValue(spotifyTrack.Position, out var jellyfinTrack))
|
||||
{
|
||||
finalTracks.Add(jellyfinTrack);
|
||||
localUsedCount++;
|
||||
continue; // Use local track, skip external search
|
||||
}
|
||||
|
||||
// No local match - try to find external track
|
||||
// First check pre-matched cache
|
||||
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||
if (matched != null)
|
||||
{
|
||||
finalTracks.Add(matched.MatchedSong);
|
||||
externalUsedCount++;
|
||||
_logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (cached): {Provider}/{Id}",
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
spotifyTrack.PrimaryArtist,
|
||||
matched.MatchedSong.ExternalProvider,
|
||||
matched.MatchedSong.ExternalId);
|
||||
finalItems.Add(itemDict);
|
||||
usedJellyfinItems.Add(matchedKey);
|
||||
localUsedCount++;
|
||||
_logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)",
|
||||
spotifyTrack.Position, spotifyTrack.Title, bestScore);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No cached match - search external providers on-demand
|
||||
try
|
||||
// No local match - try to find external track
|
||||
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||
if (matched != null && matched.MatchedSong != null)
|
||||
{
|
||||
var query = $"{spotifyTrack.Title} {spotifyTrack.PrimaryArtist}";
|
||||
var searchResults = await _metadataService.SearchSongsAsync(query, limit: 5);
|
||||
|
||||
if (searchResults.Count > 0)
|
||||
{
|
||||
// Fuzzy match to find best result
|
||||
var bestExternalMatch = searchResults
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (bestExternalMatch != null && bestExternalMatch.TotalScore >= 60)
|
||||
{
|
||||
finalTracks.Add(bestExternalMatch.Song);
|
||||
externalUsedCount++;
|
||||
_logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (on-demand): {Provider}/{Id} (score: {Score:F1}%)",
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
spotifyTrack.PrimaryArtist,
|
||||
bestExternalMatch.Song.ExternalProvider,
|
||||
bestExternalMatch.Song.ExternalId,
|
||||
bestExternalMatch.TotalScore);
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedCount++;
|
||||
_logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (best external score: {Score:F1}%, need 60%)",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||
bestExternalMatch?.TotalScore ?? 0);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedCount++;
|
||||
_logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (no external results)",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
}
|
||||
// Convert external song to Jellyfin item format
|
||||
var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
||||
finalItems.Add(externalItem);
|
||||
externalUsedCount++;
|
||||
_logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id}",
|
||||
spotifyTrack.Position, spotifyTrack.Title,
|
||||
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
else
|
||||
{
|
||||
skippedCount++;
|
||||
_logger.LogError(ex, "❌ Position #{Pos}: '{Title}' by {Artist} → ERROR searching external providers",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
_logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH",
|
||||
spotifyTrack.Position, spotifyTrack.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Add any unmatched Jellyfin tracks at the end
|
||||
var unmatchedJellyfinTracks = existingTracks
|
||||
.Where(song => !usedJellyfinTracks.Contains(song.Id))
|
||||
.ToList();
|
||||
|
||||
if (unmatchedJellyfinTracks.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("📌 Adding {Count} unmatched Jellyfin tracks at the end (not in Spotify playlist)",
|
||||
unmatchedJellyfinTracks.Count);
|
||||
|
||||
foreach (var track in unmatchedJellyfinTracks)
|
||||
{
|
||||
finalTracks.Add(track);
|
||||
localUsedCount++;
|
||||
_logger.LogInformation(" + '{Title}' by {Artist} (Jellyfin only)", track.Title, track.Artist);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
||||
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
||||
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
|
||||
|
||||
_logger.LogInformation(
|
||||
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL, {Skipped} not available)",
|
||||
spotifyPlaylistName,
|
||||
finalTracks.Count,
|
||||
localUsedCount,
|
||||
externalUsedCount,
|
||||
skippedCount);
|
||||
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
|
||||
|
||||
if (localUsedCount == 0 && existingTracks.Count > 0)
|
||||
{
|
||||
_logger.LogWarning("⚠️ WARNING: Found {Count} tracks in Jellyfin playlist but NONE matched by name!", existingTracks.Count);
|
||||
_logger.LogWarning(" → Track names may be too different between Spotify and Jellyfin");
|
||||
_logger.LogWarning(" → Check that the Jellyfin playlist has the correct tracks");
|
||||
}
|
||||
else if (localUsedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("✅ Successfully used {Local} LOCAL tracks from Jellyfin playlist", localUsedCount);
|
||||
}
|
||||
// Save to file cache for persistence across restarts
|
||||
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(finalTracks);
|
||||
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
|
||||
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
||||
|
||||
// Return raw Jellyfin response format
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = finalItems,
|
||||
TotalRecordCount = finalItems.Count,
|
||||
StartIndex = 0
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3566,6 +3545,74 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogInformation("💾 Saved {Count} playlist items to file cache for {Playlist}",
|
||||
items.Count, playlistName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads playlist items (raw Jellyfin JSON) from file cache.
|
||||
/// </summary>
|
||||
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
_logger.LogDebug("No playlist items file cache found for {Playlist} at {Path}", playlistName, filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
|
||||
|
||||
// Check if cache is too old (more than 24 hours)
|
||||
if (fileAge.TotalHours > 24)
|
||||
{
|
||||
_logger.LogInformation("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
|
||||
playlistName, fileAge.TotalHours);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Playlist items file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
|
||||
|
||||
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
|
||||
items?.Count ?? 0, playlistName, fileAge.TotalHours);
|
||||
|
||||
return items;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||
/// GET /spotify/sync?api_key=YOUR_KEY
|
||||
|
||||
@@ -16,8 +16,8 @@ public static class FuzzyMatcher
|
||||
return 0;
|
||||
}
|
||||
|
||||
var queryLower = query.ToLowerInvariant().Trim();
|
||||
var targetLower = target.ToLowerInvariant().Trim();
|
||||
var queryLower = NormalizeForMatching(query);
|
||||
var targetLower = NormalizeForMatching(target);
|
||||
|
||||
// Exact match
|
||||
if (queryLower == targetLower)
|
||||
@@ -58,6 +58,34 @@ public static class FuzzyMatcher
|
||||
var similarity = (1.0 - (double)distance / maxLength) * 60;
|
||||
return (int)Math.Max(0, similarity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a string for matching by:
|
||||
/// - Converting to lowercase
|
||||
/// - Normalizing apostrophes (', ', ') to standard '
|
||||
/// - Removing extra whitespace
|
||||
/// </summary>
|
||||
private static string NormalizeForMatching(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = text.ToLowerInvariant().Trim();
|
||||
|
||||
// Normalize different apostrophe types to standard apostrophe
|
||||
normalized = normalized
|
||||
.Replace(''', '\'') // Right single quotation mark
|
||||
.Replace(''', '\'') // Left single quotation mark
|
||||
.Replace('`', '\'') // Grave accent
|
||||
.Replace('´', '\''); // Acute accent
|
||||
|
||||
// Normalize whitespace
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Levenshtein distance between two strings.
|
||||
|
||||
@@ -168,4 +168,34 @@ public class RedisCacheService
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all keys matching a pattern (e.g., "search:*").
|
||||
/// WARNING: Use with caution as this scans all keys.
|
||||
/// </summary>
|
||||
public async Task<int> DeleteByPatternAsync(string pattern)
|
||||
{
|
||||
if (!IsEnabled) return 0;
|
||||
|
||||
try
|
||||
{
|
||||
var server = _redis!.GetServer(_redis.GetEndPoints().First());
|
||||
var keys = server.Keys(pattern: pattern).ToArray();
|
||||
|
||||
if (keys.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var deleted = await _db!.KeyDeleteAsync(keys);
|
||||
_logger.LogInformation("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
|
||||
return (int)deleted;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,11 +873,22 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Search Jellyfin Tracks</label>
|
||||
<input type="text" id="map-search-query" placeholder="Search by title or artist..." oninput="searchJellyfinTracks()">
|
||||
<input type="text" id="map-search-query" placeholder="Search by title, artist, or album..." oninput="searchJellyfinTracks()">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Tip: Use commas to search multiple terms (e.g., "It Ain't Easy, David Bowie")
|
||||
</small>
|
||||
</div>
|
||||
<div style="text-align: center; color: var(--text-secondary); margin: 12px 0;">— OR —</div>
|
||||
<div class="form-group">
|
||||
<label>Paste Jellyfin Track URL</label>
|
||||
<input type="text" id="map-jellyfin-url" placeholder="https://jellyfin.example.com/web/#/details?id=..." oninput="extractJellyfinId()">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Paste the full URL from your Jellyfin web interface
|
||||
</small>
|
||||
</div>
|
||||
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
|
||||
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
|
||||
Type to search for local tracks...
|
||||
Type to search for local tracks or paste a Jellyfin URL...
|
||||
</p>
|
||||
</div>
|
||||
<input type="hidden" id="map-playlist-name">
|
||||
@@ -1614,15 +1625,15 @@
|
||||
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
||||
} else if (t.isLocal === false) {
|
||||
statusBadge = '<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>External</span>';
|
||||
// Add manual map button for external tracks
|
||||
// Use JSON.stringify to properly escape strings for JavaScript
|
||||
const escapedName = JSON.stringify(name);
|
||||
const escapedTitle = JSON.stringify(t.title || '');
|
||||
// Safely get first artist, defaulting to empty string
|
||||
// Add manual map button for external tracks using data attributes
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
const escapedArtist = JSON.stringify(firstArtist);
|
||||
const escapedSpotifyId = JSON.stringify(t.spotifyId || '');
|
||||
mapButton = `<button class="small" onclick="openManualMap(${escapedName}, ${t.position}, ${escapedTitle}, ${escapedArtist}, ${escapedSpotifyId})" style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
|
||||
mapButton = `<button class="small map-track-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -1639,6 +1650,18 @@
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add event listeners to map buttons
|
||||
document.querySelectorAll('.map-track-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const playlistName = this.getAttribute('data-playlist-name');
|
||||
const position = parseInt(this.getAttribute('data-position'));
|
||||
const title = this.getAttribute('data-title');
|
||||
const artist = this.getAttribute('data-artist');
|
||||
const spotifyId = this.getAttribute('data-spotify-id');
|
||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
||||
}
|
||||
@@ -1745,10 +1768,13 @@
|
||||
const query = document.getElementById('map-search-query').value.trim();
|
||||
|
||||
if (!query) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear URL input when searching
|
||||
document.getElementById('map-jellyfin-url').value = '';
|
||||
|
||||
// Debounce search
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(async () => {
|
||||
@@ -1780,6 +1806,77 @@
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function extractJellyfinId() {
|
||||
const url = document.getElementById('map-jellyfin-url').value.trim();
|
||||
|
||||
if (!url) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear search input when using URL
|
||||
document.getElementById('map-search-query').value = '';
|
||||
|
||||
// Extract ID from URL patterns:
|
||||
// https://jellyfin.example.com/web/#/details?id=XXXXX&serverId=...
|
||||
// https://jellyfin.example.com/web/index.html#!/details?id=XXXXX
|
||||
let jellyfinId = null;
|
||||
|
||||
try {
|
||||
const idMatch = url.match(/[?&]id=([a-f0-9]+)/i);
|
||||
if (idMatch) {
|
||||
jellyfinId = idMatch[1];
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid URL format
|
||||
}
|
||||
|
||||
if (!jellyfinId) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Could not extract track ID from URL. Make sure it contains "?id=..."</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch track details to show preview
|
||||
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Loading track details...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/jellyfin/track/' + jellyfinId);
|
||||
const track = await res.json();
|
||||
|
||||
if (res.ok && track.id) {
|
||||
document.getElementById('map-selected-jellyfin-id').value = track.id;
|
||||
document.getElementById('map-save-btn').disabled = false;
|
||||
|
||||
document.getElementById('map-search-results').innerHTML = `
|
||||
<div class="track-item" style="border: 2px solid var(--accent); background: var(--bg-tertiary);">
|
||||
<div class="track-info">
|
||||
<h4>${escapeHtml(track.title)}</h4>
|
||||
<span class="artists">${escapeHtml(track.artist)}</span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
${track.album ? escapeHtml(track.album) : ''}
|
||||
</div>
|
||||
</div>
|
||||
<p style="text-align: center; color: var(--success); padding: 12px; margin-top: 8px;">
|
||||
✓ Track loaded from URL. Click "Save Mapping" to confirm.
|
||||
</p>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Track not found in Jellyfin</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Failed to load track details</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function selectJellyfinTrack(jellyfinId, element) {
|
||||
// Remove selection from all tracks
|
||||
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
||||
|
||||
Reference in New Issue
Block a user