Compare commits

..

4 Commits

Author SHA1 Message Date
aadda9b873 Fix apostrophe matching, add URL input for track mapping, improve search caching
Some checks failed
CI / build-and-test (push) Has been cancelled
- Enhanced FuzzyMatcher to normalize apostrophes (', ', ', etc) for better matching
- Added Redis-only caching for search results (15 min TTL)
- Added pattern-based cache deletion for search and image keys
- Added URL input field in Map to Local modal to paste Jellyfin track URLs
- Added /api/admin/jellyfin/track/{id} endpoint to fetch track details by ID
- Fixed duplicate cache key declaration in GetSpotifyPlaylistTracksOrderedAsync
- Updated cache clearing to include new spotify:playlist:items:* keys
2026-02-04 01:44:56 -05:00
8a84237f13 Fix MediaSources field appending to query string
- Properly parse and modify Fields parameter instead of appending to end
- Fixes 400 BadRequest errors from malformed URLs
- Now correctly adds MediaSources to Fields parameter value
2026-02-03 23:56:40 -05:00
e3a118e578 Fix bitrate for injected playlists by preserving raw Jellyfin items
CRITICAL FIX: Don't convert Jellyfin items to Song objects and back!

- Get raw Jellyfin playlist items with MediaSources field
- Reorder them according to Spotify positions
- Inject external tracks where needed
- Return raw items preserving ALL Jellyfin metadata (bitrate, etc)

This ensures local tracks in Spotify playlists show correct bitrate
just like regular Jellyfin playlists.
2026-02-03 23:41:29 -05:00
e17eee9bf3 Fix Map to Local button using data attributes instead of inline onclick
- Avoid JavaScript escaping issues with inline onclick handlers
- Use data attributes to store track info
- Add event listeners after DOM is created
- Prevents syntax errors with special characters in track names
2026-02-03 23:24:19 -05:00
5 changed files with 488 additions and 226 deletions

View File

@@ -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",

View File

@@ -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 if (!queryString.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
else
{
// Fields parameter exists but doesn't include MediaSources, append it
queryString = $"{queryString},MediaSources";
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
{
// 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() ?? "";
}
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist - will match by name only",
existingTracks.Count);
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", 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)
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title),
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist)
})
.Select(x => new
// Use the raw Jellyfin item (preserves ALL metadata including MediaSources!)
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
if (itemDict != null)
{
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);
finalItems.Add(itemDict);
usedJellyfinItems.Add(matchedKey);
localUsedCount++;
continue; // Use local track, skip external search
_logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)",
spotifyTrack.Position, spotifyTrack.Title, bestScore);
}
}
else
{
// 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)
if (matched != null && matched.MatchedSong != null)
{
finalTracks.Add(matched.MatchedSong);
// Convert external song to Jellyfin item format
var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
finalItems.Add(externalItem);
externalUsedCount++;
_logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (cached): {Provider}/{Id}",
spotifyTrack.Position,
spotifyTrack.Title,
spotifyTrack.PrimaryArtist,
matched.MatchedSong.ExternalProvider,
matched.MatchedSong.ExternalId);
_logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id}",
spotifyTrack.Position, spotifyTrack.Title,
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId);
}
else
{
// No cached match - search external providers on-demand
try
{
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);
}
}
catch (Exception ex)
{
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

View File

@@ -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)
@@ -59,6 +59,34 @@ public static class FuzzyMatcher
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.
/// </summary>

View File

@@ -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;
}
}
}

View File

@@ -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 => {