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

@@ -857,6 +857,39 @@
</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 -->
<div class="modal" id="link-playlist-modal">
<div class="modal-content">
@@ -1543,17 +1576,21 @@
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => {
let statusBadge = '';
let mapButton = '';
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>';
} 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>';
// 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 `
<div class="track-item">
<span class="track-position">${t.position + 1}</span>
<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>
</div>
<div class="track-meta">
@@ -1648,6 +1685,108 @@
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) {
if (!text) return '';
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');