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