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> /// <summary>
/// Trigger track matching for all playlists /// Trigger track matching for all playlists
/// </summary> /// </summary>

View File

@@ -2918,7 +2918,21 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...", _logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...",
existingTracks.Count, spotifyTracks.Count); 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<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 spotifyToJellyfinMap = new Dictionary<int, Song>(); // Spotify position -> Jellyfin track
var usedJellyfinTracks = new HashSet<string>(); // Track which Jellyfin tracks we've used var usedJellyfinTracks = new HashSet<string>(); // Track which Jellyfin tracks we've used
@@ -2926,6 +2940,20 @@ public class JellyfinController : ControllerBase
{ {
if (existingTracks.Count == 0) break; 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 // Find best matching Jellyfin track that hasn't been used yet
var bestMatch = existingTracks var bestMatch = existingTracks
.Where(song => !usedJellyfinTracks.Contains(song.Id)) .Where(song => !usedJellyfinTracks.Contains(song.Id))
@@ -2965,10 +2993,10 @@ public class JellyfinController : ControllerBase
} }
} }
_logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks", _logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks ({Manual} manual)",
spotifyToJellyfinMap.Count, spotifyTracks.Count); 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)) foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{ {
// Check if we have a Jellyfin track for this position // Check if we have a Jellyfin track for this position

View File

@@ -857,6 +857,39 @@
</div> </div>
</div> </div>
<!-- Manual Track Mapping Modal -->
<div class="modal" id="manual-map-modal">
<div class="modal-content" style="max-width: 600px;">
<h3>Map Track to Local File</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
This track is currently using an external provider. Search for and select the local Jellyfin track to use instead.
</p>
<div class="form-group">
<label>Spotify Track (Position <span id="map-position"></span>)</label>
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<strong id="map-spotify-title"></strong><br>
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
</div>
</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()">
</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...
</p>
</div>
<input type="hidden" id="map-playlist-name">
<input type="hidden" id="map-spotify-id">
<input type="hidden" id="map-selected-jellyfin-id">
<div class="modal-actions">
<button onclick="closeModal('manual-map-modal')">Cancel</button>
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
</div>
</div>
</div>
<!-- Link Playlist Modal --> <!-- Link Playlist Modal -->
<div class="modal" id="link-playlist-modal"> <div class="modal" id="link-playlist-modal">
<div class="modal-content"> <div class="modal-content">
@@ -1543,17 +1576,21 @@
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => { document.getElementById('tracks-list').innerHTML = data.tracks.map(t => {
let statusBadge = ''; let statusBadge = '';
let mapButton = '';
if (t.isLocal === true) { if (t.isLocal === true) {
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
mapButton = `<button class="small" onclick="openManualMap('${escapeHtml(name)}', ${t.position}, '${escapeHtml(t.title)}', '${escapeHtml(t.artists[0])}', '${t.spotifyId}')" style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
} }
return ` return `
<div class="track-item"> <div class="track-item">
<span class="track-position">${t.position + 1}</span> <span class="track-position">${t.position + 1}</span>
<div class="track-info"> <div class="track-info">
<h4>${escapeHtml(t.title)}${statusBadge}</h4> <h4>${escapeHtml(t.title)}${statusBadge}${mapButton}</h4>
<span class="artists">${escapeHtml(t.artists.join(', '))}</span> <span class="artists">${escapeHtml(t.artists.join(', '))}</span>
</div> </div>
<div class="track-meta"> <div class="track-meta">
@@ -1648,6 +1685,108 @@
return div.innerHTML; return div.innerHTML;
} }
// Manual track mapping
let searchTimeout = null;
function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('map-playlist-name').value = playlistName;
document.getElementById('map-position').textContent = position + 1;
document.getElementById('map-spotify-title').textContent = title;
document.getElementById('map-spotify-artist').textContent = artist;
document.getElementById('map-spotify-id').value = spotifyId;
document.getElementById('map-search-query').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-save-btn').disabled = true;
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
openModal('manual-map-modal');
}
async function searchJellyfinTracks() {
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>';
return;
}
// Debounce search
clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => {
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Searching...</div>';
try {
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
const data = await res.json();
if (data.tracks.length === 0) {
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">No tracks found</p>';
return;
}
document.getElementById('map-search-results').innerHTML = data.tracks.map(t => `
<div class="track-item" style="cursor: pointer; border: 2px solid transparent;" onclick="selectJellyfinTrack('${t.id}', this)">
<div class="track-info">
<h4>${escapeHtml(t.title)}</h4>
<span class="artists">${escapeHtml(t.artist)}</span>
</div>
<div class="track-meta">
${t.album ? escapeHtml(t.album) : ''}
</div>
</div>
`).join('');
} catch (error) {
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Search failed</p>';
}
}, 300);
}
function selectJellyfinTrack(jellyfinId, element) {
// Remove selection from all tracks
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
el.style.border = '2px solid transparent';
});
// Highlight selected track
element.style.border = '2px solid var(--primary)';
// Store selected ID and enable save button
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
document.getElementById('map-save-btn').disabled = false;
}
async function saveManualMapping() {
const playlistName = document.getElementById('map-playlist-name').value;
const spotifyId = document.getElementById('map-spotify-id').value;
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
if (!jellyfinId) {
showToast('Please select a track', 'error');
return;
}
try {
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotifyId, jellyfinId })
});
const data = await res.json();
if (res.ok) {
showToast('Track mapped successfully! Refresh the playlist to see changes.', 'success');
closeModal('manual-map-modal');
// Refresh the tracks view
viewTracks(playlistName);
} else {
showToast(data.error || 'Failed to save mapping', 'error');
}
} catch (error) {
showToast('Failed to save mapping', 'error');
}
}
function escapeJs(text) { function escapeJs(text) {
if (!text) return ''; if (!text) return '';
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');