Compare commits

...

6 Commits

Author SHA1 Message Date
6ea03b8005 fix: add proper local Jellyfin mapping modal for Map to Local button
Some checks are pending
CI / build-and-test (push) Waiting to run
Map to Local now opens a Jellyfin search modal instead of the external provider modal.
2026-02-09 18:29:15 -05:00
1369d09cbd fix: invalidate playlist cache when schedule is updated
Playlist summary cache now refreshes immediately when sync schedules are changed.
2026-02-09 18:24:59 -05:00
838151741f fix: show correct track counts and IPs in admin UI
Fixed Spotify playlists showing 0 tracks, filtered out playlist folders, and corrected session IPs to use X-Forwarded-For header.
2026-02-09 18:16:38 -05:00
88bf083386 Fix GraphQL query for fetching user playlists - use libraryV3
- Changed from fetchLibraryPlaylists to libraryV3 operation (correct Spotify GraphQL endpoint)
- Use GET request with query params instead of POST (matches Jellyfin plugin implementation)
- Updated response parsing to match libraryV3 structure (me.libraryV3.items[].item.data)
- Fixed owner field to use 'username' instead of 'name'
- This should resolve the BadRequest (400) errors when fetching user playlists
2026-02-09 16:36:10 -05:00
670544a9d6 Fix AdminController Spotify 429 rate limiting in Link Playlists tab
- Replace direct REST API calls with SpotifyApiClient GraphQL method
- Add GetUserPlaylistsAsync() method to fetch all user playlists via GraphQL
- GraphQL endpoint is much less rate-limited than REST API /me/playlists
- Enhanced playlist data with track count, owner, and image URL from GraphQL
- Simplified AdminController code by delegating to SpotifyApiClient
2026-02-09 16:32:18 -05:00
0dca6b792d Fix Spotify 429 rate limiting and startup performance issues
- Fix: Use correct HttpClient (_webApiClient) for GraphQL library playlists endpoint
  - Was using _httpClient which pointed to wrong base URL causing 429 errors
- Add: Retry logic with Retry-After header support for 429 responses
- Add: Minimum 500ms delay between library playlist pages to prevent rate limiting
- Add: 5-second timeout per endpoint benchmark ping to prevent slow endpoints from blocking startup
- Add: Documentation for timeout requirements in EndpointBenchmarkService
- Fix: ARM64 compatibility for spotify-lyrics service via platform emulation in docker-compose
2026-02-09 16:09:38 -05:00
7 changed files with 388 additions and 156 deletions

View File

