Add manual track mapping feature

- Add 'Map to Local' button for external tracks in playlist viewer
- Search Jellyfin library to find local tracks
- Save manual mappings (Spotify ID → Jellyfin ID) in cache
- Manual mappings take priority over fuzzy matching
- Clear playlist cache when mapping is saved to force refresh
- UI shows which tracks are manually mapped in logs
This commit is contained in:
2026-02-03 18:57:19 -05:00
parent d619881b8e
commit 48f69b766d
3 changed files with 271 additions and 5 deletions

View File

@@ -457,6 +457,105 @@ public class AdminController : ControllerBase
}
}
/// <summary>
/// Search Jellyfin library for tracks (for manual mapping)
/// </summary>
[HttpGet("jellyfin/search")]
public async Task<IActionResult> 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<object>();
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" });
}
}
/// <summary>
/// Save manual track mapping
/// </summary>
[HttpPost("playlists/{name}/map")]
public async Task<IActionResult> 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; } = "";
}
/// <summary>
/// Trigger track matching for all playlists
/// </summary>