mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
Compare commits
6 Commits
f135db3f60
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
6ea03b8005
|
|||
|
1369d09cbd
|
|||
|
838151741f
|
|||
|
88bf083386
|
|||
|
670544a9d6
|
|||
|
0dca6b792d
|
@@ -1528,6 +1528,12 @@ public class AdminController : ControllerBase
|
||||
|
||||
_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
|
||||
{
|
||||
message = "Configuration updated. Restart container to apply changes.",
|
||||
@@ -1939,12 +1945,6 @@ public class AdminController : ControllerBase
|
||||
|
||||
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
|
||||
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
var linkedSpotifyIds = new HashSet<string>(
|
||||
@@ -1952,82 +1952,24 @@ public class AdminController : ControllerBase
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
var playlists = new List<object>();
|
||||
var offset = 0;
|
||||
const int limit = 50;
|
||||
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
|
||||
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
|
||||
|
||||
while (true)
|
||||
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
|
||||
{
|
||||
var url = $"https://api.spotify.com/v1/me/playlists?offset={offset}&limit={limit}";
|
||||
|
||||
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);
|
||||
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;
|
||||
var name = item.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
var trackCount = 0;
|
||||
|
||||
if (item.TryGetProperty("tracks", out var tracks) &&
|
||||
tracks.TryGetProperty("total", out var total))
|
||||
{
|
||||
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 = new List<object>() });
|
||||
}
|
||||
|
||||
var playlists = spotifyPlaylists.Select(p => new
|
||||
{
|
||||
id = p.SpotifyId,
|
||||
name = p.Name,
|
||||
trackCount = p.TotalTracks,
|
||||
owner = p.OwnerName ?? "",
|
||||
isPublic = p.Public,
|
||||
isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
|
||||
}).ToList();
|
||||
|
||||
return Ok(new { playlists });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -2108,11 +2050,16 @@ public class AdminController : ControllerBase
|
||||
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
|
||||
{
|
||||
id,
|
||||
name,
|
||||
trackCount = childCount,
|
||||
trackCount = actualTrackCount,
|
||||
linkedSpotifyId,
|
||||
isConfigured,
|
||||
localTracks = trackStats.LocalTracks,
|
||||
|
||||
@@ -20,6 +20,9 @@ public class EndpointBenchmarkService
|
||||
/// <summary>
|
||||
/// Benchmarks a list of endpoints by making test requests.
|
||||
/// 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>
|
||||
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||
List<string> endpoints,
|
||||
|
||||
@@ -85,6 +85,10 @@ public class JellyfinSessionManager : IDisposable
|
||||
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
||||
|
||||
// Track this session
|
||||
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
||||
?? headers["X-Real-IP"].FirstOrDefault()
|
||||
?? "Unknown";
|
||||
|
||||
_sessions[deviceId] = new SessionInfo
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
@@ -92,7 +96,8 @@ public class JellyfinSessionManager : IDisposable
|
||||
Device = device,
|
||||
Version = version,
|
||||
LastActivity = DateTime.UtcNow,
|
||||
Headers = CloneHeaders(headers)
|
||||
Headers = CloneHeaders(headers),
|
||||
ClientIp = clientIp
|
||||
};
|
||||
|
||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||
@@ -222,6 +227,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
Client = s.Client,
|
||||
Device = s.Device,
|
||||
Version = s.Version,
|
||||
ClientIp = s.ClientIp,
|
||||
LastActivity = s.LastActivity,
|
||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||
HasWebSocket = s.WebSocket != null,
|
||||
@@ -565,6 +571,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
public ClientWebSocket? WebSocket { get; set; }
|
||||
public string? LastPlayingItemId { get; set; }
|
||||
public long? LastPlayingPositionTicks { get; set; }
|
||||
public string? ClientIp { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -349,6 +349,17 @@ public class SpotifyApiClient : IDisposable
|
||||
|
||||
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)
|
||||
{
|
||||
_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(
|
||||
string searchName,
|
||||
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);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
@@ -752,56 +775,33 @@ public class SpotifyApiClient : IDisposable
|
||||
|
||||
while (true)
|
||||
{
|
||||
// GraphQL query to fetch user playlists
|
||||
var graphqlQuery = new
|
||||
// GraphQL query to fetch user playlists - using libraryV3 operation
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
operationName = "fetchLibraryPlaylists",
|
||||
variables = new
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"
|
||||
{ "operationName", "libraryV3" },
|
||||
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
|
||||
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
|
||||
};
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{WebApiBase}/query")
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(graphqlQuery),
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
||||
var url = $"{WebApiBase}/query?{queryString}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
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)
|
||||
{
|
||||
_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) ||
|
||||
!data.TryGetProperty("me", out var me) ||
|
||||
!me.TryGetProperty("library", out var library) ||
|
||||
!library.TryGetProperty("playlists", out var playlistsData) ||
|
||||
!playlistsData.TryGetProperty("items", out var items))
|
||||
!me.TryGetProperty("libraryV3", out var library) ||
|
||||
!library.TryGetProperty("items", out var items))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Get total count
|
||||
if (library.TryGetProperty("totalCount", out var totalCount))
|
||||
{
|
||||
var total = totalCount.GetInt32();
|
||||
if (total == 0) break;
|
||||
}
|
||||
|
||||
var itemCount = 0;
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
itemCount++;
|
||||
|
||||
if (!item.TryGetProperty("playlist", out var playlist))
|
||||
if (!item.TryGetProperty("item", out var playlistItem) ||
|
||||
!playlistItem.TryGetProperty("data", out var playlist))
|
||||
{
|
||||
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() ?? "" : "";
|
||||
|
||||
// Check if name matches (case-insensitive)
|
||||
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
// Check if name matches (case-insensitive) - if searchName is provided
|
||||
if (!string.IsNullOrEmpty(searchName) &&
|
||||
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var uri = playlist.TryGetProperty("uri", out var u) ? u.GetString() ?? "" : "";
|
||||
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
playlists.Add(new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
Name = itemName,
|
||||
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
TotalTracks = 0, // GraphQL doesn't return track count in this query
|
||||
SnapshotId = null
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
Name = itemName,
|
||||
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
TotalTracks = trackCount,
|
||||
OwnerName = ownerName,
|
||||
ImageUrl = imageUrl,
|
||||
SnapshotId = null
|
||||
});
|
||||
}
|
||||
|
||||
if (itemCount < limit) break;
|
||||
offset += limit;
|
||||
|
||||
// GraphQL is less rate-limited, but still add a small delay
|
||||
if (_settings.RateLimitDelayMs > 0)
|
||||
{
|
||||
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||
}
|
||||
// Add delay between pages to avoid rate limiting
|
||||
// Library fetching can be aggressive, so use a longer delay
|
||||
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
|
||||
_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;
|
||||
}
|
||||
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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,11 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
{
|
||||
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;
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -1174,7 +1174,7 @@
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<h3>Map Track to External Provider</h3>
|
||||
<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>
|
||||
|
||||
<!-- Track Info -->
|
||||
@@ -1216,6 +1216,43 @@
|
||||
</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 -->
|
||||
<div class="modal" id="link-playlist-modal">
|
||||
<div class="modal-content">
|
||||
@@ -2997,8 +3034,27 @@
|
||||
saveBtn.disabled = !externalId;
|
||||
}
|
||||
|
||||
// Open manual mapping modal (external only)
|
||||
// Open local Jellyfin mapping modal
|
||||
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-position').textContent = position + 1;
|
||||
document.getElementById('map-spotify-title').textContent = title;
|
||||
@@ -3013,12 +3069,123 @@
|
||||
openModal('manual-map-modal');
|
||||
}
|
||||
|
||||
// Alias for backward compatibility
|
||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||
// Search Jellyfin tracks for local mapping
|
||||
async function searchJellyfinTracks() {
|
||||
const query = document.getElementById('local-map-search').value.trim();
|
||||
if (!query) {
|
||||
showToast('Please enter a search query', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
// Save manual mapping (external only)
|
||||
// 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() {
|
||||
const playlistName = document.getElementById('map-playlist-name').value;
|
||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||
|
||||
@@ -17,8 +17,11 @@ services:
|
||||
networks:
|
||||
- allstarr-network
|
||||
|
||||
# Spotify Lyrics API sidecar service
|
||||
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
|
||||
spotify-lyrics:
|
||||
image: akashrchandran/spotify-lyrics-api:latest
|
||||
platform: linux/amd64
|
||||
container_name: allstarr-spotify-lyrics
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
Reference in New Issue
Block a user