@@ -1528,6 +1528,12 @@ public class AdminController : ControllerBase
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath); _logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
// Invalidate playlist summary cache if playlists were updated
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
{
InvalidatePlaylistSummaryCache();
}
return Ok(new return Ok(new
{ {
message = "Configuration updated. Restart container to apply changes.", message = "Configuration updated. Restart container to apply changes.",
@@ -1939,12 +1945,6 @@ public class AdminController : ControllerBase
try try
{ {
var token = await _spotifyClient.GetWebAccessTokenAsync();
if (string.IsNullOrEmpty(token))
{
return StatusCode(401, new { error = "Failed to authenticate with Spotify. Check your sp_dc cookie." });
}
// Get list of already-configured Spotify playlist IDs // Get list of already-configured Spotify playlist IDs
var configuredPlaylists = await ReadPlaylistsFromEnvFile(); var configuredPlaylists = await ReadPlaylistsFromEnvFile();
var linkedSpotifyIds = new HashSet<string>( var linkedSpotifyIds = new HashSet<string>(
@@ -1952,81 +1952,23 @@ public class AdminController : ControllerBase
StringComparer.OrdinalIgnoreCase StringComparer.OrdinalIgnoreCase
); );
var playlists = new List<object>(); // Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
var offset = 0; var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
const int limit = 50;
while (true) if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
{ {
var url = $"https://api.spotify.com/v1/me/playlists?offset={offset}&limit={limit}"; return Ok(new { playlists = new List<object>() });
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlists");
return StatusCode(429, new { error = "Spotify rate limit exceeded. Please wait a moment and try again." });
} }
_logger.LogWarning("Failed to fetch Spotify playlists: {StatusCode}", response.StatusCode); var playlists = spotifyPlaylists.Select(p => new
break;
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
break;
foreach (var item in items.EnumerateArray())
{ {
var id = item.TryGetProperty("id", out var itemId) ? itemId.GetString() : null; id = p.SpotifyId,
var name = item.TryGetProperty("name", out var n) ? n.GetString() : null; name = p.Name,
var trackCount = 0; trackCount = p.TotalTracks,
owner = p.OwnerName ?? "",
if (item.TryGetProperty("tracks", out var tracks) && isPublic = p.Public,
tracks.TryGetProperty("total", out var total)) isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
{ }).ToList();
trackCount = total.GetInt32();
}
var owner = "";
if (item.TryGetProperty("owner", out var ownerObj) &&
ownerObj.TryGetProperty("display_name", out var displayName))
{
owner = displayName.GetString() ?? "";
}
var isPublic = item.TryGetProperty("public", out var pub) && pub.GetBoolean();
// Check if this playlist is already linked
var isLinked = !string.IsNullOrEmpty(id) && linkedSpotifyIds.Contains(id);
playlists.Add(new
{
id,
name,
trackCount,
owner,
isPublic,
isLinked
});
}
if (items.GetArrayLength() < limit) break;
offset += limit;
// Rate limiting
if (_spotifyApiSettings.RateLimitDelayMs > 0)
{
await Task.Delay(_spotifyApiSettings.RateLimitDelayMs);
}
}
return Ok(new { playlists }); return Ok(new { playlists });
} }
@@ -2108,11 +2050,16 @@ public class AdminController : ControllerBase
trackStats = await GetPlaylistTrackStats(id!); trackStats = await GetPlaylistTrackStats(id!);
} }
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
var actualTrackCount = isConfigured
? trackStats.LocalTracks + trackStats.ExternalTracks
: childCount;
playlists.Add(new playlists.Add(new
{ {
id, id,
name, name,
trackCount = childCount, trackCount = actualTrackCount,
linkedSpotifyId, linkedSpotifyId,
isConfigured, isConfigured,
localTracks = trackStats.LocalTracks, localTracks = trackStats.LocalTracks,

View File

@@ -20,6 +20,9 @@ public class EndpointBenchmarkService
/// <summary> /// <summary>
/// Benchmarks a list of endpoints by making test requests. /// Benchmarks a list of endpoints by making test requests.
/// Returns endpoints sorted by average response time (fastest first). /// Returns endpoints sorted by average response time (fastest first).
///
/// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints
/// from blocking startup. Recommended: 5-10 second timeout per ping.
/// </summary> /// </summary>
public async Task<List<string>> BenchmarkEndpointsAsync( public async Task<List<string>> BenchmarkEndpointsAsync(
List<string> endpoints, List<string> endpoints,

View File

@@ -85,6 +85,10 @@ public class JellyfinSessionManager : IDisposable
_logger.LogDebug("Session created for {DeviceId}", deviceId); _logger.LogDebug("Session created for {DeviceId}", deviceId);
// Track this session // Track this session
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
?? headers["X-Real-IP"].FirstOrDefault()
?? "Unknown";
_sessions[deviceId] = new SessionInfo _sessions[deviceId] = new SessionInfo
{ {
DeviceId = deviceId, DeviceId = deviceId,
@@ -92,7 +96,8 @@ public class JellyfinSessionManager : IDisposable
Device = device, Device = device,
Version = version, Version = version,
LastActivity = DateTime.UtcNow, LastActivity = DateTime.UtcNow,
Headers = CloneHeaders(headers) Headers = CloneHeaders(headers),
ClientIp = clientIp
}; };
// Start a WebSocket connection to Jellyfin on behalf of this client // Start a WebSocket connection to Jellyfin on behalf of this client
@@ -222,6 +227,7 @@ public class JellyfinSessionManager : IDisposable
Client = s.Client, Client = s.Client,
Device = s.Device, Device = s.Device,
Version = s.Version, Version = s.Version,
ClientIp = s.ClientIp,
LastActivity = s.LastActivity, LastActivity = s.LastActivity,
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1), InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
HasWebSocket = s.WebSocket != null, HasWebSocket = s.WebSocket != null,
@@ -565,6 +571,7 @@ public class JellyfinSessionManager : IDisposable
public ClientWebSocket? WebSocket { get; set; } public ClientWebSocket? WebSocket { get; set; }
public string? LastPlayingItemId { get; set; } public string? LastPlayingItemId { get; set; }
public long? LastPlayingPositionTicks { get; set; } public long? LastPlayingPositionTicks { get; set; }
public string? ClientIp { get; set; }
} }
public void Dispose() public void Dispose()

View File

@@ -349,6 +349,17 @@ public class SpotifyApiClient : IDisposable
var response = await _webApiClient.SendAsync(request, cancellationToken); var response = await _webApiClient.SendAsync(request, cancellationToken);
// Handle 429 rate limiting with exponential backoff
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
await Task.Delay(retryAfter, cancellationToken);
// Retry the request
response = await _webApiClient.SendAsync(request, cancellationToken);
}
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode); _logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
@@ -735,6 +746,18 @@ public class SpotifyApiClient : IDisposable
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync( public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
string searchName, string searchName,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{
return await GetUserPlaylistsAsync(searchName, cancellationToken);
}
/// <summary>
/// Gets all playlists from the user's library, optionally filtered by name.
/// Uses GraphQL API which is less rate-limited than REST API.
/// </summary>
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
string? searchName = null,
CancellationToken cancellationToken = default)
{ {
var token = await GetWebAccessTokenAsync(cancellationToken); var token = await GetWebAccessTokenAsync(cancellationToken);
if (string.IsNullOrEmpty(token)) if (string.IsNullOrEmpty(token))
@@ -752,56 +775,33 @@ public class SpotifyApiClient : IDisposable
while (true) while (true)
{ {
// GraphQL query to fetch user playlists // GraphQL query to fetch user playlists - using libraryV3 operation
var graphqlQuery = new var queryParams = new Dictionary<string, string>
{ {
operationName = "fetchLibraryPlaylists", { "operationName", "libraryV3" },
variables = new { "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
{ { "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
offset,
limit
},
query = @"
query fetchLibraryPlaylists($offset: Int!, $limit: Int!) {
me {
library {
playlists(offset: $offset, limit: $limit) {
totalCount
items {
playlist {
uri
name
description
images {
url
}
ownerV2 {
data {
__typename
... on User {
id
name
}
}
}
}
}
}
}
}
}"
}; };
var request = new HttpRequestMessage(HttpMethod.Post, $"{WebApiBase}/query") var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
{ var url = $"{WebApiBase}/query?{queryString}";
Content = new StringContent(
JsonSerializer.Serialize(graphqlQuery), var request = new HttpRequestMessage(HttpMethod.Get, url);
System.Text.Encoding.UTF8,
"application/json")
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request, cancellationToken); var response = await _webApiClient.SendAsync(request, cancellationToken);
// Handle 429 rate limiting with exponential backoff
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
await Task.Delay(retryAfter, cancellationToken);
// Retry the request
response = await _httpClient.SendAsync(request, cancellationToken);
}
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode); _logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
@@ -814,56 +814,157 @@ public class SpotifyApiClient : IDisposable
if (!root.TryGetProperty("data", out var data) || if (!root.TryGetProperty("data", out var data) ||
!data.TryGetProperty("me", out var me) || !data.TryGetProperty("me", out var me) ||
!me.TryGetProperty("library", out var library) || !me.TryGetProperty("libraryV3", out var library) ||
!library.TryGetProperty("playlists", out var playlistsData) || !library.TryGetProperty("items", out var items))
!playlistsData.TryGetProperty("items", out var items))
{ {
break; break;
} }
// Get total count
if (library.TryGetProperty("totalCount", out var totalCount))
{
var total = totalCount.GetInt32();
if (total == 0) break;
}
var itemCount = 0; var itemCount = 0;
foreach (var item in items.EnumerateArray()) foreach (var item in items.EnumerateArray())
{ {
itemCount++; itemCount++;
if (!item.TryGetProperty("playlist", out var playlist)) if (!item.TryGetProperty("item", out var playlistItem) ||
!playlistItem.TryGetProperty("data", out var playlist))
{
continue; continue;
}
// Check __typename to filter out folders and only include playlists
if (playlistItem.TryGetProperty("__typename", out var typename))
{
var typeStr = typename.GetString();
// Skip folders - only process Playlist types
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
{
continue;
}
}
// Get playlist URI/ID
string? uri = null;
if (playlistItem.TryGetProperty("uri", out var uriProp))
{
uri = uriProp.GetString();
}
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
{
uri = uriProp2.GetString();
}
if (string.IsNullOrEmpty(uri)) continue;
// Skip if not a playlist URI (e.g., folders have different URI format)
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
// Check if name matches (case-insensitive) // Check if name matches (case-insensitive) - if searchName is provided
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrEmpty(searchName) &&
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
{ {
var uri = playlist.TryGetProperty("uri", out var u) ? u.GetString() ?? "" : ""; continue;
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase); }
// Get track count if available - try multiple possible paths
var trackCount = 0;
if (playlist.TryGetProperty("content", out var content))
{
if (content.TryGetProperty("totalCount", out var totalTrackCount))
{
trackCount = totalTrackCount.GetInt32();
}
}
// Fallback: try attributes.itemCount
else if (playlist.TryGetProperty("attributes", out var attributes) &&
attributes.TryGetProperty("itemCount", out var itemCountProp))
{
trackCount = itemCountProp.GetInt32();
}
// Fallback: try totalCount directly
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
{
trackCount = directTotalCount.GetInt32();
}
// Log if we couldn't find track count for debugging
if (trackCount == 0)
{
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
itemName, spotifyId, playlist.GetRawText());
}
// Get owner name
string? ownerName = null;
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
ownerV2.TryGetProperty("data", out var ownerData) &&
ownerData.TryGetProperty("username", out var ownerNameProp))
{
ownerName = ownerNameProp.GetString();
}
// Get image URL
string? imageUrl = null;
if (playlist.TryGetProperty("images", out var images) &&
images.TryGetProperty("items", out var imageItems) &&
imageItems.GetArrayLength() > 0)
{
var firstImage = imageItems[0];
if (firstImage.TryGetProperty("sources", out var sources) &&
sources.GetArrayLength() > 0)
{
var firstSource = sources[0];
if (firstSource.TryGetProperty("url", out var urlProp))
{
imageUrl = urlProp.GetString();
}
}
}
playlists.Add(new SpotifyPlaylist playlists.Add(new SpotifyPlaylist
{ {
SpotifyId = spotifyId, SpotifyId = spotifyId,
Name = itemName, Name = itemName,
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null, Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
TotalTracks = 0, // GraphQL doesn't return track count in this query TotalTracks = trackCount,
OwnerName = ownerName,
ImageUrl = imageUrl,
SnapshotId = null SnapshotId = null
}); });
} }
}
if (itemCount < limit) break; if (itemCount < limit) break;
offset += limit; offset += limit;
// GraphQL is less rate-limited, but still add a small delay // Add delay between pages to avoid rate limiting
if (_settings.RateLimitDelayMs > 0) // Library fetching can be aggressive, so use a longer delay
{ var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken); _logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
} await Task.Delay(delayMs, cancellationToken);
} }
_logger.LogInformation("Found {Count} playlists matching '{SearchName}' via GraphQL", playlists.Count, searchName); _logger.LogInformation("Found {Count} playlists{Filter} via GraphQL",
playlists.Count,
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
return playlists; return playlists;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error searching user playlists for '{SearchName}' via GraphQL", searchName); _logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
return new List<SpotifyPlaylist>(); return new List<SpotifyPlaylist>();
} }
} }

