diff --git a/allstarr/Controllers/MappingController.cs b/allstarr/Controllers/MappingController.cs index dd23563d..b3633f30 100644 --- a/allstarr/Controllers/MappingController.cs +++ b/allstarr/Controllers/MappingController.cs @@ -64,6 +64,7 @@ public class MappingController : ControllerBase foreach (var mapping in playlistMappings.Values) { + var targets = await BuildExternalTargetsForManualMappingAsync(mapping); allMappings.Add(new { playlist = playlistName, @@ -72,6 +73,7 @@ public class MappingController : ControllerBase jellyfinId = mapping.JellyfinId, externalProvider = mapping.ExternalProvider, externalId = mapping.ExternalId, + externalTargets = targets, createdAt = mapping.CreatedAt }); } @@ -102,7 +104,10 @@ public class MappingController : ControllerBase /// Delete a manual track mapping /// [HttpDelete("mappings/tracks")] - public async Task DeleteTrackMapping([FromQuery] string playlist, [FromQuery] string spotifyId) + public async Task DeleteTrackMapping( + [FromQuery] string playlist, + [FromQuery] string spotifyId, + [FromQuery] string? provider = null) { if (string.IsNullOrEmpty(playlist) || string.IsNullOrEmpty(spotifyId)) { @@ -111,47 +116,34 @@ public class MappingController : ControllerBase try { - var mappingsDir = "/app/cache/mappings"; - var safeName = AdminHelperService.SanitizeFileName(playlist); - var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json"); - - if (!System.IO.File.Exists(filePath)) + var removedPlaylistManual = false; + var removedGlobal = false; + + if (!string.IsNullOrWhiteSpace(provider)) { - return NotFound(new { error = "Mapping file not found for playlist" }); - } - - // Load existing mappings - var json = await System.IO.File.ReadAllTextAsync(filePath); - var mappings = JsonSerializer.Deserialize>(json); - - if (mappings == null || !mappings.ContainsKey(spotifyId)) - { - return NotFound(new { error = "Mapping not found" }); - } - - // Remove the mapping - mappings.Remove(spotifyId); - - // Save back to file (or delete file if empty) - if (mappings.Count == 0) - { - System.IO.File.Delete(filePath); - _logger.LogInformation("🗑️ Deleted empty mapping file for playlist {Playlist}", playlist); + removedGlobal = await _mappingService.RemoveExternalProviderAsync(spotifyId, provider); + removedPlaylistManual = await TryRemovePlaylistManualProviderAsync( + playlist, + spotifyId, + provider); } else { - var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true }); - await System.IO.File.WriteAllTextAsync(filePath, updatedJson); - _logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId); - } - - // Also remove from Redis cache - var cacheKey = $"manual:mapping:{playlist}:{spotifyId}"; - await _cache.DeleteAsync(cacheKey); + removedPlaylistManual = await TryRemovePlaylistManualMappingAsync(playlist, spotifyId); + if (removedPlaylistManual) + { + var cacheKey = $"manual:mapping:{playlist}:{spotifyId}"; + await _cache.DeleteAsync(cacheKey); + } + + removedGlobal = await _mappingService.DeleteMappingAsync(spotifyId); + } + + if (!removedPlaylistManual && !removedGlobal) + { + return NotFound(new { error = "Mapping not found" }); + } - // Keep global Spotify mapping index in sync as well. - await _mappingService.DeleteMappingAsync(spotifyId); - return Ok(new { success = true, message = "Mapping deleted successfully" }); } catch (Exception ex) @@ -160,6 +152,120 @@ public class MappingController : ControllerBase return StatusCode(500, new { error = "Failed to delete track mapping" }); } } + + private async Task> BuildExternalTargetsForManualMappingAsync(ManualMappingEntry mapping) + { + var targets = new List(); + var seenProviders = new HashSet(StringComparer.OrdinalIgnoreCase); + + void AddTarget(string? provider, string? externalId, string source) + { + if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(externalId)) + { + return; + } + + var key = provider.Trim().ToLowerInvariant(); + if (!seenProviders.Add(key)) + { + return; + } + + targets.Add(new + { + provider, + externalId, + source + }); + } + + var global = await _mappingService.GetMappingAsync(mapping.SpotifyId); + if (global != null) + { + foreach (var external in global.ExternalMappings) + { + AddTarget(external.Provider, external.ExternalId, external.Source); + } + + AddTarget(global.ExternalProvider, global.ExternalId, global.Source); + } + + AddTarget(mapping.ExternalProvider, mapping.ExternalId, "manual"); + + return targets; + } + + private async Task TryRemovePlaylistManualProviderAsync( + string playlist, + string spotifyId, + string provider) + { + var mappingsDir = "/app/cache/mappings"; + var safeName = AdminHelperService.SanitizeFileName(playlist); + var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json"); + + if (!System.IO.File.Exists(filePath)) + { + return false; + } + + var json = await System.IO.File.ReadAllTextAsync(filePath); + var mappings = JsonSerializer.Deserialize>(json); + if (mappings == null || !mappings.TryGetValue(spotifyId, out var entry)) + { + return false; + } + + if (!string.Equals(entry.ExternalProvider, provider, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + mappings.Remove(spotifyId); + await SavePlaylistMappingsFileAsync(filePath, mappings, playlist, spotifyId); + return true; + } + + private async Task TryRemovePlaylistManualMappingAsync(string playlist, string spotifyId) + { + var mappingsDir = "/app/cache/mappings"; + var safeName = AdminHelperService.SanitizeFileName(playlist); + var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json"); + + if (!System.IO.File.Exists(filePath)) + { + return false; + } + + var json = await System.IO.File.ReadAllTextAsync(filePath); + var mappings = JsonSerializer.Deserialize>(json); + if (mappings == null || !mappings.ContainsKey(spotifyId)) + { + return false; + } + + mappings.Remove(spotifyId); + await SavePlaylistMappingsFileAsync(filePath, mappings, playlist, spotifyId); + return true; + } + + private async Task SavePlaylistMappingsFileAsync( + string filePath, + Dictionary mappings, + string playlist, + string spotifyId) + { + if (mappings.Count == 0) + { + System.IO.File.Delete(filePath); + _logger.LogInformation("🗑️ Deleted empty mapping file for playlist {Playlist}", playlist); + return; + } + + var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true }); + await System.IO.File.WriteAllTextAsync(filePath, updatedJson); + _logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId); + } /// /// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID diff --git a/allstarr/Controllers/SpotifyAdminController.cs b/allstarr/Controllers/SpotifyAdminController.cs index 3b892a2a..eaa828aa 100644 --- a/allstarr/Controllers/SpotifyAdminController.cs +++ b/allstarr/Controllers/SpotifyAdminController.cs @@ -560,14 +560,30 @@ public class SpotifyAdminController : ControllerBase /// Deletes a Spotify track mapping /// [HttpDelete("spotify/mappings/{spotifyId}")] - public async Task DeleteSpotifyMapping(string spotifyId) + public async Task DeleteSpotifyMapping( + string spotifyId, + [FromQuery] string? provider = null) { try { - var success = await _mappingService.DeleteMappingAsync(spotifyId); + var success = string.IsNullOrWhiteSpace(provider) + ? await _mappingService.DeleteMappingAsync(spotifyId) + : await _mappingService.RemoveExternalProviderAsync(spotifyId, provider); + if (success) { - _logger.LogInformation("Deleted mapping for {SpotifyId}", spotifyId); + if (string.IsNullOrWhiteSpace(provider)) + { + _logger.LogInformation("Deleted mapping for {SpotifyId}", spotifyId); + } + else + { + _logger.LogInformation( + "Removed provider {Provider} from mapping for {SpotifyId}", + provider, + spotifyId); + } + return Ok(new { success = true }); } diff --git a/allstarr/Services/Spotify/SpotifyMappingService.cs b/allstarr/Services/Spotify/SpotifyMappingService.cs index c5fa953e..0c402339 100644 --- a/allstarr/Services/Spotify/SpotifyMappingService.cs +++ b/allstarr/Services/Spotify/SpotifyMappingService.cs @@ -285,6 +285,65 @@ public class SpotifyMappingService return success; } + /// + /// Removes a single external provider mapping for a Spotify track ID. + /// Deletes the entire mapping when no external targets remain on an external-only mapping. + /// + public async Task RemoveExternalProviderAsync(string spotifyId, string provider) + { + if (string.IsNullOrWhiteSpace(spotifyId) || string.IsNullOrWhiteSpace(provider)) + { + return false; + } + + var mapping = await GetMappingAsync(spotifyId); + if (mapping == null) + { + return false; + } + + var normalizedProvider = provider.Trim().ToLowerInvariant(); + var removedCount = mapping.ExternalMappings.RemoveAll(m => + string.Equals(m.Provider, normalizedProvider, StringComparison.OrdinalIgnoreCase)); + + var removedLegacy = + string.Equals(mapping.ExternalProvider, normalizedProvider, StringComparison.OrdinalIgnoreCase); + if (removedLegacy) + { + mapping.ExternalProvider = null; + mapping.ExternalId = null; + removedCount++; + } + + if (removedCount == 0) + { + return false; + } + + EnsureExternalMappingsConsistency(mapping); + + if (mapping.TargetType == "external" && + !mapping.TryGetExternalTarget(preferredProvider: null, out _, out _)) + { + return await DeleteMappingAsync(spotifyId); + } + + mapping.UpdatedAt = DateTime.UtcNow; + var key = CacheKeyBuilder.BuildSpotifyGlobalMappingKey(spotifyId); + var success = await _cache.SetAsync(key, mapping, expiry: null); + + if (success) + { + await InvalidateAllPlaylistStatsCachesAsync(); + _logger.LogInformation( + "Removed external provider {Provider} from Spotify mapping {SpotifyId}", + normalizedProvider, + spotifyId); + } + + return success; + } + /// /// Gets all Spotify IDs that have mappings. /// diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 274217a1..e69e22ad 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -321,7 +321,7 @@ Playlist Spotify ID Type - Target + Target IDs Created Actions @@ -411,7 +411,8 @@ 0 - +
+
@@ -427,7 +428,8 @@ -
Name
+ + diff --git a/allstarr/wwwroot/js/api.js b/allstarr/wwwroot/js/api.js index bed9ffba..abf34ff0 100644 --- a/allstarr/wwwroot/js/api.js +++ b/allstarr/wwwroot/js/api.js @@ -100,9 +100,17 @@ export async function fetchTrackMappings() { ); } -export async function deleteTrackMapping(playlist, spotifyId) { +export async function deleteTrackMapping(playlist, spotifyId, provider = null) { + const params = new URLSearchParams({ + playlist, + spotifyId, + }); + if (provider) { + params.append("provider", provider); + } + return requestJson( - `/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`, + `/api/admin/mappings/tracks?${params.toString()}`, { method: "DELETE" }, "Failed to remove mapping", ); diff --git a/allstarr/wwwroot/js/mapping-targets.js b/allstarr/wwwroot/js/mapping-targets.js new file mode 100644 index 00000000..069ebb2c --- /dev/null +++ b/allstarr/wwwroot/js/mapping-targets.js @@ -0,0 +1,87 @@ +// Shared helpers for displaying Spotify/global external mapping targets. + +import { escapeHtml, capitalizeProvider } from "./utils.js"; + +/** + * Normalizes external targets from API payloads (camelCase or PascalCase). + */ +export function collectExternalTargets(mapping) { + const targets = []; + const seenProviders = new Set(); + + const addTarget = (provider, externalId, source) => { + if (!provider || !externalId) { + return; + } + + const key = String(provider).toLowerCase(); + if (seenProviders.has(key)) { + return; + } + + seenProviders.add(key); + targets.push({ + provider: String(provider), + externalId: String(externalId), + source: source || "", + }); + }; + + const externalTargets = + mapping.externalTargets || + mapping.ExternalTargets || + mapping.externalMappings || + mapping.ExternalMappings || + []; + + for (const ext of externalTargets) { + addTarget( + ext.provider ?? ext.Provider, + ext.externalId ?? ext.ExternalId, + ext.source ?? ext.Source, + ); + } + + addTarget( + mapping.externalProvider ?? mapping.ExternalProvider, + mapping.externalId ?? mapping.ExternalId, + mapping.source ?? mapping.Source ?? "manual", + ); + + return targets; +} + +/** + * Renders a stacked list of provider targets for dashboard tables. + */ +export function renderExternalTargetsHtml(targets, options = {}) { + const { showRemove = false, playlist = "", spotifyId = "" } = options; + + if (!Array.isArray(targets) || targets.length === 0) { + return '—'; + } + + return `
${targets + .map((target) => { + const label = capitalizeProvider(target.provider) || target.provider; + const removeBtn = showRemove + ? `` + : ""; + + const sourceHint = target.source + ? `${escapeHtml(target.source)}` + : ""; + + return `
+ ${escapeHtml(label)} + ${escapeHtml(target.externalId)} + ${sourceHint} + ${removeBtn} +
`; + }) + .join("")}
`; +} diff --git a/allstarr/wwwroot/js/operations.js b/allstarr/wwwroot/js/operations.js index c8987bef..b56a7b39 100644 --- a/allstarr/wwwroot/js/operations.js +++ b/allstarr/wwwroot/js/operations.js @@ -112,13 +112,16 @@ async function deleteDownload(path) { } } -async function deleteTrackMapping(playlist, spotifyId) { - const confirmMessage = `Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`; +async function deleteTrackMapping(playlist, spotifyId, provider = null) { + const providerLabel = provider ? ` (${provider})` : ""; + const confirmMessage = provider + ? `Remove the ${provider} mapping for ${spotifyId} in playlist "${playlist}"?\n\nOther provider mappings for this track will be kept.` + : `Remove all mappings for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Remove the global Spotify mapping\n• Allow the track to be matched automatically again\n\nThis action cannot be undone.`; const result = await runAction({ confirmMessage, - task: () => API.deleteTrackMapping(playlist, spotifyId), - success: "Mapping removed successfully", + task: () => API.deleteTrackMapping(playlist, spotifyId, provider), + success: `Mapping${providerLabel} removed successfully`, error: (err) => err.message || "Failed to remove mapping", }); diff --git a/allstarr/wwwroot/js/song-migration.js b/allstarr/wwwroot/js/song-migration.js index 3a3500a1..c2bd46fb 100644 --- a/allstarr/wwwroot/js/song-migration.js +++ b/allstarr/wwwroot/js/song-migration.js @@ -138,24 +138,26 @@ function renderNonLocalTracksPanel(tracks) { } return ` -
- - - - - - - - - - - - - - - ${tracks.map(renderNonLocalTrackRow).join("")} - -
#TitleArtistAlbumStatusISRCSpotifyDuration
+
+
+ + + + + + + + + + + + + + + ${tracks.map(renderNonLocalTrackRow).join("")} + +
#TitleArtistAlbumStatusISRCSpotifyDuration
+
`; } diff --git a/allstarr/wwwroot/js/ui.js b/allstarr/wwwroot/js/ui.js index 8cf4e843..03fda656 100644 --- a/allstarr/wwwroot/js/ui.js +++ b/allstarr/wwwroot/js/ui.js @@ -1,6 +1,10 @@ // UI updates and DOM manipulation import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js"; +import { + collectExternalTargets, + renderExternalTargetsHtml, +} from "./mapping-targets.js"; let rowMenuHandlersBound = false; let tableRowHandlersBound = false; @@ -763,7 +767,12 @@ export function updateTrackMappingsUI(data) { .map((m) => { const typeColor = "var(--success)"; const typeBadge = `external`; - const targetDisplay = `${m.externalProvider}/${m.externalId}`; + const targets = collectExternalTargets(m); + const targetDisplay = renderExternalTargetsHtml(targets, { + showRemove: true, + playlist: m.playlist, + spotifyId: m.spotifyId, + }); const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : "-"; @@ -771,17 +780,50 @@ export function updateTrackMappingsUI(data) { return ` ${escapeHtml(m.playlist)} - ${m.spotifyId} + ${escapeHtml(m.spotifyId)} ${typeBadge} ${targetDisplay} ${createdDate} - + `; }) .join(""); + + bindTrackMappingDeleteHandlers(tbody); +} + +function bindTrackMappingDeleteHandlers(tbody) { + tbody.querySelectorAll(".delete-mapping-provider-btn").forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const playlist = button.getAttribute("data-playlist"); + const spotifyId = button.getAttribute("data-spotify-id"); + const provider = button.getAttribute("data-provider"); + if (!playlist || !spotifyId || !provider) { + return; + } + window.deleteTrackMapping?.(playlist, spotifyId, provider); + }); + }); + + tbody.querySelectorAll(".delete-mapping-btn").forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const playlist = button.getAttribute("data-playlist"); + const spotifyId = button.getAttribute("data-spotify-id"); + if (!playlist || !spotifyId) { + return; + } + window.deleteTrackMapping?.(playlist, spotifyId); + }); + }); } export function updateDownloadsUI(data) { diff --git a/allstarr/wwwroot/spotify-mappings.html b/allstarr/wwwroot/spotify-mappings.html index d0d474c5..88181b2f 100644 --- a/allstarr/wwwroot/spotify-mappings.html +++ b/allstarr/wwwroot/spotify-mappings.html @@ -324,6 +324,71 @@ color: var(--text-secondary); } + .target-list { + display: flex; + flex-direction: column; + gap: 6px; + } + + .target-item { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .target-item .badge { + min-width: 72px; + text-align: center; + text-transform: lowercase; + } + + .target-source { + font-size: 0.75rem; + color: var(--text-secondary); + } + + .target-empty { + color: var(--text-secondary); + } + + .target-remove-btn { + margin-left: auto; + min-width: 28px; + padding: 2px 8px; + font-size: 1rem; + line-height: 1; + border-radius: 6px; + background: rgba(248, 81, 73, 0.12); + border: 1px solid rgba(248, 81, 73, 0.45); + color: var(--error); + cursor: pointer; + } + + .target-remove-btn:hover { + background: rgba(248, 81, 73, 0.25); + } + + .external-existing-panel { + margin-bottom: 16px; + padding: 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(13, 17, 23, 0.35); + } + + .external-existing-panel h4 { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 10px; + font-weight: 600; + } + + .external-existing-empty { + color: var(--text-secondary); + font-size: 0.85rem; + } + .pagination { display: flex; justify-content: center; @@ -642,8 +707,13 @@
+
+

Existing external mappings

+
+
+