mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 15:45:10 -05:00
Fix apostrophe matching, add URL input for track mapping, improve search caching
Some checks failed
CI / build-and-test (push) Has been cancelled
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
This commit is contained in:
@@ -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))
|
||||
{
|
||||
@@ -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
|
||||
/// </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);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <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">
|
||||
@@ -1757,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 () => {
|
||||
@@ -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 = '<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