feat(admin): multi-provider mapping UI with per-provider delete

This commit is contained in:
joshpatra committed 2026-05-22 16:19:41 -04:00
1 parent 026a4097b5
commit 63dd1bfb5f
12 files changed
+750 -86

No files matched your search

+143 -37
View File
@@ -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
/// </summary>
[HttpDelete("mappings/tracks")]
public async Task<IActionResult> DeleteTrackMapping([FromQuery] string playlist, [FromQuery] string spotifyId)
public async Task<IActionResult> 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<Dictionary<string, ManualMappingEntry>>(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<List<object>> BuildExternalTargetsForManualMappingAsync(ManualMappingEntry mapping)
{
var targets = new List<object>();
var seenProviders = new HashSet<string>(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<bool> 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<Dictionary<string, ManualMappingEntry>>(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<bool> 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<Dictionary<string, ManualMappingEntry>>(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<string, ManualMappingEntry> 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);
}
/// <summary>
/// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID
+19 -3
View File
@@ -560,14 +560,30 @@ public class SpotifyAdminController : ControllerBase
/// Deletes a Spotify track mapping
/// </summary>
[HttpDelete("spotify/mappings/{spotifyId}")]
public async Task<IActionResult> DeleteSpotifyMapping(string spotifyId)
public async Task<IActionResult> 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 });
}
@@ -285,6 +285,65 @@ public class SpotifyMappingService
return success;
}
/// <summary>
/// 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.
/// </summary>
public async Task<bool> 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;
}
/// <summary>
/// Gets all Spotify IDs that have mappings.
/// </summary>
+5 -3
View File
@@ -321,7 +321,7 @@
<th>Playlist</th>
<th>Spotify ID</th>
<th>Type</th>
<th>Target</th>
<th>Target IDs</th>
<th>Created</th>
<th>Actions</th>
</tr>
@@ -411,7 +411,8 @@
<span class="summary-value accent" id="song-migration-total-count">0</span>
</div>
</div>
<table class="playlist-table">
<div class="table-scroll song-migration-table-scroll">
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
@@ -427,7 +428,8 @@
</td>
</tr>
</tbody>
</table>
</table>
</div>
</div>
</div>
+10 -2
View File
@@ -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",
);
+87
View File
@@ -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 '<span style="color:var(--text-secondary);">—</span>';
}
return `<div class="target-list">${targets
.map((target) => {
const label = capitalizeProvider(target.provider) || target.provider;
const removeBtn = showRemove
? `<button type="button" class="target-remove-btn delete-mapping-provider-btn"
data-playlist="${escapeHtml(playlist)}"
data-spotify-id="${escapeHtml(spotifyId)}"
data-provider="${escapeHtml(target.provider)}"
title="Remove ${escapeHtml(label)} mapping">×</button>`
: "";
const sourceHint = target.source
? `<span class="target-source">${escapeHtml(target.source)}</span>`
: "";
return `<div class="target-item">
<span class="status-pill info">${escapeHtml(label)}</span>
<span class="mono target-id">${escapeHtml(target.externalId)}</span>
${sourceHint}
${removeBtn}
</div>`;
})
.join("")}</div>`;
}
+7 -4
View File
@@ -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",
});
+20 -18
View File
@@ -138,24 +138,26 @@ function renderNonLocalTracksPanel(tracks) {
}
return `
<div class="details-panel">
<table class="playlist-table" style="margin-top:8px;">
<thead>
<tr>
<th style="width:40px;">#</th>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Status</th>
<th>ISRC</th>
<th>Spotify</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
${tracks.map(renderNonLocalTrackRow).join("")}
</tbody>
</table>
<div class="details-panel song-migration-tracks-panel">
<div class="table-scroll">
<table class="playlist-table song-migration-tracks-table">
<thead>
<tr>
<th style="width:40px;">#</th>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Status</th>
<th>ISRC</th>
<th>Spotify</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
${tracks.map(renderNonLocalTrackRow).join("")}
</tbody>
</table>
</div>
</div>
`;
}
+45 -3
View File
@@ -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 = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
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 `
<tr>
<td><strong>${escapeHtml(m.playlist)}</strong></td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${m.spotifyId}</td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${escapeHtml(m.spotifyId)}</td>
<td>${typeBadge}</td>
<td>${targetDisplay}</td>
<td style="color:var(--text-secondary);font-size:0.85rem;">${createdDate}</td>
<td>
<button class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;" data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${m.spotifyId}">Remove</button>
<button type="button" class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;"
data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${escapeHtml(m.spotifyId)}"
title="Remove all mappings for this track">Remove all</button>
</td>
</tr>
`;
})
.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) {
+71 -1
View File
@@ -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 @@
<span class="mono" id="external-map-spotify-id"></span>
</div>
<div class="external-existing-panel" id="external-map-existing-panel">
<h4>Existing external mappings</h4>
<div id="external-map-existing"></div>
</div>
<div class="modal-row">
<label for="external-map-provider">Provider</label>
<label for="external-map-provider">Add or update provider</label>
<select id="external-map-provider">
<option value="squidwtf">SquidWTF</option>
<option value="deezer">Deezer</option>
+200 -15
View File
@@ -108,6 +108,108 @@ function updatePagination(pagination) {
document.getElementById("pagination").style.display = "flex";
}
/**
* Collects all target IDs for a mapping (local + per-provider external).
*/
function getMappingTargets(mapping) {
const targets = [];
const seenProviders = new Set();
const addExternal = (provider, externalId, source) => {
if (!provider || !externalId) {
return;
}
const key = String(provider).toLowerCase();
if (seenProviders.has(key)) {
return;
}
seenProviders.add(key);
targets.push({
kind: "external",
label: provider,
value: externalId,
source,
});
};
if (mapping.TargetType === "local" && mapping.LocalId) {
targets.push({
kind: "local",
label: "local",
value: mapping.LocalId,
});
}
const externalMappings = mapping.ExternalMappings || [];
for (const ext of externalMappings) {
addExternal(
ext.Provider ?? ext.provider,
ext.ExternalId ?? ext.externalId,
ext.Source ?? ext.source,
);
}
addExternal(mapping.ExternalProvider, mapping.ExternalId);
return targets;
}
/**
* Renders target IDs (local Jellyfin ID and/or external provider IDs).
*/
function renderMappingTargetsHtml(mapping, options = {}) {
const { showRemove = false, kinds = null } = options;
let targets = getMappingTargets(mapping);
if (Array.isArray(kinds) && kinds.length > 0) {
targets = targets.filter((target) => kinds.includes(target.kind));
}
const escapedSpotifyId = escapeHtml(escapeJs(mapping.SpotifyId || ""));
const escapedTitle = escapeHtml(
escapeJs(mapping.Metadata?.Title || "this track"),
);
if (targets.length === 0) {
return '<span class="mono target-empty">—</span>';
}
return `<div class="target-list">${targets
.map((target) => {
const sourceHint =
target.source && target.kind === "external"
? ` <span class="target-source">${escapeHtml(target.source)}</span>`
: "";
const removeBtn =
showRemove && target.kind === "external"
? `<button type="button" class="target-remove-btn"
onclick="deleteExternalProvider('${escapedSpotifyId}', '${escapeHtml(escapeJs(target.label))}', '${escapedTitle}')"
title="Remove ${escapeHtml(target.label)} mapping">×</button>`
: "";
return `<div class="target-item">
<span class="badge ${target.kind}">${escapeHtml(target.label)}</span>
<span class="mono">${escapeHtml(target.value)}</span>${sourceHint}
${removeBtn}
</div>`;
})
.join("")}</div>`;
}
function renderExternalMapExistingHtml(mapping) {
const html = renderMappingTargetsHtml(mapping, {
showRemove: true,
kinds: ["external"],
});
if (html.includes("target-empty")) {
return '<p class="external-existing-empty">No external provider mappings yet.</p>';
}
return html;
}
/**
* Renders the mappings table
*/
@@ -130,11 +232,6 @@ function renderMappings(mappings) {
const artworkUrl = metadata.ArtworkUrl || "/placeholder.png";
const title = metadata.Title || "Unknown Track";
const artist = metadata.Artist || "Unknown Artist";
const targetInfo =
mapping.TargetType === "local"
? mapping.LocalId
: `${mapping.ExternalProvider}:${mapping.ExternalId}`;
const escapedSpotifyId = escapeHtml(escapeJs(mapping.SpotifyId || ""));
const escapedTitle = escapeHtml(escapeJs(title));
const escapedArtist = escapeHtml(escapeJs(artist));
@@ -158,7 +255,7 @@ function renderMappings(mappings) {
<span class="badge ${mapping.TargetType}">${escapeHtml(mapping.TargetType)}</span>
</td>
<td>
<span class="mono">${escapeHtml(targetInfo)}</span>
${renderMappingTargetsHtml(mapping, { showRemove: true })}
</td>
<td>
<span class="badge ${mapping.Source}">${escapeHtml(mapping.Source)}</span>
@@ -198,7 +295,7 @@ function renderMappings(mappings) {
<th class="sortable" onclick="sortBy('title')">Track${sortIndicator("title")}</th>
<th class="sortable" onclick="sortBy('spotifyid')">Spotify ID${sortIndicator("spotifyid")}</th>
<th class="sortable" onclick="sortBy('type')">Type${sortIndicator("type")}</th>
<th>Target ID</th>
<th>Target IDs</th>
<th class="sortable" onclick="sortBy('source')">Source${sortIndicator("source")}</th>
<th class="sortable" onclick="sortBy('created')">Created${sortIndicator("created")}</th>
<th>Actions</th>
@@ -432,7 +529,7 @@ async function saveLocalMap() {
}
}
function openExternalMapModal(spotifyId, title, artist) {
async function openExternalMapModal(spotifyId, title, artist) {
externalMapContext = { spotifyId, title, artist };
document.getElementById("external-map-title").textContent = title;
@@ -442,7 +539,34 @@ function openExternalMapModal(spotifyId, title, artist) {
document.getElementById("external-map-id").value = "";
document.getElementById("external-map-save-btn").disabled = true;
const existingContainer = document.getElementById("external-map-existing");
existingContainer.innerHTML =
'<p class="external-existing-empty">Loading existing mappings...</p>';
toggleModal("external-map-modal", true);
try {
const response = await fetch(
`/api/admin/spotify/mappings/${encodeURIComponent(spotifyId)}`,
);
if (response.status === 404) {
existingContainer.innerHTML =
'<p class="external-existing-empty">No external provider mappings yet.</p>';
return;
}
if (!response.ok) {
throw new Error(
await readErrorMessage(response, "Failed to load mapping"),
);
}
const mapping = await response.json();
existingContainer.innerHTML = renderExternalMapExistingHtml(mapping);
} catch (error) {
console.error("Failed to load mapping for external modal:", error);
existingContainer.innerHTML = `<p class="external-existing-empty">${escapeHtml(error.message || "Failed to load existing mappings")}</p>`;
}
}
function closeExternalMapModal() {
@@ -498,8 +622,19 @@ async function saveExternalMap() {
);
}
closeExternalMapModal();
showToast(`Mapped to external track: ${provider}:${externalId}`, "success");
document.getElementById("external-map-id").value = "";
validateExternalMapForm();
const existingContainer = document.getElementById("external-map-existing");
const mappingResponse = await fetch(
`/api/admin/spotify/mappings/${encodeURIComponent(externalMapContext.spotifyId)}`,
);
if (mappingResponse.ok) {
const mapping = await mappingResponse.json();
existingContainer.innerHTML = renderExternalMapExistingHtml(mapping);
}
await loadMappings();
} catch (error) {
console.error("Error saving external mapping:", error);
@@ -528,17 +663,67 @@ function escapeJs(text) {
}
/**
* Deletes a Spotify track mapping
* Deletes a single external provider from a Spotify track mapping.
*/
async function deleteMapping(spotifyId, title) {
if (!confirm(`Delete mapping for "${title}"?`)) {
async function deleteExternalProvider(spotifyId, provider, title) {
if (
!confirm(`Remove the ${provider} mapping for "${title}"?\n\nOther provider mappings will be kept.`)
) {
return;
}
try {
const response = await fetch(`/api/admin/spotify/mappings/${spotifyId}`, {
method: "DELETE",
});
const response = await fetch(
`/api/admin/spotify/mappings/${encodeURIComponent(spotifyId)}?provider=${encodeURIComponent(provider)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw new Error(
await readErrorMessage(response, "Failed to remove provider mapping"),
);
}
showToast(`Removed ${provider} mapping for "${title}"`, "success");
if (
externalMapContext &&
externalMapContext.spotifyId === spotifyId &&
document.getElementById("external-map-modal").classList.contains("active")
) {
await openExternalMapModal(
externalMapContext.spotifyId,
externalMapContext.title,
externalMapContext.artist,
);
}
await loadMappings();
} catch (error) {
console.error("Error removing provider mapping:", error);
showToast(error.message || "Failed to remove provider mapping", "error");
}
}
/**
* Deletes a Spotify track mapping (all targets)
*/
async function deleteMapping(spotifyId, title) {
if (
!confirm(
`Delete the entire mapping for "${title}"?\n\nThis removes local and all external provider IDs.`,
)
) {
return;
}
try {
const response = await fetch(
`/api/admin/spotify/mappings/${encodeURIComponent(spotifyId)}`,
{
method: "DELETE",
},
);
if (!response.ok) {
throw new Error(
+84
View File
@@ -475,6 +475,13 @@ button.danger:hover {
white-space: nowrap;
}
.table-scroll {
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.playlist-table {
width: 100%;
border-collapse: collapse;
@@ -644,6 +651,83 @@ button.danger:hover {
background: rgba(13, 17, 23, 0.35);
}
#tab-song-migration .card {
min-width: 0;
overflow: hidden;
}
#tab-song-migration .details-row > td {
min-width: 0;
}
#tab-song-migration .song-migration-table-scroll > .playlist-table {
table-layout: fixed;
}
#tab-song-migration .song-migration-details-content {
display: block;
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
#tab-song-migration .song-migration-tracks-panel {
margin: 0;
}
#tab-song-migration .song-migration-tracks-panel .table-scroll {
margin-top: 0;
}
#tab-song-migration .song-migration-tracks-table {
min-width: 880px;
}
#tab-song-migration .song-migration-tracks-table .mono {
word-break: break-all;
}
.target-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.target-item {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.target-item .target-id {
font-family:
ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", monospace;
font-size: 0.85rem;
color: var(--text-secondary);
}
.target-source {
font-size: 0.75rem;
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);
}
.target-remove-btn:hover {
background: rgba(248, 81, 73, 0.25);
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));