From aadda9b873bd110cb7ee98a7bf53fa251dcdf841 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Wed, 4 Feb 2026 01:44:56 -0500 Subject: [PATCH] Fix apostrophe matching, add URL input for track mapping, improve search caching - 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 --- allstarr/Controllers/AdminController.cs | 66 ++++++++- allstarr/Controllers/JellyfinController.cs | 131 ++++++++++++++++++ allstarr/Services/Common/FuzzyMatcher.cs | 32 ++++- allstarr/Services/Common/RedisCacheService.cs | 30 ++++ allstarr/wwwroot/index.html | 91 +++++++++++- 5 files changed, 342 insertions(+), 8 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 8b6c8d6..b7363b6 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -553,6 +553,56 @@ public class AdminController : ControllerBase } } + /// + /// Get track details by Jellyfin ID (for URL-based mapping) + /// + [HttpGet("jellyfin/track/{id}")] + public async Task 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" }); + } + } + /// /// Save manual track mapping /// @@ -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", diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index b4d9a66..9d00358 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -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(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)) { @@ -334,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 @@ -2931,6 +2953,41 @@ public class JellyfinController : ControllerBase /// private async Task GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId) { + // Check Redis cache first for fast serving + var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}"; + var cachedItems = await _cache.GetAsync>>(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>(orderedCacheKey); @@ -3094,6 +3151,12 @@ public class JellyfinController : ControllerBase "๐ŸŽต Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)", spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount); + // Save to file cache for persistence across restarts + await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems); + + // 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 { @@ -3482,6 +3545,74 @@ public class JellyfinController : ControllerBase } } + /// + /// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts. + /// + private async Task SavePlaylistItemsToFile(string playlistName, List> 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); + } + } + + /// + /// Loads playlist items (raw Jellyfin JSON) from file cache. + /// + private async Task>?> 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>>(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; + } + } + /// /// Manual trigger endpoint to force fetch Spotify missing tracks. /// GET /spotify/sync?api_key=YOUR_KEY diff --git a/allstarr/Services/Common/FuzzyMatcher.cs b/allstarr/Services/Common/FuzzyMatcher.cs index 92664f0..1615994 100644 --- a/allstarr/Services/Common/FuzzyMatcher.cs +++ b/allstarr/Services/Common/FuzzyMatcher.cs @@ -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); } + + /// + /// Normalizes a string for matching by: + /// - Converting to lowercase + /// - Normalizing apostrophes (', ', ') to standard ' + /// - Removing extra whitespace + /// + 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; + } /// /// Calculates Levenshtein distance between two strings. diff --git a/allstarr/Services/Common/RedisCacheService.cs b/allstarr/Services/Common/RedisCacheService.cs index 84cc9fd..5148a0e 100644 --- a/allstarr/Services/Common/RedisCacheService.cs +++ b/allstarr/Services/Common/RedisCacheService.cs @@ -168,4 +168,34 @@ public class RedisCacheService return false; } } + + /// + /// Deletes all keys matching a pattern (e.g., "search:*"). + /// WARNING: Use with caution as this scans all keys. + /// + public async Task 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; + } + } } diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 28e2a3d..b9b40bc 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -873,11 +873,22 @@
- + + + Tip: Use commas to search multiple terms (e.g., "It Ain't Easy, David Bowie") + +
+
โ€” OR โ€”
+
+ + + + Paste the full URL from your Jellyfin web interface +

- Type to search for local tracks... + Type to search for local tracks or paste a Jellyfin URL...

@@ -1757,10 +1768,13 @@ const query = document.getElementById('map-search-query').value.trim(); if (!query) { - document.getElementById('map-search-results').innerHTML = '

Type to search for local tracks...

'; + document.getElementById('map-search-results').innerHTML = '

Type to search for local tracks or paste a Jellyfin URL...

'; return; } + // Clear URL input when searching + document.getElementById('map-jellyfin-url').value = ''; + // Debounce search clearTimeout(searchTimeout); searchTimeout = setTimeout(async () => { @@ -1792,6 +1806,77 @@ }, 300); } + async function extractJellyfinId() { + const url = document.getElementById('map-jellyfin-url').value.trim(); + + if (!url) { + document.getElementById('map-search-results').innerHTML = '

Type to search for local tracks or paste a Jellyfin URL...

'; + 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 = '

Could not extract track ID from URL. Make sure it contains "?id=..."

'; + 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 = '
Loading track details...
'; + + 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 = ` +
+
+

${escapeHtml(track.title)}

+ ${escapeHtml(track.artist)} +
+
+ ${track.album ? escapeHtml(track.album) : ''} +
+
+

+ โœ“ Track loaded from URL. Click "Save Mapping" to confirm. +

+ `; + } else { + document.getElementById('map-search-results').innerHTML = '

Track not found in Jellyfin

'; + document.getElementById('map-selected-jellyfin-id').value = ''; + document.getElementById('map-save-btn').disabled = true; + } + } catch (error) { + document.getElementById('map-search-results').innerHTML = '

Failed to load track details

'; + 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 => {