mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
4 Commits
4229924f61
...
aadda9b873
| Author | SHA1 | Date | |
|---|---|---|---|
|
aadda9b873
|
|||
|
8a84237f13
|
|||
|
e3a118e578
|
|||
|
e17eee9bf3
|
@@ -553,6 +553,56 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get track details by Jellyfin ID (for URL-based mapping)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("jellyfin/track/{id}")]
|
||||||
|
public async Task<IActionResult> GetJellyfinTrack(string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Track ID is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||||
|
|
||||||
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return StatusCode((int)response.StatusCode, new { error = "Track not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
var item = doc.RootElement;
|
||||||
|
var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||||
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||||
|
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
||||||
|
var artist = "";
|
||||||
|
|
||||||
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
artist = artistsEl[0].GetString() ?? "";
|
||||||
|
}
|
||||||
|
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||||
|
{
|
||||||
|
artist = albumArtistEl.GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { id = trackId, title, artist, album });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get Jellyfin track {Id}", id);
|
||||||
|
return StatusCode(500, new { error = "Failed to get track details" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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))
|
||||||
{
|
{
|
||||||
@@ -126,17 +140,50 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
// Ensure MediaSources is included in Fields parameter for bitrate info
|
// Ensure MediaSources is included in Fields parameter for bitrate info
|
||||||
var queryString = Request.QueryString.Value ?? "";
|
var queryString = Request.QueryString.Value ?? "";
|
||||||
if (!queryString.Contains("Fields=", StringComparison.OrdinalIgnoreCase))
|
|
||||||
|
if (!string.IsNullOrEmpty(queryString))
|
||||||
{
|
{
|
||||||
// No Fields parameter, add MediaSources
|
// Parse query string to modify Fields parameter
|
||||||
queryString = string.IsNullOrEmpty(queryString)
|
var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
? "?Fields=MediaSources"
|
|
||||||
: $"{queryString}&Fields=MediaSources";
|
if (queryParams.ContainsKey("Fields"))
|
||||||
|
{
|
||||||
|
var fieldsValue = queryParams["Fields"].ToString();
|
||||||
|
if (!fieldsValue.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Append MediaSources to existing Fields
|
||||||
|
var newFields = string.IsNullOrEmpty(fieldsValue)
|
||||||
|
? "MediaSources"
|
||||||
|
: $"{fieldsValue},MediaSources";
|
||||||
|
|
||||||
|
// Rebuild query string with updated Fields
|
||||||
|
var newQueryParams = new Dictionary<string, string>();
|
||||||
|
foreach (var kvp in queryParams)
|
||||||
|
{
|
||||||
|
if (kvp.Key == "Fields")
|
||||||
|
{
|
||||||
|
newQueryParams[kvp.Key] = newFields;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
newQueryParams[kvp.Key] = kvp.Value.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queryString = "?" + string.Join("&", newQueryParams.Select(kvp =>
|
||||||
|
$"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No Fields parameter, add it
|
||||||
|
queryString = $"{queryString}&Fields=MediaSources";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (!queryString.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
|
else
|
||||||
{
|
{
|
||||||
// Fields parameter exists but doesn't include MediaSources, append it
|
// No query string at all
|
||||||
queryString = $"{queryString},MediaSources";
|
queryString = "?Fields=MediaSources";
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint = $"{endpoint}{queryString}";
|
endpoint = $"{endpoint}{queryString}";
|
||||||
@@ -301,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
|
||||||
@@ -2898,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);
|
||||||
@@ -2912,7 +3002,7 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
||||||
orderedTracks.Count, spotifyPlaylistName);
|
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
|
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||||
var userId = _settings.UserId;
|
var userId = _settings.UserId;
|
||||||
if (string.IsNullOrEmpty(userId))
|
if (string.IsNullOrEmpty(userId))
|
||||||
@@ -2921,7 +3011,8 @@ public class JellyfinController : ControllerBase
|
|||||||
return null; // Fall back to legacy mode
|
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}",
|
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
||||||
playlistId, userId);
|
playlistId, userId);
|
||||||
@@ -2937,24 +3028,41 @@ public class JellyfinController : ControllerBase
|
|||||||
return null;
|
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 &&
|
if (existingTracksResponse != null &&
|
||||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||||
{
|
{
|
||||||
foreach (var item in items.EnumerateArray())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
var song = _modelMapper.ParseSong(item);
|
jellyfinItems.Add(item);
|
||||||
existingTracks.Add(song);
|
|
||||||
_logger.LogDebug(" 📌 Local track: {Title} - {Artist}", song.Title, song.Artist);
|
// Index by title+artist for matching
|
||||||
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||||
|
var artist = "";
|
||||||
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
artist = artistsEl[0].GetString() ?? "";
|
||||||
|
}
|
||||||
|
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||||
|
{
|
||||||
|
artist = albumArtistEl.GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = $"{title}|{artist}".ToLowerInvariant();
|
||||||
|
if (!jellyfinItemsByName.ContainsKey(key))
|
||||||
|
{
|
||||||
|
jellyfinItemsByName[key] = item;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist - will match by name only",
|
|
||||||
existingTracks.Count);
|
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId);
|
_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
|
// 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
|
// Build the final track list in correct Spotify order
|
||||||
// STRATEGY: Match Jellyfin tracks to Spotify positions, then fill gaps with external
|
var finalItems = new List<Dictionary<string, object?>>();
|
||||||
var finalTracks = new List<Song>();
|
var usedJellyfinItems = new HashSet<string>();
|
||||||
var localUsedCount = 0;
|
var localUsedCount = 0;
|
||||||
var externalUsedCount = 0;
|
var externalUsedCount = 0;
|
||||||
var skippedCount = 0;
|
|
||||||
|
|
||||||
_logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...",
|
_logger.LogInformation("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
|
||||||
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
|
|
||||||
|
|
||||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
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
|
foreach (var kvp in jellyfinItemsByName)
|
||||||
if (manualMappings.TryGetValue(spotifyTrack.SpotifyId, out var mappedJellyfinId))
|
|
||||||
{
|
{
|
||||||
var mappedTrack = existingTracks.FirstOrDefault(t => t.Id == mappedJellyfinId);
|
if (usedJellyfinItems.Contains(kvp.Key)) continue;
|
||||||
if (mappedTrack != null && !usedJellyfinTracks.Contains(mappedTrack.Id))
|
|
||||||
|
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;
|
artist = artistsEl[0].GetString() ?? "";
|
||||||
usedJellyfinTracks.Add(mappedTrack.Id);
|
}
|
||||||
_logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' → LOCAL (manual): '{JellyfinTitle}'",
|
|
||||||
spotifyTrack.Position, spotifyTrack.Title, mappedTrack.Title);
|
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
|
||||||
continue;
|
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
|
if (matchedJellyfinItem.HasValue && matchedKey != null)
|
||||||
var bestMatch = existingTracks
|
{
|
||||||
.Where(song => !usedJellyfinTracks.Contains(song.Id))
|
// Use the raw Jellyfin item (preserves ALL metadata including MediaSources!)
|
||||||
.Select(song => new
|
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||||
|
if (itemDict != null)
|
||||||
{
|
{
|
||||||
Song = song,
|
finalItems.Add(itemDict);
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title),
|
usedJellyfinItems.Add(matchedKey);
|
||||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist)
|
localUsedCount++;
|
||||||
})
|
_logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)",
|
||||||
.Select(x => new
|
spotifyTrack.Position, spotifyTrack.Title, bestScore);
|
||||||
{
|
}
|
||||||
x.Song,
|
|
||||||
x.TitleScore,
|
|
||||||
x.ArtistScore,
|
|
||||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
|
||||||
})
|
|
||||||
.OrderByDescending(x => x.TotalScore)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
// Use 70% threshold for matching
|
|
||||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
|
||||||
{
|
|
||||||
spotifyToJellyfinMap[spotifyTrack.Position] = bestMatch.Song;
|
|
||||||
usedJellyfinTracks.Add(bestMatch.Song.Id);
|
|
||||||
_logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' by {SpotifyArtist} → LOCAL: '{JellyfinTitle}' by {JellyfinArtist} (score: {Score:F1}%)",
|
|
||||||
spotifyTrack.Position,
|
|
||||||
spotifyTrack.Title,
|
|
||||||
spotifyTrack.PrimaryArtist,
|
|
||||||
bestMatch.Song.Title,
|
|
||||||
bestMatch.Song.Artist,
|
|
||||||
bestMatch.TotalScore);
|
|
||||||
}
|
|
||||||
else if (bestMatch != null)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(" ⚠️ Position #{Pos} '{SpotifyTitle}' - Best Jellyfin match too low: {Score:F1}% (need 70%)",
|
|
||||||
spotifyTrack.Position, spotifyTrack.Title, bestMatch.TotalScore);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks ({Manual} manual)",
|
|
||||||
spotifyToJellyfinMap.Count, spotifyTracks.Count, manualMappings.Count);
|
|
||||||
|
|
||||||
// Step 3: Build final playlist in Spotify order
|
|
||||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
|
||||||
{
|
|
||||||
// Check if we have a Jellyfin track for this position
|
|
||||||
if (spotifyToJellyfinMap.TryGetValue(spotifyTrack.Position, out var jellyfinTrack))
|
|
||||||
{
|
|
||||||
finalTracks.Add(jellyfinTrack);
|
|
||||||
localUsedCount++;
|
|
||||||
continue; // Use local track, skip external search
|
|
||||||
}
|
|
||||||
|
|
||||||
// No local match - try to find external track
|
|
||||||
// First check pre-matched cache
|
|
||||||
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
|
||||||
if (matched != null)
|
|
||||||
{
|
|
||||||
finalTracks.Add(matched.MatchedSong);
|
|
||||||
externalUsedCount++;
|
|
||||||
_logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (cached): {Provider}/{Id}",
|
|
||||||
spotifyTrack.Position,
|
|
||||||
spotifyTrack.Title,
|
|
||||||
spotifyTrack.PrimaryArtist,
|
|
||||||
matched.MatchedSong.ExternalProvider,
|
|
||||||
matched.MatchedSong.ExternalId);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No cached match - search external providers on-demand
|
// No local match - try to find external track
|
||||||
try
|
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||||
|
if (matched != null && matched.MatchedSong != null)
|
||||||
{
|
{
|
||||||
var query = $"{spotifyTrack.Title} {spotifyTrack.PrimaryArtist}";
|
// Convert external song to Jellyfin item format
|
||||||
var searchResults = await _metadataService.SearchSongsAsync(query, limit: 5);
|
var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
||||||
|
finalItems.Add(externalItem);
|
||||||
if (searchResults.Count > 0)
|
externalUsedCount++;
|
||||||
{
|
_logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id}",
|
||||||
// Fuzzy match to find best result
|
spotifyTrack.Position, spotifyTrack.Title,
|
||||||
var bestExternalMatch = searchResults
|
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId);
|
||||||
.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)
|
else
|
||||||
{
|
{
|
||||||
skippedCount++;
|
_logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH",
|
||||||
_logger.LogError(ex, "❌ Position #{Pos}: '{Title}' by {Artist} → ERROR searching external providers",
|
spotifyTrack.Position, spotifyTrack.Title);
|
||||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(
|
_logger.LogInformation(
|
||||||
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL, {Skipped} not available)",
|
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||||
spotifyPlaylistName,
|
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
|
||||||
finalTracks.Count,
|
|
||||||
localUsedCount,
|
|
||||||
externalUsedCount,
|
|
||||||
skippedCount);
|
|
||||||
|
|
||||||
if (localUsedCount == 0 && existingTracks.Count > 0)
|
// Save to file cache for persistence across restarts
|
||||||
{
|
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
||||||
_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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
/// <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>
|
/// <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">
|
||||||
@@ -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>';
|
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) {
|
} 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>';
|
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
|
// Add manual map button for external tracks using data attributes
|
||||||
// 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
|
|
||||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||||
const escapedArtist = JSON.stringify(firstArtist);
|
mapButton = `<button class="small map-track-btn"
|
||||||
const escapedSpotifyId = JSON.stringify(t.spotifyId || '');
|
data-playlist-name="${escapeHtml(name)}"
|
||||||
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>`;
|
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 `
|
return `
|
||||||
@@ -1639,6 +1650,18 @@
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).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) {
|
} catch (error) {
|
||||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
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();
|
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 () => {
|
||||||
@@ -1780,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