mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
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:
@@ -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, '\\"');
|
||||
|
||||
Reference in New Issue
Block a user