diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index a582804..99dd3e1 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -457,6 +457,105 @@ public class AdminController : ControllerBase } } + /// + /// Search Jellyfin library for tracks (for manual mapping) + /// + [HttpGet("jellyfin/search")] + public async Task SearchJellyfinTracks([FromQuery] string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return BadRequest(new { error = "Query is required" }); + } + + try + { + var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20"; + 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 = "Failed to search Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var tracks = new List(); + if (doc.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + var id = 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() ?? ""; + } + + tracks.Add(new { id, title, artist, album }); + } + } + + return Ok(new { tracks }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to search Jellyfin tracks"); + return StatusCode(500, new { error = "Search failed" }); + } + } + + /// + /// Save manual track mapping + /// + [HttpPost("playlists/{name}/map")] + public async Task SaveManualMapping(string name, [FromBody] ManualMappingRequest request) + { + var decodedName = Uri.UnescapeDataString(name); + + if (string.IsNullOrWhiteSpace(request.SpotifyId) || string.IsNullOrWhiteSpace(request.JellyfinId)) + { + return BadRequest(new { error = "SpotifyId and JellyfinId are required" }); + } + + try + { + // Store mapping in cache (you could also persist to a file) + var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}"; + await _cache.SetAsync(mappingKey, request.JellyfinId, TimeSpan.FromDays(365)); // Long TTL + + _logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}", + decodedName, request.SpotifyId, request.JellyfinId); + + // Clear the matched tracks cache to force re-matching + var cacheKey = $"spotify:matched:{decodedName}"; + await _cache.DeleteAsync(cacheKey); + + return Ok(new { message = "Mapping saved successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save manual mapping"); + return StatusCode(500, new { error = "Failed to save mapping" }); + } + } + + public class ManualMappingRequest + { + public string SpotifyId { get; set; } = ""; + public string JellyfinId { get; set; } = ""; + } + /// /// Trigger track matching for all playlists /// diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 4128c17..a6f5ec6 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -2918,7 +2918,21 @@ public class JellyfinController : ControllerBase _logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...", existingTracks.Count, spotifyTracks.Count); - // Step 1: For each Spotify position, find the best matching Jellyfin track + // Step 1: Check for manual mappings first + var manualMappings = new Dictionary(); // Spotify ID -> Jellyfin ID + foreach (var spotifyTrack in spotifyTracks) + { + var mappingKey = $"spotify:manual-map:{spotifyPlaylistName}:{spotifyTrack.SpotifyId}"; + var jellyfinId = await _cache.GetAsync(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(); // Spotify position -> Jellyfin track var usedJellyfinTracks = new HashSet(); // Track which Jellyfin tracks we've used @@ -2926,6 +2940,20 @@ public class JellyfinController : ControllerBase { if (existingTracks.Count == 0) break; + // Check for manual mapping first + if (manualMappings.TryGetValue(spotifyTrack.SpotifyId, out var mappedJellyfinId)) + { + var mappedTrack = existingTracks.FirstOrDefault(t => t.Id == mappedJellyfinId); + if (mappedTrack != null && !usedJellyfinTracks.Contains(mappedTrack.Id)) + { + spotifyToJellyfinMap[spotifyTrack.Position] = mappedTrack; + usedJellyfinTracks.Add(mappedTrack.Id); + _logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' → LOCAL (manual): '{JellyfinTitle}'", + spotifyTrack.Position, spotifyTrack.Title, mappedTrack.Title); + continue; + } + } + // Find best matching Jellyfin track that hasn't been used yet var bestMatch = existingTracks .Where(song => !usedJellyfinTracks.Contains(song.Id)) @@ -2965,10 +2993,10 @@ public class JellyfinController : ControllerBase } } - _logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks", - spotifyToJellyfinMap.Count, spotifyTracks.Count); + _logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks ({Manual} manual)", + spotifyToJellyfinMap.Count, spotifyTracks.Count, manualMappings.Count); - // Step 2: Build final playlist in Spotify order + // 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 diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 26edaef..24f9a90 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -857,6 +857,39 @@ + + +