mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55: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>
|
/// <summary>
|
||||||
/// Save manual track mapping
|
/// Save manual track mapping
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -896,7 +946,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear ALL Redis cache keys for Spotify playlists
|
// 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)
|
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||||
{
|
{
|
||||||
var keysToDelete = new[]
|
var keysToDelete = new[]
|
||||||
@@ -904,7 +954,8 @@ public class AdminController : ControllerBase
|
|||||||
$"spotify:playlist:{playlist.Name}",
|
$"spotify:playlist:{playlist.Name}",
|
||||||
$"spotify:missing:{playlist.Name}",
|
$"spotify:missing:{playlist.Name}",
|
||||||
$"spotify:matched:{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)
|
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 {
|
return Ok(new {
|
||||||
message = "Cache cleared successfully",
|
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}",
|
_logger.LogInformation("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
|
||||||
searchTerm, includeItemTypes, parentId, artistIds, 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 filtering by artist, handle external artists
|
||||||
if (!string.IsNullOrWhiteSpace(artistIds))
|
if (!string.IsNullOrWhiteSpace(artistIds))
|
||||||
{
|
{
|
||||||
@@ -334,6 +348,14 @@ public class JellyfinController : ControllerBase
|
|||||||
StartIndex = startIndex
|
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...");
|
_logger.LogInformation("About to serialize response...");
|
||||||
|
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
||||||
@@ -2931,6 +2953,41 @@ public class JellyfinController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
|
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
|
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
||||||
var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}";
|
var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}";
|
||||||
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
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)",
|
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||||
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
|
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 raw Jellyfin response format
|
||||||
return new JsonResult(new
|
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>
|
/// <summary>
|
||||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||||
/// GET /spotify/sync?api_key=YOUR_KEY
|
/// GET /spotify/sync?api_key=YOUR_KEY
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ public static class FuzzyMatcher
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var queryLower = query.ToLowerInvariant().Trim();
|
var queryLower = NormalizeForMatching(query);
|
||||||
var targetLower = target.ToLowerInvariant().Trim();
|
var targetLower = NormalizeForMatching(target);
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if (queryLower == targetLower)
|
if (queryLower == targetLower)
|
||||||
@@ -59,6 +59,34 @@ public static class FuzzyMatcher
|
|||||||
return (int)Math.Max(0, similarity);
|
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>
|
/// <summary>
|
||||||
/// Calculates Levenshtein distance between two strings.
|
/// Calculates Levenshtein distance between two strings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -168,4 +168,34 @@ public class RedisCacheService
|
|||||||
return false;
|
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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Search Jellyfin Tracks</label>
|
<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>
|
||||||
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
|
<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;">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="map-playlist-name">
|
<input type="hidden" id="map-playlist-name">
|
||||||
@@ -1757,10 +1768,13 @@
|
|||||||
const query = document.getElementById('map-search-query').value.trim();
|
const query = document.getElementById('map-search-query').value.trim();
|
||||||
|
|
||||||
if (!query) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear URL input when searching
|
||||||
|
document.getElementById('map-jellyfin-url').value = '';
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(async () => {
|
searchTimeout = setTimeout(async () => {
|
||||||
@@ -1792,6 +1806,77 @@
|
|||||||
}, 300);
|
}, 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) {
|
function selectJellyfinTrack(jellyfinId, element) {
|
||||||
// Remove selection from all tracks
|
// Remove selection from all tracks
|
||||||
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
||||||
|
|||||||
Reference in New Issue
Block a user