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>
|
||||
/// Trigger track matching for all playlists
|
||||
/// </summary>
|
||||
|
||||
@@ -2918,7 +2918,21 @@ public class JellyfinController : ControllerBase
|
||||
_logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...",
|
||||
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 usedJellyfinTracks = new HashSet<string>(); // Track which Jellyfin tracks we've used
|
||||
|
||||
@@ -2926,6 +2940,20 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
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
|
||||
var bestMatch = existingTracks
|
||||
.Where(song => !usedJellyfinTracks.Contains(song.Id))
|
||||
@@ -2965,10 +2993,10 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks",
|
||||
spotifyToJellyfinMap.Count, spotifyTracks.Count);
|
||||
_logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks ({Manual} manual)",
|
||||
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))
|
||||
{
|
||||
// Check if we have a Jellyfin track for this position
|
||||
|
||||
@@ -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