View File

@@ -73,7 +73,11 @@ public class SquidWTFStartupValidator : BaseStartupValidator
{ {
try try
{ {
var response = await _httpClient.GetAsync(endpoint, ct); // 5 second timeout per ping - mark slow endpoints as failed
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} }
catch catch

View File

@@ -1174,7 +1174,7 @@
<div class="modal-content" style="max-width: 600px;"> <div class="modal-content" style="max-width: 600px;">
<h3>Map Track to External Provider</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 an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead. Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Jellyfin mapping modal instead.
</p> </p>
<!-- Track Info --> <!-- Track Info -->
@@ -1216,6 +1216,43 @@
</div> </div>
</div> </div>
<!-- Local Jellyfin Track Mapping Modal -->
<div class="modal" id="local-map-modal">
<div class="modal-content" style="max-width: 700px;">
<h3>Map Track to Local Jellyfin Track</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Search your Jellyfin library and select a local track to map to this Spotify track.
</p>
<!-- Track Info -->
<div class="form-group">
<label>Spotify Track (Position <span id="local-map-position"></span>)</label>
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<strong id="local-map-spotify-title"></strong><br>
<span style="color: var(--text-secondary);" id="local-map-spotify-artist"></span>
</div>
</div>
<!-- Search Section -->
<div class="form-group">
<label>Search Jellyfin Library</label>
<input type="text" id="local-map-search" placeholder="Search for track name or artist...">
<button onclick="searchJellyfinTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
</div>
<!-- Search Results -->
<div id="local-map-results" style="max-height: 300px; overflow-y: auto; margin-top: 16px;"></div>
<input type="hidden" id="local-map-playlist-name">
<input type="hidden" id="local-map-spotify-id">
<input type="hidden" id="local-map-jellyfin-id">
<div class="modal-actions">
<button onclick="closeModal('local-map-modal')">Cancel</button>
<button class="primary" onclick="saveLocalMapping()" id="local-map-save-btn" disabled>Save Mapping</button>
</div>
</div>
</div>
<!-- Link Playlist Modal --> <!-- Link Playlist Modal -->
<div class="modal" id="link-playlist-modal"> <div class="modal" id="link-playlist-modal">
<div class="modal-content"> <div class="modal-content">
@@ -2997,8 +3034,27 @@
saveBtn.disabled = !externalId; saveBtn.disabled = !externalId;
} }
// Open manual mapping modal (external only) // Open local Jellyfin mapping modal
function openManualMap(playlistName, position, title, artist, spotifyId) { function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('local-map-playlist-name').value = playlistName;
document.getElementById('local-map-position').textContent = position + 1;
document.getElementById('local-map-spotify-title').textContent = title;
document.getElementById('local-map-spotify-artist').textContent = artist;
document.getElementById('local-map-spotify-id').value = spotifyId;
// Pre-fill search with track info
document.getElementById('local-map-search').value = `${title} ${artist}`;
// Reset fields
document.getElementById('local-map-results').innerHTML = '';
document.getElementById('local-map-jellyfin-id').value = '';
document.getElementById('local-map-save-btn').disabled = true;
openModal('local-map-modal');
}
// Open external mapping modal
function openExternalMap(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;
document.getElementById('map-spotify-title').textContent = title; document.getElementById('map-spotify-title').textContent = title;
@@ -3013,12 +3069,123 @@
openModal('manual-map-modal'); openModal('manual-map-modal');
} }
// Alias for backward compatibility // Search Jellyfin tracks for local mapping
function openExternalMap(playlistName, position, title, artist, spotifyId) { async function searchJellyfinTracks() {
openManualMap(playlistName, position, title, artist, spotifyId); const query = document.getElementById('local-map-search').value.trim();
if (!query) {
showToast('Please enter a search query', 'error');
return;
} }
// Save manual mapping (external only) const resultsDiv = document.getElementById('local-map-results');
resultsDiv.innerHTML = '<p style="text-align:center;padding:20px;">Searching...</p>';
try {
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
const data = await res.json();
if (!res.ok) {
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
return;
}
if (!data.tracks || data.tracks.length === 0) {
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">No tracks found</p>';
return;
}
resultsDiv.innerHTML = data.tracks.map(track => `
<div style="padding: 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: background 0.2s;"
onclick="selectJellyfinTrack('${escapeJs(track.id)}', '${escapeJs(track.name)}', '${escapeJs(track.artist)}')"
onmouseover="this.style.background='var(--bg-primary)'"
onmouseout="this.style.background='transparent'">
<strong>${escapeHtml(track.name)}</strong><br>
<span style="color: var(--text-secondary); font-size: 0.9em;">${escapeHtml(track.artist)}</span>
${track.album ? '<br><span style="color: var(--text-secondary); font-size: 0.85em;">' + escapeHtml(track.album) + '</span>' : ''}
</div>
`).join('');
} catch (error) {
console.error('Search error:', error);
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
}
}
// Select a Jellyfin track for mapping
function selectJellyfinTrack(jellyfinId, name, artist) {
document.getElementById('local-map-jellyfin-id').value = jellyfinId;
document.getElementById('local-map-save-btn').disabled = false;
// Highlight selected track
document.querySelectorAll('#local-map-results > div').forEach(div => {
div.style.background = 'transparent';
div.style.border = '1px solid var(--border)';
});
event.target.closest('div').style.background = 'var(--primary)';
event.target.closest('div').style.border = '1px solid var(--primary)';
}
// Save local Jellyfin mapping
async function saveLocalMapping() {
const playlistName = document.getElementById('local-map-playlist-name').value;
const spotifyId = document.getElementById('local-map-spotify-id').value;
const jellyfinId = document.getElementById('local-map-jellyfin-id').value;
if (!jellyfinId) {
showToast('Please select a Jellyfin track', 'error');
return;
}
const requestBody = {
spotifyId,
jellyfinId
};
// Show loading state
const saveBtn = document.getElementById('local-map-save-btn');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/map`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.ok) {
showToast('Track mapped successfully!', 'success');
closeModal('local-map-modal');
// Refresh the tracks view if it's open
const tracksModal = document.getElementById('tracks-modal');
if (tracksModal.style.display === 'flex') {
await viewTracks(playlistName);
}
} else {
const data = await res.json();
showToast(data.error || 'Failed to save mapping', 'error');
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
} catch (error) {
if (error.name === 'AbortError') {
showToast('Request timed out. The mapping may still be processing.', 'warning');
} else {
showToast('Failed to save mapping', 'error');
}
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
// Save manual mapping (external only) - kept for backward compatibility
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;

View File

@@ -17,8 +17,11 @@ services:
networks: networks:
- allstarr-network - allstarr-network
# Spotify Lyrics API sidecar service
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
spotify-lyrics: spotify-lyrics:
image: akashrchandran/spotify-lyrics-api:latest image: akashrchandran/spotify-lyrics-api:latest
platform: linux/amd64
container_name: allstarr-spotify-lyrics container_name: allstarr-spotify-lyrics
restart: unless-stopped restart: unless-stopped
ports: ports: