mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat(admin): multi-provider mapping UI with per-provider delete
This commit is contained in:
12 files changed
+750
-86
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user