mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Remove local Jellyfin manual mapping, keep only external mappings
This commit is contained in:
@@ -669,26 +669,10 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
bool? isLocal = null;
|
bool? isLocal = null;
|
||||||
string? externalProvider = null;
|
string? externalProvider = null;
|
||||||
|
|
||||||
// FIRST: Check for manual mapping (same as SpotifyTrackMatchingService)
|
|
||||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
|
||||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
|
||||||
bool isManualMapping = false;
|
bool isManualMapping = false;
|
||||||
string? manualMappingType = null;
|
string? manualMappingType = null;
|
||||||
string? manualMappingId = null;
|
string? manualMappingId = null;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
|
||||||
{
|
|
||||||
// Manual Jellyfin mapping exists - this track is definitely local
|
|
||||||
isLocal = true;
|
|
||||||
isManualMapping = true;
|
|
||||||
manualMappingType = "jellyfin";
|
|
||||||
manualMappingId = manualJellyfinId;
|
|
||||||
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
|
|
||||||
track.Title, manualJellyfinId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Check for external manual mapping
|
// Check for external manual mapping
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
@@ -730,9 +714,10 @@ public class AdminController : ControllerBase
|
|||||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (localTracks.Count > 0)
|
|
||||||
|
// If no manual mapping, try fuzzy matching with local tracks
|
||||||
|
if (!isManualMapping && localTracks.Count > 0)
|
||||||
{
|
{
|
||||||
// SECOND: No manual mapping, try fuzzy matching
|
|
||||||
var bestMatch = localTracks
|
var bestMatch = localTracks
|
||||||
.Select(local => new
|
.Select(local => new
|
||||||
{
|
{
|
||||||
@@ -756,7 +741,6 @@ public class AdminController : ControllerBase
|
|||||||
isLocal = true;
|
isLocal = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If not local, check if it's externally matched or missing
|
// If not local, check if it's externally matched or missing
|
||||||
if (isLocal != true)
|
if (isLocal != true)
|
||||||
@@ -832,21 +816,14 @@ public class AdminController : ControllerBase
|
|||||||
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clear and reuse tracksWithStatus for fallback
|
||||||
|
tracksWithStatus.Clear();
|
||||||
|
|
||||||
foreach (var track in spotifyTracks)
|
foreach (var track in spotifyTracks)
|
||||||
{
|
{
|
||||||
bool? isLocal = null;
|
bool? isLocal = null;
|
||||||
string? externalProvider = null;
|
string? externalProvider = null;
|
||||||
|
|
||||||
// Check for manual mappings
|
|
||||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
|
||||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
|
||||||
{
|
|
||||||
isLocal = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Check for external manual mapping
|
// Check for external manual mapping
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
@@ -888,7 +865,6 @@ public class AdminController : ControllerBase
|
|||||||
isLocal = null;
|
isLocal = null;
|
||||||
externalProvider = null;
|
externalProvider = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
tracksWithStatus.Add(new
|
tracksWithStatus.Add(new
|
||||||
{
|
{
|
||||||
@@ -1111,7 +1087,8 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save manual track mapping
|
/// Save manual external track mapping (SquidWTF/Deezer/Qobuz)
|
||||||
|
/// Note: Local Jellyfin mappings should be done via Spotify Import plugin
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("playlists/{name}/map")]
|
[HttpPost("playlists/{name}/map")]
|
||||||
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
|
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
|
||||||
@@ -1123,35 +1100,14 @@ public class AdminController : ControllerBase
|
|||||||
return BadRequest(new { error = "SpotifyId is required" });
|
return BadRequest(new { error = "SpotifyId is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that either Jellyfin mapping or external mapping is provided
|
// Only external mappings are supported now
|
||||||
var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId);
|
// Local Jellyfin mappings should be done via Spotify Import plugin
|
||||||
var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId);
|
if (string.IsNullOrWhiteSpace(request.ExternalProvider) || string.IsNullOrWhiteSpace(request.ExternalId))
|
||||||
|
|
||||||
if (!hasJellyfinMapping && !hasExternalMapping)
|
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" });
|
return BadRequest(new { error = "ExternalProvider and ExternalId are required. Use Spotify Import plugin for local Jellyfin mappings." });
|
||||||
}
|
|
||||||
|
|
||||||
if (hasJellyfinMapping && hasExternalMapping)
|
|
||||||
{
|
|
||||||
return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
|
||||||
if (hasJellyfinMapping)
|
|
||||||
{
|
|
||||||
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
|
||||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
|
||||||
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
|
||||||
|
|
||||||
// Also save to file for persistence across restarts
|
|
||||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null);
|
|
||||||
|
|
||||||
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
|
||||||
decodedName, request.SpotifyId, request.JellyfinId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||||
@@ -1164,7 +1120,6 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
||||||
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
||||||
}
|
|
||||||
|
|
||||||
// Clear all related caches to force rebuild
|
// Clear all related caches to force rebuild
|
||||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||||
@@ -1202,58 +1157,14 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
||||||
|
|
||||||
// Fetch the mapped track details to return to the UI
|
// Fetch external provider track details to return to the UI
|
||||||
string? trackTitle = null;
|
string? trackTitle = null;
|
||||||
string? trackArtist = null;
|
string? trackArtist = null;
|
||||||
string? trackAlbum = null;
|
string? trackAlbum = null;
|
||||||
bool isLocalMapping = hasJellyfinMapping;
|
|
||||||
|
|
||||||
if (hasJellyfinMapping)
|
|
||||||
{
|
|
||||||
// Fetch Jellyfin track details
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var userId = _jellyfinSettings.UserId;
|
|
||||||
var trackUrl = $"{_jellyfinSettings.Url}/Items/{request.JellyfinId}";
|
|
||||||
if (!string.IsNullOrEmpty(userId))
|
|
||||||
{
|
|
||||||
trackUrl += $"?UserId={userId}";
|
|
||||||
}
|
|
||||||
|
|
||||||
var trackRequest = new HttpRequestMessage(HttpMethod.Get, trackUrl);
|
|
||||||
trackRequest.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
||||||
|
|
||||||
var response = await _jellyfinHttpClient.SendAsync(trackRequest);
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var trackData = await response.Content.ReadAsStringAsync();
|
|
||||||
using var doc = JsonDocument.Parse(trackData);
|
|
||||||
var track = doc.RootElement;
|
|
||||||
|
|
||||||
trackTitle = track.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
|
||||||
trackArtist = track.TryGetProperty("AlbumArtist", out var artistEl) ? artistEl.GetString() :
|
|
||||||
(track.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0
|
|
||||||
? artistsEl[0].GetString() : null);
|
|
||||||
trackAlbum = track.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : null;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode}", request.JellyfinId, response.StatusCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to fetch mapped track details, but mapping was saved");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Fetch external provider track details
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
||||||
var normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
|
||||||
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
||||||
|
|
||||||
if (externalSong != null)
|
if (externalSong != null)
|
||||||
@@ -1273,7 +1184,6 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger immediate playlist rebuild with the new mapping
|
// Trigger immediate playlist rebuild with the new mapping
|
||||||
if (_matchingService != null)
|
if (_matchingService != null)
|
||||||
@@ -1307,12 +1217,12 @@ public class AdminController : ControllerBase
|
|||||||
// Return success with track details if available
|
// Return success with track details if available
|
||||||
var mappedTrack = new
|
var mappedTrack = new
|
||||||
{
|
{
|
||||||
id = hasJellyfinMapping ? request.JellyfinId : request.ExternalId,
|
id = request.ExternalId,
|
||||||
title = trackTitle ?? "Unknown",
|
title = trackTitle ?? "Unknown",
|
||||||
artist = trackArtist ?? "Unknown",
|
artist = trackArtist ?? "Unknown",
|
||||||
album = trackAlbum ?? "Unknown",
|
album = trackAlbum ?? "Unknown",
|
||||||
isLocal = isLocalMapping,
|
isLocal = false,
|
||||||
externalProvider = hasExternalMapping ? request.ExternalProvider!.ToLowerInvariant() : null
|
externalProvider = request.ExternalProvider!.ToLowerInvariant()
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
|
|||||||
@@ -805,50 +805,16 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var usedJellyfinItems = new HashSet<string>();
|
var usedJellyfinItems = new HashSet<string>();
|
||||||
var localUsedCount = 0;
|
var localUsedCount = 0;
|
||||||
var externalUsedCount = 0;
|
var externalUsedCount = 0;
|
||||||
var manualLocalCount = 0;
|
|
||||||
var manualExternalCount = 0;
|
var manualExternalCount = 0;
|
||||||
|
|
||||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested) break;
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
// FIRST: Check for manual mapping
|
|
||||||
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
|
||||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
|
||||||
|
|
||||||
JsonElement? matchedJellyfinItem = null;
|
JsonElement? matchedJellyfinItem = null;
|
||||||
string? matchedKey = null;
|
string? matchedKey = null;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
// Check for external manual mapping
|
||||||
{
|
|
||||||
// Manual Jellyfin mapping exists - fetch the Jellyfin item by ID
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}";
|
|
||||||
var (itemResponse, itemStatusCode) = await proxyService.GetJsonAsync(itemUrl, null, headers);
|
|
||||||
|
|
||||||
if (itemStatusCode == 200 && itemResponse != null)
|
|
||||||
{
|
|
||||||
matchedJellyfinItem = itemResponse.RootElement;
|
|
||||||
manualLocalCount++;
|
|
||||||
_logger.LogDebug("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
|
|
||||||
spotifyTrack.Title, manualJellyfinId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Manual Jellyfin mapping points to invalid Jellyfin ID {Id} for {Title}",
|
|
||||||
manualJellyfinId, spotifyTrack.Title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to fetch manually mapped Jellyfin item {Id}", manualJellyfinId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for external manual mapping if no Jellyfin mapping found
|
|
||||||
if (!matchedJellyfinItem.HasValue)
|
|
||||||
{
|
|
||||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
@@ -942,11 +908,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// SECOND: If no manual mapping, try fuzzy matching
|
// If no manual external mapping, try fuzzy matching with local Jellyfin tracks
|
||||||
if (!matchedJellyfinItem.HasValue)
|
|
||||||
{
|
|
||||||
double bestScore = 0;
|
double bestScore = 0;
|
||||||
|
|
||||||
foreach (var kvp in jellyfinItemsByName)
|
foreach (var kvp in jellyfinItemsByName)
|
||||||
@@ -972,7 +935,6 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
matchedKey = kvp.Key;
|
matchedKey = kvp.Key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedJellyfinItem.HasValue)
|
if (matchedJellyfinItem.HasValue)
|
||||||
{
|
{
|
||||||
@@ -1012,9 +974,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||||
|
|
||||||
var manualMappingInfo = "";
|
var manualMappingInfo = "";
|
||||||
if (manualLocalCount > 0 || manualExternalCount > 0)
|
if (manualExternalCount > 0)
|
||||||
{
|
{
|
||||||
manualMappingInfo = $" [Manual: {manualLocalCount} local, {manualExternalCount} external]";
|
manualMappingInfo = $" [Manual external: {manualExternalCount}]";
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
|
|||||||
@@ -686,17 +686,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||||
Manual mappings override automatic matching. <strong>Local (Jellyfin)</strong> mappings will be phased out in favor of the Spotify Import plugin.
|
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||||
</p>
|
</p>
|
||||||
<div id="mappings-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
<div id="mappings-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||||
<div>
|
<div>
|
||||||
<span style="color: var(--text-secondary);">Total:</span>
|
<span style="color: var(--text-secondary);">Total:</span>
|
||||||
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
|
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span style="color: var(--text-secondary);">Jellyfin (Local):</span>
|
|
||||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="mappings-jellyfin">0</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<span style="color: var(--text-secondary);">External:</span>
|
<span style="color: var(--text-secondary);">External:</span>
|
||||||
<span style="font-weight: 600; margin-left: 8px; color: var(--success);" id="mappings-external">0</span>
|
<span style="font-weight: 600; margin-left: 8px; color: var(--success);" id="mappings-external">0</span>
|
||||||
@@ -942,9 +938,9 @@
|
|||||||
<!-- Manual Track Mapping Modal -->
|
<!-- Manual Track Mapping Modal -->
|
||||||
<div class="modal" id="manual-map-modal">
|
<div class="modal" id="manual-map-modal">
|
||||||
<div class="modal-content" style="max-width: 600px;">
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
<h3>Map Track</h3>
|
<h3>Map Track to External Provider</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
Map this track to either a local Jellyfin track or provide an external provider ID.
|
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Track Info -->
|
<!-- Track Info -->
|
||||||
@@ -956,41 +952,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mapping Type Selection -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Mapping Type</label>
|
|
||||||
<select id="map-type-select" onchange="toggleMappingType()" style="width: 100%;">
|
|
||||||
<option value="jellyfin">Map to Local Jellyfin Track</option>
|
|
||||||
<option value="external">Map to External Provider ID</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Jellyfin Mapping Section -->
|
|
||||||
<div id="jellyfin-mapping-section">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Search Jellyfin Tracks</label>
|
|
||||||
<input type="text" id="map-search-query" placeholder="Search by title, artist, or album..." oninput="searchJellyfinTracks()">
|
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
|
||||||
Tip: Use commas to search multiple terms (e.g., "It Ain't Easy, David Bowie")
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div style="text-align: center; color: var(--text-secondary); margin: 12px 0;">— OR —</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Paste Jellyfin Track URL</label>
|
|
||||||
<input type="text" id="map-jellyfin-url" placeholder="https://jellyfin.example.com/web/#/details?id=..." oninput="extractJellyfinId()">
|
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
|
||||||
Paste the full URL from your Jellyfin web interface
|
|
||||||
</small>
|
|
||||||
</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 or paste a Jellyfin URL...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- External Mapping Section -->
|
<!-- External Mapping Section -->
|
||||||
<div id="external-mapping-section" style="display: none;">
|
<div id="external-mapping-section">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>External Provider</label>
|
<label>External Provider</label>
|
||||||
<select id="map-external-provider" style="width: 100%;">
|
<select id="map-external-provider" style="width: 100%;">
|
||||||
@@ -1012,7 +975,6 @@
|
|||||||
|
|
||||||
<input type="hidden" id="map-playlist-name">
|
<input type="hidden" id="map-playlist-name">
|
||||||
<input type="hidden" id="map-spotify-id">
|
<input type="hidden" id="map-spotify-id">
|
||||||
<input type="hidden" id="map-selected-jellyfin-id">
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button onclick="closeModal('manual-map-modal')">Cancel</button>
|
<button onclick="closeModal('manual-map-modal')">Cancel</button>
|
||||||
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
|
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
|
||||||
@@ -1388,9 +1350,8 @@
|
|||||||
const res = await fetch('/api/admin/mappings/tracks');
|
const res = await fetch('/api/admin/mappings/tracks');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Update summary
|
// Update summary (only external now)
|
||||||
document.getElementById('mappings-total').textContent = data.totalCount || 0;
|
document.getElementById('mappings-total').textContent = data.externalCount || 0;
|
||||||
document.getElementById('mappings-jellyfin').textContent = data.jellyfinCount || 0;
|
|
||||||
document.getElementById('mappings-external').textContent = data.externalCount || 0;
|
document.getElementById('mappings-external').textContent = data.externalCount || 0;
|
||||||
|
|
||||||
const tbody = document.getElementById('mappings-table-body');
|
const tbody = document.getElementById('mappings-table-body');
|
||||||
@@ -1400,17 +1361,20 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = data.mappings.map((m, index) => {
|
// Filter to only show external mappings
|
||||||
const typeColor = m.type === 'jellyfin' ? 'var(--accent)' : 'var(--success)';
|
const externalMappings = data.mappings.filter(m => m.type === 'external');
|
||||||
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;">${m.type}</span>`;
|
|
||||||
|
|
||||||
let targetDisplay = '';
|
if (externalMappings.length === 0) {
|
||||||
if (m.type === 'jellyfin') {
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found. Local Jellyfin mappings should be managed via Spotify Import plugin.</td></tr>';
|
||||||
targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;">${m.jellyfinId}</span>`;
|
return;
|
||||||
} else {
|
|
||||||
targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = externalMappings.map((m, index) => {
|
||||||
|
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 createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-';
|
const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -1442,7 +1406,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteTrackMapping(playlist, spotifyId) {
|
async function deleteTrackMapping(playlist, spotifyId) {
|
||||||
if (!confirm(`Remove manual 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• For local (Jellyfin) tracks: Stop injecting locally if now available via Spotify Import plugin\n• For external tracks: Allow re-matching with potentially better results\n\nThis action cannot be undone.`)) {
|
if (!confirm(`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.`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2318,34 +2282,6 @@
|
|||||||
document.getElementById('map-save-btn').disabled = false;
|
document.getElementById('map-save-btn').disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle between Jellyfin and external mapping modes
|
|
||||||
function toggleMappingType() {
|
|
||||||
const mappingType = document.getElementById('map-type-select').value;
|
|
||||||
const jellyfinSection = document.getElementById('jellyfin-mapping-section');
|
|
||||||
const externalSection = document.getElementById('external-mapping-section');
|
|
||||||
const saveBtn = document.getElementById('map-save-btn');
|
|
||||||
|
|
||||||
if (mappingType === 'jellyfin') {
|
|
||||||
jellyfinSection.style.display = 'block';
|
|
||||||
externalSection.style.display = 'none';
|
|
||||||
// Reset external fields
|
|
||||||
document.getElementById('map-external-id').value = '';
|
|
||||||
// Check if Jellyfin track is selected
|
|
||||||
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
|
|
||||||
saveBtn.disabled = !jellyfinId;
|
|
||||||
} else {
|
|
||||||
jellyfinSection.style.display = 'none';
|
|
||||||
externalSection.style.display = 'block';
|
|
||||||
// Reset Jellyfin fields
|
|
||||||
document.getElementById('map-search-query').value = '';
|
|
||||||
document.getElementById('map-jellyfin-url').value = '';
|
|
||||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
|
||||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Enter an external provider ID above</p>';
|
|
||||||
// Check if external mapping is valid
|
|
||||||
validateExternalMapping();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate external mapping input
|
// Validate external mapping input
|
||||||
function validateExternalMapping() {
|
function validateExternalMapping() {
|
||||||
const externalId = document.getElementById('map-external-id').value.trim();
|
const externalId = document.getElementById('map-external-id').value.trim();
|
||||||
@@ -2355,7 +2291,7 @@
|
|||||||
saveBtn.disabled = !externalId;
|
saveBtn.disabled = !externalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the openManualMap function to reset the modal state
|
// Open manual mapping modal (external only)
|
||||||
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||||
document.getElementById('map-playlist-name').value = playlistName;
|
document.getElementById('map-playlist-name').value = playlistName;
|
||||||
document.getElementById('map-position').textContent = position + 1;
|
document.getElementById('map-position').textContent = position + 1;
|
||||||
@@ -2363,74 +2299,38 @@
|
|||||||
document.getElementById('map-spotify-artist').textContent = artist;
|
document.getElementById('map-spotify-artist').textContent = artist;
|
||||||
document.getElementById('map-spotify-id').value = spotifyId;
|
document.getElementById('map-spotify-id').value = spotifyId;
|
||||||
|
|
||||||
// Reset to Jellyfin mapping mode
|
// Reset fields
|
||||||
document.getElementById('map-type-select').value = 'jellyfin';
|
|
||||||
document.getElementById('jellyfin-mapping-section').style.display = 'block';
|
|
||||||
document.getElementById('external-mapping-section').style.display = 'none';
|
|
||||||
|
|
||||||
// Reset all fields
|
|
||||||
document.getElementById('map-search-query').value = '';
|
|
||||||
document.getElementById('map-jellyfin-url').value = '';
|
|
||||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
|
||||||
document.getElementById('map-external-id').value = '';
|
document.getElementById('map-external-id').value = '';
|
||||||
document.getElementById('map-external-provider').value = 'SquidWTF';
|
document.getElementById('map-external-provider').value = 'SquidWTF';
|
||||||
document.getElementById('map-save-btn').disabled = true;
|
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 or paste a Jellyfin URL...</p>';
|
|
||||||
|
|
||||||
openModal('manual-map-modal');
|
openModal('manual-map-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open external mapping modal (pre-set to external mode)
|
// Alias for backward compatibility
|
||||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||||
document.getElementById('map-playlist-name').value = playlistName;
|
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||||
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;
|
|
||||||
|
|
||||||
// Set to external mapping mode
|
|
||||||
document.getElementById('map-type-select').value = 'external';
|
|
||||||
document.getElementById('jellyfin-mapping-section').style.display = 'none';
|
|
||||||
document.getElementById('external-mapping-section').style.display = 'block';
|
|
||||||
|
|
||||||
// Reset all fields
|
|
||||||
document.getElementById('map-search-query').value = '';
|
|
||||||
document.getElementById('map-jellyfin-url').value = '';
|
|
||||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
|
||||||
document.getElementById('map-external-id').value = '';
|
|
||||||
document.getElementById('map-external-provider').value = 'SquidWTF';
|
|
||||||
document.getElementById('map-save-btn').disabled = true;
|
|
||||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Enter an external provider ID above</p>';
|
|
||||||
|
|
||||||
openModal('manual-map-modal');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the saveManualMapping function to handle both types
|
// Save manual mapping (external only)
|
||||||
async function saveManualMapping() {
|
async function saveManualMapping() {
|
||||||
const playlistName = document.getElementById('map-playlist-name').value;
|
const playlistName = document.getElementById('map-playlist-name').value;
|
||||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||||
const mappingType = document.getElementById('map-type-select').value;
|
|
||||||
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
|
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
|
||||||
|
|
||||||
let requestBody = { spotifyId };
|
|
||||||
|
|
||||||
if (mappingType === 'jellyfin') {
|
|
||||||
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
|
|
||||||
if (!jellyfinId) {
|
|
||||||
showToast('Please select a track', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
requestBody.jellyfinId = jellyfinId;
|
|
||||||
} else {
|
|
||||||
const externalProvider = document.getElementById('map-external-provider').value;
|
const externalProvider = document.getElementById('map-external-provider').value;
|
||||||
const externalId = document.getElementById('map-external-id').value.trim();
|
const externalId = document.getElementById('map-external-id').value.trim();
|
||||||
|
|
||||||
if (!externalId) {
|
if (!externalId) {
|
||||||
showToast('Please enter an external provider ID', 'error');
|
showToast('Please enter an external provider ID', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requestBody.externalProvider = externalProvider;
|
|
||||||
requestBody.externalId = externalId;
|
const requestBody = {
|
||||||
}
|
spotifyId,
|
||||||
|
externalProvider,
|
||||||
|
externalId
|
||||||
|
};
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
const saveBtn = document.getElementById('map-save-btn');
|
const saveBtn = document.getElementById('map-save-btn');
|
||||||
@@ -2453,8 +2353,7 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const mappingTypeText = mappingType === 'jellyfin' ? 'local Jellyfin track' : `${requestBody.externalProvider} ID`;
|
showToast(`✓ Track mapped to ${requestBody.externalProvider} - rebuilding playlist...`, 'success');
|
||||||
showToast(`✓ Track mapped to ${mappingTypeText} - rebuilding playlist...`, 'success');
|
|
||||||
closeModal('manual-map-modal');
|
closeModal('manual-map-modal');
|
||||||
|
|
||||||
// Show rebuilding indicator
|
// Show rebuilding indicator
|
||||||
@@ -2462,27 +2361,15 @@
|
|||||||
|
|
||||||
// Show detailed info toast after a moment
|
// Show detailed info toast after a moment
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (mappingType === 'jellyfin') {
|
|
||||||
showToast('🔄 Rebuilding playlist with your local track mapping...', 'info', 8000);
|
|
||||||
} else {
|
|
||||||
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
|
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
|
||||||
}
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// Update the track in the UI without refreshing
|
// Update the track in the UI without refreshing
|
||||||
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
|
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
|
||||||
if (trackItem) {
|
if (trackItem) {
|
||||||
const titleEl = trackItem.querySelector('.track-info h4');
|
const titleEl = trackItem.querySelector('.track-info h4');
|
||||||
if (titleEl && mappingType === 'jellyfin' && data.track) {
|
if (titleEl) {
|
||||||
// For Jellyfin mappings, update with actual track info
|
// Update status badge to show provider
|
||||||
const titleText = data.track.title;
|
|
||||||
const newStatusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
|
||||||
titleEl.innerHTML = escapeHtml(titleText) + newStatusBadge;
|
|
||||||
|
|
||||||
const artistEl = trackItem.querySelector('.track-info .artists');
|
|
||||||
if (artistEl) artistEl.textContent = data.track.artist;
|
|
||||||
} else if (titleEl && mappingType === 'external') {
|
|
||||||
// For external mappings, update status badge to show provider
|
|
||||||
const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
|
const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
|
||||||
const capitalizedProvider = capitalizeProvider(requestBody.externalProvider);
|
const capitalizedProvider = capitalizeProvider(requestBody.externalProvider);
|
||||||
const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(capitalizedProvider)}</span>`;
|
const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(capitalizedProvider)}</span>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user